Add multi-account support and update project docs
- Add SavedAccount/AccountStore and rework ServerProvider around a list of saved accounts: switch/remove/add, swipe-to-cycle on the avatar button, per-account browsing prefs (sort/view/filters/cache), and a session-generation guard so a stale fetch from an abandoned account can't land in the newly active one - Migrate existing single-account installs into the new schema automatically so upgrading users stay logged in - Settings gains an Accounts section; LoginView gains an "add account" mode pushed on top of the main shell instead of replacing it - Give every Scrollbar in the app a thicker, draggable, Android-style thumb via a shared ScrollbarTheme instead of the default hairline - Splash screen: use the monochrome app icon (tinted per theme) and a smaller spinner - Update CLAUDE.md and .claude/context/*.md to reflect the accumulated multi-account, sharing, and UI changes across this session, and add a standing instruction to keep these docs current going forward Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,12 +4,18 @@
|
||||
|
||||
```
|
||||
lib/
|
||||
main.dart # app root, theme wiring, top-level navigation switch
|
||||
models/ # plain data classes (NextcloudItem, NextcloudShare, ...)
|
||||
main.dart # app root, theme wiring, top-level navigation switch,
|
||||
# MainShellView (bottom-nav shell), share-intent listener
|
||||
models/ # plain data classes (NextcloudItem, NextcloudShare,
|
||||
# SavedAccount, AppTab, ...)
|
||||
providers/ # ServerProvider — the single app-wide ChangeNotifier
|
||||
services/ # network/IO: NextcloudService, LoginFlowService
|
||||
services/ # network/IO: NextcloudService, LoginFlowService,
|
||||
# AccountStore
|
||||
theme/ # AppTheme (Material 3 ThemeData)
|
||||
views/ # one screen each (FilesView, PhotosView, ...)
|
||||
views/ # one screen each (FilesView, PhotosView, TrashView,
|
||||
# SharesView, RecentView, ActivityView, SearchView,
|
||||
# AccountView, LoginView, FileViewerScreen,
|
||||
# ShareUploadView)
|
||||
widgets/ # reusable pieces shared across views
|
||||
details/ # the file-details bottom sheet and its tabs
|
||||
```
|
||||
@@ -22,38 +28,83 @@ read via `context.watch`/`context.read`.
|
||||
|
||||
## State management
|
||||
|
||||
There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart).
|
||||
It is created once in `main()` and provided at the root with `provider`'s
|
||||
`ChangeNotifierProvider`. It owns:
|
||||
There is exactly one `ChangeNotifier`: [`ServerProvider`](../../lib/providers/server_provider.dart)
|
||||
(~1500 lines). It is created once in `main()` and provided at the root with
|
||||
`provider`'s `ChangeNotifierProvider`. It owns:
|
||||
|
||||
- auth/session state (`isLoggedIn`, `isRestoringSession`, login-flow status)
|
||||
- **Multi-account state**: the list of saved accounts (`accounts`), which one
|
||||
is active (`activeAccountId`/`activeAccount`), and a `_sessionGeneration`
|
||||
counter incremented on every account switch so an in-flight fetch from the
|
||||
account just left can recognize it's stale and discard its result instead
|
||||
of writing into the newly-active account's state — every method that
|
||||
writes fetched data into a shared field (`refreshData`, `fetchAllMedia`,
|
||||
`fetchTrash`, `fetchShares`, `fetchRecent`,
|
||||
`_applyCredentialsForAccount`) captures the generation at entry and checks
|
||||
it before each write. See `server.md` for the full account-switch/storage
|
||||
story.
|
||||
- auth/session state for whichever account is active (`isLoggedIn`,
|
||||
`isRestoringSession`, login-flow status)
|
||||
- the active `NextcloudService` instance (null until logged in)
|
||||
- all fetched data (`items`, `quota`, `activities`)
|
||||
- navigation-within-files state (`currentFolderPath`, `pathStack`)
|
||||
- UI settings that persist across launches (theme mode, seed color, dynamic
|
||||
color toggle, bottom-bar opacity/blur, sort/filter/view-mode prefs)
|
||||
- all fetched data, scoped to the active account (`items`, `quota`,
|
||||
`activities`, `photoItems`, `trashItems`, `shares`, `recentItems`) — all
|
||||
torn down and refetched fresh on every account switch (no simultaneous
|
||||
multi-account state; only one account's content is ever live in memory)
|
||||
- navigation-within-files state (`currentFolderPath`, `pathStack`), plus an
|
||||
in-memory `_directoryCache` keyed by folder path (see `CachePolicy`)
|
||||
- UI settings that persist across launches — split into **global** (theme
|
||||
mode, seed color, dynamic-color toggle, bottom-bar opacity/blur,
|
||||
tap-tab-to-scroll-top, seek bar style, tab order/visibility/default, swipe
|
||||
actions) and **per-account** (grid/list view, favorites-only, show-hidden,
|
||||
storage scope, Photos sort, Files' per-folder sort map, cache policy) — see
|
||||
`server.md` for exactly which is which and why
|
||||
|
||||
New app-wide state belongs on `ServerProvider` as a private field + getter +
|
||||
a method that mutates it and calls `notifyListeners()`. Screen-local state
|
||||
(e.g. a `TextEditingController`, an expanded/collapsed flag) stays in that
|
||||
view's own `State` class — see `standards.md` for the split.
|
||||
New **global** state belongs on `ServerProvider` as a private field + getter
|
||||
+ a method that mutates it, calls `notifyListeners()`, and persists via
|
||||
`_prefsFuture`. New **per-account** state follows the same shape but
|
||||
persists through `_persistAccountPref` (namespaces the pref key under
|
||||
`acct_<activeAccountId>_...` via `AccountStore.accountPrefKey`) and must be
|
||||
reset/reloaded in `_applyAccountPrefs` so it's correct after a switch.
|
||||
Screen-local state (e.g. a `TextEditingController`, an expanded/collapsed
|
||||
flag) stays in that view's own `State` class — see `standards.md` for the
|
||||
split.
|
||||
|
||||
There's no separate repository/data layer: views call `ServerProvider`
|
||||
methods directly, which call `NextcloudService`/`LoginFlowService`.
|
||||
methods directly, which call `NextcloudService`/`LoginFlowService`/
|
||||
`AccountStore`.
|
||||
|
||||
## Navigation / screen flow
|
||||
|
||||
`main.dart`'s `NextcloudApp` picks the app's `home` screen from provider
|
||||
state, no named routes:
|
||||
|
||||
- `provider.isRestoringSession` → `_SplashView` (spinner while
|
||||
`flutter_secure_storage`/`shared_preferences` are read on startup)
|
||||
- `provider.isRestoringSession` → `_SplashView` (monochrome app icon,
|
||||
tinted via `ColorFiltered` to the theme's `onSurface` so it works in both
|
||||
light/dark, plus a small spinner, while `flutter_secure_storage`/
|
||||
`shared_preferences` are read on startup)
|
||||
- else `!provider.isLoggedIn` → `LoginView` (server-address entry + Login
|
||||
Flow v2)
|
||||
Flow v2) — `isLoggedIn` is only ever false here or after the last saved
|
||||
account is removed; switching between multiple saved accounts never
|
||||
routes through this screen (see `server.md`)
|
||||
- else `MainShellView`
|
||||
|
||||
`MainShellView` is a bottom-nav `IndexedStack` with three persistent tabs —
|
||||
Files, Photos, Activity — each keeping its own `ScrollController` so state
|
||||
(scroll position, `IndexedStack`'s built-but-hidden trees) survives tab
|
||||
switches. `SearchView` and the file-details sheet are pushed on top via
|
||||
`Navigator`/`showModalBottomSheet` rather than being tabs.
|
||||
`MainShellView` is a bottom-nav `IndexedStack` over up to 6 tabs — Files,
|
||||
Photos, Activity, Trash, Shares, Recent — user-configurable (order,
|
||||
visibility up to `maxVisibleTabs`, default tab) via `AppTab`/`ServerProvider`
|
||||
and rendered through `buildAppTabView` (`widgets/app_tab_view_builder.dart`).
|
||||
Each tab keeps its own `ScrollController` (survives tab switches via
|
||||
`IndexedStack`'s built-but-hidden trees) and tapping the already-active tab
|
||||
scrolls it back to top (`tapTabToScrollTop` setting). `MainShellView` also
|
||||
owns the app's share-intent listener (`receive_sharing_intent`): both
|
||||
`getInitialMedia()` (cold start via another app's "Share to...") and
|
||||
`getMediaStream()` (already running) push `ShareUploadView`.
|
||||
|
||||
Five of the six tabs (all but Files) plus each tab's own controls share
|
||||
[`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) — a
|
||||
`CustomScrollView` with a pull-down "sync status" header (Google
|
||||
Photos-style), a `Scrollbar`, and a classic Material refresh spinner shown
|
||||
during a pull-triggered sync. `SearchView`, `AccountView` (Settings), the
|
||||
file-details sheet, the share sheet, and `ShareUploadView` (the
|
||||
share-to-upload destination picker) are pushed on top via
|
||||
`Navigator`/`showModalBottomSheet`/`showGradualBottomSheet` rather than
|
||||
being tabs. `ProfileAvatarButton` (top-right on every tab) opens Settings on
|
||||
tap and cycles between saved accounts on a vertical swipe.
|
||||
|
||||
+89
-22
@@ -9,12 +9,18 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
|
||||
1. `LoginFlowService.initiate(serverUrl)` POSTs to
|
||||
`{server}/index.php/login/v2`, gets back a browser login URL + a poll
|
||||
endpoint/token.
|
||||
2. The app opens the login URL in the system browser (`url_launcher`); the
|
||||
user authenticates and authorizes there.
|
||||
2. The app opens the login URL in a Chrome Custom Tab (`url_launcher`,
|
||||
`LaunchMode.inAppBrowserView` — real Chrome, so saved passwords/autofill
|
||||
work, unlike Flutter's own embedded web view); the user authenticates and
|
||||
authorizes there. There's no way to close the tab automatically on
|
||||
success (Login Flow v2 never redirects back into the app, and a Custom
|
||||
Tab belongs to Chrome's own task) — the user switches back manually.
|
||||
3. `ServerProvider` polls `LoginFlowService.poll(pollEndpoint, token)` every
|
||||
2 seconds (`Timer.periodic`, see `_pollTimer`/`_pollTimeoutTimer` in
|
||||
`server_provider.dart`) until it gets a 200 with `server`/`loginName`/
|
||||
`appPassword`, a non-404 error, or a 10-minute timeout.
|
||||
`appPassword`, a non-404 error, or a 10-minute timeout. A single dropped
|
||||
connection mid-poll (`http.ClientException`) is swallowed and retried on
|
||||
the next tick rather than aborting the whole flow.
|
||||
4. The returned **app password** (scoped, revocable) is what gets stored and
|
||||
used for every subsequent request — real user passwords are never in
|
||||
memory or on disk.
|
||||
@@ -23,41 +29,102 @@ via [`LoginFlowService`](../../lib/services/login_flow_service.dart):
|
||||
drives `LoginView`'s UI; see `LoginFlowService`'s doc comment for the full
|
||||
flow rationale before changing it.
|
||||
|
||||
`startLoginFlow(serverUrl, {addAccount = false})` is reused verbatim for
|
||||
both the first/only login and "add another account" (pushed from Settings
|
||||
while already logged into a different account, `LoginView(isAddingAccount:
|
||||
true)`) — `addAccount` only flags `isAddAccountFlow` for the UI (so the
|
||||
pushed screen knows to auto-pop on success and cancel the flow on
|
||||
back-swipe); the persistence path on success is identical either way, see
|
||||
below.
|
||||
|
||||
## Talking to the server
|
||||
|
||||
[`NextcloudService`](../../lib/services/nextcloud_service.dart) is the
|
||||
client for an authenticated session — constructed with `serverUrl` +
|
||||
`username` + the app password, one instance per login (held as
|
||||
`ServerProvider.service`, recreated on login/logout).
|
||||
`ServerProvider.service`, recreated on every login/switch/logout). It's
|
||||
effectively stateless per-instance (three final fields, headers rebuilt per
|
||||
request), which is what makes it trivial to have one saved per account
|
||||
rather than needing a rewrite for multi-account support.
|
||||
|
||||
- **Files**: WebDAV (`PROPFIND`/`MKCOL`/`DELETE`/`MOVE` etc. against
|
||||
- **Files**: WebDAV (`PROPFIND`/`MKCOL`/`DELETE`/`MOVE`/`PUT` etc. against
|
||||
`/remote.php/dav/files/{username}/...`) via raw `http`/`dio` calls with a
|
||||
hand-rolled XML request body and `package:xml` for parsing responses —
|
||||
there is no WebDAV client dependency. `_parseDavDate`/`_davPath` in this
|
||||
file exist because WebDAV responses use RFC 1123 dates and either bare
|
||||
paths or full URLs for `href`; reuse them rather than re-deriving.
|
||||
- **Everything else** (shares, activity, trash, favorites, quota, user info)
|
||||
goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON in, with the
|
||||
`OCS-APIRequest: true` header required on every OCS call.
|
||||
- **Everything else** (shares, activity, trash, favorites, quota, user info,
|
||||
file versions) goes through Nextcloud's OCS APIs (`/ocs/v2.php/...`), JSON
|
||||
in, with the `OCS-APIRequest: true` header required on every OCS call.
|
||||
- Auth header is HTTP Basic (`username:appPassword`, base64), built in
|
||||
`_headers`/exposed as `authHeaders` for widgets that need to hit URLs
|
||||
directly (e.g. `Image.network(url, headers: service.authHeaders)` for
|
||||
thumbnails/previews).
|
||||
- Downloads stream through `Dio` (`downloadToFile`) for progress callbacks;
|
||||
small in-app previews (text/PDF) use `fetchBytes` via `package:http`.
|
||||
- **Uploads** (`uploadFileFromPath(folderPath, fileName, localFilePath,
|
||||
{onProgress})`) stream the local file via `Dio().put()` with an explicit
|
||||
`Content-Length` and `onSendProgress`, mirroring the download path. The
|
||||
`ServerProvider` wrapper always uploads into `_currentFolderPath` — the
|
||||
share-to-upload flow (`ShareUploadView`) gets a caller-chosen destination
|
||||
by navigating there first (`navigateToAbsoluteFolder`), then uploading.
|
||||
- **Receiving a shared file from another app**: `receive_sharing_intent`
|
||||
(Android `ACTION_SEND`/`ACTION_SEND_MULTIPLE`, `android:launchMode`
|
||||
`singleTask` in the manifest so a second share while running hits
|
||||
`onNewIntent` instead of spawning a new instance). `MainShellView` listens
|
||||
via `getInitialMedia()`/`getMediaStream()` and pushes `ShareUploadView`,
|
||||
which reuses the same `uploadFileFromPath` path after the user picks a
|
||||
destination folder.
|
||||
|
||||
## Session persistence
|
||||
## Multi-account storage & session persistence
|
||||
|
||||
- **Credentials** (`server`, `loginName`, `appPassword`) live in
|
||||
`flutter_secure_storage` — OS keychain/keystore-backed, never
|
||||
`shared_preferences`.
|
||||
- **UI/app preferences** (theme mode, seed color, dynamic-color toggle,
|
||||
bottom-bar opacity/blur, grid vs. list, sort field, hidden-files toggle,
|
||||
etc.) live in `shared_preferences` — see the `_pref*` key constants at the
|
||||
top of `server_provider.dart`.
|
||||
- On startup, `ServerProvider._restoreSession()` reads the secure-storage
|
||||
keys and, if all three are present, rebuilds a `NextcloudService` without
|
||||
re-hitting the login flow (`_applyCredentials(..., persist: false)`).
|
||||
`isRestoringSession` gates the splash screen until this resolves — see
|
||||
`standards.md` for why widget tests must mock both storage channels
|
||||
rather than relying on this async path throwing naturally.
|
||||
[`AccountStore`](../../lib/services/account_store.dart) owns everything
|
||||
account-identity-related; `ServerProvider` owns everything about which
|
||||
account is *currently* live (see `architecture.md`).
|
||||
|
||||
- **Per-account secrets**: one `flutter_secure_storage` key per account,
|
||||
`nc_app_password_<accountId>` — never `shared_preferences`. `accountId` is
|
||||
deterministic (`SavedAccount.makeId(serverUrl, username)`, a slug of both),
|
||||
so re-adding the same account refreshes its password instead of creating a
|
||||
duplicate.
|
||||
- **Account identity list** (non-secret: id/serverUrl/username) and
|
||||
**which one is active** live in `shared_preferences` as `accounts_list`
|
||||
(JSON array) and `active_account_id`.
|
||||
- **Global UI prefs** (theme, dynamic color, AMOLED, bottom-bar
|
||||
opacity/blur, tap-to-scroll-top, seek bar style, tab order/hidden/default,
|
||||
swipe actions) stay flat, un-namespaced `shared_preferences` keys — same
|
||||
as before multi-account, untouched by switching.
|
||||
- **Per-account browsing prefs** (grid/list view, favorites-only ×2,
|
||||
storage scope, show-hidden ×2, Photos sort field/ascending, Files'
|
||||
per-folder sort map, cache policy/interval — the full list is
|
||||
`AccountStore.perAccountPrefKeys`) are namespaced `acct_<accountId>_<key>`
|
||||
and reloaded on every switch via `ServerProvider._applyAccountPrefs`.
|
||||
- **Legacy migration**: `AccountStore.migrateLegacyIfNeeded` runs once ever
|
||||
(guarded by the `account_migration_v1_done` flag), turning a pre-multi-
|
||||
account install's 3 flat secure-storage keys + flat browsing prefs into
|
||||
the first saved (and active) account, so upgrading users are never logged
|
||||
out. Never assume the legacy keys are gone — always check the migration
|
||||
flag rather than the keys' absence.
|
||||
- On startup, `ServerProvider._init()` awaits the migration, loads the
|
||||
account list + active id, then `_restoreSession()` looks up the active
|
||||
account's password and calls `_applyCredentialsForAccount` (the renamed,
|
||||
generation-guarded, account-aware version of what used to be
|
||||
`_applyCredentials`) to rebuild the session without re-hitting the login
|
||||
flow.
|
||||
- **Switching accounts** (`switchAccount`/`cycleToNextAccount`/
|
||||
`cycleToPreviousAccount`/`removeAccount`'s fallback, plus landing on a
|
||||
freshly-added account) all funnel through the single `_activateAccount`
|
||||
engine: bump `_sessionGeneration`, cancel any pending login flow, clear
|
||||
every content field *without* ever setting `isLoggedIn` false (that's the
|
||||
detail that keeps `main.dart`'s root routing from bouncing through
|
||||
`LoginView` mid-switch), reload the target account's prefs, then verify
|
||||
its credentials and refetch everything. This is a full teardown-and-reload
|
||||
every time — there is deliberately no simultaneous multi-account state or
|
||||
background sync; only one account's content is ever live.
|
||||
- `logout()` is just `removeAccount(activeAccountId)` — with other accounts
|
||||
saved it falls back to one of them instead of ending the session;
|
||||
`isLoggedIn` only ever becomes `false` when the *last* account is removed.
|
||||
|
||||
`isRestoringSession` still gates the splash screen until the above resolves
|
||||
— see `standards.md` for why widget tests must mock both storage channels
|
||||
rather than relying on this async path throwing naturally.
|
||||
|
||||
@@ -33,6 +33,25 @@ class/method already makes obvious.
|
||||
and `context.read<ServerProvider>()` for one-off calls from callbacks
|
||||
(matches `LoginView._handleContinue`).
|
||||
|
||||
## `ServerProvider` conventions
|
||||
|
||||
- Any method that fetches data and writes it into a shared field
|
||||
(`refreshData`, `fetchAllMedia`, `fetchTrash`, `fetchShares`,
|
||||
`fetchRecent`, `_applyCredentialsForAccount`) must guard against a stale
|
||||
write from an account the user has since switched away from: capture
|
||||
`final gen = _sessionGeneration;` at entry, and check
|
||||
`if (gen != _sessionGeneration) return;` immediately after each `await`
|
||||
before touching any field or calling `notifyListeners()`. Follow this
|
||||
pattern for any new fetch method added to the provider.
|
||||
- New persisted state on `ServerProvider` must be classified global vs.
|
||||
per-account (see `architecture.md`/`server.md`) up front — global state
|
||||
uses a plain `_prefsFuture.then((p) => p.setX(key, value))`; per-account
|
||||
state goes through `_persistAccountPref(key, (p, namespacedKey) =>
|
||||
p.setX(namespacedKey, value))` and must also be handled in
|
||||
`_applyAccountPrefs` (both the "reset to default when no account" and the
|
||||
"load for this account" branches) so it's correct immediately after a
|
||||
switch, not just at startup.
|
||||
|
||||
## Testing
|
||||
|
||||
- Widget tests must mock platform channels that the app touches on startup
|
||||
|
||||
@@ -29,18 +29,49 @@ widgets. Key points:
|
||||
- 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**: a project-wide `scrollbarTheme` (`AppTheme._scrollbarTheme`)
|
||||
gives every `Scrollbar` in the app a thick, rounded, always-visible,
|
||||
draggable thumb (Android fast-scroll style) derived from
|
||||
`colorScheme.onSurfaceVariant` — don't pass per-instance `thickness`/
|
||||
`radius`/`thumbVisibility`/`interactive`, just wrap scrollable content in a
|
||||
plain `Scrollbar(child: ...)` (pass `controller:` matching the scrollable's
|
||||
own when one exists) and it picks up the theme automatically.
|
||||
|
||||
## Reusable chrome
|
||||
|
||||
- [`FrostedGlassContainer`](../../lib/widgets/frosted_glass_container.dart) —
|
||||
the blurred/translucent pill background shared by all floating chrome
|
||||
(bottom nav bar, media-viewer action bar). Reuse this for any new floating
|
||||
overlay instead of building a new blur/shadow combo.
|
||||
(bottom nav bar, media-viewer top/bottom bars and video transport
|
||||
controls). Reuse this for any new floating overlay instead of building a
|
||||
new blur/shadow combo.
|
||||
- [`FloatingBottomNavBar`](../../lib/widgets/floating_bottom_bar.dart) — the
|
||||
main tab bar; opacity/blur are user-adjustable settings
|
||||
(`ServerProvider.bottomBarOpacity`/`bottomBarBlur`), not constants — pull
|
||||
new adjustable visual knobs from the provider the same way rather than
|
||||
hardcoding them.
|
||||
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
|
||||
the pull-to-sync `CustomScrollView` header shared by 5 of the 6 tabs (see
|
||||
`architecture.md`); also where the pull-to-refresh gesture thresholds and
|
||||
the classic Material refresh spinner live.
|
||||
- [`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.
|
||||
|
||||
Reference in New Issue
Block a user