Compare commits
23 Commits
v0.4.0
...
80441c3367
| Author | SHA1 | Date | |
|---|---|---|---|
| 80441c3367 | |||
| d3bf4853de | |||
| 21b6ddc29b | |||
| d393cf022e | |||
| 21e235a07f | |||
| c55ee7f47e | |||
| 6cbfdb1e65 | |||
| b84a973559 | |||
| c14342aa51 | |||
| aeda919376 | |||
| 15a4d44c3f | |||
| fbe8b0bab6 | |||
| 94a5c26d6c | |||
| b95fc45548 | |||
| 3edd45d5f4 | |||
| 0051e97bc7 | |||
| a19fdd3582 | |||
| 7acbe54f46 | |||
| c7c49a9d6b | |||
| 1530c83fbc | |||
| c65913c62a | |||
| 17e9451494 | |||
| b0117acac1 |
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -17,3 +17,6 @@ gradlew text eol=lf
|
||||
*.png binary
|
||||
*.webp 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
|
||||
# broken build can't reach the deployable branch. Debug builds are auto-signed,
|
||||
# Gate every pull request into `main` or `edge` on lint + unit tests + a debug
|
||||
# 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
|
||||
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
||||
#
|
||||
@@ -18,9 +18,14 @@
|
||||
|
||||
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:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, edge]
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
|
||||
13
README.md
13
README.md
@@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host.
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
||||
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
||||
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
|
||||
attached to a Gitea release comes at M6.
|
||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
|
||||
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
|
||||
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
|
||||
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),
|
||||
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.
|
||||
@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
|
||||
import com.runicgateway.app.core.auth.sso.SsoAuthManager
|
||||
import com.runicgateway.app.core.push.PushNotifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.LifecycleResumeEffect
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.ui.AppViewModel
|
||||
import com.runicgateway.app.ui.AppViewModel.AppState
|
||||
@@ -30,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver
|
||||
import com.runicgateway.app.ui.RunicApp
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.connect.ConnectScreen
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.ui.theme.RunicGatewayTheme
|
||||
import com.runicgateway.app.ui.theme.parseBrandColor
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
@@ -39,8 +40,8 @@ import javax.inject.Inject
|
||||
/**
|
||||
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
|
||||
* connect screen until a shard site is configured (§3), then the main app.
|
||||
* The Material theme is seeded from the per-shard brand accent, and asset-path
|
||||
* resolution is provided to the whole tree.
|
||||
* The Material theme is resolved from the shard's published appearance (M12),
|
||||
* and asset-path resolution is provided to the whole tree.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@@ -57,9 +58,16 @@ class MainActivity : ComponentActivity() {
|
||||
// consumed once by RunicApp which navigates to the stream's screen.
|
||||
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?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
||||
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
|
||||
handleSsoCallback(intent)
|
||||
// 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.
|
||||
@@ -69,9 +77,21 @@ class MainActivity : ComponentActivity() {
|
||||
val appViewModel: AppViewModel = hiltViewModel()
|
||||
val state by appViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
|
||||
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
|
||||
// resolved token map is applied field by field over the shipped palette,
|
||||
// so NONE — before the site is connected, or when settings can't be
|
||||
// read — is the app exactly as it shipped.
|
||||
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
|
||||
|
||||
RunicGatewayTheme(accent = accent) {
|
||||
// The admin's theme and nav can change while the app is backgrounded
|
||||
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
|
||||
// re-validation RunicApp already does. Best-effort and silent.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
appViewModel.refreshAppearance()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
RunicGatewayTheme(appearance = appearance) {
|
||||
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -83,10 +103,14 @@ class MainActivity : ComponentActivity() {
|
||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||
is AppState.Ready ->
|
||||
RunicApp(
|
||||
brand = s.brand,
|
||||
appearance = s.appearance,
|
||||
onChangeServer = appViewModel::changeServer,
|
||||
deepLinkStream = pendingStream,
|
||||
onDeepLinkConsumed = { pendingStream = null },
|
||||
deepLinkRef = pendingRef,
|
||||
onDeepLinkConsumed = {
|
||||
pendingStream = null
|
||||
pendingRef = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +127,13 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,13 @@
|
||||
*/
|
||||
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.NotificationSubscriptionsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||
import retrofit2.http.Body
|
||||
@@ -13,10 +18,13 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
|
||||
* (endpoint) registration and per-user stream subscriptions. Every call rides the
|
||||
* The notification surface under `/auth/me` (PLAN.md §11): device (endpoint)
|
||||
* 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
|
||||
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
|
||||
* registration only ever succeeds while signed in.
|
||||
@@ -40,4 +48,41 @@ interface NotificationsApi {
|
||||
|
||||
@PUT("api/v1/auth/me/notifications/subscriptions")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -72,3 +72,139 @@ data class NotificationStreamsDto(
|
||||
data class NotificationSubscriptionsDto(
|
||||
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>,
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
/**
|
||||
* DTOs for the public site/identity endpoints. Shapes mirror the backend
|
||||
@@ -80,4 +81,28 @@ data class SettingsDto(
|
||||
val brand: BrandDto = BrandDto(),
|
||||
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
|
||||
val push: PushConfigDto = PushConfigDto(),
|
||||
/**
|
||||
* The admin's **resolved** theme tokens — the CSS custom properties the site
|
||||
* paints, already layered `:root ← preset ← custom` by the server
|
||||
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
|
||||
* means "the shipped defaults" and is the untouched-instance path.
|
||||
*
|
||||
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
|
||||
* purpose: a single unexpected value must not fail the decode of the whole
|
||||
* settings payload and take `brand` and `push` down with it. It is coerced
|
||||
* field-by-field by `SiteAppearance.from`.
|
||||
*
|
||||
* The raw `theme_visual` / `brand_assets` rows ride along in this same
|
||||
* response and are deliberately **not** modeled — they are inputs, and
|
||||
* re-deriving a palette from them would be a second `resolveThemeTokens` in
|
||||
* Kotlin, guaranteed to drift (§3).
|
||||
*/
|
||||
val theme: JsonElement? = null,
|
||||
/**
|
||||
* The public nav overrides, as the raw JSON **string** stored in
|
||||
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
|
||||
* client's `parseJsonSetting` does. Absent when the admin never edited the
|
||||
* nav.
|
||||
*/
|
||||
@SerialName("nav_public") val navPublic: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
||||
/**
|
||||
* Parse a JSON-valued settings row, client side — the second stage of decoding
|
||||
* `nav_public` (THEMING_AND_NAV.md §3).
|
||||
*
|
||||
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
|
||||
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
|
||||
* the row arrives as a **string inside** the already-decoded settings object,
|
||||
* and a malformed or wrong-shaped one must read as **absent** — the surface
|
||||
* falls back to the coded default — never as an error and never as a
|
||||
* half-applied object.
|
||||
*/
|
||||
private val settingsJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/**
|
||||
* @param raw the raw stored value, as it arrived in the settings payload
|
||||
* @return the parsed object, or null when absent/malformed
|
||||
*/
|
||||
fun parseJsonSetting(raw: String?): JsonObject? {
|
||||
if (raw.isNullOrEmpty()) return null
|
||||
val parsed = try {
|
||||
settingsJson.parseToJsonElement(raw)
|
||||
} catch (_: SerializationException) {
|
||||
return null
|
||||
}
|
||||
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
|
||||
// every consumer of these keys as a syntax error is.
|
||||
return parsed as? JsonObject
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* Everything the app renders itself with that the shard's admin controls
|
||||
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
|
||||
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
|
||||
* so the theme and the drawer can never disagree about which shard they are showing.
|
||||
*
|
||||
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
|
||||
* that predates the feature, and a settings call that failed outright are all the
|
||||
* same state here, and all three must render exactly as the app did before this
|
||||
* milestone existed (§2). That is why nothing on this class is nullable except
|
||||
* [brand], which was already nullable and whose absence already meant "use the
|
||||
* bundled strings".
|
||||
*/
|
||||
data class SiteAppearance(
|
||||
/** The per-shard branding block; null when settings couldn't be loaded. */
|
||||
val brand: BrandDto? = null,
|
||||
/**
|
||||
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
|
||||
* Empty means "the shipped defaults" — the server never emits an empty map,
|
||||
* but absent and empty are the same thing to the app and it must not depend
|
||||
* on that.
|
||||
*/
|
||||
val theme: Map<String, String> = emptyMap(),
|
||||
/**
|
||||
* The parsed `nav_public` row, or null when the admin never edited the nav.
|
||||
* Kept as the raw object here; reading `items` / `sections` / `links` out of
|
||||
* it is the job of the phases that render them.
|
||||
*/
|
||||
val navPublic: JsonObject? = null,
|
||||
) {
|
||||
companion object {
|
||||
/** The shipped app: no brand, no overrides. Also what a failed load means. */
|
||||
val NONE = SiteAppearance()
|
||||
|
||||
/**
|
||||
* Build the appearance from a `GET /public/settings` body. Forgiving
|
||||
* field by field (§2): a bad `--accent` must not discard a good `--bg`
|
||||
* beside it, and a malformed `nav_public` must not cost the theme.
|
||||
*/
|
||||
fun from(settings: SettingsDto?): SiteAppearance {
|
||||
if (settings == null) return NONE
|
||||
return SiteAppearance(
|
||||
brand = settings.brand,
|
||||
theme = themeTokens(settings.theme as? JsonObject),
|
||||
navPublic = parseJsonSetting(settings.navPublic),
|
||||
)
|
||||
}
|
||||
|
||||
// Every themable token is a string server-side (validated on write, and
|
||||
// resolveThemeTokens only ever copies a validated value). Anything else
|
||||
// is dropped rather than coerced, so an unexpected value costs exactly
|
||||
// its own token and the rest of the palette still applies.
|
||||
private fun themeTokens(raw: JsonObject?): Map<String, String> {
|
||||
if (raw.isNullOrEmpty()) return emptyMap()
|
||||
return buildMap {
|
||||
for ((token, value) in raw) {
|
||||
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
if (!text.isNullOrBlank()) put(token, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package com.runicgateway.app.data.repository
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
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.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
@@ -35,6 +36,7 @@ class AuthRepository @Inject constructor(
|
||||
private val ssoApi: SsoApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: PushManager,
|
||||
private val inboxCache: InboxCache,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
private val json: Json,
|
||||
@@ -183,6 +185,18 @@ class AuthRepository @Inject constructor(
|
||||
} catch (_: Exception) {
|
||||
// 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()
|
||||
try {
|
||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||
|
||||
@@ -6,16 +6,23 @@ 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.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.NotificationSubscriptionsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Device registration + per-user stream subscriptions over the opt-in push surface
|
||||
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
|
||||
* Device registration, stream subscriptions, per-channel preferences and the
|
||||
* 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`
|
||||
* (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> =
|
||||
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() }
|
||||
}
|
||||
|
||||
@@ -11,13 +11,16 @@ import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||
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.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
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
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StorageModule {
|
||||
@@ -38,4 +41,9 @@ abstract class StorageModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||
import com.runicgateway.app.data.repository.SettingsRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -20,8 +20,8 @@ import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
|
||||
* or the main UI shows, and holds the per-shard branding the theme is seeded
|
||||
* from. Activity-scoped so the whole app observes one state.
|
||||
* or the main UI shows, and holds the per-shard [SiteAppearance] the theme and
|
||||
* the drawer are built from. Activity-scoped so the whole app observes one state.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(
|
||||
@@ -38,8 +38,11 @@ class AppViewModel @Inject constructor(
|
||||
/** No shard site configured yet — show the connect screen. */
|
||||
data object NeedsConnection : AppState
|
||||
|
||||
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
|
||||
data class Ready(val brand: BrandDto?) : AppState
|
||||
/**
|
||||
* A site is configured. [appearance] is [SiteAppearance.NONE] when settings
|
||||
* couldn't be loaded — the shipped app, still fully usable (§2).
|
||||
*/
|
||||
data class Ready(val appearance: SiteAppearance) : AppState
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow<AppState>(AppState.Loading)
|
||||
@@ -48,7 +51,7 @@ class AppViewModel @Inject constructor(
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
_state.value = if (connectionRepository.restore()) {
|
||||
AppState.Ready(loadBrand())
|
||||
AppState.Ready(loadAppearance())
|
||||
} else {
|
||||
AppState.NeedsConnection
|
||||
}
|
||||
@@ -57,7 +60,30 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
/** Called by the connect screen once a site has been validated + saved. */
|
||||
fun onConnected() {
|
||||
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
|
||||
viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-read the appearance while the app is already running — on resume, beside
|
||||
* the session's own re-validation (§5.5). An admin who re-skins the site from
|
||||
* a laptop and picks the phone up should see it.
|
||||
*
|
||||
* Best-effort, and silent either way: a failed refresh **keeps the last good
|
||||
* appearance** rather than dropping back to the shipped one, so a moment of
|
||||
* no connectivity does not repaint a themed shard. There is no loading state
|
||||
* and no error surface. Ignored unless a site is configured.
|
||||
*/
|
||||
fun refreshAppearance() {
|
||||
if (_state.value !is AppState.Ready) return
|
||||
viewModelScope.launch {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
||||
pushManager.setNtfyUrl(settings.push.ntfyUrl)
|
||||
// changeServer() may have raced us back to the connect screen while the
|
||||
// call was in flight; don't resurrect Ready on top of it.
|
||||
if (_state.value is AppState.Ready) {
|
||||
_state.value = AppState.Ready(SiteAppearance.from(settings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Settings → Server switch: hard reset back to the connect screen (§3). */
|
||||
@@ -69,14 +95,14 @@ class AppViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public settings for branding and feed the shard's push relay URL into the
|
||||
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
|
||||
* Load public settings for the appearance and feed the shard's push relay URL into
|
||||
* the [PushManager] (§11) — its arrival is what lets push re-register after a restart
|
||||
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
||||
*/
|
||||
private suspend fun loadBrand(): BrandDto? {
|
||||
private suspend fun loadAppearance(): SiteAppearance {
|
||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
||||
return settings?.brand
|
||||
return SiteAppearance.from(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemColors
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -32,7 +35,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
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.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -48,24 +54,32 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.runicgateway.app.R
|
||||
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.appearance.SiteAppearance
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||
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.home.HomeScreen
|
||||
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.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.PostScreen
|
||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
||||
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
||||
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.player.CharacterSheetScreen
|
||||
import com.runicgateway.app.ui.player.CharactersScreen
|
||||
@@ -84,6 +98,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
|
||||
import com.runicgateway.app.ui.shard.RulesScreen
|
||||
import com.runicgateway.app.ui.shard.ShardBoard
|
||||
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.WikiScreen
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -109,13 +124,16 @@ private val TOP_LEVEL_ROUTES = setOf(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RunicApp(
|
||||
brand: BrandDto?,
|
||||
appearance: SiteAppearance,
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
deepLinkStream: String? = null,
|
||||
deepLinkRef: String? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val brand = appearance.brand
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -124,16 +142,26 @@ fun RunicApp(
|
||||
// What this shard publishes, independently of who the caller is (§5, M11).
|
||||
val shardFeatures by sessionViewModel.shardFeatures.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),
|
||||
// and re-read the unread count with it: a tickle that arrived while the app was
|
||||
// away is exactly what brings someone back to it.
|
||||
LifecycleResumeEffect(Unit) {
|
||||
sessionViewModel.revalidate()
|
||||
inboxBadgeViewModel.refresh()
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val unread by inboxBadgeViewModel.unread.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).
|
||||
LaunchedEffect(deepLinkStream) {
|
||||
LaunchedEffect(deepLinkStream, deepLinkRef) {
|
||||
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 }
|
||||
launchSingleTop = true
|
||||
}
|
||||
@@ -141,9 +169,37 @@ fun RunicApp(
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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(
|
||||
drawerState = drawerState,
|
||||
@@ -161,6 +217,14 @@ fun RunicApp(
|
||||
// unreachable. See RunicGateway M10.
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
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 = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
@@ -169,17 +233,34 @@ fun RunicApp(
|
||||
)
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
entries.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(stringResource(entry.labelRes)) },
|
||||
selected = currentRoute == entry.route,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateTopLevel(entry.route)
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
nav.forEach { node ->
|
||||
if (node is NavNode.Section) {
|
||||
// A group the admin created: its label as a header, its rows
|
||||
// beneath it. Always open — a drawer is already a vertical
|
||||
// list, so the website's dropdown does not translate (§6.3).
|
||||
Text(
|
||||
text = node.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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,
|
||||
) { openNode(child) }
|
||||
}
|
||||
} else {
|
||||
NavRow(node, currentRoute, drawerItemColors, unread = unread) { openNode(node) }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
@@ -203,6 +284,7 @@ fun RunicApp(
|
||||
}
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
shape = LocalShardStructure.current.pill,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
@@ -213,6 +295,7 @@ fun RunicApp(
|
||||
onChangeServer()
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
shape = LocalShardStructure.current.pill,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
@@ -230,13 +313,24 @@ fun RunicApp(
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = (brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name)).uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val name = brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name)
|
||||
// The logo stands in for the title here, so unlike the drawer's
|
||||
// it is named for a screen reader — and it falls back to the
|
||||
// text when the instance has no logo or the load fails (§5.6).
|
||||
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 = {
|
||||
if (isTopLevel) {
|
||||
@@ -267,6 +361,80 @@ 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,
|
||||
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
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
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
|
||||
private fun RunicNavHost(
|
||||
navController: NavHostController,
|
||||
@@ -284,7 +452,19 @@ private fun RunicNavHost(
|
||||
composable(Routes.HOME) {
|
||||
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 ->
|
||||
navController.navigate(Routes.post(category, idOrSlug))
|
||||
})
|
||||
@@ -399,9 +579,20 @@ private fun RunicNavHost(
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// 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) {
|
||||
is Session.SignedIn -> NotificationsScreen()
|
||||
is Session.SignedIn -> InboxScreen(
|
||||
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
|
||||
)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,6 +45,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -139,7 +139,7 @@ private fun PostsTab(
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { post ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(post.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
@@ -190,7 +190,7 @@ private fun WikiTab(
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.id }) { cat ->
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -38,6 +37,7 @@ 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 support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
|
||||
@@ -100,7 +100,7 @@ private fun SupportPageCard(
|
||||
onReply: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
|
||||
Text(
|
||||
|
||||
@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -107,7 +107,7 @@ fun AccountScreen(
|
||||
|
||||
@Composable
|
||||
private fun IdentityCard(username: String, roleLabel: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(20.dp)) {
|
||||
Text(text = username, style = MaterialTheme.typography.titleLarge)
|
||||
StatusPill(
|
||||
@@ -154,7 +154,7 @@ private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCode
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
@@ -38,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
|
||||
@@ -117,7 +117,7 @@ fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val joined = remember(codes) { codes.joinToString("\n") }
|
||||
|
||||
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
@@ -30,6 +29,7 @@ import com.runicgateway.app.data.api.dto.TrustedDeviceDto
|
||||
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
|
||||
|
||||
/**
|
||||
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
|
||||
@@ -108,7 +108,7 @@ fun TrustedDevicesScreen(
|
||||
|
||||
@Composable
|
||||
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
|
||||
@@ -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() }
|
||||
@@ -14,24 +14,22 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.runicgateway.app.ui.theme.ShardCardBottom
|
||||
import com.runicgateway.app.ui.theme.ShardCardTop
|
||||
import com.runicgateway.app.ui.theme.LocalShardPalette
|
||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||
import com.runicgateway.app.ui.theme.ShardDanger
|
||||
import com.runicgateway.app.ui.theme.ShardDangerBg
|
||||
import com.runicgateway.app.ui.theme.ShardElevated
|
||||
import com.runicgateway.app.ui.theme.ShardFaint
|
||||
import com.runicgateway.app.ui.theme.ShardOutline
|
||||
import com.runicgateway.app.ui.theme.ShardPillBg
|
||||
import com.runicgateway.app.ui.theme.ShardPillFg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccess
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessBg
|
||||
import com.runicgateway.app.ui.theme.ShardSuccessDot
|
||||
@@ -43,6 +41,14 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
|
||||
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
|
||||
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
|
||||
* no state, no data dependencies — so any screen can adopt them.
|
||||
*
|
||||
* This is the app's **only** file that reaches past `MaterialTheme` for a
|
||||
* themable value, so it is the one place M12 had to migrate: the surface, line
|
||||
* and accent tokens come from [LocalShardPalette] and the pill shape and card
|
||||
* depth from [LocalShardStructure], both following the shard's theme
|
||||
* (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants
|
||||
* stay imported directly — those are semantic and never themed, mirroring the
|
||||
* server's `FIXED_TOKENS`.
|
||||
*/
|
||||
|
||||
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
|
||||
@@ -50,21 +56,26 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
|
||||
|
||||
private data class PillColors(val fg: Color, val bg: Color)
|
||||
|
||||
@Composable
|
||||
private fun toneColors(tone: PillTone): PillColors = when (tone) {
|
||||
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
|
||||
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
|
||||
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
|
||||
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
|
||||
PillTone.Neutral, PillTone.Info ->
|
||||
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
|
||||
}
|
||||
|
||||
/**
|
||||
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
|
||||
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
|
||||
*
|
||||
* The one place `--radius-pill` lands: the app's other two [CircleShape] uses are
|
||||
* 8dp status dots, and a dot stays a dot however square the shard makes its site.
|
||||
*/
|
||||
@Composable
|
||||
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
|
||||
val c = toneColors(tone)
|
||||
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
|
||||
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
@@ -81,7 +92,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
|
||||
PillTone.Success -> ShardSuccessDot
|
||||
PillTone.Warning -> ShardWarning
|
||||
PillTone.Danger -> ShardDanger
|
||||
PillTone.Neutral, PillTone.Info -> ShardFaint
|
||||
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
|
||||
}
|
||||
Box(modifier.size(8.dp).clip(CircleShape).background(color))
|
||||
}
|
||||
@@ -95,7 +106,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = ShardFaint,
|
||||
color = LocalShardPalette.current.faint,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
@@ -104,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
|
||||
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
|
||||
* soft shadow, used for the home status card, the shard-online banner, and the
|
||||
* vendor card. [content] is laid out in a padded [Column].
|
||||
*
|
||||
* The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was
|
||||
* built with — the same value, now following `--radius-card`'s ratio (§5.2). The
|
||||
* shadow this doc always claimed is finally drawn, at the depth `--shadow-card`
|
||||
* resolves to (§5.4).
|
||||
*/
|
||||
@Composable
|
||||
fun FeatureCard(
|
||||
@@ -111,17 +127,40 @@ fun FeatureCard(
|
||||
contentPadding: Int = 18,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val palette = LocalShardPalette.current
|
||||
val shape = MaterialTheme.shapes.medium
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
|
||||
.shadow(LocalShardStructure.current.cardElevation, shape)
|
||||
.clip(shape)
|
||||
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
|
||||
.border(1.dp, palette.outline, shape),
|
||||
) {
|
||||
Column(Modifier.padding(contentPadding.dp), content = content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Material [Card] at the shard's resolved depth — the app's standard card, and
|
||||
* the reason every screen's `Card(` became a `ShardCard(`.
|
||||
*
|
||||
* `Card` takes its elevation as a **default argument**, not from the theme, so
|
||||
* unlike the color scheme and the shape scale there is no way to make
|
||||
* `--shadow-card` reach ~24 call sites without a wrapper. Passing
|
||||
* [CardDefaults.cardElevation] at each site instead would have put the same line
|
||||
* in eighteen files and let one drift. A `Card(` outside this file is therefore a
|
||||
* card the shard cannot theme, which makes the invariant greppable.
|
||||
*/
|
||||
@Composable
|
||||
fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
|
||||
* the slate accent over a bordered dark track.
|
||||
@@ -129,13 +168,14 @@ fun FeatureCard(
|
||||
@Composable
|
||||
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
|
||||
val pct = fraction.coerceIn(0f, 1f)
|
||||
val palette = LocalShardPalette.current
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(6.dp)
|
||||
.clip(RoundedCornerShape(3.dp))
|
||||
.background(ShardElevated)
|
||||
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
|
||||
.background(palette.elevated)
|
||||
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
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.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
@@ -59,6 +60,12 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.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 = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
|
||||
@@ -52,6 +52,15 @@ data class MenuEntry(
|
||||
* isn't shard-derived and only [access] applies.
|
||||
*/
|
||||
val feature: 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,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -99,14 +108,26 @@ fun visibleEntries(
|
||||
entries: List<MenuEntry>,
|
||||
session: Session,
|
||||
features: ShardFeatures? = null,
|
||||
): List<MenuEntry> =
|
||||
entries.filter { entry ->
|
||||
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
|
||||
}
|
||||
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
||||
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
|
||||
|
||||
/**
|
||||
* [visibleEntries] for a single entry — the same two filters, and the same
|
||||
* boundary. Split out because the drawer is a tree once an admin groups rows into
|
||||
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
|
||||
* both callers must ask exactly one question or a sectioned row could be gated by
|
||||
* a rule its top-level twin is not.
|
||||
*/
|
||||
fun isEntryVisible(
|
||||
entry: MenuEntry,
|
||||
session: Session,
|
||||
features: ShardFeatures? = 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 && (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
|
||||
}
|
||||
}
|
||||
}
|
||||
220
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
220
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
|
||||
/**
|
||||
* 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 array quoted right beside it — the coupling is visible and
|
||||
* reviewable in one place rather than spread across the drawer's call sites.
|
||||
*
|
||||
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
|
||||
* exported owner of the 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: 'Screenshots', to: '/site/screenshots' },
|
||||
* { label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
* { label: 'Newsletter', to: '/site/newsletter' },
|
||||
* { label: 'Wiki', to: '/wiki' },
|
||||
* { label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||
* { label: 'Market', to: '/site/market', feature: 'market' },
|
||||
* { label: 'About', to: '/site/about' },
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* 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`.
|
||||
*/
|
||||
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
||||
WebNavPath("/", Routes.HOME),
|
||||
WebNavPath("/site/news", Routes.NEWS),
|
||||
// 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/shard", Routes.SHARD),
|
||||
// Behind the Shard hub in the app, deliberately — no drawer row either.
|
||||
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
|
||||
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
|
||||
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
|
||||
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
|
||||
WebNavPath("/site/rules", Routes.SHARD_RULES),
|
||||
WebNavPath("/site/atlas", Routes.ATLAS),
|
||||
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||
WebNavPath("/site/market", Routes.SHARD_MARKET),
|
||||
WebNavPath("/site/about", Routes.page("about")),
|
||||
)
|
||||
|
||||
/**
|
||||
* 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",
|
||||
)
|
||||
|
||||
/**
|
||||
* 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/about" element={<About />} />
|
||||
* <Route path="/site/status" element={<Status />} />
|
||||
* <Route path="/site/shard" element={<Shard />} />
|
||||
* <Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
|
||||
* <Route path="/site/atlas" element={<Atlas />} />
|
||||
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||
* <Route path="/wiki" element={<Wiki />} />
|
||||
* <Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
* // 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)
|
||||
* /wiki → WIKI
|
||||
* /wiki/<slug> → WIKI_PAGE
|
||||
* /site/<shard surface> → the mapped shard route (§6.2)
|
||||
* /site/atlas/<slug> → ATLAS_CREATURE
|
||||
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /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**, whatever its route part
|
||||
* says. No app route takes either, so a native match would quietly drop what the
|
||||
* admin wrote; the browser honors it exactly.
|
||||
*
|
||||
* 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 normalized = normalizeWebPath(path) ?: return null
|
||||
if (normalized.any { it == '?' || it == '#' }) return null
|
||||
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
|
||||
|
||||
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" -> null
|
||||
segments.size == 3 && segments[1] == "newsletter" ->
|
||||
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
|
||||
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
|
||||
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
|
||||
Routes.marketVendor(segments[3])
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
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,8 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
|
||||
/**
|
||||
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||
* plain strings for Navigation-Compose; argument-bearing routes expose a
|
||||
@@ -14,6 +16,19 @@ object Routes {
|
||||
const val WIKI = "wiki"
|
||||
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). */
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
@@ -22,8 +37,15 @@ object Routes {
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
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_SETTINGS = "notifications/settings"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
@@ -79,6 +101,13 @@ object Routes {
|
||||
|
||||
fun page(slug: String) = "page/$slug"
|
||||
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"
|
||||
|
||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||
@@ -108,4 +137,23 @@ object Routes {
|
||||
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
||||
else -> HOME
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:"
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ 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.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
@@ -29,6 +28,7 @@ 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
|
||||
|
||||
/** News hub with category tabs and a post list (PLAN.md §6.1). */
|
||||
@Composable
|
||||
@@ -82,7 +82,7 @@ private fun PostList(
|
||||
|
||||
@Composable
|
||||
private fun PostRow(post: PostDto, onClick: () -> Unit) {
|
||||
Card(
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.news
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
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
|
||||
@@ -21,9 +23,15 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class NewsViewModel @Inject constructor(
|
||||
private val contentRepository: ContentRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : 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()
|
||||
|
||||
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,53 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
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.** Express serializes a `Date` to ISO-8601 with a `Z`, but the value
|
||||
* starts life as a MariaDB `DATETIME`, and a column 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 — that is what the server stores — rather than as local time, which
|
||||
* would silently shift every timestamp by the device's offset.
|
||||
*
|
||||
* 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 = parseInstant(raw) ?: return null
|
||||
return try {
|
||||
formatter.withZone(zone).format(instant)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseInstant(raw: String): Instant? {
|
||||
val text = raw.trim()
|
||||
if (text.isEmpty()) return null
|
||||
return try {
|
||||
Instant.parse(text)
|
||||
} catch (_: Exception) {
|
||||
try {
|
||||
// No zone on the wire → UTC, because that is what the server stored.
|
||||
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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,
|
||||
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,
|
||||
// so the view model resolves it against the configured shard
|
||||
// before anything is opened.
|
||||
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,241 @@
|
||||
/*
|
||||
* 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.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 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.
|
||||
*/
|
||||
@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 {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,271 @@
|
||||
/*
|
||||
* 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, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = 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 ->
|
||||
val selectable = itemSelectable(item, hasLinkedAccount)
|
||||
ItemRow(
|
||||
item = item,
|
||||
channelsById = channelsById,
|
||||
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
|
||||
enabled = !busy && selectable,
|
||||
pushSupported = pushSupported,
|
||||
onSetMode = onSetMode,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemRow(
|
||||
item: NotificationChannelItemDto,
|
||||
channelsById: Map<String, NotificationChannelDto>,
|
||||
hint: String?,
|
||||
enabled: Boolean,
|
||||
pushSupported: Boolean,
|
||||
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = hint ?: item.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = if (hint != null) 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
|
||||
ChannelControl(
|
||||
channel = channel,
|
||||
mode = item.modes[channelId] ?: channel.defaultMode,
|
||||
enabled = enabled,
|
||||
onSetMode = { mode -> onSetMode(item, channelId, mode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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,152 @@
|
||||
/*
|
||||
* 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.NotificationChannelItemDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
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
|
||||
|
||||
/** 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 playerShard: PlayerShardRepository,
|
||||
private val pushManager: PushManager,
|
||||
) : 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 — 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(prefs = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
||||
// 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) }
|
||||
|
||||
/**
|
||||
* 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 (channel == CHANNEL_PUSH && !itemSelectable(item, 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 an item's controls are 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 itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||
!item.requiresLinkedAccount || 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
|
||||
@@ -13,7 +13,6 @@ 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.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,6 +36,7 @@ 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.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatBar
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
@@ -289,7 +289,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
|
||||
@Composable
|
||||
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
|
||||
@@ -13,7 +13,6 @@ 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.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -39,6 +38,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -90,7 +90,7 @@ fun CharactersScreen(
|
||||
@Composable
|
||||
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var code by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
@@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView
|
||||
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
|
||||
var account by rememberSaveable { mutableStateOf("") }
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
@@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
|
||||
Card(
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -28,6 +27,7 @@ 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 player's own houses with home/decay status (PLAN.md §6.3), text-only. An
|
||||
@@ -60,7 +60,7 @@ fun MyHousesScreen(
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: PlayerHouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind
|
||||
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
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
@@ -79,7 +79,7 @@ fun VendorsScreen(
|
||||
|
||||
@Composable
|
||||
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
|
||||
when (sales) {
|
||||
@@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
|
||||
|
||||
@Composable
|
||||
private fun VendorCard(vendor: VendorDto) {
|
||||
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),
|
||||
|
||||
@@ -16,7 +16,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -41,6 +40,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -95,7 +95,7 @@ fun AtlasScreen(
|
||||
@Composable
|
||||
private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) {
|
||||
val slug = creature.slug
|
||||
Card(
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier),
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@@ -44,7 +44,7 @@ fun ChampsScreen(
|
||||
|
||||
@Composable
|
||||
private fun ChampCard(champ: ChampDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -12,7 +12,6 @@ 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.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -33,6 +32,7 @@ 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 town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
|
||||
@Composable
|
||||
@@ -84,7 +84,7 @@ private fun CityCard(
|
||||
onExpand: () -> Unit,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
Column(
|
||||
Modifier
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
|
||||
@Composable
|
||||
@@ -42,7 +42,7 @@ fun GuildsScreen(
|
||||
|
||||
@Composable
|
||||
private fun GuildCard(guild: GuildDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
|
||||
@@ -44,7 +44,7 @@ fun HousesScreen(
|
||||
|
||||
@Composable
|
||||
private fun HouseCard(house: HouseDto) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
|
||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -26,6 +25,7 @@ import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.PointsEntryDto
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via
|
||||
@@ -52,7 +52,7 @@ fun LeaderboardsScreen(
|
||||
|
||||
@Composable
|
||||
private fun BoardCard(board: PointsBoardDto, placeholderName: String) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,6 +36,7 @@ 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
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock.
|
||||
@@ -99,7 +99,7 @@ fun MarketScreen(
|
||||
@Composable
|
||||
private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) {
|
||||
val vendorSerial = listing.vendor?.serial
|
||||
Card(
|
||||
ShardCard(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier),
|
||||
|
||||
@@ -12,7 +12,6 @@ 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.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -30,6 +29,7 @@ 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
|
||||
|
||||
/**
|
||||
@@ -167,7 +167,7 @@ private fun CapsCard(caps: RulesetCapsDto) {
|
||||
|
||||
@Composable
|
||||
private fun RuleCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
content()
|
||||
|
||||
@@ -12,7 +12,6 @@ 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.Card
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
@@ -37,6 +36,7 @@ import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.canSee
|
||||
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
|
||||
|
||||
/**
|
||||
@@ -193,7 +193,7 @@ private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Un
|
||||
}
|
||||
}
|
||||
if (boards.isEmpty()) return
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
boards.forEachIndexed { index, (board, labelRes) ->
|
||||
Text(
|
||||
|
||||
@@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color
|
||||
* without the leading `#`) into a Compose [Color]. Returns null for anything
|
||||
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
|
||||
* Pure logic — covered by JVM unit tests.
|
||||
*
|
||||
* Also the parser for every color token in the shard's resolved theme map
|
||||
* ([ShardPalette.resolve], M12): the server validates those as `#RGB` or
|
||||
* `#RRGGBB` on write, and a null here is what makes a token that slipped
|
||||
* through anyway cost only itself.
|
||||
*/
|
||||
fun parseBrandColor(hex: String?): Color? {
|
||||
if (hex.isNullOrBlank()) return null
|
||||
|
||||
@@ -6,35 +6,117 @@ package com.runicgateway.app.ui.theme
|
||||
import androidx.compose.ui.text.ExperimentalTextApi
|
||||
import androidx.compose.ui.text.font.Font
|
||||
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.FontWeight
|
||||
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,
|
||||
* and the top-bar title. Shipped as a single weight-axis **variable** font
|
||||
* (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`);
|
||||
* the 500/600/700 instances the design uses are pinned via [FontVariation]
|
||||
* (supported on API 26+, and our minSdk is 29).
|
||||
* - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif,
|
||||
* 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).
|
||||
* All eight webfonts are **bundled**, not downloadable: the Play Store font
|
||||
* provider is the only downloadable-font source Compose ships with, so a
|
||||
* de-Googled device would silently fall back and every text style would gain an
|
||||
* async loading state. The binaries are taken verbatim from `google/fonts`, which
|
||||
* is how [Cinzel] arrived in M5; each carries its SIL OFL licence under
|
||||
* `app/licenses/`, **never** under `res/font/` (aapt rejects a `.txt` there).
|
||||
*
|
||||
* 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)
|
||||
private fun cinzel(weight: FontWeight) =
|
||||
private fun variable(resId: Int, weight: FontWeight, style: FontStyle = FontStyle.Normal) =
|
||||
Font(
|
||||
R.font.cinzel_variable,
|
||||
resId,
|
||||
weight = weight,
|
||||
style = style,
|
||||
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(
|
||||
cinzel(FontWeight.Medium), // 500
|
||||
cinzel(FontWeight.SemiBold), // 600
|
||||
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
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
126
app/src/main/java/com/runicgateway/app/ui/theme/ShardPalette.kt
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* The shard's resolved color palette — the fifteen themable tokens of
|
||||
* `GET /public/settings`' `theme` map, parsed into Compose colors
|
||||
* (THEMING_AND_NAV.md §5.1).
|
||||
*
|
||||
* **The default value of every field is the shipped constant from
|
||||
* [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette
|
||||
* *is* the website's `runic-gateway` preset, value for value, because both were
|
||||
* drawn from the same `theme.css`. So [Shipped] renders exactly as the app did
|
||||
* before this milestone, and an instance with no `theme_visual` row resolves
|
||||
* back to it token by token (§2, AC-1).
|
||||
*
|
||||
* The palette has two consumers and one resolution: ten of the fifteen tokens
|
||||
* have a Material role and are fed into the [androidx.compose.material3.ColorScheme]
|
||||
* by [shardColorScheme]; the other five have none, and reach the screens that
|
||||
* need them through [LocalShardPalette].
|
||||
*/
|
||||
@Immutable
|
||||
data class ShardPalette(
|
||||
/** `--bg-deep` — the page behind everything. */
|
||||
val page: Color = ShardPage,
|
||||
/** `--bg` — the screen background. */
|
||||
val surface: Color = ShardSurface,
|
||||
/** `--panel-flat` — top bar, inputs, drawer, list tracks. */
|
||||
val elevated: Color = ShardElevated,
|
||||
/** `--panel-a` — feature-card gradient, top. No Material role. */
|
||||
val cardTop: Color = ShardCardTop,
|
||||
/** `--panel-b` — feature-card gradient, bottom. No Material role. */
|
||||
val cardBottom: Color = ShardCardBottom,
|
||||
/** `--line` — borders and input outlines. */
|
||||
val outline: Color = ShardOutline,
|
||||
/** `--line-soft` — hairline row dividers. */
|
||||
val divider: Color = ShardDivider,
|
||||
/** `--ink` — the brightest headings. No Material role. */
|
||||
val heading: Color = ShardHeading,
|
||||
/** `--head` — heading on a surface. No Material role. */
|
||||
val headingDim: Color = ShardHeadingDim,
|
||||
/** `--text` — body copy. */
|
||||
val body: Color = ShardBody,
|
||||
/** `--muted` — secondary text. */
|
||||
val muted: Color = ShardMuted,
|
||||
/** `--dim` — meta and faint labels. No Material role. */
|
||||
val faint: Color = ShardFaint,
|
||||
/** `--accent` — links and secondary highlights. */
|
||||
val accent: Color = ShardAccent,
|
||||
/** `--accent-bright` — the filled CTA surface. */
|
||||
val cta: Color = ShardCta,
|
||||
/** `--blue` — the neutral/info pill background. */
|
||||
val pillBg: Color = ShardPillBg,
|
||||
) {
|
||||
/**
|
||||
* Text drawn on the [cta] fill. **Derived, never themed** — it tracks
|
||||
* `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a
|
||||
* literal (§5.1). A value expressed in terms of another token must follow
|
||||
* it, or a future light preset inherits a dark one and looks broken.
|
||||
*/
|
||||
val onCta: Color get() = page
|
||||
|
||||
/**
|
||||
* The neutral/info pill's foreground. Also derived: `ShardPillFg` and
|
||||
* `ShardCta` are the same `--accent-bright` value, so the pill's text
|
||||
* follows the CTA fill rather than being a sixteenth token the contract
|
||||
* does not have.
|
||||
*/
|
||||
val pillFg: Color get() = cta
|
||||
|
||||
companion object {
|
||||
/** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */
|
||||
val Shipped = ShardPalette()
|
||||
|
||||
/**
|
||||
* Resolve a `theme` token map into a palette, **field by field** (§2).
|
||||
* A token that is missing, blank or unparseable falls back to its
|
||||
* shipped value on its own; a bad `--accent` must never discard a good
|
||||
* `--bg` beside it (AC-2).
|
||||
*
|
||||
* [brandAccent] is the pre-feature branding path and must keep working:
|
||||
* an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row
|
||||
* still tints its links and highlights. It seeds `--accent` only — the
|
||||
* server resolves `brand.accent` as `theme['--accent'] || env`, so the
|
||||
* token always wins where both exist.
|
||||
*/
|
||||
fun resolve(theme: Map<String, String>, brandAccent: Color? = null): ShardPalette {
|
||||
if (theme.isEmpty() && brandAccent == null) return Shipped
|
||||
fun token(name: String, shipped: Color): Color =
|
||||
parseBrandColor(theme[name]) ?: shipped
|
||||
return ShardPalette(
|
||||
page = token("--bg-deep", ShardPage),
|
||||
surface = token("--bg", ShardSurface),
|
||||
elevated = token("--panel-flat", ShardElevated),
|
||||
cardTop = token("--panel-a", ShardCardTop),
|
||||
cardBottom = token("--panel-b", ShardCardBottom),
|
||||
outline = token("--line", ShardOutline),
|
||||
divider = token("--line-soft", ShardDivider),
|
||||
heading = token("--ink", ShardHeading),
|
||||
headingDim = token("--head", ShardHeadingDim),
|
||||
body = token("--text", ShardBody),
|
||||
muted = token("--muted", ShardMuted),
|
||||
faint = token("--dim", ShardFaint),
|
||||
accent = token("--accent", brandAccent ?: ShardAccent),
|
||||
cta = token("--accent-bright", ShardCta),
|
||||
pillBg = token("--blue", ShardPillBg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live palette, for the five tokens with no Material role and for the
|
||||
* components that draw the card gradient. Everything that *can* go through
|
||||
* `MaterialTheme.colorScheme` still should — this is the escape hatch, not the
|
||||
* front door.
|
||||
*
|
||||
* Defaulted to [ShardPalette.Shipped] so previews and any composable outside
|
||||
* [RunicGatewayTheme] still draw the shipped palette rather than crashing.
|
||||
*/
|
||||
val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped }
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The shard's resolved corner radii and card depth — the `structure` half of the
|
||||
* admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to
|
||||
* [ShardPalette].
|
||||
*
|
||||
* **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came
|
||||
* from the M5 mockup and the website's from `theme.css`; the two scales genuinely
|
||||
* differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in
|
||||
* would restyle an untouched app the day this milestone shipped, so each field is
|
||||
* scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped
|
||||
* theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every
|
||||
* field and is a provable no-op (§2, AC-1).
|
||||
*
|
||||
* Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation].
|
||||
*/
|
||||
@Immutable
|
||||
data class ShardStructure(
|
||||
/** The Material shape scale, ratio-scaled off the app's own shipped dp values. */
|
||||
val shapes: Shapes = ShippedShapes,
|
||||
/**
|
||||
* `--radius-pill`. Not part of [shapes]: the app draws its chips with
|
||||
* [CircleShape], which is a percentage and so has no dp for a ratio to scale.
|
||||
* Resolved as a literal instead — the only rule available — see [pillShape].
|
||||
*/
|
||||
val pill: Shape = CircleShape,
|
||||
/** `--shadow-card`, mapped onto Material elevation (§5.4). */
|
||||
val cardElevation: Dp = ShippedCardElevation,
|
||||
) {
|
||||
companion object {
|
||||
/** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */
|
||||
val Shipped = ShardStructure()
|
||||
|
||||
/**
|
||||
* Resolve a `theme` token map into a structure, **field by field** (§2):
|
||||
* a `--radius-panel` the server never validated must not cost the
|
||||
* `--radius-card` beside it, exactly as in [ShardPalette.resolve].
|
||||
*/
|
||||
fun resolve(theme: Map<String, String>): ShardStructure {
|
||||
if (theme.isEmpty()) return Shipped
|
||||
val input = ratio(theme["--radius-input"], BaseInputPx)
|
||||
val card = ratio(theme["--radius-card"], BaseCardPx)
|
||||
val panel = ratio(theme["--radius-panel"], BasePanelPx)
|
||||
return ShardStructure(
|
||||
shapes = Shapes(
|
||||
extraSmall = corner(ShippedExtraSmallDp, input),
|
||||
small = corner(ShippedSmallDp, input),
|
||||
medium = corner(ShippedMediumDp, card),
|
||||
// extraLarge has no web counterpart and follows the panel
|
||||
// ratio, since it is the panel family.
|
||||
large = corner(ShippedLargeDp, panel),
|
||||
extraLarge = corner(ShippedExtraLargeDp, panel),
|
||||
),
|
||||
pill = pillShape(theme["--radius-pill"]),
|
||||
cardElevation = elevation(theme["--shadow-card"]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The live structure, for the two things Material's theme cannot carry: the pill
|
||||
* shape, and a card elevation ([androidx.compose.material3.Card] takes its
|
||||
* elevation as a default argument, not from a composition local). The shape
|
||||
* scale itself reaches screens through `MaterialTheme.shapes` and needs nothing
|
||||
* here.
|
||||
*/
|
||||
val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped }
|
||||
|
||||
// ── the shipped scale ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The app's own dp values, which the ratios scale. Kept here rather than in
|
||||
// Theme.kt so the resolution and the thing it resolves back to sit together.
|
||||
|
||||
private const val ShippedExtraSmallDp = 8
|
||||
private const val ShippedSmallDp = 8
|
||||
private const val ShippedMediumDp = 12
|
||||
private const val ShippedLargeDp = 16
|
||||
private const val ShippedExtraLargeDp = 24
|
||||
|
||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||
internal val ShippedShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp),
|
||||
small = RoundedCornerShape(ShippedSmallDp.dp),
|
||||
medium = RoundedCornerShape(ShippedMediumDp.dp),
|
||||
large = RoundedCornerShape(ShippedLargeDp.dp),
|
||||
extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* The depth an unthemed instance draws its cards at.
|
||||
*
|
||||
* **This is the one field of this milestone that is deliberately not a no-op.**
|
||||
* The app has been flat since M5 — Material's filled `Card` is `Level0` and
|
||||
* `FeatureCard` never had the shadow its own docs claimed — while the
|
||||
* `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is
|
||||
* applied as written rather than rebased on the app's flat baseline, so every
|
||||
* card gains this depth and the admin's four-step control reads the same on the
|
||||
* phone as on the web. Approved by the org lead as an amendment to §2.
|
||||
*/
|
||||
private val ShippedCardElevation = 4.dp
|
||||
|
||||
// ── the runic-gateway baselines ───────────────────────────────────────────
|
||||
//
|
||||
// The preset the app's own scale corresponds to (server/src/config/themePresets.js).
|
||||
// A resolved value is meaningful only against these: the ratio, not the number,
|
||||
// is what crosses from the web scale to the app's.
|
||||
|
||||
private const val BaseInputPx = 8f
|
||||
private const val BaseCardPx = 10f
|
||||
private const val BasePanelPx = 12f
|
||||
private const val BasePillPx = 999f
|
||||
|
||||
/**
|
||||
* Below half the pill baseline the chip stops reading as a pill and becomes a
|
||||
* rounded rectangle, so an admin who squares the site off squares off the app's
|
||||
* chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s
|
||||
* 999px does not.
|
||||
*/
|
||||
private const val PillCircleFloorPx = BasePillPx / 2f
|
||||
|
||||
// A radius as the server writes it: an integer count of px, 0..999, always with
|
||||
// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value
|
||||
// this app can scale, and falls back to the shipped dp on its own.
|
||||
private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""")
|
||||
|
||||
// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no
|
||||
// unit, so the blur is the second px length.
|
||||
private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""")
|
||||
|
||||
/**
|
||||
* `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact
|
||||
* string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`,
|
||||
* but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value
|
||||
* `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass
|
||||
* through that dropdown. An exact match would have missed the one preset whose
|
||||
* point is a heavier shadow. Matching the blur puts any future preset on the
|
||||
* nearest step instead of silently on the default.
|
||||
*/
|
||||
private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp)
|
||||
|
||||
private fun parseRadiusPx(raw: String?): Float? =
|
||||
raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull()
|
||||
|
||||
private fun ratio(raw: String?, baselinePx: Float): Float =
|
||||
parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f
|
||||
|
||||
/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */
|
||||
private fun corner(shippedDp: Int, ratio: Float) =
|
||||
RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp)
|
||||
|
||||
private fun pillShape(raw: String?): Shape {
|
||||
val px = parseRadiusPx(raw) ?: return CircleShape
|
||||
return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp)
|
||||
}
|
||||
|
||||
private fun elevation(raw: String?): Dp {
|
||||
val value = raw?.trim() ?: return ShippedCardElevation
|
||||
if (value.equals("none", ignoreCase = true)) return 0.dp
|
||||
val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull()
|
||||
?.groupValues?.get(1)?.toFloatOrNull()
|
||||
?: return ShippedCardElevation
|
||||
return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation
|
||||
}
|
||||
@@ -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()]
|
||||
}
|
||||
@@ -3,78 +3,100 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.remember
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
|
||||
/**
|
||||
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
|
||||
* design is a single deep blue-black theme, so there is no light variant and the
|
||||
* system light/dark setting is intentionally ignored. Material roles are mapped
|
||||
* onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on
|
||||
* the theme without per-screen color work.
|
||||
* Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md
|
||||
* §5.1). The app is **dark-only** — the design is a single deep blue-black
|
||||
* theme, so there is no light variant and the system light/dark setting is
|
||||
* intentionally ignored; every v1 preset on the website is dark too.
|
||||
*
|
||||
* Ten of the palette's fifteen tokens land here, which is why the ~20
|
||||
* token-based screens take on a shard's theme with no per-screen color work.
|
||||
* Pure, so the no-op proof (AC-1) can assert on it directly.
|
||||
*/
|
||||
private val ShardColorScheme = darkColorScheme(
|
||||
primary = ShardCta, // filled CTA buttons
|
||||
onPrimary = ShardOnCta,
|
||||
secondary = ShardAccent, // links / secondary highlights
|
||||
onSecondary = ShardOnCta,
|
||||
tertiary = ShardAccent,
|
||||
onTertiary = ShardOnCta,
|
||||
background = ShardPage,
|
||||
onBackground = ShardBody,
|
||||
surface = ShardSurface,
|
||||
onSurface = ShardBody,
|
||||
surfaceVariant = ShardElevated,
|
||||
onSurfaceVariant = ShardMuted,
|
||||
surfaceContainer = ShardElevated,
|
||||
surfaceContainerHigh = ShardElevated,
|
||||
surfaceContainerLow = ShardSurface,
|
||||
outline = ShardOutline,
|
||||
outlineVariant = ShardDivider,
|
||||
secondaryContainer = ShardPillBg, // neutral chips / selected drawer item
|
||||
onSecondaryContainer = ShardPillFg,
|
||||
internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme(
|
||||
primary = palette.cta, // filled CTA buttons
|
||||
onPrimary = palette.onCta,
|
||||
secondary = palette.accent, // links / secondary highlights
|
||||
onSecondary = palette.onCta,
|
||||
tertiary = palette.accent,
|
||||
onTertiary = palette.onCta,
|
||||
background = palette.page,
|
||||
onBackground = palette.body,
|
||||
surface = palette.surface,
|
||||
onSurface = palette.body,
|
||||
surfaceVariant = palette.elevated,
|
||||
onSurfaceVariant = palette.muted,
|
||||
surfaceContainer = 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,
|
||||
surfaceContainerLowest = palette.surface,
|
||||
outline = palette.outline,
|
||||
outlineVariant = palette.divider,
|
||||
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
|
||||
onSecondaryContainer = palette.pillFg,
|
||||
// Semantic, never themed — mirrors the server's FIXED_TOKENS (§4).
|
||||
error = ShardDanger,
|
||||
onError = ShardOnCta,
|
||||
onError = palette.onCta,
|
||||
errorContainer = ShardDangerBg,
|
||||
onErrorContainer = ShardDanger,
|
||||
)
|
||||
|
||||
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
|
||||
private val ShardShapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
/**
|
||||
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
|
||||
* publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
|
||||
* secondary roles so buttons and highlights carry that shard's color while the rest
|
||||
* of the deep blue-black system stays intact. With no accent, the slate default is
|
||||
* used.
|
||||
* App theme, themed by the shard (M12). [appearance] carries the resolved token
|
||||
* map the admin's Appearance page publishes; it is applied field by field over
|
||||
* the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings
|
||||
* rows, a backend that predates the feature, or a settings call that failed —
|
||||
* renders as the app did before this milestone (§2), the one exception being the
|
||||
* card depth [ShardStructure] documents.
|
||||
*
|
||||
* The palette reaches screens two ways: through [MaterialTheme]'s color scheme
|
||||
* 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
|
||||
* scale for everything Material draws, [LocalShardStructure] for the pill and
|
||||
* 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
|
||||
fun RunicGatewayTheme(
|
||||
accent: Color? = null,
|
||||
appearance: SiteAppearance = SiteAppearance.NONE,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colorScheme = if (accent != null) {
|
||||
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
|
||||
} else {
|
||||
ShardColorScheme
|
||||
val palette = remember(appearance) {
|
||||
ShardPalette.resolve(
|
||||
theme = appearance.theme,
|
||||
brandAccent = parseBrandColor(appearance.brand?.accent),
|
||||
)
|
||||
}
|
||||
val colorScheme = remember(palette) { shardColorScheme(palette) }
|
||||
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
|
||||
val typeface = remember(appearance) { ShardTypeface.resolve(appearance.theme) }
|
||||
val typography = remember(typeface) { shardTypography(typeface) }
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = ShardShapes,
|
||||
content = content,
|
||||
)
|
||||
CompositionLocalProvider(
|
||||
LocalShardPalette provides palette,
|
||||
LocalShardStructure provides structure,
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = typography,
|
||||
shapes = structure.shapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,73 +9,82 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
/**
|
||||
* The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]):
|
||||
* [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif)
|
||||
* for parchment body copy, and [AppSans] for the letter-spaced label/meta/button
|
||||
* roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup.
|
||||
* The M5 type scale, drawn in whichever three families the shard resolved to
|
||||
* (THEMING_AND_NAV.md §5.3).
|
||||
*
|
||||
* 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(
|
||||
// Display / headline / title — Cinzel engraved serif
|
||||
internal fun shardTypography(faces: ShardTypeface) = Typography(
|
||||
// Display / headline / title — the engraved serif role
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
|
||||
),
|
||||
// Body — parchment serif
|
||||
// Body — the parchment serif role
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
||||
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
||||
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
|
||||
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
|
||||
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(
|
||||
fontFamily = AppSans, fontWeight = FontWeight.Bold,
|
||||
fontFamily = faces.sans, fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
||||
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = AppSans, fontWeight = FontWeight.Medium,
|
||||
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
@@ -30,6 +29,7 @@ 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
|
||||
|
||||
/** Wiki index: search field + page list (PLAN.md §6.1). */
|
||||
@Composable
|
||||
@@ -72,7 +72,7 @@ fun WikiScreen(
|
||||
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
|
||||
items(pages, key = { it.id }) { page ->
|
||||
Card(
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
|
||||
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,6 +37,8 @@
|
||||
|
||||
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
||||
<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_news">News</string>
|
||||
<string name="menu_wiki">Wiki</string>
|
||||
@@ -482,7 +484,7 @@
|
||||
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
||||
<string name="menu_notifications">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_personal">Your game account</string>
|
||||
<string name="notifications_requires_link">Link a game account to enable this.</string>
|
||||
@@ -493,6 +495,18 @@
|
||||
<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>
|
||||
|
||||
<!-- 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. -->
|
||||
<string name="push_channel_messages">Shard notifications</string>
|
||||
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
||||
|
||||
@@ -82,4 +82,71 @@ class NotificationsDtoTest {
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -69,5 +71,32 @@ class PublicDtoTest {
|
||||
assertFalse(dto.registration.password)
|
||||
assertEquals("", dto.brand.name)
|
||||
assertEquals(null, dto.push.ntfyUrl)
|
||||
// …and one that predates admin theming: both M12 fields are simply absent
|
||||
// (THEMING_AND_NAV.md §2 — absence means the shipped defaults).
|
||||
assertEquals(null, dto.theme)
|
||||
assertEquals(null, dto.navPublic)
|
||||
}
|
||||
|
||||
@Test fun settingsDecodesTheThemeAndNavRows() {
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{
|
||||
"theme":{"--accent":"#c8a45c","--radius-card":"3px"},
|
||||
"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}",
|
||||
"theme_visual":"{\"preset\":\"fantasy\"}"
|
||||
}""",
|
||||
)
|
||||
assertEquals("#c8a45c", (dto.theme as JsonObject)["--accent"]?.jsonPrimitive?.content)
|
||||
// nav_public stays a raw string here: settings.value is TEXT, so it is
|
||||
// parsed a second time by SiteAppearance.
|
||||
assertEquals("""{"/wiki":{"label":"Codex"}}""", dto.navPublic)
|
||||
}
|
||||
|
||||
@Test fun anUnexpectedThemeKindStillDecodesTheRest() {
|
||||
// `theme` is a raw JsonElement precisely so a value we did not expect
|
||||
// cannot fail the decode and take brand/push with it.
|
||||
val dto = json.decodeFromString<SettingsDto>(
|
||||
"""{"theme":"nonsense","brand":{"name":"UOMysticmoon"}}""",
|
||||
)
|
||||
assertEquals("UOMysticmoon", dto.brand.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
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.NotificationSubscriptionsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||
|
||||
/**
|
||||
* A configurable fake of [NotificationsApi] for the inbox and settings ViewModel
|
||||
* tests. Read endpoints return their `var`; [error] makes every call throw, which
|
||||
* is how the offline and server-error branches are driven.
|
||||
*
|
||||
* [pages] keys the inbox by its cursor — `null` is the first page — so a test can
|
||||
* describe a two-page inbox without a callback, and [lastPrefsUpdate] records the
|
||||
* body of the sparse PUT so a test can assert that ONE pair was sent.
|
||||
*/
|
||||
class FakeNotificationsApi : NotificationsApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var streams: NotificationStreamsDto = NotificationStreamsDto()
|
||||
var subscriptions: NotificationSubscriptionsDto = NotificationSubscriptionsDto(emptyList())
|
||||
var channelPrefs: NotificationChannelPrefsDto = NotificationChannelPrefsDto()
|
||||
var pages: Map<Long?, NotificationInboxDto> = mapOf(null to NotificationInboxDto())
|
||||
var unread: NotificationUnreadDto = NotificationUnreadDto()
|
||||
var readResult: NotificationReadResultDto = NotificationReadResultDto(ok = true)
|
||||
|
||||
var lastPrefsUpdate: List<NotificationChannelPrefDto>? = null
|
||||
var markedRead: MutableList<Long> = mutableListOf()
|
||||
var markAllReadCalls: Int = 0
|
||||
var inboxCalls: MutableList<Long?> = mutableListOf()
|
||||
|
||||
private fun failIfSet() { error?.let { throw it } }
|
||||
|
||||
override suspend fun registerDevice(body: RegisterDeviceRequest): PushDeviceDto {
|
||||
failIfSet()
|
||||
return PushDeviceDto(id = 1)
|
||||
}
|
||||
|
||||
override suspend fun listDevices(): List<PushDeviceDto> {
|
||||
failIfSet()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun deleteDevice(id: Long) = failIfSet()
|
||||
|
||||
override suspend fun streams(): NotificationStreamsDto {
|
||||
failIfSet()
|
||||
return streams
|
||||
}
|
||||
|
||||
override suspend fun subscriptions(): NotificationSubscriptionsDto {
|
||||
failIfSet()
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
override suspend fun putSubscriptions(body: NotificationSubscriptionsDto): NotificationSubscriptionsDto {
|
||||
failIfSet()
|
||||
subscriptions = body
|
||||
return body
|
||||
}
|
||||
|
||||
override suspend fun channelPrefs(): NotificationChannelPrefsDto {
|
||||
failIfSet()
|
||||
return channelPrefs
|
||||
}
|
||||
|
||||
override suspend fun putChannelPrefs(body: NotificationChannelPrefsUpdateDto): NotificationChannelPrefsDto {
|
||||
failIfSet()
|
||||
lastPrefsUpdate = body.prefs
|
||||
return channelPrefs
|
||||
}
|
||||
|
||||
override suspend fun inbox(limit: Int?, before: Long?, unread: Boolean?): NotificationInboxDto {
|
||||
failIfSet()
|
||||
inboxCalls.add(before)
|
||||
return pages[before] ?: NotificationInboxDto()
|
||||
}
|
||||
|
||||
override suspend fun unreadCount(): NotificationUnreadDto {
|
||||
failIfSet()
|
||||
return unread
|
||||
}
|
||||
|
||||
override suspend fun markRead(id: Long): NotificationReadResultDto {
|
||||
failIfSet()
|
||||
markedRead.add(id)
|
||||
return readResult
|
||||
}
|
||||
|
||||
override suspend fun markAllRead(): NotificationReadResultDto {
|
||||
failIfSet()
|
||||
markAllReadCalls++
|
||||
return readResult
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The second-stage parse of a JSON-valued settings row (THEMING_AND_NAV.md §3).
|
||||
* The rule under test is the one the web client's `parseJsonSetting` states:
|
||||
* anything that is not a plain object reads as **absent**, never as an error.
|
||||
*/
|
||||
class SettingsJsonTest {
|
||||
|
||||
@Test fun parsesAPlainObject() {
|
||||
val parsed = parseJsonSetting("""{"/wiki":{"label":"Codex","order":0}}""")
|
||||
assertEquals(1, parsed!!.size)
|
||||
assertEquals(setOf("/wiki"), parsed.keys)
|
||||
}
|
||||
|
||||
@Test fun parsesTheWrappedPublicShape() {
|
||||
val parsed = parseJsonSetting(
|
||||
"""{"items":{"/":{"hidden":true}},"sections":[{"id":"s1","label":"Play"}],"links":[]}""",
|
||||
)
|
||||
assertEquals(setOf("items", "sections", "links"), parsed!!.keys)
|
||||
}
|
||||
|
||||
@Test fun absentValuesReadAsNull() {
|
||||
assertNull(parseJsonSetting(null))
|
||||
assertNull(parseJsonSetting(""))
|
||||
}
|
||||
|
||||
@Test fun malformedJsonReadsAsNull() {
|
||||
assertNull(parseJsonSetting("{"))
|
||||
assertNull(parseJsonSetting("""{"a":}"""))
|
||||
assertNull(parseJsonSetting("not json at all"))
|
||||
}
|
||||
|
||||
@Test fun nonObjectJsonReadsAsNull() {
|
||||
// A stored `null`, number, string or array is as unusable to every
|
||||
// consumer of these keys as a syntax error is.
|
||||
assertNull(parseJsonSetting("null"))
|
||||
assertNull(parseJsonSetting("4"))
|
||||
assertNull(parseJsonSetting("\"x\""))
|
||||
assertNull(parseJsonSetting("[]"))
|
||||
}
|
||||
|
||||
@Test fun unusualKeysAndValuesSurviveVerbatim() {
|
||||
// The parse stage validates the *kind*, not the shape — a nonsense entry
|
||||
// is dropped later, by the phase that reads it.
|
||||
val parsed = parseJsonSetting("""{"/site/news":{"order":"first"},"nonsense":7}""")
|
||||
assertEquals(JsonPrimitive(7), parsed!!["nonsense"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.appearance
|
||||
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [SiteAppearance.from] — the coercion between the settings payload and what the
|
||||
* theme and the drawer read (THEMING_AND_NAV.md §2, §3).
|
||||
*
|
||||
* The claims that matter here are the two the milestone rests on: an untouched
|
||||
* instance resolves to *nothing* (so the shipped app renders), and a bad token
|
||||
* costs exactly its own token.
|
||||
*/
|
||||
class SiteAppearanceTest {
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
coerceInputValues = true
|
||||
}
|
||||
|
||||
private fun appearanceOf(body: String) =
|
||||
SiteAppearance.from(json.decodeFromString<SettingsDto>(body))
|
||||
|
||||
@Test fun untouchedInstanceResolvesToNoOverrides() {
|
||||
// No theme_visual row, no nav_public row: the shipped app, exactly (§2).
|
||||
val appearance = appearanceOf("""{"brand":{"name":"UOMysticmoon"}}""")
|
||||
assertTrue(appearance.theme.isEmpty())
|
||||
assertNull(appearance.navPublic)
|
||||
assertEquals("UOMysticmoon", appearance.brand?.name)
|
||||
}
|
||||
|
||||
@Test fun failedSettingsCallIsTheSameAsNoOverrides() {
|
||||
assertSame(SiteAppearance.NONE, SiteAppearance.from(null))
|
||||
assertNull(SiteAppearance.NONE.brand)
|
||||
assertTrue(SiteAppearance.NONE.theme.isEmpty())
|
||||
assertNull(SiteAppearance.NONE.navPublic)
|
||||
}
|
||||
|
||||
@Test fun resolvedThemeTokensAreReadAsAMap() {
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":{"--accent":"#c8a45c","--bg":"#1a1410","--radius-card":"10px",
|
||||
"--shadow-card":"none","--sans":"Inter, sans-serif"}}""",
|
||||
)
|
||||
assertEquals("#c8a45c", appearance.theme["--accent"])
|
||||
assertEquals("#1a1410", appearance.theme["--bg"])
|
||||
assertEquals("10px", appearance.theme["--radius-card"])
|
||||
assertEquals("none", appearance.theme["--shadow-card"])
|
||||
assertEquals("Inter, sans-serif", appearance.theme["--sans"])
|
||||
}
|
||||
|
||||
@Test fun anEmptyThemeMapIsTheSameAsAbsent() {
|
||||
// The server returns null rather than {} — the app must not depend on that.
|
||||
assertTrue(appearanceOf("""{"theme":{}}""").theme.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aBadTokenCostsOnlyItself() {
|
||||
// AC-2 in miniature at the decode boundary: a non-string or blank value is
|
||||
// dropped field-by-field, and its neighbours still apply.
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":{"--accent":"#c8a45c","--bg":7,"--line":null,"--ink":" "}}""",
|
||||
)
|
||||
assertEquals(mapOf("--accent" to "#c8a45c"), appearance.theme)
|
||||
}
|
||||
|
||||
@Test fun aThemeOfTheWrongKindDoesNotCostTheBrand() {
|
||||
// The whole reason `theme` is modeled as a raw JsonElement: one unexpected
|
||||
// value must not fail the decode and take brand and push down with it.
|
||||
val appearance = appearanceOf(
|
||||
"""{"theme":"not an object","brand":{"name":"UOMysticmoon","accent":"#7f99bd"},
|
||||
"push":{"ntfyUrl":"https://ntfy.example.com"}}""",
|
||||
)
|
||||
assertTrue(appearance.theme.isEmpty())
|
||||
assertEquals("#7f99bd", appearance.brand?.accent)
|
||||
}
|
||||
|
||||
@Test fun navPublicIsParsedASecondTime() {
|
||||
// It arrives as a JSON string inside a JSON object, because settings.value
|
||||
// is TEXT.
|
||||
val appearance = appearanceOf("""{"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}"}""")
|
||||
assertEquals(setOf("/wiki"), appearance.navPublic?.keys)
|
||||
}
|
||||
|
||||
@Test fun aMalformedNavPublicDoesNotCostTheTheme() {
|
||||
val appearance = appearanceOf("""{"nav_public":"{oops","theme":{"--accent":"#c8a45c"}}""")
|
||||
assertNull(appearance.navPublic)
|
||||
assertEquals("#c8a45c", appearance.theme["--accent"])
|
||||
}
|
||||
}
|
||||
@@ -38,15 +38,22 @@ class ContentViewModelTest {
|
||||
private val settings = SettingsRepository(api)
|
||||
|
||||
// ── News hub ──────────────────────────────────────────────────────────
|
||||
/** No category argument: how every route into the hub but §6.2's arrives. */
|
||||
private fun newsViewModel(category: String? = null) =
|
||||
NewsViewModel(
|
||||
content,
|
||||
SavedStateHandle(category?.let { mapOf(Routes.Args.CATEGORY to it) } ?: emptyMap()),
|
||||
)
|
||||
|
||||
@Test fun newsLoadsSelectedCategory() {
|
||||
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
|
||||
val vm = NewsViewModel(content)
|
||||
val vm = newsViewModel()
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
assertEquals(1, (vm.state.value as UiState.Success).data.size)
|
||||
}
|
||||
|
||||
@Test fun newsSelectCategoryReloads() {
|
||||
val vm = NewsViewModel(content)
|
||||
val vm = newsViewModel()
|
||||
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
|
||||
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
|
||||
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
|
||||
@@ -55,7 +62,20 @@ class ContentViewModelTest {
|
||||
|
||||
@Test fun newsServerErrorIsUiError() {
|
||||
api.error = httpError(500)
|
||||
assertTrue(NewsViewModel(content).state.value is UiState.Error)
|
||||
assertTrue(newsViewModel().state.value is UiState.Error)
|
||||
}
|
||||
|
||||
@Test fun newsOpensOnTheCategoryTheRouteAsksFor() {
|
||||
// The app's half of an admin's nav override or added link pointing at one of
|
||||
// the website's three category pages (THEMING_AND_NAV.md §6.2).
|
||||
val vm = newsViewModel("five-on-friday")
|
||||
assertEquals(ContentRepository.PostCategory.FIVE_ON_FRIDAY, vm.category.value)
|
||||
}
|
||||
|
||||
@Test fun newsFallsBackToTheDefaultFeedForAnUnknownCategory() {
|
||||
// A hand-edited settings row, or a category the site has and the app doesn't.
|
||||
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel("bogus").category.value)
|
||||
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel().category.value)
|
||||
}
|
||||
|
||||
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.components
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* §5.6's one testable rule: **an empty slot resolves to nothing.** The drawing
|
||||
* itself is out of reach here — the app carries no Robolectric, so a composable
|
||||
* body cannot run in a JVM test and phase 4's layout is AC-5's job — but the
|
||||
* decision of whether to draw at all is pure, and it is the decision that keeps
|
||||
* an unbranded instance laying out as it did before M12.
|
||||
*
|
||||
* The resolver is faked as the absolute-URL join the real one performs
|
||||
* (`AppViewModel.resolveAsset`, unchanged by this phase), so these assert
|
||||
* [brandAssetUrl]'s own contract rather than re-testing the network layer.
|
||||
*/
|
||||
class BrandAssetsTest {
|
||||
|
||||
private val resolve: (String?) -> String? = { path ->
|
||||
when {
|
||||
path.isNullOrBlank() -> null
|
||||
path.startsWith("http") -> path
|
||||
else -> "https://shard.example${if (path.startsWith("/")) "" else "/"}$path"
|
||||
}
|
||||
}
|
||||
|
||||
// --- the empty slot: every shape "not set" arrives in ------------------
|
||||
|
||||
@Test
|
||||
fun `a null slot resolves to nothing`() {
|
||||
assertNull(brandAssetUrl(null, resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty slot resolves to nothing`() {
|
||||
// The server publishes "" for an asset that was never uploaded, and BrandDto
|
||||
// defaults to it — this is the case that carries the untouched instance.
|
||||
assertNull(brandAssetUrl("", resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whitespace-only slot resolves to nothing`() {
|
||||
assertNull(brandAssetUrl(" ", resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shipped brand has neither a logo nor a hero`() {
|
||||
// AC-1 for phase 4: nothing about a default BrandDto puts an image on screen.
|
||||
val brand = BrandDto()
|
||||
assertNull(brandAssetUrl(brand.logo, resolve))
|
||||
assertNull(brandAssetUrl(brand.hero, resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed settings load leaves no brand to draw`() {
|
||||
// SiteAppearance.NONE is what a dead backend produces (§2). It has no brand
|
||||
// at all, so both slots are absent rather than empty.
|
||||
val brand: BrandDto? = SiteAppearance.NONE.brand
|
||||
assertNull(brand)
|
||||
assertNull(brandAssetUrl(brand?.logo, resolve))
|
||||
assertNull(brandAssetUrl(brand?.hero, resolve))
|
||||
}
|
||||
|
||||
// --- the filled slot ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a site-relative upload resolves against the shard's base`() {
|
||||
assertEquals(
|
||||
"https://shard.example/uploads/brand/logo.png",
|
||||
brandAssetUrl("/uploads/brand/logo.png", resolve),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absolute URL passes through`() {
|
||||
// BRAND_LOGO may be set to an off-site URL; the resolver leaves those alone.
|
||||
assertEquals(
|
||||
"https://cdn.example/logo.svg",
|
||||
brandAssetUrl("https://cdn.example/logo.svg", resolve),
|
||||
)
|
||||
}
|
||||
|
||||
// --- the second blank check -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a resolver that returns nothing resolves to nothing`() {
|
||||
// No base URL configured yet: the real resolver hands the path back or gives
|
||||
// up. Either way the slot must not become an image request.
|
||||
assertNull(brandAssetUrl("/uploads/brand/logo.png") { null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resolver that returns blank resolves to nothing`() {
|
||||
// Why the blank check is on both sides of the resolver, not just the input.
|
||||
assertNull(brandAssetUrl("/uploads/brand/logo.png") { "" })
|
||||
assertNull(brandAssetUrl("/uploads/brand/logo.png") { " " })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.repository.ShardFeature
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The public-nav override merge (THEMING_AND_NAV.md §6): label, order and hidden,
|
||||
* applied to the coded [APP_MENU] and nothing else.
|
||||
*
|
||||
* Two things these tests are really about. **AC-1** — an instance whose admin never
|
||||
* touched the nav must get the drawer the app shipped with, which here is the
|
||||
* strongest possible assertion: the same list instance back. And **AC-3** — the
|
||||
* merge runs before [visibleEntries] and cannot reach past it, so a `hidden: false`
|
||||
* on a gated row still shows nothing.
|
||||
*/
|
||||
class NavOverridesTest {
|
||||
|
||||
private fun nav(vararg items: Pair<String, JsonObject>): JsonObject =
|
||||
buildJsonObject { for ((path, entry) in items) put(path, entry) }
|
||||
|
||||
private fun entry(
|
||||
label: String? = null,
|
||||
order: Int? = null,
|
||||
hidden: Boolean? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
label?.let { put("label", it) }
|
||||
order?.let { put("order", it) }
|
||||
hidden?.let { put("hidden", it) }
|
||||
}
|
||||
|
||||
private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }
|
||||
|
||||
/** The public block's routes, in coded order — the first nine of APP_MENU. */
|
||||
private val codedPublic = listOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
|
||||
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
|
||||
)
|
||||
|
||||
// ── AC-1: the untouched instance ─────────────────────────────────────
|
||||
|
||||
@Test fun noStoredRowReturnsTheCodedMenuItself() {
|
||||
// Identity, not equality: the drawer of an instance that never edited its
|
||||
// nav is the shipped one, and nothing was rebuilt to arrive at it.
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, null))
|
||||
}
|
||||
|
||||
@Test fun anEmptyRowReturnsTheCodedMenuItself() {
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, buildJsonObject { }))
|
||||
}
|
||||
|
||||
@Test fun aRowWithNothingUsableInItReturnsTheCodedMenuItself() {
|
||||
// A blank label, a non-finite order, `hidden: false`, a path the app has no
|
||||
// screen for, and a path it maps but doesn't put in the drawer. None of it
|
||||
// says anything, so none of it may cost the coded menu.
|
||||
val stored = nav(
|
||||
"/" to entry(label = " "),
|
||||
"/site/news" to entry(hidden = false),
|
||||
"/admin/appearance" to entry(label = "Nope"),
|
||||
"/site/screenshots" to entry(label = "Shots", order = 0),
|
||||
"/site/champs" to entry(hidden = true),
|
||||
)
|
||||
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
||||
}
|
||||
|
||||
@Test fun aMalformedEntryIsDroppedAndItsNeighbourKept() {
|
||||
val stored = buildJsonObject {
|
||||
put("/site/news", "not an object")
|
||||
put("/wiki", entry(label = "Codex"))
|
||||
}
|
||||
|
||||
val merged = applyNavOverrides(APP_MENU, stored)
|
||||
|
||||
assertEquals(codedPublic, merged.take(9).map { it.route })
|
||||
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
||||
assertNull(merged.first { it.route == Routes.NEWS }.label)
|
||||
}
|
||||
|
||||
// ── Labels ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun aLabelOverridesTheBundledString() {
|
||||
val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm ")))
|
||||
|
||||
val shard = merged.first { it.route == Routes.SHARD }
|
||||
assertEquals("The Realm", shard.label)
|
||||
// The override lands on `label` and nothing else — the gates are untouched.
|
||||
assertEquals(ShardFeature.STATUS, shard.feature)
|
||||
assertEquals(MenuAccess.PUBLIC, shard.access)
|
||||
assertEquals(codedPublic, merged.take(9).map { it.route })
|
||||
}
|
||||
|
||||
@Test fun aNonStringLabelIsIgnored() {
|
||||
val stored = buildJsonObject { put("/wiki", buildJsonObject { put("label", 7) }) }
|
||||
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
||||
}
|
||||
|
||||
// ── Hidden ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun hiddenDropsTheRow() {
|
||||
val routes = routes(nav("/site/market" to entry(hidden = true)))
|
||||
|
||||
assertTrue(Routes.SHARD_MARKET !in routes)
|
||||
assertEquals(APP_MENU.size - 1, routes.size)
|
||||
}
|
||||
|
||||
@Test fun homeCanBeHidden() {
|
||||
// Mirrors the website, where `/` is hideable too. Home stays the NavHost's
|
||||
// start destination and stays reachable by back-press; the app does not
|
||||
// invent a policy the site doesn't have.
|
||||
val routes = routes(nav("/" to entry(hidden = true)))
|
||||
|
||||
assertTrue(Routes.HOME !in routes)
|
||||
}
|
||||
|
||||
@Test fun hiddenFalseHidesNothing() {
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false))))
|
||||
}
|
||||
|
||||
@Test fun hiddenWinsOverALabelOnTheSameRow() {
|
||||
val routes = routes(nav("/wiki" to entry(label = "Codex", hidden = true)))
|
||||
|
||||
assertTrue(Routes.WIKI !in routes)
|
||||
}
|
||||
|
||||
// ── Order ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
|
||||
// The website's own indices: About is 15 and Home is 0, so swapping them
|
||||
// is what an admin dragging About to the top writes.
|
||||
val routes = routes(
|
||||
nav(
|
||||
"/site/about" to entry(order = 0),
|
||||
"/" to entry(order = 15),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
|
||||
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
|
||||
),
|
||||
routes.take(9),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
|
||||
// The tie-break that needs the website's order rather than the app's: an
|
||||
// explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where
|
||||
// the three news categories sit between News and Wiki). Explicit wins.
|
||||
val routes = routes(nav("/site/about" to entry(order = 5)))
|
||||
|
||||
assertEquals(
|
||||
listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
|
||||
routes.take(4),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
|
||||
// Contact, Account, Notifications, the three player groups and the four
|
||||
// staff rows have no website counterpart to be reordered against (§6.2).
|
||||
val tail = APP_MENU.drop(9).map { it.route }
|
||||
|
||||
val merged = routes(nav("/site/about" to entry(order = 0)))
|
||||
|
||||
assertEquals(tail, merged.drop(9))
|
||||
}
|
||||
|
||||
@Test fun reorderingAndHidingCompose() {
|
||||
val routes = routes(
|
||||
nav(
|
||||
"/site/about" to entry(order = 0),
|
||||
"/" to entry(hidden = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(Routes.page("about"), routes.first())
|
||||
assertTrue(Routes.HOME !in routes)
|
||||
}
|
||||
|
||||
// ── The two stored shapes ────────────────────────────────────────────
|
||||
|
||||
@Test fun theWrappedShapeIsRead() {
|
||||
// Website phase 10 wraps the map as {items, sections, links} without
|
||||
// migrating what phases 6-8 stored bare, so both shapes are live.
|
||||
val stored = buildJsonObject {
|
||||
put("items", nav("/wiki" to entry(label = "Codex")))
|
||||
put("sections", buildJsonObject { })
|
||||
put("links", buildJsonObject { })
|
||||
}
|
||||
|
||||
val merged = applyNavOverrides(APP_MENU, stored)
|
||||
|
||||
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
||||
}
|
||||
|
||||
@Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() {
|
||||
// This merge is items-only; `buildNavTree` is what renders the structure
|
||||
// around them (§6.3), and it leans on this staying true — an `items` map
|
||||
// that says nothing still returns the coded menu itself.
|
||||
val stored = buildJsonObject {
|
||||
put("items", buildJsonObject { })
|
||||
put("sections", buildJsonObject { put("id", "lore") })
|
||||
}
|
||||
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
||||
}
|
||||
|
||||
// ── AC-3: the merge cannot reach past the gates ──────────────────────
|
||||
|
||||
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
|
||||
val stored = nav(
|
||||
"/site/market" to entry(label = "Bazaar", hidden = false, order = 0),
|
||||
)
|
||||
|
||||
val visible = visibleEntries(
|
||||
applyNavOverrides(APP_MENU, stored),
|
||||
Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)),
|
||||
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
|
||||
).map { it.route }
|
||||
|
||||
// Relabeled and moved to the front, and still not shown: the shard does not
|
||||
// publish the market, and an admin does not outrank that.
|
||||
assertTrue(Routes.SHARD_MARKET !in visible)
|
||||
assertTrue(Routes.SHARD in visible)
|
||||
}
|
||||
|
||||
@Test fun anOverrideCannotUnhideARoleGatedRow() {
|
||||
val stored = nav("/" to entry(order = 99))
|
||||
|
||||
val visible = visibleEntries(
|
||||
applyNavOverrides(APP_MENU, stored),
|
||||
Session.SignedOut,
|
||||
features = null,
|
||||
).map { it.route }
|
||||
|
||||
assertTrue(Routes.ACCOUNT !in visible)
|
||||
assertTrue(Routes.ADMIN_DASHBOARD !in visible)
|
||||
assertTrue(Routes.PLAYER_CHARACTERS !in visible)
|
||||
}
|
||||
|
||||
@Test fun theGatesRunOnTheMergedListNotTheCodedOne() {
|
||||
// Hiding is subtractive on top of the gates, so the two compose: the row an
|
||||
// admin hid is gone, and so is the row this caller may not see.
|
||||
val stored = nav("/wiki" to entry(hidden = true))
|
||||
|
||||
val visible = visibleEntries(
|
||||
applyNavOverrides(APP_MENU, stored),
|
||||
Session.SignedOut,
|
||||
ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS)),
|
||||
).map { it.route }
|
||||
|
||||
assertTrue(Routes.WIKI !in visible)
|
||||
assertTrue(Routes.SHARD_MARKET !in visible)
|
||||
assertTrue(Routes.HOME in visible)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||
*
|
||||
* This is the milestone's one piece of cross-repo coupling, so the tests are
|
||||
* mostly about the table's *shape* — that it stays complete, unambiguous, and
|
||||
* honest about which rows the app actually surfaces in its drawer.
|
||||
*/
|
||||
class NavPathsTest {
|
||||
|
||||
@Test fun everyWebsiteNavPathIsMapped() {
|
||||
// The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the
|
||||
// site adds one, this is the test that says so — a path with no mapping is
|
||||
// silently unresolvable in phase 6's link handling.
|
||||
assertEquals(16, WEBSITE_PUBLIC_NAV.size)
|
||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
|
||||
}
|
||||
|
||||
@Test fun everyMappedRouteIsDistinct() {
|
||||
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
|
||||
// row's position from the sort.
|
||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEBSITE_PUBLIC_NAV.map { it.route }.toSet().size)
|
||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_ROUTE_ORDER.size)
|
||||
}
|
||||
|
||||
@Test fun theWebsitesOrderIsPreserved() {
|
||||
// Load-bearing: a stored `order` is an index into this list.
|
||||
assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME])
|
||||
assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS])
|
||||
assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI])
|
||||
assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")])
|
||||
}
|
||||
|
||||
@Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() {
|
||||
// Nine of the sixteen have a drawer row. The other seven are mapped but not
|
||||
// surfaced — three news category tabs and the four Shard hub boards — and
|
||||
// an override for one of them is ignored rather than obeyed (§6.2).
|
||||
val coded = APP_MENU.map { it.route }.toSet()
|
||||
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"/", "/site/news", "/wiki", "/site/shard", "/site/rules",
|
||||
"/site/atlas", "/site/leaderboards", "/site/market", "/site/about",
|
||||
),
|
||||
surfaced,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun theSevenUnsurfacedPathsStillResolveToAScreen() {
|
||||
// Phase 6's added links resolve against the same table, and there a category
|
||||
// tab or a hub board is a perfectly good destination.
|
||||
val unsurfaced = listOf(
|
||||
"/site/screenshots", "/site/five-on-friday", "/site/newsletter",
|
||||
"/site/champs", "/site/guilds", "/site/governors", "/site/houses",
|
||||
)
|
||||
|
||||
assertTrue(unsurfaced.all { appRouteForWebPath(it) != null })
|
||||
assertTrue(unsurfaced.none { appRouteForWebPath(it) in APP_MENU.map { e -> e.route } })
|
||||
}
|
||||
|
||||
@Test fun theNewsCategoriesMapToTheirTab() {
|
||||
assertEquals("news?category=screenshots", appRouteForWebPath("/site/screenshots"))
|
||||
assertEquals("news?category=five-on-friday", appRouteForWebPath("/site/five-on-friday"))
|
||||
assertEquals("news?category=newsletter", appRouteForWebPath("/site/newsletter"))
|
||||
// The plain news path is the un-argumented route, so it matches the drawer's
|
||||
// coded row and opens the default tab.
|
||||
assertEquals(Routes.NEWS, appRouteForWebPath("/site/news"))
|
||||
}
|
||||
|
||||
@Test fun theCategoryRouteMatchesTheNavHostPattern() {
|
||||
// The pattern the NavHost declares and the value callers navigate to have to
|
||||
// agree on the query key, or the argument arrives as null and the screen
|
||||
// silently opens the default tab.
|
||||
assertEquals("news?category={category}", Routes.NEWS_ROUTE)
|
||||
assertTrue(Routes.NEWS_ROUTE.startsWith("${Routes.NEWS}?"))
|
||||
for (category in PostCategory.entries) {
|
||||
assertEquals("${Routes.NEWS}?category=${category.urlSlug}", Routes.news(category))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun theRoutePatternStripsToTheTopLevelRoute() {
|
||||
// How RunicApp recognizes the News destination: `destination.route` is the
|
||||
// pattern, and the drawer's row is the bare route.
|
||||
assertEquals(Routes.NEWS, Routes.NEWS_ROUTE.substringBefore('?'))
|
||||
assertEquals(Routes.NEWS, Routes.news(PostCategory.NEWSLETTER).substringBefore('?'))
|
||||
}
|
||||
|
||||
// ── Lookup hygiene ───────────────────────────────────────────────────
|
||||
|
||||
@Test fun anUnknownPathResolvesToNothing() {
|
||||
assertNull(appRouteForWebPath("/admin/appearance"))
|
||||
assertNull(appRouteForWebPath("/site/news/some-post"))
|
||||
assertNull(appRouteForWebPath("https://elsewhere.example/"))
|
||||
}
|
||||
|
||||
@Test fun blankAndNullResolveToNothing() {
|
||||
assertNull(appRouteForWebPath(null))
|
||||
assertNull(appRouteForWebPath(""))
|
||||
assertNull(appRouteForWebPath(" "))
|
||||
}
|
||||
|
||||
@Test fun aTrailingSlashIsTolerated() {
|
||||
// A hand-edited settings row may carry one; the root is left alone.
|
||||
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
|
||||
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
|
||||
assertEquals(Routes.HOME, appRouteForWebPath("/"))
|
||||
}
|
||||
|
||||
// ── resolveWebPath: an added link may name any page on the site (§6.3) ──
|
||||
|
||||
@Test fun theNavTablesSixteenPathsResolveTheSameWay() {
|
||||
// An added link to a path the nav already knows must land where the nav row
|
||||
// does, or the same destination would behave differently depending on how
|
||||
// the admin reached it.
|
||||
for (row in WEBSITE_PUBLIC_NAV) {
|
||||
assertEquals(row.route, resolveWebPath(row.path))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun theSitesDetailRoutesResolve() {
|
||||
// Read off website/client/src/App.jsx. Note what is NOT here: the site has
|
||||
// no /site/news/<id> route — its one post-detail route is the newsletter's.
|
||||
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
|
||||
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
|
||||
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
|
||||
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
|
||||
}
|
||||
|
||||
@Test fun aTopLevelSlugIsACmsPage() {
|
||||
// The site serves CMS pages from a top-level /<slug>, so this is the rule
|
||||
// that opens an admin's own page natively rather than in a browser.
|
||||
assertEquals(Routes.page("donate"), resolveWebPath("/donate"))
|
||||
assertEquals(Routes.page("about"), resolveWebPath("/site/about"))
|
||||
}
|
||||
|
||||
@Test fun theSitesOwnSectionsAreNotCmsPages() {
|
||||
// React Router ranks its static routes above /:slug, and so must the app —
|
||||
// otherwise a link to the admin panel would open a 404 CMS page in-app
|
||||
// instead of the real thing in a browser.
|
||||
for (path in listOf("/admin", "/account", "/player", "/site", "/invite", "/preview", "/api", "/uploads")) {
|
||||
assertNull(path, resolveWebPath(path))
|
||||
}
|
||||
// /wiki is reserved from the catch-all but mapped by the table above it.
|
||||
assertEquals(Routes.WIKI, resolveWebPath("/wiki"))
|
||||
}
|
||||
|
||||
@Test fun aPathTheAppHasNoScreenForHandsOff() {
|
||||
assertNull(resolveWebPath("/site/status"))
|
||||
assertNull(resolveWebPath("/site/shard/activity"))
|
||||
assertNull(resolveWebPath("/account/login"))
|
||||
assertNull(resolveWebPath("/admin/navigation"))
|
||||
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
|
||||
}
|
||||
|
||||
@Test fun aQueryOrFragmentHandsOff() {
|
||||
// No app route takes either, so a native match would quietly drop what the
|
||||
// admin wrote. The browser honors it exactly.
|
||||
assertNull(resolveWebPath("/site/news?tag=patch"))
|
||||
assertNull(resolveWebPath("/donate#tiers"))
|
||||
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))
|
||||
}
|
||||
|
||||
@Test fun aMalformedPathResolvesToNothing() {
|
||||
assertNull(resolveWebPath(null))
|
||||
assertNull(resolveWebPath(""))
|
||||
assertNull(resolveWebPath("/site//news"))
|
||||
assertNull(resolveWebPath("https://elsewhere.example/donate"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.repository.ShardFeature
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Drawer sections and added links (THEMING_AND_NAV.md §6.3) — the tree build and
|
||||
* the gate that prunes it.
|
||||
*
|
||||
* Three things these tests are really about. **AC-1**: an admin who created no
|
||||
* structure gets phase 5 back untouched, and an untouched instance gets the coded
|
||||
* [APP_MENU] entries themselves. **AC-3**: [pruneNav] runs after the build and
|
||||
* remains the boundary — including inside a section, and including the case where
|
||||
* it empties one. And the link path rule, which is what keeps "an override may
|
||||
* never introduce navigation" true of a feature whose whole job is to add entries:
|
||||
* a link may name any page **on this site**, and nothing else.
|
||||
*/
|
||||
class NavTreeTest {
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun stored(
|
||||
items: JsonObject = buildJsonObject { },
|
||||
sections: List<JsonObject> = emptyList(),
|
||||
links: List<JsonObject> = emptyList(),
|
||||
): JsonObject = buildJsonObject {
|
||||
put("items", items)
|
||||
put("sections", buildJsonArray { sections.forEach { add(it) } })
|
||||
put("links", buildJsonArray { links.forEach { add(it) } })
|
||||
}
|
||||
|
||||
private fun items(vararg entries: Pair<String, JsonObject>): JsonObject =
|
||||
buildJsonObject { for ((path, entry) in entries) put(path, entry) }
|
||||
|
||||
private fun item(
|
||||
label: String? = null,
|
||||
order: Int? = null,
|
||||
hidden: Boolean? = null,
|
||||
section: String? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
label?.let { put("label", it) }
|
||||
order?.let { put("order", it) }
|
||||
hidden?.let { put("hidden", it) }
|
||||
section?.let { put("section", it) }
|
||||
}
|
||||
|
||||
private fun section(id: String, label: String? = "Lore", order: Int? = null): JsonObject =
|
||||
buildJsonObject {
|
||||
put("id", id)
|
||||
label?.let { put("label", it) }
|
||||
order?.let { put("order", it) }
|
||||
}
|
||||
|
||||
private fun link(
|
||||
id: String = "l1",
|
||||
label: String? = "Donate",
|
||||
to: String? = "/donate",
|
||||
order: Int? = null,
|
||||
section: String? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
put("id", id)
|
||||
label?.let { put("label", it) }
|
||||
to?.let { put("to", it) }
|
||||
order?.let { put("order", it) }
|
||||
section?.let { put("section", it) }
|
||||
}
|
||||
|
||||
private fun tree(navPublic: JsonObject?) = buildNavTree(APP_MENU, navPublic)
|
||||
|
||||
/** Top-level routes, with a section standing in as `section:<id>`. */
|
||||
private fun List<NavNode>.shape(): List<String> = map {
|
||||
when (it) {
|
||||
is NavNode.Item -> it.entry.route
|
||||
is NavNode.Link -> "link:${it.id}"
|
||||
is NavNode.Section -> "section:${it.id}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<NavNode>.section(id: String): NavNode.Section =
|
||||
filterIsInstance<NavNode.Section>().first { it.id == id }
|
||||
|
||||
private fun List<NavNode>.link(id: String): NavNode.Link =
|
||||
filterIsInstance<NavNode.Link>().first { it.id == id }
|
||||
|
||||
// ── AC-1: no structure means phase 5, unchanged ──────────────────────
|
||||
|
||||
@Test fun noStoredRowIsTheCodedMenu() {
|
||||
val nodes = tree(null)
|
||||
|
||||
assertEquals(APP_MENU.size, nodes.size)
|
||||
// The entries themselves, not copies: with nothing stored, nothing was
|
||||
// rebuilt to arrive at the drawer the app shipped with.
|
||||
APP_MENU.forEachIndexed { index, entry ->
|
||||
assertSame(entry, (nodes[index] as NavNode.Item).entry)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun withoutSectionsOrLinksTheBuildIsTheFlatMerge() {
|
||||
// Phase 6 adds structure; it does not re-implement phase 5. An items-only
|
||||
// row must give exactly what applyNavOverrides gives.
|
||||
val row = stored(items = items("/site/about" to item(order = 0)))
|
||||
|
||||
assertEquals(
|
||||
applyNavOverrides(APP_MENU, row).map { it.route },
|
||||
tree(row).shape(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun malformedSectionsAndLinksAreNotStructure() {
|
||||
// Wrong kinds where the arrays should be — a hand-edited row, or the bare
|
||||
// items map phases 6-8 stored. Neither is structure, so neither may cost
|
||||
// the coded menu.
|
||||
val row = buildJsonObject {
|
||||
put("items", buildJsonObject { })
|
||||
put("sections", buildJsonObject { put("id", "lore") })
|
||||
put("links", "nope")
|
||||
}
|
||||
|
||||
assertEquals(APP_MENU.map { it.route }, tree(row).shape())
|
||||
}
|
||||
|
||||
// ── Sections ─────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun aSectionCollectsItsMembersBeneathIt() {
|
||||
val row = stored(
|
||||
items = items(
|
||||
"/wiki" to item(section = "lore"),
|
||||
"/site/about" to item(section = "lore"),
|
||||
),
|
||||
sections = listOf(section("lore", label = " The Realm ")),
|
||||
)
|
||||
|
||||
val nodes = tree(row)
|
||||
|
||||
assertTrue(Routes.WIKI !in nodes.shape())
|
||||
assertEquals("The Realm", nodes.section("lore").label)
|
||||
assertEquals(
|
||||
listOf(Routes.WIKI, Routes.page("about")),
|
||||
nodes.section("lore").items.shape(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun aSectionWithNoOrderAppendsAfterTheCodedRows() {
|
||||
// An admin-created entity with no stored order appends in creation order
|
||||
// rather than jumping to the front on a 0 default. The app's own rows stay
|
||||
// behind it, where they already sit (§6.2).
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(section = "lore")),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val shape = tree(row).shape()
|
||||
|
||||
// Eight public rows are left at the top level (Wiki moved into the section),
|
||||
// then the section, then the app's own rows.
|
||||
assertEquals("section:lore", shape[8])
|
||||
assertEquals(Routes.CONTACT, shape[9])
|
||||
}
|
||||
|
||||
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
|
||||
// Sections sort on the same number line as everything else: the website's
|
||||
// sixteen indices, then admin-created entities after them.
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(section = "lore")),
|
||||
sections = listOf(section("lore", order = 0)),
|
||||
)
|
||||
|
||||
assertEquals("section:lore", tree(row).shape().first())
|
||||
}
|
||||
|
||||
@Test fun aSectionWithoutAUsableLabelIsDroppedAndItsMembersStayPut() {
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(section = "lore")),
|
||||
sections = listOf(section("lore", label = " ")),
|
||||
)
|
||||
|
||||
val nodes = tree(row)
|
||||
|
||||
assertTrue(nodes.filterIsInstance<NavNode.Section>().isEmpty())
|
||||
// The section never existed, so the reference to it is dangling and the row
|
||||
// is an ordinary top-level one — not a row that vanished with its section.
|
||||
assertTrue(Routes.WIKI in nodes.shape())
|
||||
}
|
||||
|
||||
@Test fun aRepeatedSectionIdKeepsTheFirst() {
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(section = "lore")),
|
||||
sections = listOf(section("lore", label = "First"), section("lore", label = "Second")),
|
||||
)
|
||||
|
||||
val sections = tree(row).filterIsInstance<NavNode.Section>()
|
||||
|
||||
assertEquals(1, sections.size)
|
||||
assertEquals("First", sections.single().label)
|
||||
}
|
||||
|
||||
@Test fun anItemNamingAnUnknownSectionStaysTopLevel() {
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(section = "nope")),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val nodes = tree(row)
|
||||
|
||||
assertTrue(Routes.WIKI in nodes.shape())
|
||||
assertTrue(nodes.section("lore").items.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aHiddenItemIsDroppedEvenInsideASection() {
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(hidden = true, section = "lore")),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val nodes = tree(row)
|
||||
|
||||
assertTrue(Routes.WIKI !in nodes.shape())
|
||||
assertTrue(nodes.section("lore").items.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aLabelStillLandsOnASectionedRow() {
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(label = "Codex", section = "lore")),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val wiki = tree(row).section("lore").items.filterIsInstance<NavNode.Item>().single()
|
||||
|
||||
assertEquals("Codex", wiki.entry.label)
|
||||
// The override lands on the label and nothing else — the gates are untouched.
|
||||
assertEquals(MenuAccess.PUBLIC, wiki.entry.access)
|
||||
assertNull(wiki.entry.feature)
|
||||
}
|
||||
|
||||
@Test fun aSectionRequestForARowTheDrawerDoesNotSurfaceIsIgnored() {
|
||||
// Same rule as phase 5's: the app puts the hub boards behind the Shard hub
|
||||
// deliberately, and grouping is no more an invitation to surface one than
|
||||
// relabelling was (§6.2).
|
||||
val row = stored(
|
||||
items = items("/site/champs" to item(section = "lore", label = "Champs")),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val nodes = tree(row)
|
||||
|
||||
assertTrue(nodes.section("lore").items.isEmpty())
|
||||
assertTrue(Routes.SHARD_CHAMPS !in nodes.shape())
|
||||
}
|
||||
|
||||
// ── Added links ──────────────────────────────────────────────────────
|
||||
|
||||
@Test fun aLinkTheAppCanResolveCarriesItsRoute() {
|
||||
val row = stored(links = listOf(link(to = "/wiki/smithing")))
|
||||
|
||||
assertEquals(Routes.wikiPage("smithing"), tree(row).link("l1").route)
|
||||
}
|
||||
|
||||
@Test fun aLinkTheAppCannotResolveHandsOff() {
|
||||
// A null route is the Custom Tab; the path is kept verbatim so the browser
|
||||
// gets exactly what the admin wrote.
|
||||
val row = stored(links = listOf(link(to = "/site/status")))
|
||||
|
||||
val node = tree(row).link("l1")
|
||||
|
||||
assertNull(node.route)
|
||||
assertEquals("/site/status", node.path)
|
||||
}
|
||||
|
||||
@Test fun aLinkThatWouldLeaveTheOriginIsDropped() {
|
||||
// The website's own read rule, ported: a stored value that is not a
|
||||
// single-slash site path is dropped rather than rendered, so a hand-edited
|
||||
// row cannot put an off-site link in the drawer.
|
||||
val bad = listOf(
|
||||
"//evil.example/x", "https://evil.example", "donate", "/don ate",
|
||||
"/don\"ate", "/don'ate", "/don<ate", "/don\\ate",
|
||||
)
|
||||
|
||||
for (to in bad) {
|
||||
assertTrue(to, tree(stored(links = listOf(link(to = to)))).filterIsInstance<NavNode.Link>().isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aLinkWithoutAnIdLabelOrPathIsDropped() {
|
||||
val row = stored(
|
||||
links = listOf(
|
||||
buildJsonObject {
|
||||
put("label", "No id")
|
||||
put("to", "/a")
|
||||
},
|
||||
link(id = "no-label", label = null),
|
||||
link(id = "no-to", to = null),
|
||||
link(id = "blank-label", label = " "),
|
||||
link(id = "good"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("good"), tree(row).filterIsInstance<NavNode.Link>().map { it.id })
|
||||
}
|
||||
|
||||
@Test fun aRepeatedLinkIdKeepsTheFirst() {
|
||||
val row = stored(links = listOf(link(id = "l1", label = "First"), link(id = "l1", label = "Second")))
|
||||
|
||||
assertEquals("First", tree(row).link("l1").label)
|
||||
}
|
||||
|
||||
@Test fun linksAppendAfterTheCodedRowsInCreationOrder() {
|
||||
val row = stored(links = listOf(link(id = "a"), link(id = "b")))
|
||||
|
||||
val shape = tree(row).shape()
|
||||
|
||||
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
|
||||
assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
|
||||
}
|
||||
|
||||
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
|
||||
val row = stored(links = listOf(link(order = 0)))
|
||||
|
||||
assertEquals("link:l1", tree(row).shape().first())
|
||||
}
|
||||
|
||||
@Test fun aLinkCanSitInsideASection() {
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(section = "lore")),
|
||||
sections = listOf(section("lore")),
|
||||
links = listOf(link(section = "lore"), link(id = "top")),
|
||||
)
|
||||
|
||||
val nodes = tree(row)
|
||||
|
||||
assertEquals(listOf(Routes.WIKI, "link:l1"), nodes.section("lore").items.shape())
|
||||
assertTrue("link:top" in nodes.shape())
|
||||
}
|
||||
|
||||
@Test fun aLinkNamingAnUnknownSectionStaysTopLevel() {
|
||||
// Its destination is still good; only the grouping was wrong.
|
||||
val row = stored(links = listOf(link(section = "nope")))
|
||||
|
||||
assertTrue("link:l1" in tree(row).shape())
|
||||
}
|
||||
|
||||
// ── AC-3: the gates run after the build, and empty a section honestly ──
|
||||
|
||||
private val admin = Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN))
|
||||
|
||||
private fun prune(nodes: List<NavNode>, session: Session, features: ShardFeatures?) =
|
||||
pruneNav(nodes) { isEntryVisible(it, session, features) }
|
||||
|
||||
@Test fun aSectionEmptiedByTheGatesIsDropped() {
|
||||
// The case the rule exists for: a group whose every member is withheld by
|
||||
// the shard's visibility config must not draw as a header over nothing.
|
||||
val row = stored(
|
||||
items = items("/site/market" to item(section = "lore")),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val pruned = prune(
|
||||
tree(row),
|
||||
admin,
|
||||
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
|
||||
)
|
||||
|
||||
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
|
||||
val row = stored(
|
||||
items = items(
|
||||
"/site/market" to item(section = "lore"),
|
||||
"/wiki" to item(section = "lore"),
|
||||
),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val pruned = prune(
|
||||
tree(row),
|
||||
admin,
|
||||
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
|
||||
)
|
||||
|
||||
assertEquals(listOf(Routes.WIKI), pruned.section("lore").items.shape())
|
||||
}
|
||||
|
||||
@Test fun anOverrideCannotUnhideAGatedRowByGroupingIt() {
|
||||
// Relabelled, moved to the front, marked `hidden: false` and tucked into a
|
||||
// section of its own — and still not shown, because the shard does not
|
||||
// publish the market and an admin does not outrank that.
|
||||
val row = stored(
|
||||
items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
|
||||
sections = listOf(section("lore", order = 0)),
|
||||
)
|
||||
|
||||
val pruned = prune(
|
||||
tree(row),
|
||||
admin,
|
||||
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
|
||||
)
|
||||
|
||||
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
|
||||
assertTrue(pruned.none { it is NavNode.Item && it.entry.route == Routes.SHARD_MARKET })
|
||||
}
|
||||
|
||||
@Test fun aSectionSurvivesOnALinkAlone() {
|
||||
// Links carry no gate — the page behind one enforces its own access — so a
|
||||
// section holding one is never emptied by the caller's role.
|
||||
val row = stored(
|
||||
items = items("/site/market" to item(section = "lore")),
|
||||
sections = listOf(section("lore")),
|
||||
links = listOf(link(section = "lore")),
|
||||
)
|
||||
|
||||
val pruned = prune(tree(row), Session.SignedOut, ShardFeatures(level = "anonymous", visible = emptySet()))
|
||||
|
||||
assertEquals(listOf("link:l1"), pruned.section("lore").items.shape())
|
||||
}
|
||||
|
||||
@Test fun theAppsOwnRowsAreStillGatedInTheTree() {
|
||||
val row = stored(
|
||||
items = items("/wiki" to item(section = "lore")),
|
||||
sections = listOf(section("lore")),
|
||||
)
|
||||
|
||||
val shape = prune(tree(row), Session.SignedOut, features = null).shape()
|
||||
|
||||
assertTrue(Routes.ACCOUNT !in shape)
|
||||
assertTrue(Routes.ADMIN_DASHBOARD !in shape)
|
||||
assertTrue(Routes.PLAYER_CHARACTERS !in shape)
|
||||
assertTrue(Routes.CONTACT in shape)
|
||||
}
|
||||
|
||||
@Test fun pruningAnUntouchedTreeIsTheCodedMenusVisibleEntries() {
|
||||
// The two paths through the drawer have to agree: prune(tree) for a caller
|
||||
// is exactly visibleEntries of the coded menu for that caller.
|
||||
val features = ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS, ShardFeature.MARKET))
|
||||
|
||||
assertEquals(
|
||||
visibleEntries(APP_MENU, admin, features).map { it.route },
|
||||
prune(tree(null), admin, features).shape(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The inbox timestamp (ENGAGEMENT.md phase 8). Both wire shapes have to be read,
|
||||
* and the zoneless one has to be read as UTC — reading it as local time would
|
||||
* shift every stamp by the device's offset and nobody would notice until they
|
||||
* travelled.
|
||||
*/
|
||||
class InboxFormattingTest {
|
||||
|
||||
private val zone = ZoneId.of("America/New_York")
|
||||
private val format: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US)
|
||||
|
||||
@Test fun readsAnIsoStampWithAZone() {
|
||||
// 12:30 UTC is 08:30 in New York on that date (EDT).
|
||||
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00.000Z", zone, format))
|
||||
}
|
||||
|
||||
@Test fun readsAZonelessStampAsUtc() {
|
||||
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31 12:30:00", zone, format))
|
||||
}
|
||||
|
||||
@Test fun readsAZonelessStampWithATSeparator() {
|
||||
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00", zone, format))
|
||||
}
|
||||
|
||||
@Test fun anUnparseableStampShowsNothingRatherThanFailing() {
|
||||
assertNull(inboxTimestamp("sometime last week", zone, format))
|
||||
assertNull(inboxTimestamp("", zone, format))
|
||||
assertNull(inboxTimestamp(" ", zone, format))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.StoredSession
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.inbox.InboxCache
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.FakeInboxCache
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The inbox view model (ENGAGEMENT.md phase 8): the pull behind the tickle, the
|
||||
* keyset paging, the optimistic reads, and the offline snapshot — including the
|
||||
* one property that makes caching a person's notifications safe at all, that a
|
||||
* snapshot never crosses an account.
|
||||
*/
|
||||
class InboxViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
private class FakeTokenStore(private var stored: StoredSession?) : TokenStore {
|
||||
override fun load(): StoredSession? = stored
|
||||
override fun save(session: StoredSession) { stored = session }
|
||||
override fun clear() { stored = null }
|
||||
}
|
||||
|
||||
private val api = FakeNotificationsApi()
|
||||
private val cache = FakeInboxCache()
|
||||
private val baseUrl = BaseUrlHolder().apply { set("https://shard.example/".toHttpUrl()) }
|
||||
|
||||
private fun session(userId: Long = 7) =
|
||||
SessionManager(FakeTokenStore(StoredSession("access", "refresh", userId, "alice", "player")))
|
||||
|
||||
private fun viewModel(sessionManager: SessionManager = session()) =
|
||||
InboxViewModel(NotificationsRepository(api), cache, sessionManager, baseUrl)
|
||||
|
||||
private fun item(id: Long, read: Boolean = false) =
|
||||
NotificationItemDto(id = id, triggerId = "team.post.created", title = "Post $id", read = read)
|
||||
|
||||
private fun shown(vm: InboxViewModel) = (vm.state.value.items as? UiState.Success)?.data
|
||||
|
||||
@Test fun loadsTheFirstPageAndCachesIt() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||
val vm = viewModel()
|
||||
|
||||
assertEquals(listOf(2L, 1L), shown(vm)?.map { it.id })
|
||||
assertEquals(2, vm.state.value.unread)
|
||||
assertFalse(vm.state.value.fromCache)
|
||||
assertEquals(1, cache.writes)
|
||||
}
|
||||
|
||||
@Test fun offlineFallsBackToTheSnapshotAndSaysSo() {
|
||||
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||
api.error = IOException("offline")
|
||||
val vm = viewModel()
|
||||
|
||||
assertEquals(listOf(9L), shown(vm)?.map { it.id })
|
||||
assertEquals(1, vm.state.value.unread)
|
||||
assertTrue(vm.state.value.fromCache)
|
||||
}
|
||||
|
||||
@Test fun aSnapshotIsNeverShownToAnotherAccount() {
|
||||
// The property the whole cache design rests on: user 7's items must not
|
||||
// appear under user 8's session, on a device both have signed into.
|
||||
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||
api.error = IOException("offline")
|
||||
val vm = viewModel(session(userId = 8))
|
||||
|
||||
assertNull(shown(vm))
|
||||
assertTrue(vm.state.value.items is UiState.Error)
|
||||
assertEquals(0, vm.state.value.unread)
|
||||
}
|
||||
|
||||
@Test fun aServerErrorWithNothingCachedIsTheScreen() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
|
||||
assertTrue(vm.state.value.items is UiState.Error)
|
||||
assertFalse(vm.state.value.fromCache)
|
||||
}
|
||||
|
||||
@Test fun loadMorePagesOnTheLastIdNotAnOffset() {
|
||||
api.pages = mapOf(
|
||||
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true, unread = 2),
|
||||
8L to NotificationInboxDto(items = listOf(item(7)), hasMore = false, unread = 2),
|
||||
)
|
||||
val vm = viewModel()
|
||||
vm.loadMore()
|
||||
|
||||
assertEquals(listOf(null, 8L), api.inboxCalls)
|
||||
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
|
||||
assertFalse(vm.state.value.hasMore)
|
||||
}
|
||||
|
||||
@Test fun loadMoreDropsAnIdAlreadyOnScreen() {
|
||||
// A keyset window can shift under a concurrent write; a duplicate id in a
|
||||
// LazyColumn key is a crash, not a cosmetic problem.
|
||||
api.pages = mapOf(
|
||||
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true),
|
||||
8L to NotificationInboxDto(items = listOf(item(8), item(7))),
|
||||
)
|
||||
val vm = viewModel()
|
||||
vm.loadMore()
|
||||
|
||||
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
|
||||
}
|
||||
|
||||
@Test fun loadMoreDoesNothingWhileShowingTheCache() {
|
||||
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||
api.error = IOException("offline")
|
||||
val vm = viewModel()
|
||||
api.inboxCalls.clear()
|
||||
vm.loadMore()
|
||||
|
||||
assertTrue(api.inboxCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun markReadFlipsTheRowAndTakesTheServersCount() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||
api.readResult = NotificationReadResultDto(ok = true, unread = 1)
|
||||
val vm = viewModel()
|
||||
vm.markRead(2)
|
||||
|
||||
assertEquals(listOf(2L), api.markedRead)
|
||||
assertTrue(shown(vm)!!.first { it.id == 2L }.read)
|
||||
assertEquals(1, vm.state.value.unread)
|
||||
}
|
||||
|
||||
@Test fun markReadIsNotSentTwiceForAnItemAlreadyRead() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2, read = true)), unread = 0))
|
||||
val vm = viewModel()
|
||||
vm.markRead(2)
|
||||
|
||||
assertTrue(api.markedRead.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun markAllReadEmptiesTheBadgeAndUpdatesTheSnapshot() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||
val vm = viewModel()
|
||||
val writesAfterLoad = cache.writes
|
||||
vm.markAllRead()
|
||||
|
||||
assertEquals(1, api.markAllReadCalls)
|
||||
assertEquals(0, vm.state.value.unread)
|
||||
assertTrue(shown(vm)!!.all { it.read })
|
||||
// Without this write, going offline right after reading everything would
|
||||
// bring the badge back on the next cold open.
|
||||
assertEquals(writesAfterLoad + 1, cache.writes)
|
||||
}
|
||||
|
||||
// ── The item link (found by the live rig, not by a test) ──────────────
|
||||
|
||||
@Test fun aSiteRelativeUrlIsResolvedAgainstTheShard() {
|
||||
// What the server actually writes: the template's button block renders a
|
||||
// path, because on the web the reader is already on the site.
|
||||
val vm = viewModel()
|
||||
val item = item(1).copy(url = "/guilds/the-silver-anvil/forum/403")
|
||||
assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(item))
|
||||
}
|
||||
|
||||
@Test fun anAbsoluteUrlIsLeftAlone() {
|
||||
val vm = viewModel()
|
||||
assertEquals("https://elsewhere.example/x", vm.linkFor(item(1).copy(url = "https://elsewhere.example/x")))
|
||||
}
|
||||
|
||||
@Test fun anItemWithNoUrlHasNoLink() {
|
||||
val vm = viewModel()
|
||||
assertNull(vm.linkFor(item(1)))
|
||||
assertNull(vm.linkFor(item(1).copy(url = " ")))
|
||||
}
|
||||
|
||||
@Test fun aUrlThatCouldNotBeOpenedSafelyResolvesToNothing() {
|
||||
val vm = viewModel()
|
||||
assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)")))
|
||||
assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end")))
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import com.runicgateway.app.core.push.PushStreams
|
||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -12,8 +12,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests the pure push helpers: the stream → deep-link route map (PLAN.md §11 work
|
||||
* item 7) and the personal-stream gating (a personal stream needs a linked account).
|
||||
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
|
||||
* §11 work item 7), the tickle routing that ENGAGEMENT.md phase 8 layered over it,
|
||||
* and the personal-item gating (a personal id needs a linked game account).
|
||||
*/
|
||||
class NotificationRoutingTest {
|
||||
|
||||
@@ -32,14 +33,41 @@ class NotificationRoutingTest {
|
||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
||||
}
|
||||
|
||||
@Test fun personalStreamNeedsLinkedAccount() {
|
||||
val personal = NotificationStreamDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
||||
assertFalse(streamSelectable(personal, hasLinkedAccount = false))
|
||||
assertTrue(streamSelectable(personal, hasLinkedAccount = true))
|
||||
@Test fun personalItemNeedsLinkedAccount() {
|
||||
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
||||
assertFalse(itemSelectable(personal, hasLinkedAccount = false))
|
||||
assertTrue(itemSelectable(personal, hasLinkedAccount = true))
|
||||
}
|
||||
|
||||
@Test fun generalStreamIsAlwaysSelectable() {
|
||||
val general = NotificationStreamDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
||||
assertTrue(streamSelectable(general, hasLinkedAccount = false))
|
||||
@Test fun generalItemIsAlwaysSelectable() {
|
||||
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
||||
assertTrue(itemSelectable(general, hasLinkedAccount = false))
|
||||
}
|
||||
|
||||
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────
|
||||
|
||||
@Test fun inboxRefLandsOnTheInboxWhateverTheStream() {
|
||||
// The engine's stream id is a TRIGGER id in the one namespace, so most of
|
||||
// them are strangers to `forStream` — and every one of those would have
|
||||
// dropped the user on Home if the ref were not read.
|
||||
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("team.post.created", "notification:42"))
|
||||
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("uo.house.idoc_warning", "notification:7"))
|
||||
}
|
||||
|
||||
@Test fun anInboxRefWinsOverAStreamThatHasItsOwnScreen() {
|
||||
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle(PushStreams.NEWS_POST, "notification:1"))
|
||||
}
|
||||
|
||||
@Test fun everyOtherTickleKeepsTheRouteItAlwaysHad() {
|
||||
assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, null))
|
||||
assertEquals(Routes.SHARD, Routes.forTickle(PushStreams.CHAMP_START, "0x40001234"))
|
||||
assertEquals(Routes.PLAYER_HOUSES, Routes.forTickle(PushStreams.HOUSE_IDOC, "britain-2026-08-31"))
|
||||
assertEquals(Routes.HOME, Routes.forTickle("something.new", null))
|
||||
}
|
||||
|
||||
@Test fun aRefThatMerelyMentionsNotificationIsNotAnInboxRef() {
|
||||
// Prefix, not `contains`: a ref is opaque and another producer's could
|
||||
// easily carry the word without being a row id.
|
||||
assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, "post-notification:3"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
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.data.api.fake.FakeNotificationsApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The per-channel preferences the settings screen renders (ENGAGEMENT.md phases 3
|
||||
* and 8). These are the wire-shape properties the UI is built ON rather than
|
||||
* around, so they are asserted here rather than trusted: the controls come from
|
||||
* each item's own `channels`, and the modes from each channel's own `modes`.
|
||||
*
|
||||
* The view model itself needs a [com.runicgateway.app.core.push.PushManager],
|
||||
* which owns a foreground service and a `Context`; the sparse-PUT shape it sends
|
||||
* is asserted through the repository instead, which is the part that could be
|
||||
* wrong on the wire.
|
||||
*/
|
||||
class NotificationSettingsViewModelTest {
|
||||
|
||||
private val push = NotificationChannelDto(
|
||||
id = "push", label = "Push", carriesContent = false,
|
||||
defaultMode = "off", supportsDigest = false, modes = listOf("off", "instant"),
|
||||
)
|
||||
private val email = NotificationChannelDto(
|
||||
id = "email", label = "Email", carriesContent = true,
|
||||
defaultMode = "off", supportsDigest = true, modes = listOf("off", "instant", "digest"),
|
||||
)
|
||||
|
||||
private fun prefs() = NotificationChannelPrefsDto(
|
||||
channels = listOf(push, email),
|
||||
items = listOf(
|
||||
NotificationChannelItemDto(
|
||||
id = "news.post", label = "News posts",
|
||||
channels = listOf("push", "email"),
|
||||
modes = mapOf("push" to "instant", "email" to "off"),
|
||||
),
|
||||
NotificationChannelItemDto(
|
||||
id = "uo.house.idoc_warning", label = "House in danger",
|
||||
personal = true, requiresLinkedAccount = true,
|
||||
// A trigger-only id: nothing is registered to push it, so it carries
|
||||
// no push key at all — the screen must render no push control rather
|
||||
// than a dead switch.
|
||||
channels = listOf("email"),
|
||||
modes = mapOf("email" to "digest"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test fun aTriggerOnlyIdOffersNoPushControl() {
|
||||
val item = prefs().items.first { it.id == "uo.house.idoc_warning" }
|
||||
assertEquals(listOf("email"), item.channels)
|
||||
assertNull(item.modes[CHANNEL_PUSH])
|
||||
}
|
||||
|
||||
@Test fun emailIsTheChannelThatCarriesDigest() {
|
||||
assertEquals(listOf("off", "instant", "digest"), email.modes)
|
||||
assertEquals(listOf("off", "instant"), push.modes)
|
||||
}
|
||||
|
||||
@Test fun personalItemsStillNeedALinkedAccount() {
|
||||
val personal = prefs().items.first { it.personal }
|
||||
assertEquals(false, itemSelectable(personal, hasLinkedAccount = false))
|
||||
assertEquals(true, itemSelectable(personal, hasLinkedAccount = true))
|
||||
}
|
||||
|
||||
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {
|
||||
// The sparse PUT is the whole reason this screen can save a single control
|
||||
// without holding the table: anything more in the body could clobber a
|
||||
// channel it is not showing.
|
||||
val api = FakeNotificationsApi()
|
||||
api.channelPrefs = prefs()
|
||||
val repo = com.runicgateway.app.data.repository.NotificationsRepository(api)
|
||||
|
||||
repo.setChannelMode("news.post", "email", "digest")
|
||||
|
||||
assertEquals(1, api.lastPrefsUpdate?.size)
|
||||
assertEquals("news.post", api.lastPrefsUpdate?.first()?.id)
|
||||
assertEquals("email", api.lastPrefsUpdate?.first()?.channel)
|
||||
assertEquals("digest", api.lastPrefsUpdate?.first()?.mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* **AC-1, the no-op proof** (THEMING_AND_NAV.md §7). An instance with no
|
||||
* `theme_visual` row must resolve to a color scheme equal to the one the app
|
||||
* shipped before M12 — not "close enough", identical, because the app's M5
|
||||
* palette *is* the website's `runic-gateway` preset.
|
||||
*
|
||||
* Two things this test has to work around:
|
||||
*
|
||||
* - **`ColorScheme` does not implement `equals`** (checked against material3
|
||||
* 1.3.0), so "the full scheme, not a spot check" is a field-by-field compare.
|
||||
* It is done by reflection over every color-valued getter rather than by a
|
||||
* hand-written list, so a role that is added to Material — or one the mapping
|
||||
* forgets — cannot silently escape the assertion.
|
||||
* - The expected value is a **verbatim copy of the pre-M12 `ShardColorScheme`**,
|
||||
* kept here rather than referenced, so the proof is against what the app used
|
||||
* to do and not against whatever [shardColorScheme] does today.
|
||||
*/
|
||||
class ShardColorSchemeTest {
|
||||
|
||||
/** The scheme exactly as `ui/theme/Theme.kt` declared it before M12. */
|
||||
private val preM12Scheme = darkColorScheme(
|
||||
primary = ShardCta,
|
||||
onPrimary = ShardOnCta,
|
||||
secondary = ShardAccent,
|
||||
onSecondary = ShardOnCta,
|
||||
tertiary = ShardAccent,
|
||||
onTertiary = ShardOnCta,
|
||||
background = ShardPage,
|
||||
onBackground = ShardBody,
|
||||
surface = ShardSurface,
|
||||
onSurface = ShardBody,
|
||||
surfaceVariant = ShardElevated,
|
||||
onSurfaceVariant = ShardMuted,
|
||||
surfaceContainer = ShardElevated,
|
||||
surfaceContainerHigh = ShardElevated,
|
||||
surfaceContainerLow = ShardSurface,
|
||||
outline = ShardOutline,
|
||||
outlineVariant = ShardDivider,
|
||||
secondaryContainer = ShardPillBg,
|
||||
onSecondaryContainer = ShardPillFg,
|
||||
error = ShardDanger,
|
||||
onError = ShardOnCta,
|
||||
errorContainer = ShardDangerBg,
|
||||
onErrorContainer = ShardDanger,
|
||||
)
|
||||
|
||||
/**
|
||||
* The roles phase 8 deliberately moves off Material's defaults, and the values
|
||||
* they move to.
|
||||
*
|
||||
* `surfaceContainerHighest` is the one that matters: it is
|
||||
* `FilledCardTokens.ContainerColor`, so it is what every `ShardCard` paints with.
|
||||
* Leaving it unmapped meant all 26 of them drew in `darkColorScheme()`'s grey
|
||||
* rather than `--panel-a` — on themed instances *and* on untouched ones, which is
|
||||
* why this is a visible change to the shipped app and not only a theming fix. It
|
||||
* had been that way since M5; the AC-5 walk in phase 8 is what surfaced it,
|
||||
* because M12 themed everything around the cards and left them behind.
|
||||
*
|
||||
* `surfaceContainerLowest` has no reader in this app today (the phase 8 sweep
|
||||
* checked every Material component the app draws) and is mapped for consistency
|
||||
* with `surfaceContainerLow`, not to fix anything.
|
||||
*
|
||||
* Everything else stays exactly where it was — that is what the test below is for.
|
||||
*/
|
||||
private val deliberatelyChanged = mapOf(
|
||||
"surfaceContainerHighest" to ShardElevated,
|
||||
"surfaceContainerLowest" to ShardSurface,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the shipped palette reproduces the pre-M12 color scheme but for the card container`() {
|
||||
assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.Shipped))
|
||||
}
|
||||
|
||||
/** The same claim from the other end: an absent theme map is the shipped app. */
|
||||
@Test
|
||||
fun `an absent theme map reproduces the pre-M12 color scheme`() {
|
||||
assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.resolve(emptyMap())))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every role but [deliberatelyChanged] is byte-for-byte the pre-M12 value, and
|
||||
* each of those really did move — asserting the new value alone would still pass
|
||||
* if Material's default happened to equal it.
|
||||
*/
|
||||
private fun assertPreM12ApartFromTheCardContainer(actual: ColorScheme) {
|
||||
val before = roles(preM12Scheme)
|
||||
val after = roles(actual)
|
||||
assertEquals(before - deliberatelyChanged.keys, after - deliberatelyChanged.keys)
|
||||
for ((role, expected) in deliberatelyChanged) {
|
||||
assertEquals("$role should follow the palette", expected, after[role])
|
||||
assertNotEquals("$role was already the palette's value", expected, before[role])
|
||||
}
|
||||
}
|
||||
|
||||
/** Sanity: the comparison is capable of failing, and covers the whole scheme. */
|
||||
@Test
|
||||
fun `the role comparison sees every color role`() {
|
||||
val roles = roles(preM12Scheme)
|
||||
// material3 1.3.0 declares 36 color roles; fewer than that means the
|
||||
// reflection has stopped seeing them and the comparison above went hollow.
|
||||
assertTrue("expected the full Material role set, got ${roles.keys}", roles.size >= 36)
|
||||
assertEquals(ShardCta, roles["primary"])
|
||||
assertNotEquals(
|
||||
roles,
|
||||
roles(shardColorScheme(ShardPalette.Shipped.copy(cta = Color(0xFFC9973F)))),
|
||||
)
|
||||
}
|
||||
|
||||
/** A themed shard moves the roles its tokens own, and only those. */
|
||||
@Test
|
||||
fun `a theme token reaches its Material role`() {
|
||||
val themed = shardColorScheme(
|
||||
ShardPalette.resolve(mapOf("--accent" to "#c9973f", "--bg-deep" to "#120c07")),
|
||||
)
|
||||
val roles = roles(themed)
|
||||
assertEquals(Color(0xFFC9973F), roles["secondary"])
|
||||
assertEquals(Color(0xFFC9973F), roles["tertiary"])
|
||||
assertEquals(Color(0xFF120C07), roles["background"])
|
||||
assertEquals(Color(0xFF120C07), roles["onPrimary"]) // derived: onCta tracks --bg-deep
|
||||
assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens
|
||||
}
|
||||
|
||||
/**
|
||||
* The phase 8 fix, stated as the thing a shard operator actually sees: set
|
||||
* `--panel-flat` and the app's cards follow. This is the assertion that would have
|
||||
* failed before the AC-5 walk, when `surfaceContainerHighest` — Material's filled
|
||||
* `Card` container — was left at `darkColorScheme()`'s grey.
|
||||
*/
|
||||
@Test
|
||||
fun `--panel-flat reaches the Material card container`() {
|
||||
val roles = roles(shardColorScheme(ShardPalette.resolve(mapOf("--panel-flat" to "#1f160d"))))
|
||||
val panel = Color(0xFF1F160D)
|
||||
assertEquals(panel, roles["surfaceContainerHighest"]) // CardDefaults.cardColors()
|
||||
assertEquals(panel, roles["surfaceVariant"])
|
||||
assertEquals(panel, roles["surfaceContainer"])
|
||||
assertEquals(panel, roles["surfaceContainerHigh"])
|
||||
}
|
||||
|
||||
/**
|
||||
* Every color role of a scheme, by name. `Color` is a value class, so the
|
||||
* roles are the `long`-returning getters and their names carry Kotlin's
|
||||
* mangling suffix (`getPrimary-0d7_KjU`), which is stripped here.
|
||||
*/
|
||||
private fun roles(scheme: ColorScheme): Map<String, Color> =
|
||||
ColorScheme::class.java.declaredMethods
|
||||
.filter {
|
||||
it.name.startsWith("get") &&
|
||||
it.returnType == java.lang.Long.TYPE &&
|
||||
it.parameterCount == 0
|
||||
}
|
||||
.associate { method ->
|
||||
val name = method.name.removePrefix("get").substringBefore('-')
|
||||
.replaceFirstChar { it.lowercase() }
|
||||
// The getter hands back Color's packed ULong bits, not an ARGB int.
|
||||
name to Color((method.invoke(scheme) as Long).toULong())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The token → palette resolution (THEMING_AND_NAV.md §5.1) and its
|
||||
* forgiving-on-read rule (§2, AC-2).
|
||||
*/
|
||||
class ShardPaletteTest {
|
||||
|
||||
/** AC-1, the palette half: no theme row resolves to the shipped M5 palette. */
|
||||
@Test
|
||||
fun `no tokens resolve to the shipped palette`() {
|
||||
assertSame(ShardPalette.Shipped, ShardPalette.resolve(emptyMap()))
|
||||
}
|
||||
|
||||
/** Every one of the fifteen has a home, and lands in the right one. */
|
||||
@Test
|
||||
fun `all fifteen tokens map to their palette field`() {
|
||||
val p = ShardPalette.resolve(
|
||||
mapOf(
|
||||
"--bg" to "#010101",
|
||||
"--bg-deep" to "#020202",
|
||||
"--panel-a" to "#030303",
|
||||
"--panel-b" to "#040404",
|
||||
"--panel-flat" to "#050505",
|
||||
"--line" to "#060606",
|
||||
"--line-soft" to "#070707",
|
||||
"--accent" to "#080808",
|
||||
"--accent-bright" to "#090909",
|
||||
"--ink" to "#0a0a0a",
|
||||
"--head" to "#0b0b0b",
|
||||
"--text" to "#0c0c0c",
|
||||
"--muted" to "#0d0d0d",
|
||||
"--dim" to "#0e0e0e",
|
||||
"--blue" to "#0f0f0f",
|
||||
),
|
||||
)
|
||||
assertEquals(Color(0xFF010101), p.surface)
|
||||
assertEquals(Color(0xFF020202), p.page)
|
||||
assertEquals(Color(0xFF030303), p.cardTop)
|
||||
assertEquals(Color(0xFF040404), p.cardBottom)
|
||||
assertEquals(Color(0xFF050505), p.elevated)
|
||||
assertEquals(Color(0xFF060606), p.outline)
|
||||
assertEquals(Color(0xFF070707), p.divider)
|
||||
assertEquals(Color(0xFF080808), p.accent)
|
||||
assertEquals(Color(0xFF090909), p.cta)
|
||||
assertEquals(Color(0xFF0A0A0A), p.heading)
|
||||
assertEquals(Color(0xFF0B0B0B), p.headingDim)
|
||||
assertEquals(Color(0xFF0C0C0C), p.body)
|
||||
assertEquals(Color(0xFF0D0D0D), p.muted)
|
||||
assertEquals(Color(0xFF0E0E0E), p.faint)
|
||||
assertEquals(Color(0xFF0F0F0F), p.pillBg)
|
||||
}
|
||||
|
||||
/**
|
||||
* AC-2. One valid token applies; four malformed ones each fall back on their
|
||||
* own, and none of them costs the good one beside it.
|
||||
*/
|
||||
@Test
|
||||
fun `a malformed token costs only itself`() {
|
||||
val p = ShardPalette.resolve(
|
||||
mapOf(
|
||||
"--accent" to "#c9973f", // the one valid token
|
||||
"--bg" to "not a color",
|
||||
"--ink" to "",
|
||||
"--line" to "#12",
|
||||
"--text" to "rgb(1, 2, 3)",
|
||||
),
|
||||
)
|
||||
assertEquals(Color(0xFFC9973F), p.accent)
|
||||
assertEquals(ShardPalette.Shipped.surface, p.surface)
|
||||
assertEquals(ShardPalette.Shipped.heading, p.heading)
|
||||
assertEquals(ShardPalette.Shipped.outline, p.outline)
|
||||
assertEquals(ShardPalette.Shipped.body, p.body)
|
||||
}
|
||||
|
||||
/** A token the app does not know is not an error — it is simply not read. */
|
||||
@Test
|
||||
fun `unknown tokens are ignored`() {
|
||||
val p = ShardPalette.resolve(mapOf("--mode-live" to "#ff0000", "--radius-card" to "2px"))
|
||||
assertEquals(ShardPalette.Shipped, p)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pre-feature branding path: a `BRAND_ACCENT_COLOR` with no theme row
|
||||
* still tints links and highlights, and nothing else moves.
|
||||
*/
|
||||
@Test
|
||||
fun `brand accent seeds only the accent token`() {
|
||||
val p = ShardPalette.resolve(emptyMap(), brandAccent = Color(0xFFC9973F))
|
||||
assertEquals(Color(0xFFC9973F), p.accent)
|
||||
assertEquals(ShardPalette.Shipped.copy(accent = Color(0xFFC9973F)), p)
|
||||
}
|
||||
|
||||
/**
|
||||
* The server resolves `brand.accent` as `theme['--accent'] || env`, so the two
|
||||
* can only disagree if a client is holding a stale brand — the token wins.
|
||||
*/
|
||||
@Test
|
||||
fun `the accent token beats the brand accent`() {
|
||||
val p = ShardPalette.resolve(mapOf("--accent" to "#4f8ef7"), brandAccent = Color(0xFFC9973F))
|
||||
assertEquals(Color(0xFF4F8EF7), p.accent)
|
||||
}
|
||||
|
||||
/** An unparseable brand accent is the same as none. */
|
||||
@Test
|
||||
fun `a malformed brand accent falls back to the shipped accent`() {
|
||||
assertEquals(ShardPalette.Shipped, ShardPalette.resolve(emptyMap(), parseBrandColor("nope")))
|
||||
}
|
||||
|
||||
/**
|
||||
* The derived pair (§5.1): expressed in terms of another token, so they must
|
||||
* follow it rather than being frozen at the shipped literal.
|
||||
*/
|
||||
@Test
|
||||
fun `derived colors track the tokens they are expressed in`() {
|
||||
val p = ShardPalette.resolve(mapOf("--bg-deep" to "#120c07", "--accent-bright" to "#e8c374"))
|
||||
assertEquals(Color(0xFF120C07), p.onCta)
|
||||
assertEquals(Color(0xFFE8C374), p.pillFg)
|
||||
}
|
||||
|
||||
/** And on the shipped palette they are exactly today's two constants. */
|
||||
@Test
|
||||
fun `derived colors are the shipped constants by default`() {
|
||||
assertEquals(ShardOnCta, ShardPalette.Shipped.onCta)
|
||||
assertEquals(ShardPillFg, ShardPalette.Shipped.pillFg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* [ShardStructure.resolve] — the radius ratio (§5.2) and the shadow map (§5.4).
|
||||
*
|
||||
* The no-op proof is easier here than it was for the palette: material3's
|
||||
* [Shapes] *does* implement `equals` (unlike `ColorScheme`), so a whole shape
|
||||
* scale can be compared in one assertion. As in [ShardColorSchemeTest] the
|
||||
* expected value is a **verbatim copy of the pre-M12 scale**, kept here rather
|
||||
* than referenced, so the proof is against what the app used to draw and not
|
||||
* against whatever [ShippedShapes] says today.
|
||||
*/
|
||||
class ShardStructureTest {
|
||||
|
||||
/** The scale exactly as `ui/theme/Theme.kt` declared it before M12. */
|
||||
private val preM12Shapes = Shapes(
|
||||
extraSmall = RoundedCornerShape(8.dp),
|
||||
small = RoundedCornerShape(8.dp),
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp),
|
||||
extraLarge = RoundedCornerShape(24.dp),
|
||||
)
|
||||
|
||||
/** The `runic-gateway` preset's structure tokens, as the server publishes them. */
|
||||
private val runicGateway = mapOf(
|
||||
"--radius-pill" to "999px",
|
||||
"--radius-panel" to "12px",
|
||||
"--radius-card" to "10px",
|
||||
"--radius-input" to "8px",
|
||||
"--shadow-card" to "0 14px 34px rgba(0, 0, 0, 0.3)",
|
||||
)
|
||||
|
||||
private val fantasy = mapOf(
|
||||
"--radius-pill" to "4px",
|
||||
"--radius-panel" to "3px",
|
||||
"--radius-card" to "2px",
|
||||
"--radius-input" to "2px",
|
||||
"--shadow-card" to "0 16px 38px rgba(0, 0, 0, 0.45)",
|
||||
)
|
||||
|
||||
private val modern = mapOf(
|
||||
"--radius-pill" to "8px",
|
||||
"--radius-panel" to "8px",
|
||||
"--radius-card" to "6px",
|
||||
"--radius-input" to "6px",
|
||||
"--shadow-card" to "0 8px 20px rgba(0, 0, 0, 0.25)",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `the shipped scale is the pre-M12 scale`() {
|
||||
assertEquals(preM12Shapes, ShardStructure.Shipped.shapes)
|
||||
assertSame(CircleShape, ShardStructure.Shipped.pill)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no theme resolves to the shipped structure`() {
|
||||
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(emptyMap()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a theme with no structure tokens resolves to the shipped structure`() {
|
||||
// A shard that themed its colors only still draws the app's own radii:
|
||||
// every ratio is 1.0 because every token is absent.
|
||||
val colorsOnly = mapOf("--accent" to "#7f99bd", "--bg" to "#0b1220")
|
||||
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(colorsOnly))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the runic-gateway preset is a no-op`() {
|
||||
// AC-1 for the structure half: an admin who explicitly picks the preset
|
||||
// the app was drawn from gets ratio 1.0 on all four fields.
|
||||
assertEquals(ShardStructure.Shipped, ShardStructure.resolve(runicGateway))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fantasy scales the app's own dp, it does not adopt the web's`() {
|
||||
val s = ShardStructure.resolve(fantasy)
|
||||
// 2/8 -> 8dp becomes 2dp; the web's own value is also 2px, coincidentally.
|
||||
assertEquals(RoundedCornerShape(2.dp), s.shapes.extraSmall)
|
||||
assertEquals(RoundedCornerShape(2.dp), s.shapes.small)
|
||||
// 2/10 -> 12dp * 0.2 = 2.4, rounded.
|
||||
assertEquals(RoundedCornerShape(2.dp), s.shapes.medium)
|
||||
// 3/12 -> 16dp * 0.25. The web value is 3px; the app's is 4dp, which is
|
||||
// the whole point of the ratio.
|
||||
assertEquals(RoundedCornerShape(4.dp), s.shapes.large)
|
||||
// extraLarge has no web counterpart and follows the panel ratio.
|
||||
assertEquals(RoundedCornerShape(6.dp), s.shapes.extraLarge)
|
||||
assertEquals(RoundedCornerShape(4.dp), s.pill)
|
||||
// 38px blur is nearer Default's 34 than Deep's 44.
|
||||
assertEquals(4.dp, s.cardElevation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modern scales the app's own dp`() {
|
||||
val s = ShardStructure.resolve(modern)
|
||||
assertEquals(RoundedCornerShape(6.dp), s.shapes.extraSmall)
|
||||
assertEquals(RoundedCornerShape(7.dp), s.shapes.medium)
|
||||
assertEquals(RoundedCornerShape(11.dp), s.shapes.large)
|
||||
assertEquals(RoundedCornerShape(16.dp), s.shapes.extraLarge)
|
||||
assertEquals(RoundedCornerShape(8.dp), s.pill)
|
||||
assertEquals(2.dp, s.cardElevation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bad radius costs only its own field`() {
|
||||
val s = ShardStructure.resolve(
|
||||
mapOf(
|
||||
"--radius-input" to "8", // no unit; the server never writes this
|
||||
"--radius-card" to "huge",
|
||||
"--radius-panel" to "3px", // good, and must still apply
|
||||
"--radius-pill" to "",
|
||||
),
|
||||
)
|
||||
assertEquals(preM12Shapes.extraSmall, s.shapes.extraSmall)
|
||||
assertEquals(preM12Shapes.medium, s.shapes.medium)
|
||||
assertEquals(RoundedCornerShape(4.dp), s.shapes.large)
|
||||
assertSame(CircleShape, s.pill)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zero radius squares the corner off rather than clamping to the shipped value`() {
|
||||
val s = ShardStructure.resolve(mapOf("--radius-card" to "0px", "--radius-input" to "0px"))
|
||||
assertEquals(RoundedCornerShape(0.dp), s.shapes.medium)
|
||||
assertEquals(RoundedCornerShape(0.dp), s.shapes.extraSmall)
|
||||
assertNotEquals(preM12Shapes, s.shapes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the pill keeps its circle until the site is squared off`() {
|
||||
assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "999px")).pill)
|
||||
assertSame(CircleShape, ShardStructure.resolve(mapOf("--radius-pill" to "500px")).pill)
|
||||
assertEquals(RoundedCornerShape(499.dp), ShardStructure.resolve(mapOf("--radius-pill" to "499px")).pill)
|
||||
assertEquals(RoundedCornerShape(0.dp), ShardStructure.resolve(mapOf("--radius-pill" to "0px")).pill)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every shadow option lands on its step`() {
|
||||
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
|
||||
assertEquals(0.dp, elevation("none"))
|
||||
assertEquals(2.dp, elevation("0 8px 20px rgba(0, 0, 0, 0.25)"))
|
||||
assertEquals(4.dp, elevation("0 14px 34px rgba(0, 0, 0, 0.3)"))
|
||||
assertEquals(8.dp, elevation("0 18px 44px rgba(0, 0, 0, 0.45)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shadow the options do not contain lands on the nearest step`() {
|
||||
// The reason the match is on blur and not on the exact string: the
|
||||
// Fantasy preset's own --shadow-card is not one of SHADOW_OPTIONS'
|
||||
// four values, because a preset's tokens never pass through that
|
||||
// dropdown. An exact match would have dropped it on the floor.
|
||||
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
|
||||
assertEquals(4.dp, elevation("0 16px 38px rgba(0, 0, 0, 0.45)"))
|
||||
assertEquals(8.dp, elevation("0 20px 60px rgba(0, 0, 0, 0.5)"))
|
||||
assertEquals(2.dp, elevation("0 2px 4px rgba(0, 0, 0, 0.2)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreadable shadow keeps the shipped depth`() {
|
||||
fun elevation(shadow: String) = ShardStructure.resolve(mapOf("--shadow-card" to shadow)).cardElevation
|
||||
assertEquals(ShardStructure.Shipped.cardElevation, elevation("inset 0 0 nonsense"))
|
||||
assertEquals(ShardStructure.Shipped.cardElevation, elevation(""))
|
||||
// One length is an offset, not a blur — an incomplete value is not a
|
||||
// reason to flatten every card on the shard.
|
||||
assertEquals(ShardStructure.Shipped.cardElevation, elevation("0 14px"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shipped depth is the runic-gateway default, not flat`() {
|
||||
// The one deliberate departure from §2: the app has been flat since M5
|
||||
// (material3's filled Card is Level0 and FeatureCard drew no shadow),
|
||||
// while the preset the app was drawn from selects the "Default" shadow.
|
||||
// §5.4 is applied as written, so an untouched instance gains this depth.
|
||||
assertEquals(4.dp, ShardStructure.Shipped.cardElevation)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.theme
|
||||
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
import androidx.compose.material3.Typography as MaterialTypography
|
||||
|
||||
/**
|
||||
* [ShardTypeface.resolve] and [shardTypography] — the font third of the theme
|
||||
* (§5.3).
|
||||
*
|
||||
* Like the shape scale and unlike the color scheme, material3's [MaterialTypography]
|
||||
* implements `equals` (checked in the 1.3.0 bytecode), so the no-op proof is one
|
||||
* comparison against a **verbatim copy of the pre-M12 scale** kept in this file.
|
||||
* Copying it rather than referencing `shardTypography(Shipped)` is the point: the
|
||||
* assertion is against what the app used to draw, so a stray edit to a size or a
|
||||
* letter-spacing in `Type.kt` fails here rather than quietly redefining "shipped".
|
||||
*/
|
||||
class ShardTypefaceTest {
|
||||
|
||||
// ── the stacks, exactly as the server publishes them ──────────────────────
|
||||
|
||||
private val runicGateway = mapOf(
|
||||
"--serif" to "Georgia, \"Times New Roman\", serif",
|
||||
"--display" to "Cinzel, Georgia, serif",
|
||||
"--sans" to "\"Helvetica Neue\", Arial, sans-serif",
|
||||
)
|
||||
|
||||
private val fantasy = mapOf(
|
||||
"--serif" to "'EB Garamond', Georgia, serif",
|
||||
"--display" to "Cinzel, Georgia, serif",
|
||||
"--sans" to "'EB Garamond', Georgia, serif",
|
||||
)
|
||||
|
||||
private val modern = mapOf(
|
||||
"--serif" to "Inter, Arial, sans-serif",
|
||||
"--display" to "'Work Sans', Arial, sans-serif",
|
||||
"--sans" to "Inter, Arial, sans-serif",
|
||||
)
|
||||
|
||||
// ── the no-op proof (AC-1) ────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `no theme resolves to the shipped families`() {
|
||||
assertEquals(ShardTypeface.Shipped, ShardTypeface.resolve(emptyMap()))
|
||||
assertSame(Cinzel, ShardTypeface.Shipped.display)
|
||||
assertSame(FontFamily.Serif, ShardTypeface.Shipped.serif)
|
||||
assertSame(FontFamily.SansSerif, ShardTypeface.Shipped.sans)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shipped scale is the pre-M12 scale`() {
|
||||
assertEquals(preM12Typography, shardTypography(ShardTypeface.Shipped))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the runic-gateway preset is a no-op`() {
|
||||
assertEquals(ShardTypeface.Shipped, ShardTypeface.resolve(runicGateway))
|
||||
assertEquals(preM12Typography, shardTypography(ShardTypeface.resolve(runicGateway)))
|
||||
}
|
||||
|
||||
// ── the presets that bypass the per-role dropdown ─────────────────────────
|
||||
|
||||
@Test
|
||||
fun `the fantasy preset puts EB Garamond in the serif and sans roles`() {
|
||||
val faces = ShardTypeface.resolve(fantasy)
|
||||
assertSame(Cinzel, faces.display)
|
||||
assertSame(EBGaramond, faces.serif)
|
||||
// Not an option the sans dropdown offers — a preset's tokens are copied
|
||||
// verbatim and never pass through it.
|
||||
assertSame(EBGaramond, faces.sans)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the modern preset puts Work Sans in the display role and Inter in the serif role`() {
|
||||
val faces = ShardTypeface.resolve(modern)
|
||||
// Neither of these is in its role's FONT_OPTIONS list.
|
||||
assertSame(WorkSans, faces.display)
|
||||
assertSame(Inter, faces.serif)
|
||||
assertSame(Inter, faces.sans)
|
||||
}
|
||||
|
||||
// ── every option on the shortlist ─────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `every serif option resolves`() {
|
||||
assertSame(EBGaramond, serifFor("'EB Garamond', Georgia, serif"))
|
||||
assertSame(Merriweather, serifFor("Merriweather, Georgia, serif"))
|
||||
assertSame(PlayfairDisplay, serifFor("'Playfair Display', Georgia, serif"))
|
||||
assertSame(IMFellEnglish, serifFor("'IM Fell English', Georgia, serif"))
|
||||
assertSame(FontFamily.Serif, serifFor("Georgia, \"Times New Roman\", serif"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every display option resolves`() {
|
||||
assertSame(Cinzel, displayFor("Cinzel, Georgia, serif"))
|
||||
assertSame(PlayfairDisplay, displayFor("'Playfair Display', Georgia, serif"))
|
||||
assertSame(EBGaramond, displayFor("'EB Garamond', Georgia, serif"))
|
||||
assertSame(IMFellEnglish, displayFor("'IM Fell English', Georgia, serif"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every sans option resolves`() {
|
||||
assertSame(Inter, sansFor("Inter, Arial, sans-serif"))
|
||||
assertSame(WorkSans, sansFor("'Work Sans', Arial, sans-serif"))
|
||||
assertSame(SourceSans3, sansFor("'Source Sans 3', Arial, sans-serif"))
|
||||
assertSame(FontFamily.SansSerif, sansFor("\"Helvetica Neue\", Arial, sans-serif"))
|
||||
}
|
||||
|
||||
// ── parsing: only the first name carries the choice ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `the fallback chain after the first comma is ignored`() {
|
||||
// Same family however the web fallbacks are written, and a serif stack in
|
||||
// the sans role still resolves to what it names.
|
||||
assertSame(Merriweather, sansFor("Merriweather, Georgia, serif"))
|
||||
assertSame(Merriweather, sansFor("Merriweather"))
|
||||
assertSame(Merriweather, sansFor("Merriweather , whatever , serif"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `quoting and casing do not matter`() {
|
||||
assertSame(WorkSans, sansFor("'Work Sans', Arial, sans-serif"))
|
||||
assertSame(WorkSans, sansFor("\"Work Sans\", Arial, sans-serif"))
|
||||
assertSame(WorkSans, sansFor("Work Sans, Arial, sans-serif"))
|
||||
assertSame(WorkSans, sansFor(" 'WORK SANS' , Arial "))
|
||||
}
|
||||
|
||||
// ── per-field fallback (AC-2) ─────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `an unreadable role falls back to its own shipped family, not another role's`() {
|
||||
val faces = ShardTypeface.resolve(
|
||||
mapOf(
|
||||
"--display" to "'Playfair Display', Georgia, serif",
|
||||
"--serif" to "Comic Sans MS, cursive",
|
||||
"--sans" to "",
|
||||
),
|
||||
)
|
||||
assertSame(PlayfairDisplay, faces.display)
|
||||
assertSame(ShardTypeface.Shipped.serif, faces.serif)
|
||||
assertSame(ShardTypeface.Shipped.sans, faces.sans)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a theme with no font tokens keeps all three shipped families`() {
|
||||
// A non-empty theme that touches only colors must not disturb the type.
|
||||
assertEquals(
|
||||
ShardTypeface.Shipped,
|
||||
ShardTypeface.resolve(mapOf("--accent" to "#c9a227")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank or comma-only stack falls back`() {
|
||||
assertSame(ShardTypeface.Shipped.sans, sansFor(" "))
|
||||
assertSame(ShardTypeface.Shipped.sans, sansFor(","))
|
||||
assertSame(ShardTypeface.Shipped.sans, sansFor("'', Arial, sans-serif"))
|
||||
}
|
||||
|
||||
// ── the scale itself only ever changes family ─────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `a themed scale differs from the shipped one only in its families`() {
|
||||
val themed = shardTypography(ShardTypeface.resolve(fantasy))
|
||||
assertNotEquals(preM12Typography, themed)
|
||||
assertEquals(
|
||||
preM12Typography.bodyLarge,
|
||||
themed.bodyLarge.copy(fontFamily = ShardTypeface.Shipped.serif),
|
||||
)
|
||||
assertEquals(
|
||||
preM12Typography.labelLarge,
|
||||
themed.labelLarge.copy(fontFamily = ShardTypeface.Shipped.sans),
|
||||
)
|
||||
assertEquals(
|
||||
preM12Typography.displayLarge,
|
||||
themed.displayLarge.copy(fontFamily = ShardTypeface.Shipped.display),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each role reaches the styles it owns`() {
|
||||
val themed = shardTypography(ShardTypeface.resolve(modern))
|
||||
// display/headline/title
|
||||
assertSame(WorkSans, themed.displaySmall.fontFamily)
|
||||
assertSame(WorkSans, themed.headlineMedium.fontFamily)
|
||||
assertSame(WorkSans, themed.titleSmall.fontFamily)
|
||||
// body
|
||||
assertSame(Inter, themed.bodyLarge.fontFamily)
|
||||
assertSame(Inter, themed.bodySmall.fontFamily)
|
||||
// labels
|
||||
assertSame(Inter, themed.labelLarge.fontFamily)
|
||||
assertSame(Inter, themed.labelSmall.fontFamily)
|
||||
}
|
||||
|
||||
private fun serifFor(stack: String) =
|
||||
ShardTypeface.resolve(mapOf("--serif" to stack)).serif
|
||||
|
||||
private fun displayFor(stack: String) =
|
||||
ShardTypeface.resolve(mapOf("--display" to stack)).display
|
||||
|
||||
private fun sansFor(stack: String) =
|
||||
ShardTypeface.resolve(mapOf("--sans" to stack)).sans
|
||||
|
||||
/**
|
||||
* The type scale exactly as `ui/theme/Type.kt` declared it before M12, with the
|
||||
* three families it named directly.
|
||||
*/
|
||||
private val preM12Typography = MaterialTypography(
|
||||
displayLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
|
||||
),
|
||||
displayMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
|
||||
),
|
||||
displaySmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
headlineSmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
titleMedium = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
titleSmall = TextStyle(
|
||||
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
|
||||
),
|
||||
bodyLarge = TextStyle(
|
||||
fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodyMedium = TextStyle(
|
||||
fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
|
||||
),
|
||||
bodySmall = TextStyle(
|
||||
fontFamily = FontFamily.Serif, fontWeight = FontWeight.Normal,
|
||||
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
|
||||
),
|
||||
labelLarge = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
|
||||
),
|
||||
labelMedium = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.SansSerif, fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.util
|
||||
|
||||
import com.runicgateway.app.core.inbox.InboxCache
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
|
||||
/**
|
||||
* In-memory [InboxCache] for the inbox view-model tests — the same shape of stand-in
|
||||
* `SessionManagerTest` uses for the encrypted token store.
|
||||
*
|
||||
* It keeps the real implementation's ONE load-bearing rule: a snapshot is handed
|
||||
* back only to the owner that wrote it. A fake that ignored the key would let the
|
||||
* cross-account test pass against a cache that leaks.
|
||||
*/
|
||||
class FakeInboxCache : InboxCache {
|
||||
|
||||
private var owner: String? = null
|
||||
private var snapshot: InboxCache.Snapshot? = null
|
||||
|
||||
var writes: Int = 0
|
||||
var cleared: Int = 0
|
||||
|
||||
override suspend fun read(owner: String): InboxCache.Snapshot? =
|
||||
if (this.owner == owner) snapshot else null
|
||||
|
||||
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
|
||||
writes++
|
||||
this.owner = owner
|
||||
snapshot = InboxCache.Snapshot(
|
||||
items = items.take(InboxCache.MAX_ITEMS),
|
||||
unread = unread,
|
||||
savedAt = 1_700_000_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
cleared++
|
||||
owner = null
|
||||
snapshot = null
|
||||
}
|
||||
|
||||
/** Seed a snapshot as if a previous session had pulled one. */
|
||||
suspend fun seed(owner: String, items: List<NotificationItemDto>, unread: Int) {
|
||||
write(owner, items, unread)
|
||||
writes = 0
|
||||
}
|
||||
}
|
||||
@@ -32,10 +32,17 @@ sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/j
|
||||
# tests), and Android-framework glue (Keystore-backed stores, foreground push service,
|
||||
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
|
||||
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
|
||||
#
|
||||
# ui/theme/ is excluded FILE BY FILE, not as a directory. It held only constants and
|
||||
# composables when COVERAGE_PLAN.md §2 phase 0 drew the list; M12 added three pure
|
||||
# resolvers to it (ShardPalette, ShardStructure, ShardTypeface) which are the
|
||||
# milestone's core logic and are covered 98–100%. A `ui/theme/**` glob would drop them
|
||||
# out of the denominator and hide a future regression in them. Theme.kt is the one
|
||||
# composable left in the directory.
|
||||
sonar.coverage.exclusions=\
|
||||
app/src/main/java/**/ui/**/*Screen.kt,\
|
||||
app/src/main/java/**/ui/**/*Screen*.kt,\
|
||||
app/src/main/java/**/ui/theme/**,\
|
||||
app/src/main/java/**/ui/theme/Theme.kt,\
|
||||
app/src/main/java/**/ui/components/**,\
|
||||
app/src/main/java/**/ui/page/BlockRenderer.kt,\
|
||||
app/src/main/java/**/ui/shard/ShardComponents.kt,\
|
||||
|
||||
Reference in New Issue
Block a user