Build APK / build (push) Successful in 5m36s
- The avatar menu card's border was going missing wherever an opaque row sat against it (every corner but the header's) - a Container combining `border` with its own `clipBehavior` paints the border as part of the outer decoration, then the clipped child on top right up to the same boundary, with no gap for the border's stroke to show through. Fixed with the standard pattern: no clipBehavior on the bordered Container, a 1px-inset ClipRRect around the filled, clipped content instead. - NooTopBar.androidTitleTrailing (renamed from androidTitleReplacement) now sits beside the tab title instead of replacing it - the title keeps its natural width (Flexible, ellipsizing only if truly out of room) with the inline search bar taking the remaining space to its right, rather than the title disappearing whenever search moves into the top bar. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
327 lines
21 KiB
Markdown
327 lines
21 KiB
Markdown
# Styling
|
||
|
||
**Migration in progress:** the target look is
|
||
[`design-system/DESIGN_SYSTEM.md`](design-system/DESIGN_SYSTEM.md) — warm
|
||
neutrals, one violet accent, pill controls, Schibsted Grotesk/Instrument
|
||
Sans, no gradients/shadows — built from the `widgets/noo/` component kit
|
||
(see "Noo design-system components" below). Rebuilt so far: the app shell
|
||
(top/bottom bars, drawer, sidebar, toolbar), Files/Offline, Photos,
|
||
Favorites, Recent, Activity, Trash, Shares, Settings, the lock screen,
|
||
login, `ShareUploadView`, `MoveCopyDestinationPicker`,
|
||
`MoveCopyConflictSheet`, the shared `Breadcrumbs` widget, and the file
|
||
details/share bottom sheets/dialogs (`DetailsSheet`/`ShareSheet`). **Still on
|
||
the pre-rework Material 3 theme** documented in "Theme" and "Reusable
|
||
chrome" below: `FileViewerScreen` (the media viewer), `SearchView`, and
|
||
`LoginWebviewView`. Update this file to describe each area as it gets
|
||
reworked, rather than leaving stale Material 3 guidance next to a design
|
||
system that's already superseded it.
|
||
|
||
## Theme
|
||
|
||
All theming goes through [`AppTheme`](../../lib/theme/app_theme.dart)
|
||
(`AppTheme.light`/`AppTheme.dark`) — don't set colors/fonts ad hoc in
|
||
widgets. Key points:
|
||
|
||
- **Material 3**, seed-color based. `AppTheme.seedColors` is the picker list
|
||
users choose from; `defaultNextcloudBlue` (`#0082C9`) is the fallback.
|
||
- **Dynamic color** (Android 12+ Material You / desktop accent color) is
|
||
supported via `package:dynamic_color`'s `DynamicColorBuilder` wrapping the
|
||
whole app in `main.dart`. When available and `useDynamicColor` is on, the
|
||
OS-provided `ColorScheme` wins over the seed color — always thread both
|
||
`dynamicScheme` and `useDynamicColor` through when adding a theme knob.
|
||
- **Font**: Inter via `google_fonts`, applied through
|
||
`GoogleFonts.interTextTheme(...)`. In tests, set
|
||
`GoogleFonts.config.allowRuntimeFetching = false` in `setUpAll` — without
|
||
it, the font-fetch call to Google's CDN can stall `pumpAndSettle`
|
||
indefinitely (see `standards.md`).
|
||
- **Cards**: flat (`elevation: 0`), 20px rounded corners,
|
||
`surfaceContainerLow`.
|
||
- **App bars**: flat, not centered, `surface` background.
|
||
- Two Flutter defaults are deliberately overridden app-wide rather than
|
||
per-widget, each with a comment explaining why in `app_theme.dart`:
|
||
Android predictive-back page transitions, and the non-2023 `SliderTheme`.
|
||
Follow that pattern (a themed default + a comment) instead of overriding
|
||
per-instance if you need the same behavior elsewhere.
|
||
- Dark theme supports an `amoled` flag that flattens every surface tone to
|
||
pure black — extend `colorScheme.copyWith(...)` there if a new surface
|
||
role needs the same treatment, don't hardcode `Colors.black` at call sites.
|
||
- **Scrollbars are deliberately not shown** — every scrollable list in the
|
||
app is a plain `ListView`/`CustomScrollView` with no `Scrollbar` wrapper
|
||
and no `scrollbarTheme` override (tried once, explicitly reverted). Don't
|
||
reintroduce one without being asked.
|
||
|
||
## Noo design-system components
|
||
|
||
The rebuilt UI is assembled from `lib/widgets/noo/`, which implements
|
||
`DESIGN_SYSTEM.md` §2–3. Build new screens from these rather than from raw
|
||
Material widgets or the pre-rework chrome below.
|
||
|
||
- **Tokens** live in [`design_tokens.dart`](../../lib/theme/design_tokens.dart):
|
||
colors via `context.nooColors` (a `NooColors` `ThemeExtension`), plus
|
||
`NooText`, `NooSpace`, `NooRadii`, `NooSizes`, `NooMotion` and
|
||
`nooDialogShadow`. `NooText` styles set no color; callers add it with
|
||
`copyWith(color: ...)`. Flutter's `TextStyle.height` is a multiple of font
|
||
size, so the spec's CSS line-height maps to it directly (for example,
|
||
0.9 → `height: 0.9`).
|
||
- **Icons are Lucide** (`lucide_icons_flutter`, `LucideIcons.*`) inside
|
||
`noo/`, never Material `Icons.*`.
|
||
- **Components are data-agnostic.** They take strings, icons, colors and
|
||
callbacks, not `NextcloudItem`/`AppTab`, so the screen layer maps models
|
||
onto them.
|
||
- **Platform differences** are chosen with a flag rather than by reading the
|
||
platform inside the widget: `NooNavStyle` (`ios`/`android`, with
|
||
`NooNavStyle.fromPlatform`) for nav chrome, and `iosStyle` on rows and
|
||
cards for the ellipsis vs. vertical-ellipsis overflow icon.
|
||
|
||
Catalog:
|
||
|
||
| Folder | Components |
|
||
|---|---|
|
||
| `core/` | `NooButton`, `NooFab`, `NooChip`, `NooSegmentedControl`, `NooToggle`, `NooSearchField`, `NooAvatar`, `NooBadge`, `NooProgressBar` |
|
||
| `lists/` | `NooGroupedList`, `NooSettingsRow`, `NooTabOrderRow`, `NooBanner`, `NooSummaryCard`, `NooSelectionBar` |
|
||
| `files/` | `NooFileKind` (spec §1.2 tiles; `NooFileKind.from(name:, mimeType:, isDirectory:)`), `NooFileTile`, `NooStatusIcon`/`NooSyncStatus`, `NooFileRow` (mobile 64px), `NooFileTableHeader`/`NooFileTableRow` (desktop), `NooSwipeAction` |
|
||
| `media/` | `NooGridCard`, `NooPhotoTile` (video badge, selection), `NooPhotoGroupHeader`/`NooPhotoGrid` (sliver, or `.box`), `NooActivityItem`, `NooStatCard` |
|
||
| `nav/` | `NooBottomBar`, `NooTopBar` (a `PreferredSizeWidget`) with `NooTopBarButton`/`NooTopBarBack`, `NooDrawer` with its `Account`/`Storage`/`Item`/`Link` parts, `NooSidebar` with `NooSidebarItem`/`Divider`/`Account`/`Storage`, `NooToolbar` |
|
||
| `overlays/` | `showNooSheet`, `showNooDialog`/`NooDialog`, `NooOverlayHeader`, `NooTextField`, and the share parts `NooShareSection`, `NooPersonAccessRow`, `NooPermissionPill` |
|
||
|
||
Gotchas:
|
||
- `NooDrawer` can't set its own scrim. The host `Scaffold` needs
|
||
`drawerScrimColor: context.nooColors.scrim`.
|
||
- `NooGroupedList` draws dividers by showing `line` through 1px gaps, so each
|
||
child must paint its own surface (`NooSettingsRow` and `NooTabOrderRow`
|
||
do). Its `collapsible`/`initiallyExpanded` params (off by default) make
|
||
`label` a tap target that shows/hides the card - no current caller opts
|
||
in (Settings' mobile sections used to, when every section rendered
|
||
inline in one long column; now each section is its own pushed screen -
|
||
see `account_view.dart`'s doc comment - so there's nothing left to
|
||
collapse). The params stay on the component itself since it's otherwise
|
||
generic.
|
||
- `NooSwipeAction` reveals its action on a normal swipe (tap the block to
|
||
trigger it) but also fires it directly if the drag goes far enough (past
|
||
1.8x the block's width) before release - a full swipe-through does delete
|
||
in one gesture, it's not tap-only anymore.
|
||
- Window chrome (macOS traffic lights, the Windows 40px title bar) isn't
|
||
built yet. `NooSidebar.windowControls` is the slot for it.
|
||
- Always read colors through `context.nooColors`, never
|
||
`Theme.of(context).colorScheme` - the latter is Flutter's own Material 3
|
||
scheme, reseeded by the user's accent color choice (`AppTheme.light`/
|
||
`.dark`), so anything painted from it carries a faint hue of whatever
|
||
accent is picked instead of the app's neutral palette. `sticky_header_delegate.dart`
|
||
shipped with this bug once already (a pinned controls-row header tinted
|
||
by the accent instead of matching its screen's plain `colors.bg`).
|
||
- `NooSegmentedControl` defaults to `onSurface: false` (`colors.surface`
|
||
fill) - right when it's sitting directly on `colors.bg` (List/Grid
|
||
toggle, Shares' scope switcher), but pass `onSurface: true` for one
|
||
placed inside a sheet or dialog (already `colors.surface`), or its pill
|
||
track blends invisibly into the sheet instead of reading as a grouped
|
||
control (Files'/Photos' sort and type-filter sheets do this).
|
||
- Any `RefreshIndicator` needs `physics: const AlwaysScrollableScrollPhysics()`
|
||
on its scrollable child, or pull-to-refresh silently can't be triggered
|
||
once the list is short enough to fit the viewport (empty, or one item) -
|
||
every tab's `CustomScrollView` sets this explicitly for exactly that
|
||
reason.
|
||
|
||
## Reusable chrome
|
||
|
||
**Rebuilt on the Noo design system** (Files/Offline, Photos, Favorites,
|
||
Recent, Activity, Trash, Shares, Settings, the app shell, lock screen,
|
||
login, `ShareUploadView`, `MoveCopyDestinationPicker`, `DetailsSheet` and
|
||
`ShareSheet`): these no longer use the pieces below. Their own building
|
||
blocks are noted where they matter:
|
||
|
||
- `FilesControlsRow` (`lib/widgets/files_controls_row.dart`) — now built from
|
||
`NooChip`/`NooSegmentedControl`; still the sort/hidden/scope/type-filter/
|
||
view-mode row shared by Files and Offline (`showStorageScope: false` for
|
||
Offline), and reused as-is by Favorites.
|
||
- `lib/widgets/files/file_breadcrumb_row.dart` — the noo-styled breadcrumb
|
||
trail Files uses in place of the shared
|
||
[`Breadcrumbs`](../../lib/widgets/breadcrumbs.dart) widget. `Breadcrumbs`
|
||
is now also noo-styled (same tokens, private-widget-turned-shared) — it's
|
||
used only by `ShareUploadView`/`MoveCopyDestinationPicker`, which is why
|
||
it was safe to restyle directly instead of forking another
|
||
`FileBreadcrumbRow`-style copy; don't move Files back onto it.
|
||
`lib/widgets/tabs/` (`tab_state_slivers.dart`, `tab_day_groups.dart`,
|
||
`tab_location.dart`) — the loading/error/empty-state slivers and
|
||
day/month grouping helpers shared by Recent/Activity/Trash/Shares.
|
||
`tab_state_slivers.dart` also has `topBarSliver`, which every regular
|
||
tab's view uses to plant its own `AppTopBar` instance as the first sliver
|
||
in its `CustomScrollView` (a thin wrapper around the framework's
|
||
`SliverFloatingHeader`) instead of the shell passing one shared instance
|
||
to `Scaffold.appBar` - see its doc comment and `architecture.md`'s
|
||
"Mobile" bullet for why.
|
||
`lib/widgets/settings/` — Settings' 8 section widgets plus
|
||
`settings_section.dart`'s `SettingsSection`/`showSettingsPicker` and
|
||
`settings_dialogs.dart`'s `confirmRemoveAccount`.
|
||
`lib/widgets/shell/shell_common.dart` — account/storage formatting,
|
||
`openSettings`/`openSearch`, `showAccountSwitcher`, `ShellAvatarButton`,
|
||
`ShellSearchLauncher`, shared by the mobile and desktop shell chrome.
|
||
- [`getItemIcon`/`getIconColor`/`ItemThumbnail`](../../lib/widgets/item_icon.dart)
|
||
— `getItemIcon`/`getIconColor` are superseded by `NooFileKind` in
|
||
rebuilt screens; `ItemThumbnail` is still reused as-is, fed into
|
||
`NooFileTile`/`NooFileRow`/`NooFileTableRow`/`NooGridCard`'s `thumbnail`
|
||
slot. `ShareUploadView`/`MoveCopyDestinationPicker` don't use any of the
|
||
three any more - both destination pickers now list folders only (see
|
||
below), and a folder never gets a real thumbnail (only image/video do),
|
||
so a plain `NooFileTile(kind: NooFileKind.folder)` covers every row.
|
||
- `DetailsSheet`/`ShareSheet` (`lib/widgets/details/details_sheet.dart`,
|
||
`lib/widgets/share_sheet.dart`) are rebuilt: `showNooSheet`/`showNooDialog`
|
||
per `NooLayout.isDesktop`, with the header built from `detailsFileTile`
|
||
(a `NooFileTile` keyed by `NooFileKind.from`) and `detailsMetaLine` -
|
||
shared top-level helpers in `details_sheet.dart` so both sheets open on
|
||
the same header, replacing the old `DetailsHeader` widget. Neither sheet
|
||
uses `showGradualBottomSheet`'s drag-to-resize any more: `DetailsSheet`
|
||
swaps Info/Versions/Activity with a `NooSegmentedControl` (there's no
|
||
tab-strip component in the noo kit) instead of a `TabBar`/`TabBarView`,
|
||
and both sheets' content sits in one `Column` so `showNooSheet`'s/
|
||
`showNooDialog`'s own `SingleChildScrollView` handles overflow - a fixed
|
||
page per tab no longer needs a resizable sheet to see the rest.
|
||
`showGradualBottomSheet` (`lib/widgets/gradual_bottom_sheet.dart`) has no
|
||
remaining callers as a result.
|
||
`ShareSheet`'s "Share with people"/"Share link"/"Send file directly"
|
||
sections follow `DESIGN_SYSTEM.md` §4 via `NooShareSection`/
|
||
`NooPersonAccessRow`/`NooPermissionPill`; the permission pill and the
|
||
link's permission/expiry chips are read-only display (no
|
||
`ItemOperations` call updates a share's permission/expiry/password/
|
||
hide-download yet) - promote those to real controls once that exists.
|
||
Removing a person/group/email share is reachable by tapping their
|
||
permission pill, which opens a small "Remove access" menu.
|
||
[`MoveCopyConflictSheet`](../../lib/widgets/move_copy_conflict_sheet.dart)
|
||
is rebuilt: `showNooDialog`/`showNooSheet` (per `NooLayout.isDesktop`), a
|
||
`NooGroupedList` of file-tile rows with an inline
|
||
`NooSegmentedControl<ConflictChoice>` once "Decide per item" is picked,
|
||
and `NooButton`s for overwrite-all/keep-both/decide-per-item/confirm. Its
|
||
`show(BuildContext, List<MoveCopyConflict>)` API is unchanged.
|
||
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
||
the blurred/translucent pill background for the media viewer's top/bottom
|
||
bars and video transport controls (its other former user, the floating
|
||
bottom nav bar, is gone - see below). Reuse this for any new floating
|
||
overlay instead of building a new blur/shadow combo.
|
||
- `SettingsController.bottomBarOpacity`/`bottomBarBlur` and
|
||
`lib/widgets/floating_bottom_bar.dart`/`media_grid_tile.dart`/
|
||
`swipeable_item.dart`/`sync_status_badge.dart`/`selectable_thumbnail.dart`
|
||
are gone: the bottom bar is now the flat, non-blurred `NooBottomBar` (no
|
||
opacity/blur knob - flat surfaces per the design system), grid tiles are
|
||
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
|
||
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
|
||
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
|
||
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
|
||
don't confuse the two: this one is still non-blurred, just inset with a
|
||
`line` border and `nooDialogShadow` (the app's one other shadow user - see
|
||
that constant's doc comment) instead of edge to edge (no opacity/blur knob
|
||
either). The host `Scaffold` needs `extendBody: true` while it's active
|
||
(`main.dart` already wires this off `SettingsController.bottomBarStyle`),
|
||
which also means every tab's own scrollable list has to reserve enough
|
||
bottom padding to clear the bar - nothing does that automatically once the
|
||
body draws behind it. Use `bottomBarClearance(context)`
|
||
(`tab_state_slivers.dart`) for that rather than a flat `100`; it already
|
||
accounts for both bar styles.
|
||
Settings' separate "Search in bottom bar" toggle
|
||
(`SettingsController.searchInBottomBar`) adds a never-highlighted Search
|
||
entry to either bar style (`NooBottomBar`'s `searchDestination`/
|
||
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
|
||
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
|
||
anywhere that needs the *current* cap on regular tabs.
|
||
- `SettingsController.navMenuStyle` (`NooNavMenuStyle.drawer`/`avatarMenu`,
|
||
Settings → Appearance → "Navigation menu") picks what opens hidden tabs +
|
||
Settings on mobile: the original hamburger-opens-`AppDrawer` pattern, or
|
||
the avatar button opens [`showAvatarMenu`](../../lib/widgets/avatar_menu.dart)
|
||
instead. Wired through `AppTopBar`→`NooTopBar.onMenu` (null in `avatarMenu`
|
||
mode - no menu icon renders at all, see `NooTopBar`'s `lead` logic) and
|
||
`ShellAvatarButton`'s new `onTap`/`label` overrides (`shell_common.dart`) -
|
||
a caller passing a custom `onTap` *must* also pass a matching `label`, or
|
||
the tooltip/semantics still say "Accounts" for a button that no longer
|
||
opens the account switcher. `showAvatarMenu` is this app's first use of
|
||
`showGeneralDialog` directly (`barrierColor: Colors.transparent` +
|
||
`barrierDismissible: true` for a non-dimming click-outside-to-close menu,
|
||
not a modal flow) - there's no existing anchored-popup primitive here
|
||
(`PopupMenuButton`'s own width doesn't stretch to a full content column),
|
||
so don't reach for `showNooSheet`/`showNooDialog` for something shaped
|
||
like this. It's positioned just past the status bar (`SafeArea`'s own
|
||
inset, not the top bar's full height on top of that) so it covers the
|
||
top bar - including the tab title - rather than sitting below it, and
|
||
its card carries two stacked `boxShadow`s rather than just
|
||
`nooDialogShadow` alone: that one shadow's blur is wide and soft enough
|
||
to read as basically invisible on a small card over a dark theme's
|
||
near-black `bg` (a dark, diffuse shadow needs real density close to the
|
||
edge to be visible against an already-dark backdrop), so a second,
|
||
tighter, more opaque contact shadow underneath it gives real elevation
|
||
in both themes. The card's border can go missing wherever an opaque row
|
||
sits against it too (every corner but the header's, which has no
|
||
full-bleed fill of its own) if a `Container` combines `border` with its
|
||
own `clipBehavior` - that paints the border as part of the *outer*
|
||
decoration, then the clipped child on top right up to the same boundary,
|
||
with no gap for the border's own stroke to show through. Fixed the same
|
||
way any bordered-and-clipped `Container` should be: no `clipBehavior` on
|
||
the bordered `Container` itself, and a 1px-inset `ClipRRect` (radius
|
||
reduced by that same 1px) around the filled, clipped content instead, so
|
||
it never paints over the border. Also added
|
||
`NooTopBar.androidTitleTrailing`: Android has no large title to put a
|
||
second search row under the way iOS's `search:` slot does, so an inline
|
||
search bar (`AppTopBar` passes a plain `ShellSearchLauncher()` when
|
||
search isn't in the bottom bar) sits to the title's own right in that
|
||
flexible slot - `Flexible`, not `Expanded`, so the title still shrinks/
|
||
ellipsizes if there's truly no room but doesn't claim more than it needs
|
||
otherwise - instead of the title being replaced by it.
|
||
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||
the pull-to-sync `CustomScrollView` header with the persistent sync-status
|
||
chip and pull-to-refresh gesture/spinner. Every screen (including
|
||
`ShareUploadView`/`MoveCopyDestinationPicker`, its last two users) has
|
||
dropped it for a plain `RefreshIndicator` + `CustomScrollView`
|
||
(device-sync status now shows per-row via `NooStatusIcon`/the Offline
|
||
`NooSummaryCard`, not a shared header chip), so the `SyncedHeaderScaffold`
|
||
class itself is now dead code - kept only because the same file's
|
||
top-level `formatBytes` helper is still widely used
|
||
(`files_view.dart`/`favorites_view.dart`/`shell_common.dart`/
|
||
`widgets/details/*`).
|
||
- [`SegmentedIconGroup`/`ToggleIconButton`](../../lib/widgets/segmented_icon_toggle.dart)
|
||
and [`SortMenuButton`](../../lib/widgets/sort_menu_button.dart) — the
|
||
Material sort/filter-chip pieces `ShareUploadView`/
|
||
`MoveCopyDestinationPicker` used to mirror Files' old controls row with.
|
||
Both destination pickers dropped that whole row (they only ever browse
|
||
folders, so sort/hidden/scope/type-filter/grid controls don't apply), so
|
||
these two files are now dead code too - `Breadcrumbs` is the only shared
|
||
widget promoted to noo styling instead of removed, since Files' own
|
||
`FileBreadcrumbRow` proved the same trail is still wanted elsewhere.
|
||
- [`SeekBarPainter`/`SeekBarPreview`](../../lib/widgets/seek_bar_painter.dart)
|
||
— the four `MediaProgressBarStyle` presets (Default/Wavy/Slim/Squiggly)
|
||
for the video player's seek bar, plus a perpetually-animated
|
||
`SeekBarPreview` wrapper used by the Settings style picker so every
|
||
preview always matches the real widget exactly (same painter, just fed
|
||
demo `progress`/`phase` values). Add new seek-bar presets here, not by
|
||
forking the painter.
|
||
- Chrome inside the media viewer (`file_viewer_screen.dart` — the top bar's
|
||
back button + filename, the bottom action bar, the video transport
|
||
controls) all share one small hand-rolled icon-button pattern
|
||
(`_ActionIconButton`: `InkWell` + `Icon` at a fixed 22px, colored from
|
||
`colorScheme.onSurface` unless overridden) rather than plain `IconButton`s
|
||
— match this instead of adding a bare `IconButton` in that screen, since a
|
||
default-styled one visibly stands out against the rest (this was a real
|
||
bug: an unstyled back button read as "too large" next to everything else).
|
||
- A title/label that might overflow a fixed-width chrome bar (e.g. the media
|
||
viewer's filename) should use `_MarqueeTitle`-style logic — measure with
|
||
`TextPainter` first and only switch to a scrolling `Marquee` when the text
|
||
actually doesn't fit, rather than marqueeing unconditionally.
|
||
- Icons: prefer `Icons.*_rounded` (matches the rest of the app) or
|
||
`material_symbols_icons` where Material Symbols are already in use; avoid
|
||
mixing in the sharp/outlined default set.
|
||
|
||
## Conventions
|
||
|
||
- No hardcoded colors for anything themeable — pull from
|
||
`Theme.of(context).colorScheme`, not `Colors.blue` etc. (per-file-type
|
||
icon tinting in `files_view.dart`'s `_getIconColor` is the one deliberate
|
||
exception, since those colors are content-identity cues, not theme).
|
||
- Use `colorScheme.surfaceContainer*`/`onSurfaceVariant` tokens for
|
||
elevation/secondary text rather than manual opacity on black/white.
|
||
- **Anywhere the app shows its own icon in-app** (splash, lock screen,
|
||
login screen) uses `assets/icon/app_icon_monochrome.png` — a plain white
|
||
silhouette on transparent, tinted via `ColorFiltered(colorFilter:
|
||
ColorFilter.mode(colorScheme.onSurface, BlendMode.srcIn), ...)` so it
|
||
reads correctly in both light and dark mode. Never the full-color
|
||
`app_icon.png`/adaptive-icon assets for in-app UI — those are for the
|
||
launcher icon only. The launcher icon is maintained by hand from an
|
||
IconKitchen export (`mipmap-*` in `android/app/src/main/res`); there is no
|
||
generator step. `assets/icon/app_icon_monochrome.png` is deliberately a
|
||
tightly-cropped glyph (unlike the launcher's safe-zone-padded monochrome
|
||
layer), so it renders at a sensible size at 72-80px.
|