diff --git a/docs/BUGS.md b/docs/BUGS.md index 7740bac..102c77a 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -2,5 +2,3 @@ title: Bugs updated: 2026-03-18 --- - -Logging out crashes the app. It requires a restart to fix. diff --git a/docs/tasks/phase-4.md b/docs/tasks/phase-4.md index dcf8136..18324f9 100644 --- a/docs/tasks/phase-4.md +++ b/docs/tasks/phase-4.md @@ -16,7 +16,7 @@ ## M13 — Media Playback & Download -- [ ] Add `photo_view`, `video_player`, `chewie`, `dio`, `gal`, `permission_handler` to `pubspec.yaml` +- [x] Add `photo_view`, `video_player`, `chewie`, `dio`, `gal`, `permission_handler` to `pubspec.yaml` - [ ] `ImageViewerScreen` — full-screen `PageView` of `PhotoView` widgets loading `fullsize` URLs with hero animation from thumbnail - [ ] Page indicator for multi-image posts; alt text bar at the bottom of each page - [ ] Swipe-down-to-dismiss gesture on image viewer diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf7..391a902 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 037fa3a..f5f26f4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -72,15 +72,15 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/workmanager/ios" SPEC CHECKSUMS: - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b - path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba - share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 - sqlite3_flutter_libs: f9114e4bbe1f2e03dd543373c53d23245982ca13 - url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa - workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 + sqlite3_flutter_libs: b3e120efe9a82017e5552a620f696589ed4f62ab + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + workmanager: 01be2de7f184bd15de93a1812936a2b7f42ef07e PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 75444ad..8905b61 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -2,6 +2,12 @@ + BGTaskSchedulerPermittedIdentifiers + + lazurite.scheduled_post + + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +30,34 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + processing + fetch + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,19 +75,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - - - BGTaskSchedulerPermittedIdentifiers - - lazurite.scheduled_post - - UIBackgroundModes - - processing - fetch - diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index 5930949..f05f23b 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -104,6 +104,10 @@ class AppRouter { ), StatefulShellRoute.indexedStack( builder: (context, state, navigationShell) { + if (!context.read().state.isAuthenticated) { + return AppShell(navigationShell: navigationShell); + } + UnreadCountCubit? existingCubit; try { existingCubit = context.read(); diff --git a/lib/core/router/app_shell.dart b/lib/core/router/app_shell.dart index 35b544e..b231d52 100644 --- a/lib/core/router/app_shell.dart +++ b/lib/core/router/app_shell.dart @@ -1,65 +1,307 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; -class AppShell extends StatelessWidget { +class AppShellScope extends InheritedWidget { + const AppShellScope({super.key, required super.child, required this.openMenu}); + + final VoidCallback openMenu; + + static AppShellScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(covariant AppShellScope oldWidget) => openMenu != oldWidget.openMenu; +} + +class AppShellMenuButton extends StatelessWidget { + const AppShellMenuButton({super.key}); + + @override + Widget build(BuildContext context) { + final shellScope = AppShellScope.maybeOf(context); + + return IconButton(tooltip: 'Open menu', onPressed: shellScope?.openMenu, icon: const Icon(Icons.menu)); + } +} + +class AppShell extends StatefulWidget { const AppShell({super.key, required this.navigationShell}); final StatefulNavigationShell navigationShell; + @override + State createState() => _AppShellState(); +} + +class _AppShellState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + + void _openMenu() { + _scaffoldKey.currentState?.openDrawer(); + } + @override Widget build(BuildContext context) { - return Scaffold( - body: navigationShell, - bottomNavigationBar: NavigationBar( - height: 50, - backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, - selectedIndex: navigationShell.currentIndex, - onDestinationSelected: (index) { - navigationShell.goBranch(index, initialLocation: index == navigationShell.currentIndex); - }, - indicatorShape: RoundedSuperellipseBorder(borderRadius: BorderRadius.circular(10)), - labelBehavior: NavigationDestinationLabelBehavior.alwaysHide, - destinations: _destinations, + return AppShellScope( + openMenu: _openMenu, + child: Scaffold( + key: _scaffoldKey, + drawer: _AppMenu(navigationShell: widget.navigationShell, rootContext: context), + body: widget.navigationShell, ), ); } +} + +class _AppMenu extends StatelessWidget { + const _AppMenu({required this.navigationShell, required this.rootContext}); + + final StatefulNavigationShell navigationShell; + final BuildContext rootContext; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tokens = rootContext.watch().state.tokens; + final displayName = tokens?.displayName ?? tokens?.handle ?? 'Guest'; + final handle = tokens?.handle ?? 'Sign in required'; + final initials = _initialsFor(tokens?.displayName ?? tokens?.handle ?? 'L'); + final drawerWidth = (MediaQuery.sizeOf(context).width * 0.82).clamp(280.0, 320.0).toDouble(); - List get _destinations => [ - const NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), - const NavigationDestination(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search'), - NavigationDestination( - icon: BlocBuilder( - builder: (context, state) { - return Badge( - isLabelVisible: state.hasUnread, - label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), - child: const Icon(Icons.notifications_outlined), - ); - }, + return SizedBox( + width: drawerWidth, + child: Drawer( + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), + child: Row( + children: [ + Text('Menu', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + const Spacer(), + IconButton( + tooltip: 'Close menu', + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () => _runAfterClose(context, () => navigationShell.goBranch(3, initialLocation: false)), + child: Ink( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + // TODO: Add user avatar (keep initials as fallback) + CircleAvatar(radius: 24, child: Text(initials)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 2), + Text( + '@$handle', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + ], + ), + ), + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + _MenuTile( + icon: Icons.home_outlined, + selectedIcon: Icons.home, + label: 'Home', + isSelected: navigationShell.currentIndex == 0, + onTap: () => _selectBranch(context, 0), + ), + _MenuTile( + icon: Icons.search_outlined, + selectedIcon: Icons.search, + label: 'Search', + isSelected: navigationShell.currentIndex == 1, + onTap: () => _selectBranch(context, 1), + ), + _MenuTile( + icon: Icons.rss_feed_outlined, + selectedIcon: Icons.rss_feed, + label: 'Feeds', + onTap: () => _pushRoute(context, '/feeds'), + ), + _MenuTile( + icon: Icons.notifications_outlined, + selectedIcon: Icons.notifications, + label: 'Notifications', + isSelected: navigationShell.currentIndex == 2, + trailing: _notificationsBadge(), + onTap: () => _selectBranch(context, 2), + ), + _MenuTile( + icon: Icons.chat_bubble_outline, + selectedIcon: Icons.chat_bubble, + label: 'Messages', + isSelected: navigationShell.currentIndex == 4, + onTap: () => _selectBranch(context, 4), + ), + _MenuTile( + icon: Icons.person_outline, + selectedIcon: Icons.person, + label: 'Profile', + isSelected: navigationShell.currentIndex == 3, + onTap: () => _selectBranch(context, 3), + ), + const Divider(height: 24), + _MenuTile( + icon: Icons.add_circle_outline, + selectedIcon: Icons.add_circle, + label: 'New Post', + onTap: () => _pushRoute(context, '/compose'), + ), + _MenuTile( + icon: Icons.settings_outlined, + selectedIcon: Icons.settings, + label: 'Settings', + isSelected: navigationShell.currentIndex == 5, + onTap: () => _selectBranch(context, 5), + ), + const Divider(height: 24), + _MenuTile( + icon: Icons.logout, + selectedIcon: Icons.logout, + label: 'Log Out', + isDestructive: true, + onTap: () => + _runAfterClose(context, () => rootContext.read().add(const LogoutRequested())), + ), + ], + ), + ), + ], + ), + ), ), - selectedIcon: BlocBuilder( - builder: (context, state) { - return Badge( - isLabelVisible: state.hasUnread, - label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), - child: const Icon(Icons.notifications), - ); - }, + ); + } + + Widget _notificationsBadge() { + try { + rootContext.read(); + } catch (_) { + return const SizedBox.shrink(); + } + + return BlocBuilder( + builder: (context, state) { + if (!state.hasUnread) { + return const SizedBox.shrink(); + } + + return Badge( + label: Text(state.count > 99 ? '99+' : state.count.toString(), style: const TextStyle(fontSize: 10)), + ); + }, + ); + } + + void _selectBranch(BuildContext context, int index) { + _runAfterClose( + context, + () => navigationShell.goBranch(index, initialLocation: index == navigationShell.currentIndex), + ); + } + + void _pushRoute(BuildContext context, String location) { + _runAfterClose(context, () => GoRouter.of(rootContext).push(location)); + } + + void _runAfterClose(BuildContext context, VoidCallback action) { + Navigator.of(context).pop(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (rootContext.mounted) { + action(); + } + }); + } + + String _initialsFor(String value) { + final parts = value.trim().split(RegExp(r'\s+')).where((part) => part.isNotEmpty).take(2).toList(); + if (parts.isEmpty) { + return 'L'; + } + + return parts.map((part) => part.characters.first.toUpperCase()).join(); + } +} + +class _MenuTile extends StatelessWidget { + const _MenuTile({ + required this.icon, + required this.selectedIcon, + required this.label, + required this.onTap, + this.isSelected = false, + this.isDestructive = false, + this.trailing, + }); + + final IconData icon; + final IconData selectedIcon; + final String label; + final VoidCallback onTap; + final bool isSelected; + final bool isDestructive; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = isDestructive + ? theme.colorScheme.error + : isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurface; + + return ListTile( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + leading: Icon(isSelected ? selectedIcon : icon, color: color), + title: Text( + label, + style: theme.textTheme.titleMedium?.copyWith(color: color, fontWeight: FontWeight.w600), ), - label: 'Notifications', - ), - const NavigationDestination(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), - const NavigationDestination( - icon: Icon(Icons.chat_bubble_outline), - selectedIcon: Icon(Icons.chat_bubble), - label: 'Messages', - ), - const NavigationDestination( - icon: Icon(Icons.settings_outlined), - selectedIcon: Icon(Icons.settings), - label: 'Settings', - ), - ]; + trailing: trailing, + selected: isSelected, + selectedTileColor: theme.colorScheme.primaryContainer.withValues(alpha: 0.45), + onTap: onTap, + ); + } } diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 5488929..83e9417 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -149,8 +149,7 @@ class _LoginScreenState extends State { children: [ const Icon(Icons.bug_report_outlined), const SizedBox(width: 8), - Text('App Password Login', style: theme.textTheme.titleMedium), - const Spacer(), + Expanded(child: Text('App Password Login', style: theme.textTheme.titleMedium)), TextButton( onPressed: () { setState(() { diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index 2600c20..5d33196 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -4,6 +4,7 @@ import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/cubit/feed_preferences_cubit.dart'; import 'package:lazurite/features/feed/data/feed_repository.dart'; @@ -42,7 +43,7 @@ class _HomeFeedScreenState extends State { if (prefsState.status == FeedPreferencesStatus.error) { return Scaffold( - appBar: AppBar(title: _title), + appBar: AppBar(leading: const AppShellMenuButton(), title: _title), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -65,7 +66,7 @@ class _HomeFeedScreenState extends State { if (pinnedFeeds.isEmpty) { return Scaffold( - appBar: AppBar(title: _title), + appBar: AppBar(leading: const AppShellMenuButton(), title: _title), body: Center( child: Padding( padding: const EdgeInsets.all(24), @@ -93,6 +94,7 @@ class _HomeFeedScreenState extends State { return Scaffold( appBar: AppBar( + leading: const AppShellMenuButton(), title: _title, actions: [IconButton(icon: const Icon(Icons.rss_feed), onPressed: () => context.push('/feeds'))], ), diff --git a/lib/features/messages/presentation/convo_list_screen.dart b/lib/features/messages/presentation/convo_list_screen.dart index 81a92f6..e9e7903 100644 --- a/lib/features/messages/presentation/convo_list_screen.dart +++ b/lib/features/messages/presentation/convo_list_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; import 'package:lazurite/features/messages/presentation/widgets/convo_list_item.dart'; @@ -64,6 +65,7 @@ class _ConvoListScreenState extends State with SingleTickerProv Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + leading: const AppShellMenuButton(), title: Text('Messages', style: Theme.of(context).textTheme.titleMedium), bottom: TabBar( controller: _tabController, diff --git a/lib/features/notifications/presentation/notifications_screen.dart b/lib/features/notifications/presentation/notifications_screen.dart index d3d4d24..c035dce 100644 --- a/lib/features/notifications/presentation/notifications_screen.dart +++ b/lib/features/notifications/presentation/notifications_screen.dart @@ -1,6 +1,7 @@ import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; import 'package:lazurite/features/notifications/presentation/widgets/notification_list_item.dart'; @@ -54,6 +55,7 @@ class _NotificationsScreenState extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + leading: const AppShellMenuButton(), title: _title, actions: [TextButton(onPressed: _markAllRead, child: const Text('Mark All Read'))], ), diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 764a7cc..a2cbe96 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; @@ -110,7 +111,7 @@ class _ProfileScreenState extends State with SingleTickerProvider icon: const Icon(Icons.arrow_back), onPressed: () => context.canPop() ? context.pop() : context.go('/profile'), ) - : null, + : const AppShellMenuButton(), actions: [ IconButton(icon: const Icon(Icons.settings_outlined), onPressed: () => context.go('/settings')), ], diff --git a/lib/features/search/presentation/search_screen.dart b/lib/features/search/presentation/search_screen.dart index ebafd9a..9bbca46 100644 --- a/lib/features/search/presentation/search_screen.dart +++ b/lib/features/search/presentation/search_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/search/bloc/search_bloc.dart'; @@ -129,6 +130,8 @@ class _SearchScreenState extends State { ), child: Row( children: [ + const AppShellMenuButton(), + const SizedBox(width: 8), Expanded( child: TextField( controller: _searchController, diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 2d7d622..aa1ddcd 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/router/app_shell.dart'; import 'package:lazurite/core/theme/app_theme.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; @@ -13,14 +14,15 @@ class SettingsScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + leading: const AppShellMenuButton(), title: _title(context), actions: [ - TextButton( + IconButton( + tooltip: 'Log Out', onPressed: () { context.read().add(const LogoutRequested()); - context.go('/login'); }, - child: Text('Log Out', style: TextStyle(color: Theme.of(context).colorScheme.error)), + icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), ), ], ), @@ -102,7 +104,6 @@ class SettingsScreen extends StatelessWidget { isDestructive: true, onTap: () { context.read().add(const LogoutRequested()); - context.go('/login'); }, ), const SizedBox(height: 24), @@ -144,26 +145,29 @@ class SettingsScreen extends StatelessWidget { padding: const EdgeInsets.all(16), child: SizedBox( width: double.infinity, - child: SegmentedButton<_AppearanceMode>( - segments: const [ - ButtonSegment(value: _AppearanceMode.system, label: Text('System')), - ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), - ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), - ], - selected: {_AppearanceMode.fromState(state)}, - onSelectionChanged: (selected) { - final mode = selected.first; - switch (mode) { - case _AppearanceMode.system: - settingsCubit.setUseSystemTheme(true); - case _AppearanceMode.light: - settingsCubit.setUseSystemTheme(false); - settingsCubit.setThemeVariant(AppThemeVariant.light); - case _AppearanceMode.dark: - settingsCubit.setUseSystemTheme(false); - settingsCubit.setThemeVariant(AppThemeVariant.dark); - } - }, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SegmentedButton<_AppearanceMode>( + segments: const [ + ButtonSegment(value: _AppearanceMode.system, label: Text('System')), + ButtonSegment(value: _AppearanceMode.light, label: Text('Light')), + ButtonSegment(value: _AppearanceMode.dark, label: Text('Dark')), + ], + selected: {_AppearanceMode.fromState(state)}, + onSelectionChanged: (selected) { + final mode = selected.first; + switch (mode) { + case _AppearanceMode.system: + settingsCubit.setUseSystemTheme(true); + case _AppearanceMode.light: + settingsCubit.setUseSystemTheme(false); + settingsCubit.setThemeVariant(AppThemeVariant.light); + case _AppearanceMode.dark: + settingsCubit.setUseSystemTheme(false); + settingsCubit.setThemeVariant(AppThemeVariant.dark); + } + }, + ), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index 201d465..d9795cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -201,6 +201,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + chewie: + dependency: "direct main" + description: + name: chewie + sha256: "44bcfc5f0dfd1de290c87c9d86a61308b3282a70b63435d5557cfd60f54a69ca" + url: "https://pub.dev" + source: hosted + version: "1.13.0" cli_config: dependency: transitive description: @@ -289,6 +297,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" cupertino_icons: dependency: "direct main" description: @@ -329,6 +345,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.1" + dio: + dependency: "direct main" + description: + name: dio + sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c + url: "https://pub.dev" + source: hosted + version: "5.9.2" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" + url: "https://pub.dev" + source: hosted + version: "2.1.2" drift: dependency: "direct main" description: @@ -496,6 +528,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + gal: + dependency: "direct main" + description: + name: gal + sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee" + url: "https://pub.dev" + source: hosted + version: "2.3.2" glob: dependency: transitive description: @@ -536,6 +576,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: @@ -792,6 +840,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: transitive + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: @@ -856,6 +920,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + permission_handler: + dependency: "direct main" + description: + name: permission_handler + sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 + url: "https://pub.dev" + source: hosted + version: "12.0.1" + permission_handler_android: + dependency: transitive + description: + name: permission_handler_android + sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6" + url: "https://pub.dev" + source: hosted + version: "13.0.1" + permission_handler_apple: + dependency: transitive + description: + name: permission_handler_apple + sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023 + url: "https://pub.dev" + source: hosted + version: "9.4.7" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24" + url: "https://pub.dev" + source: hosted + version: "0.1.3+5" + permission_handler_platform_interface: + dependency: transitive + description: + name: permission_handler_platform_interface + sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878 + url: "https://pub.dev" + source: hosted + version: "4.3.0" + permission_handler_windows: + dependency: transitive + description: + name: permission_handler_windows + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" + url: "https://pub.dev" + source: hosted + version: "0.2.1" petitparser: dependency: transitive description: @@ -864,6 +976,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + sha256: "1fc3d970a91295fbd1364296575f854c9863f225505c28c46e0a03e48960c75e" + url: "https://pub.dev" + source: hosted + version: "0.15.0" platform: dependency: transitive description: @@ -1221,6 +1341,46 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "9862c67c4661c98f30fe707bc1a4f97d6a0faa76784f485d282668e4651a7ac3" + url: "https://pub.dev" + source: hosted + version: "2.9.4" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: af0e5b8a7a4876fb37e7cc8cb2a011e82bb3ecfa45844ef672e32cb14a1f259e + url: "https://pub.dev" + source: hosted + version: "2.9.4" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec" + url: "https://pub.dev" + source: hosted + version: "6.6.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" vm_service: dependency: transitive description: @@ -1229,6 +1389,22 @@ packages: url: "https://pub.dev" source: hosted version: "15.0.2" + wakelock_plus: + dependency: transitive + description: + name: wakelock_plus + sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03" + url: "https://pub.dev" + source: hosted + version: "1.4.0" watcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index a06f105..123b3b0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,12 @@ dependencies: plugin_platform_interface: ^2.1.8 url_launcher_platform_interface: ^2.3.2 connectivity_plus: ^7.0.0 + photo_view: ^0.15.0 + video_player: ^2.11.1 + chewie: ^1.13.0 + dio: ^5.9.2 + gal: ^2.3.2 + permission_handler: ^12.0.1 dev_dependencies: flutter_test: diff --git a/test/core/router/app_router_test.dart b/test/core/router/app_router_test.dart index 8dd94db..9afc80c 100644 --- a/test/core/router/app_router_test.dart +++ b/test/core/router/app_router_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bloc_test/bloc_test.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; @@ -38,6 +40,8 @@ void main() { late MockSettingsCubit settingsCubit; late MockUnreadCountCubit unreadCountCubit; late MockNotificationRepository notificationRepository; + late StreamController authController; + late AuthState currentAuthState; const tokens = AuthTokens( accessToken: 'access', @@ -65,8 +69,10 @@ void main() { settingsCubit = MockSettingsCubit(); unreadCountCubit = MockUnreadCountCubit(); notificationRepository = MockNotificationRepository(); + authController = StreamController.broadcast(); + currentAuthState = const AuthState.authenticated(tokens); - when(() => authBloc.state).thenReturn(const AuthState.authenticated(tokens)); + when(() => authBloc.state).thenAnswer((_) => currentAuthState); when(() => feedPreferencesCubit.state).thenReturn(const FeedPreferencesState.loaded(feeds: [])); when(() => profileBloc.state).thenReturn(ProfileState.loaded(profile: profile)); when(() => feedBloc.state).thenReturn( @@ -82,7 +88,7 @@ void main() { when(() => unreadCountCubit.state).thenReturn(const UnreadCountState(0)); when(() => notificationRepository.getUnreadCount()).thenAnswer((_) async => 0); - whenListen(authBloc, const Stream.empty(), initialState: const AuthState.authenticated(tokens)); + whenListen(authBloc, authController.stream, initialState: currentAuthState); whenListen( feedPreferencesCubit, const Stream.empty(), @@ -111,6 +117,10 @@ void main() { whenListen(unreadCountCubit, const Stream.empty(), initialState: const UnreadCountState(0)); }); + tearDown(() async { + await authController.close(); + }); + Widget buildSubject() { return MultiBlocProvider( providers: [ @@ -128,23 +138,89 @@ void main() { ); } - testWidgets('renders bottom navigation and switches authenticated branches', (tester) async { + testWidgets('opens the side menu and switches authenticated branches', (tester) async { + await tester.binding.setSurfaceSize(const Size(430, 932)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildSubject()); await tester.pumpAndSettle(); - expect(find.byIcon(Icons.home), findsOneWidget); - expect(find.byIcon(Icons.person_outline), findsOneWidget); - expect(find.byIcon(Icons.settings_outlined), findsOneWidget); + expect(find.byTooltip('Open menu'), findsOneWidget); expect(find.text('No feeds pinned'), findsOneWidget); - await tester.tap(find.byIcon(Icons.person_outline).first); + await tester.tap(find.byTooltip('Open menu')); + await tester.pumpAndSettle(); + + expect(find.text('Menu'), findsOneWidget); + expect(find.text('New Post'), findsOneWidget); + await tester.scrollUntilVisible(find.text('Log Out'), 200, scrollable: find.byType(Scrollable).last); + expect(find.text('Log Out'), findsOneWidget); + + await tester.tap(find.text('Profile')); await tester.pumpAndSettle(); expect(find.text('River Tam'), findsOneWidget); - await tester.tap(find.byIcon(Icons.settings_outlined).first); + await tester.tap(find.byTooltip('Open menu')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Settings')); await tester.pumpAndSettle(); expect(find.text('APPEARANCE'), findsOneWidget); }); + + testWidgets('redirects to login after logout without crashing on the settings branch', (tester) async { + await tester.binding.setSurfaceSize(const Size(430, 932)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final router = AppRouter(authBloc: authBloc).router; + + final widget = MultiBlocProvider( + providers: [ + BlocProvider.value(value: authBloc), + BlocProvider.value(value: feedPreferencesCubit), + BlocProvider.value(value: profileBloc), + BlocProvider.value(value: feedBloc), + BlocProvider.value(value: settingsCubit), + ], + child: BlocBuilder( + builder: (context, state) { + final app = MaterialApp.router(routerConfig: router); + if (!state.isAuthenticated) { + return app; + } + + return MultiBlocProvider( + providers: [BlocProvider.value(value: unreadCountCubit)], + child: RepositoryProvider.value(value: notificationRepository, child: app), + ); + }, + ), + ); + + await tester.pumpWidget(widget); + await tester.pumpAndSettle(); + + router.go('/settings'); + await tester.pumpAndSettle(); + + expect(find.text('APPEARANCE'), findsOneWidget); + + await tester.tap(find.byTooltip('Log Out')); + await tester.pump(); + + verify(() => authBloc.add(const LogoutRequested())).called(1); + + currentAuthState = const AuthState.unauthenticated(); + authController.add(currentAuthState); + + await tester.pump(); + await tester.pumpAndSettle(); + + expect(find.text('Continue with BlueSky OAuth'), findsOneWidget); + expect(tester.takeException(), isNull); + + router.dispose(); + }); }