From 6564d37a2a4140cd8ca355befd0d383931b69942 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 8 May 2026 21:48:52 -0400 Subject: [PATCH] feat(flutter): MainAppBarActions widget with admin-gated overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared AppBar actions for top-level screens — three primary icons (Home/Library/Search, current screen suppressed) plus a kebab containing Playlists/Discover/Settings and (only when isAdmin) Admin. Replaces the ad-hoc per-screen IconButton lists; centralises admin-gating logic in one place so /admin entry can't accidentally leak to non-admins. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../shared/widgets/main_app_bar_actions.dart | 63 +++++++++++++++++ .../shared/main_app_bar_actions_test.dart | 70 +++++++++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 flutter_client/lib/shared/widgets/main_app_bar_actions.dart create mode 100644 flutter_client/test/shared/main_app_bar_actions_test.dart diff --git a/flutter_client/lib/shared/widgets/main_app_bar_actions.dart b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart new file mode 100644 index 00000000..eedc72a4 --- /dev/null +++ b/flutter_client/lib/shared/widgets/main_app_bar_actions.dart @@ -0,0 +1,63 @@ +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 '../../theme/theme_extension.dart'; + +/// Shared AppBar `actions` for top-level screens. Renders Home / Library / +/// Search as primary icons (suppressing the icon for [currentRoute]) plus +/// a kebab overflow with Playlists / Discover / Settings and (for admins) +/// Admin. +class MainAppBarActions extends ConsumerWidget { + const MainAppBarActions({super.key, required this.currentRoute}); + + final String currentRoute; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final fs = Theme.of(context).extension()!; + final user = ref.watch(authControllerProvider).valueOrNull; + final isAdmin = user?.isAdmin ?? false; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (currentRoute != '/home') + IconButton( + key: const Key('app_bar_home'), + icon: Icon(Icons.home, color: fs.parchment), + tooltip: 'Home', + onPressed: () => context.go('/home'), + ), + if (currentRoute != '/library') + IconButton( + key: const Key('app_bar_library'), + icon: Icon(Icons.library_music, color: fs.parchment), + tooltip: 'Library', + onPressed: () => context.push('/library'), + ), + if (currentRoute != '/search') + IconButton( + key: const Key('app_bar_search'), + icon: Icon(Icons.search, color: fs.parchment), + tooltip: 'Search', + onPressed: () => context.push('/search'), + ), + PopupMenuButton( + key: const Key('app_bar_overflow'), + icon: Icon(Icons.more_vert, color: fs.parchment), + tooltip: 'More', + onSelected: (route) => context.push(route), + itemBuilder: (_) => [ + const PopupMenuItem(value: '/playlists', child: Text('Playlists')), + const PopupMenuItem(value: '/discover', child: Text('Discover')), + const PopupMenuItem(value: '/settings', child: Text('Settings')), + if (isAdmin) + const PopupMenuItem(value: '/admin', child: Text('Admin')), + ], + ), + ], + ); + } +} diff --git a/flutter_client/test/shared/main_app_bar_actions_test.dart b/flutter_client/test/shared/main_app_bar_actions_test.dart new file mode 100644 index 00000000..495369ae --- /dev/null +++ b/flutter_client/test/shared/main_app_bar_actions_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:minstrel/auth/auth_provider.dart'; +import 'package:minstrel/models/user.dart'; +import 'package:minstrel/shared/widgets/main_app_bar_actions.dart'; +import 'package:minstrel/theme/theme_data.dart'; + +class _StubAuth extends AuthController { + _StubAuth(this._user); + final User? _user; + @override + Future build() async => _user; +} + +Widget _harness({required User? user, required String currentRoute}) { + return ProviderScope( + overrides: [ + authControllerProvider.overrideWith(() => _StubAuth(user)), + ], + child: MaterialApp( + theme: buildThemeData(), + home: Scaffold( + appBar: AppBar( + actions: [MainAppBarActions(currentRoute: currentRoute)], + ), + ), + ), + ); +} + +void main() { + const adminUser = User(id: 'u1', username: 'admin', isAdmin: true); + const regularUser = User(id: 'u2', username: 'alice', isAdmin: false); + + testWidgets('renders Library + Search primary icons + kebab on /home', + (t) async { + await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); + await t.pumpAndSettle(); + expect(find.byKey(const Key('app_bar_library')), findsOneWidget); + expect(find.byKey(const Key('app_bar_search')), findsOneWidget); + expect(find.byKey(const Key('app_bar_home')), findsNothing); + expect(find.byKey(const Key('app_bar_overflow')), findsOneWidget); + }); + + testWidgets('suppresses Library icon when currentRoute is /library', + (t) async { + await t.pumpWidget(_harness(user: regularUser, currentRoute: '/library')); + await t.pumpAndSettle(); + expect(find.byKey(const Key('app_bar_library')), findsNothing); + expect(find.byKey(const Key('app_bar_home')), findsOneWidget); + }); + + testWidgets('overflow includes Admin only for admin users', (t) async { + await t.pumpWidget(_harness(user: adminUser, currentRoute: '/home')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('app_bar_overflow'))); + await t.pumpAndSettle(); + expect(find.text('Admin'), findsOneWidget); + }); + + testWidgets('overflow omits Admin for non-admin users', (t) async { + await t.pumpWidget(_harness(user: regularUser, currentRoute: '/home')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('app_bar_overflow'))); + await t.pumpAndSettle(); + expect(find.text('Admin'), findsNothing); + }); +}