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();
+ });
}