02d9f39845
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>
106 lines
4.2 KiB
Dart
106 lines
4.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../auth/auth_provider.dart';
|
|
import '../auth/login_screen.dart';
|
|
import '../auth/server_url_screen.dart';
|
|
import '../library/album_detail_screen.dart';
|
|
import '../library/artist_detail_screen.dart';
|
|
import '../discover/discover_screen.dart';
|
|
import '../library/home_screen.dart';
|
|
import '../library/library_screen.dart';
|
|
import '../player/now_playing_screen.dart';
|
|
import '../player/player_bar.dart';
|
|
import '../player/queue_screen.dart';
|
|
import '../playlists/playlist_detail_screen.dart';
|
|
import '../playlists/playlists_list_screen.dart';
|
|
import '../requests/requests_screen.dart';
|
|
import '../search/search_screen.dart';
|
|
import '../settings/settings_screen.dart';
|
|
import '../update/update_banner.dart';
|
|
import '../admin/admin_landing_screen.dart';
|
|
import '../admin/admin_requests_screen.dart';
|
|
import '../admin/admin_quarantine_screen.dart';
|
|
import '../admin/admin_users_screen.dart';
|
|
import 'widgets/version_gate.dart';
|
|
|
|
/// Exposed as a Provider so its single argument is a real `Ref` (the
|
|
/// constructor's redirect closures use `ref.read`). Widgets consume the
|
|
/// router via `ref.watch(routerProvider)` instead of constructing it
|
|
/// themselves with a `WidgetRef`, which can't satisfy the `Ref` type.
|
|
final routerProvider = Provider<GoRouter>((ref) => buildRouter(ref));
|
|
|
|
GoRouter buildRouter(Ref ref) {
|
|
return GoRouter(
|
|
initialLocation: '/',
|
|
redirect: (ctx, state) async {
|
|
final url = await ref.read(serverUrlProvider.future);
|
|
if (url == null) return '/server-url';
|
|
final user = await ref.read(authControllerProvider.future);
|
|
final loc = state.matchedLocation;
|
|
if (user == null && loc != '/login' && loc != '/server-url') {
|
|
return '/login';
|
|
}
|
|
if (user != null && (loc == '/login' || loc == '/server-url')) {
|
|
return '/home';
|
|
}
|
|
if (loc.startsWith('/admin') && !user!.isAdmin) {
|
|
return '/home';
|
|
}
|
|
return null;
|
|
},
|
|
routes: [
|
|
GoRoute(path: '/', redirect: (_, __) => '/home'),
|
|
GoRoute(path: '/server-url', builder: (_, __) => const ServerUrlScreen()),
|
|
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
|
ShellRoute(
|
|
builder: (ctx, state, child) => VersionGate(child: _ShellWithPlayerBar(child: child)),
|
|
routes: [
|
|
GoRoute(path: '/home', builder: (_, __) => const HomeScreen()),
|
|
GoRoute(
|
|
path: '/artists/:id',
|
|
builder: (_, s) => ArtistDetailScreen(id: s.pathParameters['id']!),
|
|
),
|
|
GoRoute(
|
|
path: '/albums/:id',
|
|
builder: (_, s) => AlbumDetailScreen(id: s.pathParameters['id']!),
|
|
),
|
|
GoRoute(path: '/now-playing', builder: (_, __) => const NowPlayingScreen()),
|
|
GoRoute(path: '/queue', builder: (_, __) => const QueueScreen()),
|
|
GoRoute(path: '/search', builder: (_, __) => const SearchScreen()),
|
|
GoRoute(path: '/library', builder: (_, __) => const LibraryScreen()),
|
|
GoRoute(path: '/discover', builder: (_, __) => const DiscoverScreen()),
|
|
GoRoute(path: '/settings', builder: (_, __) => const SettingsScreen()),
|
|
GoRoute(path: '/playlists', builder: (_, __) => const PlaylistsListScreen()),
|
|
GoRoute(
|
|
path: '/playlists/:id',
|
|
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
|
),
|
|
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
|
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
|
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
|
|
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
|
|
GoRoute(path: '/admin/users', builder: (_, __) => const AdminUsersScreen()),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _ShellWithPlayerBar extends StatelessWidget {
|
|
const _ShellWithPlayerBar({required this.child});
|
|
final Widget child;
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
const UpdateBanner(),
|
|
Expanded(child: child),
|
|
const PlayerBar(),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|