Mechanical sweep across 30 files: every Material Icons.* replaced with
the signed-off Lucide equivalent + a flutter_lucide import per file.
Zero Material Icons.* remain in lib/; no unused imports.
Judgment-call mappings: album->disc_3, library_music->library_big,
playlist_play->list_video, graphic_eq->audio_lines,
system_update->download, restore->archive_restore,
download_done->circle_check_big.
track_actions_sheet like menu row: collapsed `liked ? favorite :
favorite_border` to a single LucideIcons.heart (the row's Like/Unlike
text label conveys state). Icon-only LikeButton + the notification keep
the filled-vs-outline shape per the design decision.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Player bar: drop the volume slider — phone hardware controls volume.
Right cluster collapses to a single row of shuffle/repeat/queue.
Playlist card: GestureDetector defers to children by default, so taps
over the cover image area didn't always register. Add HitTestBehavior.opaque
so the whole card surface is tappable.
Update banner: was 3-4× the height of its text — IconButton's default
48dp tap target plus generous vertical padding dominated. Tighten
padding (8→4), shrink dismiss button to 32×32 with iconSize 16, slim
the action button to 28dp height with shrinkWrap tap target.
Home screen: ClampingScrollPhysics + 140dp bottom padding (already
applied in working tree) so scroll stops just above the mini-player.
Closes the operator-facing half of #397. Soft banner mounts at the
top of the shell when the server has a newer APK bundled at
/api/client/version; tap Install → APK downloads to cache → Android
PackageInstaller intent fires → user taps Install in the system
dialog → app restarts on the new version.
### Dart side
- lib/update/update_info.dart — wire shape for /api/client/version
- lib/update/client_update_provider.dart — Riverpod AsyncNotifier
polling on app start + every 24h. Compares semver via pub_semver
with non-semver string-equality fallback. Server 404 = "no update
channel" = silent. Companion shouldShowUpdateBannerProvider gates
on a per-version dismissed-set (in-memory; resets on restart).
- lib/update/installer.dart — UpdateInstaller wraps dio.download +
the MethodChannel call to MainActivity.kt.
- lib/update/update_banner.dart — Material banner with idle /
downloading (with progress) / error stages. Mounted in
_ShellWithPlayerBar above the route content; SizedBox.shrink()
when no update available so non-shell routes (login, server-url)
see no banner anyway.
- isVersionNewer() extracted as a pure function and tested across
semver, prerelease ordering, leading-v normalization, and
non-semver fallback (date-tag style).
### Android side
- AndroidManifest.xml — REQUEST_INSTALL_PACKAGES permission;
FileProvider with authorities ${applicationId}.fileprovider.
- res/xml/file_paths.xml — exposes the cache directory so the
downloaded APK can be served via content:// URI (file:// is
blocked since Android 7).
- MainActivity.kt — MethodChannel handler builds the FileProvider
URI and fires Intent.ACTION_VIEW with FLAG_GRANT_READ_URI_PERMISSION
so the system installer can read across the process boundary.
Phase 3 (CI sequencing — bake APK + version file into /app/client/
during release.yml's tag flow) is the remaining piece. Without it
the server returns 404 from /api/client/version and the banner
silently does nothing — graceful degradation, but no actual updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>