mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: update menu + settings layouts

* update changelog

+162 -146
+4
CHANGELOG.md
··· 60 60 #### 2026-04-12 61 61 62 62 - Multiple account support (controlled from settings and sidebar/menu) 63 + 64 + #### 2026-04-14 65 + 66 + - Post editing via delete-recreate
+139 -88
lib/core/router/app_shell.dart
··· 155 155 return SizedBox( 156 156 width: drawerWidth, 157 157 child: Drawer( 158 - child: SafeArea( 159 - child: Column( 160 - children: [ 161 - Padding( 162 - padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), 163 - child: Row( 164 - children: [ 165 - Text('Lazurite', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 166 - const Spacer(), 167 - IconButton( 168 - tooltip: 'Close menu', 169 - onPressed: () => Navigator.of(context).pop(), 170 - icon: const Icon(Icons.close), 158 + child: Column( 159 + children: [ 160 + Container( 161 + padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top), 162 + decoration: BoxDecoration( 163 + color: theme.colorScheme.surface, 164 + border: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), 165 + ), 166 + child: Column( 167 + children: [ 168 + Padding( 169 + padding: const EdgeInsets.fromLTRB(12, 8, 8, 8), 170 + child: Row( 171 + children: [ 172 + Text('Lazurite', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), 173 + const Spacer(), 174 + IconButton( 175 + tooltip: 'Close menu', 176 + onPressed: () => Navigator.of(context).pop(), 177 + icon: const Icon(Icons.close), 178 + ), 179 + ], 171 180 ), 172 - ], 173 - ), 181 + ), 182 + _buildProfileTag(context, displayName, handle, initials, did), 183 + ], 174 184 ), 175 - _buildProfileTag(context, displayName, handle, initials, did), 176 - Expanded( 177 - child: ListView( 178 - padding: const EdgeInsets.symmetric(horizontal: 8), 179 - children: [ 180 - _MenuTile( 181 - icon: Icons.home_outlined, 182 - selectedIcon: Icons.home, 183 - label: 'Home', 184 - isSelected: navigationShell.currentIndex == 0, 185 - onTap: () => _selectBranch(context, 0), 186 - ), 187 - _MenuTile( 188 - icon: Icons.search_outlined, 189 - selectedIcon: Icons.search, 190 - label: 'Search', 191 - isSelected: navigationShell.currentIndex == 1, 192 - onTap: () => _selectBranch(context, 1), 193 - ), 194 - _MenuTile( 195 - icon: Icons.rss_feed_outlined, 196 - selectedIcon: Icons.rss_feed, 197 - label: 'Feeds', 198 - onTap: () => _pushRoute(context, '/feeds'), 199 - ), 200 - _MenuTile( 201 - icon: Icons.notifications_outlined, 202 - selectedIcon: Icons.notifications, 203 - label: 'Notifications', 204 - isSelected: isNotificationsRoute, 205 - trailing: _notificationsBadge(), 206 - onTap: () => _goRoute(context, '/alerts'), 207 - ), 208 - _MenuTile( 209 - icon: Icons.chat_bubble_outline, 210 - selectedIcon: Icons.chat_bubble, 211 - label: 'Messages', 212 - isSelected: isMessagesRoute, 213 - onTap: () => _goRoute(context, '/alerts/messages'), 214 - ), 215 - _MenuTile( 216 - icon: Icons.person_outline, 217 - selectedIcon: Icons.person, 218 - label: 'Profile', 219 - isSelected: navigationShell.currentIndex == 3, 220 - onTap: () => _selectBranch(context, 3), 221 - ), 222 - const Divider(height: 24), 223 - _MenuTile( 224 - icon: Icons.add_circle_outline, 225 - selectedIcon: Icons.add_circle, 226 - label: 'New Post', 227 - tooltip: isOffline ? offlineActionMessage('compose a post') : null, 228 - onTap: isOffline ? null : () => _pushRoute(context, '/compose'), 229 - ), 230 - _MenuTile( 231 - icon: Icons.settings_outlined, 232 - selectedIcon: Icons.settings, 233 - label: 'Settings', 234 - onTap: () => _pushRoute(context, '/settings'), 235 - ), 236 - const Divider(height: 24), 237 - _MenuTile( 238 - icon: Icons.logout, 239 - selectedIcon: Icons.logout, 240 - label: 'Log Out', 241 - isDestructive: true, 242 - onTap: () => 243 - _runAfterClose(context, () => rootContext.read<AuthBloc>().add(const LogoutRequested())), 244 - ), 245 - ], 185 + ), 186 + Expanded( 187 + child: SafeArea( 188 + top: false, 189 + child: ClipRect( 190 + child: ListView( 191 + padding: const EdgeInsets.symmetric(horizontal: 8), 192 + children: [ 193 + _MenuTile( 194 + icon: Icons.add_circle_outline, 195 + selectedIcon: Icons.add_circle, 196 + label: 'New Post', 197 + tooltip: isOffline ? offlineActionMessage('compose a post') : null, 198 + onTap: isOffline ? null : () => _pushRoute(context, '/compose'), 199 + ), 200 + const Divider(height: 24), 201 + const _MenuSectionLabel(label: 'Navigation'), 202 + _MenuTile( 203 + icon: Icons.home_outlined, 204 + selectedIcon: Icons.home, 205 + label: 'Home', 206 + isSelected: navigationShell.currentIndex == 0, 207 + onTap: () => _selectBranch(context, 0), 208 + ), 209 + _MenuTile( 210 + icon: Icons.search_outlined, 211 + selectedIcon: Icons.search, 212 + label: 'Search', 213 + isSelected: navigationShell.currentIndex == 1, 214 + onTap: () => _selectBranch(context, 1), 215 + ), 216 + _MenuTile( 217 + icon: Icons.rss_feed_outlined, 218 + selectedIcon: Icons.rss_feed, 219 + label: 'Feeds', 220 + onTap: () => _pushRoute(context, '/feeds'), 221 + ), 222 + _MenuTile( 223 + icon: Icons.notifications_outlined, 224 + selectedIcon: Icons.notifications, 225 + label: 'Notifications', 226 + isSelected: isNotificationsRoute, 227 + trailing: _notificationsBadge(), 228 + onTap: () => _goRoute(context, '/alerts'), 229 + ), 230 + _MenuTile( 231 + icon: Icons.chat_bubble_outline, 232 + selectedIcon: Icons.chat_bubble, 233 + label: 'Messages', 234 + isSelected: isMessagesRoute, 235 + onTap: () => _goRoute(context, '/alerts/messages'), 236 + ), 237 + _MenuTile( 238 + icon: Icons.person_outline, 239 + selectedIcon: Icons.person, 240 + label: 'Profile', 241 + isSelected: navigationShell.currentIndex == 3, 242 + onTap: () => _selectBranch(context, 3), 243 + ), 244 + const Divider(height: 24), 245 + const _MenuSectionLabel(label: 'Advanced'), 246 + _MenuTile( 247 + icon: Icons.code_outlined, 248 + selectedIcon: Icons.code, 249 + label: 'AT Explorer', 250 + onTap: () => _pushRoute(context, '/settings/devtools'), 251 + ), 252 + _MenuTile( 253 + icon: Icons.cleaning_services_outlined, 254 + selectedIcon: Icons.cleaning_services, 255 + label: 'Audit Follows', 256 + onTap: () => _pushRoute(context, '/settings/clean-follows'), 257 + ), 258 + const Divider(height: 24), 259 + _MenuTile( 260 + icon: Icons.settings_outlined, 261 + selectedIcon: Icons.settings, 262 + label: 'Settings', 263 + onTap: () => _pushRoute(context, '/settings'), 264 + ), 265 + _MenuTile( 266 + icon: Icons.logout, 267 + selectedIcon: Icons.logout, 268 + label: 'Log Out', 269 + isDestructive: true, 270 + onTap: () => 271 + _runAfterClose(context, () => rootContext.read<AuthBloc>().add(const LogoutRequested())), 272 + ), 273 + ], 274 + ), 246 275 ), 247 276 ), 248 - ], 249 - ), 277 + ), 278 + ], 250 279 ), 251 280 ), 252 281 ); ··· 343 372 } 344 373 345 374 return parts.map((part) => part.characters.first.toUpperCase()).join(); 375 + } 376 + } 377 + 378 + class _MenuSectionLabel extends StatelessWidget { 379 + const _MenuSectionLabel({required this.label}); 380 + 381 + final String label; 382 + 383 + @override 384 + Widget build(BuildContext context) { 385 + final theme = Theme.of(context); 386 + return Padding( 387 + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), 388 + child: Text( 389 + label.toUpperCase(), 390 + style: theme.textTheme.labelSmall?.copyWith( 391 + color: theme.colorScheme.onSurfaceVariant, 392 + fontWeight: FontWeight.w700, 393 + letterSpacing: 0.8, 394 + ), 395 + ), 396 + ); 346 397 } 347 398 } 348 399
+13 -42
lib/features/settings/presentation/settings_screen.dart
··· 119 119 _buildSectionHeader(context, 'About'), 120 120 _SettingsTile( 121 121 icon: Icons.code_outlined, 122 - title: 'Dev Tools', 123 - subtitle: 'PDS Explorer', 122 + title: 'AT Explorer', 123 + subtitle: 'View PDS Records', 124 124 onTap: () => context.push('/settings/devtools'), 125 125 ), 126 126 _SettingsTile( ··· 347 347 ); 348 348 } 349 349 350 - Widget _buildAdvancedSettings(BuildContext context) { 350 + Widget _buildDeveloperSettings(BuildContext context) { 351 + final settingsCubit = context.read<SettingsCubit>(); 352 + 351 353 return BlocBuilder<SettingsCubit, SettingsState>( 352 354 builder: (context, state) { 353 355 return Container( ··· 358 360 ), 359 361 color: Theme.of(context).cardColor, 360 362 ), 361 - child: _ConstellationUrlTile( 362 - currentUrl: state.constellationUrl, 363 - onChanged: (url) => context.read<SettingsCubit>().setConstellationUrl(url), 363 + child: _SettingsTile( 364 + icon: Icons.cloud_off_outlined, 365 + title: 'Go Offline', 366 + subtitle: 'Turn off online connectivity', 367 + trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), 364 368 ), 365 369 ); 366 370 }, 367 371 ); 368 372 } 369 373 370 - Widget _buildDeveloperSettings(BuildContext context) { 371 - final settingsCubit = context.read<SettingsCubit>(); 372 - 374 + Widget _buildAdvancedSettings(BuildContext context) { 373 375 return BlocBuilder<SettingsCubit, SettingsState>( 374 376 builder: (context, state) { 375 377 return Container( ··· 380 382 ), 381 383 color: Theme.of(context).cardColor, 382 384 ), 383 - child: _SettingsTile( 384 - icon: Icons.cloud_off_outlined, 385 - title: 'Go Offline', 386 - subtitle: 'Turn off online connectivity', 387 - trailing: Switch.adaptive(value: state.simulateOffline, onChanged: settingsCubit.setSimulateOffline), 388 - ), 385 + child: _ConstellationUrlTile(currentUrl: state.constellationUrl), 389 386 ); 390 387 }, 391 388 ); ··· 644 641 } 645 642 646 643 class _ConstellationUrlTile extends StatelessWidget { 647 - const _ConstellationUrlTile({required this.currentUrl, required this.onChanged}); 644 + const _ConstellationUrlTile({required this.currentUrl}); 648 645 649 646 final String currentUrl; 650 - final ValueChanged<String> onChanged; 651 - 652 - Future<void> _showEditDialog(BuildContext context) async { 653 - final controller = TextEditingController(text: currentUrl); 654 - final result = await showDialog<String>( 655 - context: context, 656 - builder: (context) => AlertDialog( 657 - title: const Text('Constellation URL'), 658 - content: TextField( 659 - controller: controller, 660 - keyboardType: TextInputType.url, 661 - autocorrect: false, 662 - decoration: const InputDecoration(hintText: 'https://constellation.microcosm.blue'), 663 - ), 664 - actions: [ 665 - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Cancel')), 666 - TextButton(onPressed: () => Navigator.of(context).pop(controller.text.trim()), child: const Text('Save')), 667 - ], 668 - ), 669 - ); 670 - if (result != null && result.isNotEmpty) { 671 - onChanged(result); 672 - } 673 - } 674 647 675 648 @override 676 649 Widget build(BuildContext context) { ··· 678 651 leading: const Icon(Icons.hub_outlined), 679 652 title: const Text('Constellation URL'), 680 653 subtitle: Text(currentUrl, maxLines: 1, overflow: TextOverflow.ellipsis), 681 - trailing: const Icon(Icons.edit_outlined), 682 - onTap: () => _showEditDialog(context), 683 654 ); 684 655 } 685 656 }
+4
test/core/router/app_router_test.dart
··· 244 244 expect(find.text('NOTIFICATIONS'), findsOneWidget); 245 245 expect(find.text('MESSAGES'), findsOneWidget); 246 246 expect(find.text('SETTINGS'), findsOneWidget); 247 + await tester.scrollUntilVisible(find.text('AUDIT FOLLOWS'), 200, scrollable: find.byType(Scrollable).last); 248 + expect(find.text('ADVANCED'), findsOneWidget); 249 + expect(find.text('AT EXPLORER'), findsOneWidget); 250 + expect(find.text('AUDIT FOLLOWS'), findsOneWidget); 247 251 }); 248 252 249 253 testWidgets('drawer profile tag opens account switcher sheet', (tester) async {
+2 -16
test/features/settings/presentation/settings_screen_test.dart
··· 232 232 expect(find.text('Help & Support'), findsNothing); 233 233 }); 234 234 235 - testWidgets('shows Advanced section with Constellation URL tile', (tester) async { 235 + testWidgets('shows Advanced section with read-only Constellation URL tile', (tester) async { 236 236 await tester.pumpWidget(buildSubject()); 237 237 await tester.pumpAndSettle(); 238 238 ··· 242 242 expect(find.text('ADVANCED'), findsOneWidget); 243 243 expect(find.text('Constellation URL'), findsOneWidget); 244 244 expect(find.text('https://constellation.microcosm.blue'), findsOneWidget); 245 - }); 246 - 247 - testWidgets('Constellation URL tile opens edit dialog on tap', (tester) async { 248 - await tester.pumpWidget(buildSubject()); 249 - await tester.pumpAndSettle(); 250 - 251 - await tester.scrollUntilVisible(find.text('Constellation URL'), 300); 252 - await tester.pumpAndSettle(); 253 - 254 - await tester.tap(find.text('Constellation URL')); 255 - await tester.pumpAndSettle(); 256 - 257 - expect(find.text('Constellation URL'), findsWidgets); 258 - expect(find.text('Cancel'), findsOneWidget); 259 - expect(find.text('Save'), findsOneWidget); 245 + expect(find.byIcon(Icons.edit_outlined), findsNothing); 260 246 }); 261 247 262 248 testWidgets('shows Video Upload Limits tile in Account section', (tester) async {