Phase 5, first step, of docs/material-design-conformance.md. Two absences, both structural.
**Sixteen copies of the same dead end.** The tree held sixteen instances of
Column(horizontalAlignment = CenterHorizontally) {
Spacer(Modifier.height(48.dp))
Text("Something went wrong")
}
and five of the same shape saying "No events were found". **Not one of the sixteen offered
a retry.** Every failure in this app named no cause and had no way forward but the back
button.
`ErrorState` and `EmptyState` replace all 21. Deliberately plain -- an icon, a line, and
for errors an action when the caller has one to give. `ErrorState`'s `onRetry` is nullable
so that passing null is a *decision* a reader can see, rather than the absence of a
parameter nobody thought about.
`EmptyState`'s message is **required**, with no default, and that is the point of the
change rather than a detail. "No events were found" was shown for five different absences:
nobody you follow, nobody following you, an empty feed, no replies, no search results. A
shared default would have preserved exactly that. They now read "You aren't following
anyone yet.", "Nobody is following you yet.", "Nothing in this feed yet.", "No replies to
this yet." and "Nothing matched that search." -- and `no_events_were_found` is deleted.
**Zero snackbars across 43 Scaffolds.** No `Snackbar`, no `SnackbarHost`, no
`SnackbarHostState` anywhere. Every transient outcome -- an invite failing, a key package
published, a message not sent -- had nowhere to be reported, so the code either said
nothing or navigated away and hoped.
`LocalSnackbarHostState` is a composition local rather than a parameter because of where
the reporting happens: a view model coroutine finishing a call is several composables below
the `Scaffold` that owns the host, and threading the state down would be the same plumbing
repeated 43 times and forgotten on the 44th. One host is provided in `MantraApp`; only one
Scaffold is composed at a time under a NavHost, so the message renders on whichever screen
is on top.
It **throws** rather than defaulting to a detached `SnackbarHostState()`. A default would
make `notify(...)` a silent no-op on any screen that forgot the host, which is precisely
the failure this file exists to end.
**Wired to a real action, not left as infrastructure.** `publishNewKeyPackage` and
`rotateKeyPackage` were fire and forget: you tapped, a coroutine ran, and nothing on screen
changed -- indistinguishable from a tap that missed. Both take an `onDone` and the screen
reports it. Verified on emulator-5554: tapping Publish shows "Key package published" and
the count goes 2 -> 3.
**Externalising the strings made four copy problems visible, which is the argument for
having done it.** With 364 strings in one file rather than scattered through 60
composables, `%1$s Key Packages`, `replying To %1$s` and **three surviving mentions of the
old product name** were sitting in plain sight. All corrected. (They had been fixed once
already and lost: the previous commit reverted the tree to fix an unrelated import bug and
re-ran the extractor over the original text. Worth recording, because it is what a
revert-and-redo costs when a script is the thing being iterated on.)
**And it made the title-case checker stop covering anything.** `m3-title-case.py` scanned
`.kt` files, so when phase 4 moved the strings out it went on reporting zero while the four
above sat in `strings.xml`. It now reads the catalogue too, and that path is verified by
flipping one entry to "Try Again" and watching it fail. Externalising narrows what a source
scan can see; the check has to follow.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, unchanged. The state
composables and the snackbar host are composition-time behaviour and this repo has no
Compose UI test infrastructure; what stands in for it is the device run above.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
4.4 KiB
Python
Executable File
100 lines
4.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Find UI strings still written in title case.
|
|
|
|
M3's style guide: "All text, including titles, headings, labels, menu items, navigation
|
|
components, app bars, and buttons should use sentence-style capitalization." Product names
|
|
and branded terms keep their capitals.
|
|
|
|
Two things this gets right that the first, grep-based version did not:
|
|
|
|
- It allows lowercase articles inside a title-cased phrase, so "Invite a Friend" is
|
|
caught. Requiring every word after the first to be capitalised missed four strings.
|
|
- It scans the whole file rather than one line at a time, so a `Text(` whose literal is
|
|
on the next line is caught. That missed one more.
|
|
|
|
Sample data is excluded by name rather than by pattern, because "Steve Biko" and "To Kill
|
|
a Mockingbird" are title case for the correct reason: they are a person and a book.
|
|
|
|
Usage: m3-title-case.py [--list]
|
|
"""
|
|
import os, re, sys
|
|
|
|
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
|
|
CATALOGUE = 'composeApp/src/commonMain/composeResources/values/strings.xml'
|
|
|
|
SMALL = {'a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'via',
|
|
'in', 'on', 'at', 'with', 'from', 'by'}
|
|
|
|
# People, book titles and other proper nouns used as preview and test fixtures.
|
|
SAMPLE = {
|
|
'Steve Biko', 'John Doe', 'Frank Talk', 'Alan Turing',
|
|
'To Kill a Mockingbird', 'Man With A Plan', 'Woman Of Few Words',
|
|
}
|
|
|
|
|
|
def offenders():
|
|
found = []
|
|
# The catalogue, first. When phase 4 moved 364 strings out of composables it moved
|
|
# them out of this checker's reach at the same time -- it scanned .kt files only, so
|
|
# it went on reporting zero while "%1$s Key Packages" and three surviving mentions of
|
|
# the old product name sat in strings.xml. Externalising narrows what a source scan
|
|
# can see; the check has to follow.
|
|
if os.path.exists(CATALOGUE):
|
|
for m in re.finditer(r'<string name="([^"]+)">([^<]*)</string>',
|
|
open(CATALOGUE, encoding='utf-8').read()):
|
|
phrase = m.group(2)
|
|
if phrase in SAMPLE:
|
|
continue
|
|
words = [w for w in re.sub(r'%\d+\$s', '', phrase).split() if w]
|
|
if len(words) < 2 or not words[0][:1].isupper():
|
|
continue
|
|
later = [w for w in words[1:] if w.lower() not in SMALL]
|
|
if not later:
|
|
continue
|
|
if all(w[:1].isupper() for w in later) and \
|
|
any(w[:1].isupper() and w[1:].islower() for w in later):
|
|
found.append((CATALOGUE, m.group(1), phrase))
|
|
for root, _, files in os.walk(UI):
|
|
for f in sorted(files):
|
|
if not f.endswith('.kt'):
|
|
continue
|
|
path = os.path.join(root, f)
|
|
text = open(path, encoding='utf-8').read()
|
|
for m in re.finditer(r'"([^"$\\]{4,90})"', text):
|
|
phrase = m.group(1)
|
|
if phrase in SAMPLE:
|
|
continue
|
|
line_start = text.rfind('\n', 0, m.start()) + 1
|
|
line = text[line_start:text.find('\n', m.start())]
|
|
if line.lstrip().startswith(('//', '*', '/*')):
|
|
continue
|
|
# A log line is not UI copy. `logger.d("Queried Sync")` is written for
|
|
# whoever is reading logcat, and sentence-casing it would be cargo cult.
|
|
if re.search(r'\blogger\s*\.\s*[dewiv]\s*\(', line):
|
|
continue
|
|
words = phrase.split()
|
|
if len(words) < 2 or not words[0][:1].isupper():
|
|
continue
|
|
later = [w for w in words[1:] if w.lower() not in SMALL]
|
|
if not later:
|
|
continue
|
|
# Title case: every significant word capitalised, and at least one of
|
|
# them an ordinary capitalised word rather than an acronym like NIP.
|
|
if all(w[:1].isupper() for w in later) and \
|
|
any(w[:1].isupper() and w[1:].islower() for w in later):
|
|
found.append((path, text[:m.start()].count('\n') + 1, phrase))
|
|
return found
|
|
|
|
|
|
def main():
|
|
found = offenders()
|
|
print(f' {"Title Case in UI strings":42s} {len(found):6d}')
|
|
if '--list' in sys.argv:
|
|
for path, line, phrase in found:
|
|
print(f' {path.replace(UI + "/", "")}:{line} {phrase!r}')
|
|
return 1 if found else 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|