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

feat: message requests with accept or decline flow

Changed files
+353 -10
doc
lib
src
features
infrastructure
test
+3 -3
doc/roadmap.txt
··· 118 118 - handle app crash during send (resume on restart) 119 119 120 120 Phase 5: Message Requests and Accept Flow 121 - - [ ] MessageRequestCard widget: 121 + - [x] MessageRequestCard widget: 122 122 - display sender profile (avatar, display name, handle) 123 123 - message preview 124 124 - accept and decline buttons 125 - - [ ] Accept flow: 125 + - [x] Accept flow: 126 126 - call chat.bsky.convo.acceptConvo 127 127 - move conversation to main list 128 128 - enable sending messages 129 - - [ ] Decline flow: 129 + - [x] Decline flow: 130 130 - hide conversation (client-side only) 131 131 - user can still accept later 132 132
+31 -2
lib/src/features/dms/presentation/conversation_list_screen.dart
··· 1 1 import 'package:flutter/material.dart'; 2 2 import 'package:flutter_riverpod/flutter_riverpod.dart'; 3 3 import 'package:go_router/go_router.dart'; 4 + import 'package:lazurite/src/features/dms/presentation/widgets/message_request_card.dart'; 4 5 5 6 import '../../../core/animations/animation_utils.dart'; 6 7 import '../../../core/widgets/empty_state.dart'; ··· 19 20 20 21 class _ConversationListScreenState extends ConsumerState<ConversationListScreen> { 21 22 final ScrollController _scrollController = ScrollController(); 23 + final Set<String> _declinedConvoIds = {}; 22 24 23 25 @override 24 26 void initState() { ··· 38 40 } 39 41 } 40 42 43 + void _declineConversation(String convoId) { 44 + setState(() { 45 + _declinedConvoIds.add(convoId); 46 + }); 47 + ScaffoldMessenger.of(context).showSnackBar( 48 + SnackBar( 49 + content: const Text('Message request declined'), 50 + action: SnackBarAction( 51 + label: 'Undo', 52 + onPressed: () { 53 + setState(() { 54 + _declinedConvoIds.remove(convoId); 55 + }); 56 + }, 57 + ), 58 + ), 59 + ); 60 + } 61 + 41 62 @override 42 63 Widget build(BuildContext context) { 43 64 final state = ref.watch(conversationListProvider); ··· 61 82 ); 62 83 } 63 84 64 - final requests = conversations.where((c) => !c.isAccepted).toList(); 85 + final requests = conversations 86 + .where((c) => !c.isAccepted && !_declinedConvoIds.contains(c.convoId)) 87 + .toList(); 65 88 final active = conversations.where((c) => c.isAccepted).toList(); 66 89 67 90 return PullToRefreshWrapper( ··· 86 109 SliverList( 87 110 delegate: SliverChildBuilderDelegate((context, index) { 88 111 final convo = requests[index]; 89 - return ConversationListItem( 112 + return MessageRequestCard( 90 113 conversation: convo, 91 114 onTap: () => context.push('/messages/${convo.convoId}'), 115 + onAccept: () async { 116 + await ref 117 + .read(conversationListProvider.notifier) 118 + .acceptConversation(convo.convoId); 119 + }, 120 + onDecline: () => _declineConversation(convo.convoId), 92 121 ); 93 122 }, childCount: requests.length), 94 123 ),
+142
lib/src/features/dms/presentation/widgets/message_request_card.dart
··· 1 + import 'package:flutter/material.dart'; 2 + 3 + import '../../../../core/utils/date_formatter.dart'; 4 + import '../../../../core/widgets/avatar.dart'; 5 + import '../../domain/dm_conversation.dart'; 6 + 7 + /// A card widget for displaying a message request. 8 + /// 9 + /// Shows the sender's profile, message preview, and accept/decline buttons 10 + /// for unaccepted conversation requests. 11 + class MessageRequestCard extends StatelessWidget { 12 + const MessageRequestCard({ 13 + super.key, 14 + required this.conversation, 15 + required this.onAccept, 16 + required this.onDecline, 17 + this.onTap, 18 + }); 19 + 20 + /// The conversation request to display. 21 + final DmConversation conversation; 22 + 23 + /// Called when the user accepts the request. 24 + final VoidCallback onAccept; 25 + 26 + /// Called when the user declines the request (client-side hide). 27 + final VoidCallback onDecline; 28 + 29 + /// Called when the user taps the card to view the conversation. 30 + final VoidCallback? onTap; 31 + 32 + @override 33 + Widget build(BuildContext context) { 34 + final theme = Theme.of(context); 35 + final colorScheme = theme.colorScheme; 36 + final otherParty = conversation.otherParty; 37 + final lastMessageAt = conversation.lastMessageAt; 38 + 39 + return Card( 40 + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 41 + child: InkWell( 42 + onTap: onTap, 43 + borderRadius: BorderRadius.circular(12), 44 + child: Padding( 45 + padding: const EdgeInsets.all(16), 46 + child: Column( 47 + crossAxisAlignment: CrossAxisAlignment.start, 48 + children: [ 49 + Row( 50 + children: [ 51 + Avatar(imageUrl: otherParty.avatar, radius: 24), 52 + const SizedBox(width: 12), 53 + Expanded( 54 + child: Column( 55 + crossAxisAlignment: CrossAxisAlignment.start, 56 + children: [ 57 + Text( 58 + otherParty.displayName ?? otherParty.handle, 59 + style: theme.textTheme.titleMedium?.copyWith( 60 + fontWeight: FontWeight.bold, 61 + ), 62 + maxLines: 1, 63 + overflow: TextOverflow.ellipsis, 64 + ), 65 + const SizedBox(height: 2), 66 + Text( 67 + '@${otherParty.handle}', 68 + style: theme.textTheme.bodySmall?.copyWith( 69 + color: colorScheme.onSurfaceVariant, 70 + ), 71 + maxLines: 1, 72 + overflow: TextOverflow.ellipsis, 73 + ), 74 + ], 75 + ), 76 + ), 77 + if (lastMessageAt != null) ...[ 78 + const SizedBox(width: 8), 79 + Text( 80 + DateFormatter.formatRelative(lastMessageAt), 81 + style: theme.textTheme.bodySmall?.copyWith( 82 + color: colorScheme.onSurfaceVariant, 83 + ), 84 + ), 85 + ], 86 + ], 87 + ), 88 + const SizedBox(height: 12), 89 + if (conversation.lastMessageText != null) ...[ 90 + Container( 91 + padding: const EdgeInsets.all(12), 92 + decoration: BoxDecoration( 93 + color: colorScheme.surfaceContainerHighest, 94 + borderRadius: BorderRadius.circular(8), 95 + ), 96 + child: Row( 97 + children: [ 98 + Icon( 99 + Icons.chat_bubble_outline, 100 + size: 16, 101 + color: colorScheme.onSurfaceVariant, 102 + ), 103 + const SizedBox(width: 8), 104 + Expanded( 105 + child: Text( 106 + conversation.lastMessageText!, 107 + style: theme.textTheme.bodyMedium?.copyWith( 108 + color: colorScheme.onSurface, 109 + ), 110 + maxLines: 2, 111 + overflow: TextOverflow.ellipsis, 112 + ), 113 + ), 114 + ], 115 + ), 116 + ), 117 + const SizedBox(height: 16), 118 + ], 119 + Row( 120 + children: [ 121 + Expanded( 122 + child: OutlinedButton( 123 + onPressed: onDecline, 124 + style: OutlinedButton.styleFrom( 125 + foregroundColor: colorScheme.onSurfaceVariant, 126 + ), 127 + child: const Text('Decline'), 128 + ), 129 + ), 130 + const SizedBox(width: 12), 131 + Expanded( 132 + child: FilledButton(onPressed: onAccept, child: const Text('Accept')), 133 + ), 134 + ], 135 + ), 136 + ], 137 + ), 138 + ), 139 + ), 140 + ); 141 + } 142 + }
+23 -4
lib/src/infrastructure/db/tables.dart
··· 349 349 ]; 350 350 } 351 351 352 - /// Stores local app settings as key-value pairs. 352 + /// Stores device-level app settings as key-value pairs. 353 353 /// 354 - /// Used for theme mode, theme pack ID, font scale, and other user preferences. 354 + /// Used for theme mode, theme pack ID, font scale, and similar UI preferences. 355 + /// These settings are intentionally global (not scoped by ownerDid) because they 356 + /// represent device-level preferences that apply across all user accounts. 357 + /// 358 + /// For example, if a user prefers dark mode or a specific font size, these 359 + /// settings persist when switching between accounts on the same device. 360 + /// 355 361 /// Key-value design allows adding new settings without schema migrations. 356 362 class LocalSettings extends Table { 357 363 /// Setting key (e.g., 'themeMode', 'themePackId'). ··· 389 395 Set<Column> get primaryKey => {type, ownerDid}; 390 396 } 391 397 392 - /// Stores user-customized themes. 398 + /// Stores device-level custom theme definitions. 393 399 /// 394 400 /// Custom themes are based on a built-in theme pack with color role overrides. 401 + /// These themes are intentionally global (not scoped by ownerDid) as they 402 + /// represent device-level visual customizations that apply to all user accounts. 403 + /// 404 + /// Users can create and share custom themes across their accounts on the device, 405 + /// providing a consistent visual experience regardless of which account is active. 406 + /// 395 407 /// The overrides and other data are stored as JSON for flexibility. 396 408 class CustomThemes extends Table { 397 409 /// Unique identifier for this custom theme. ··· 419 431 Set<Column> get primaryKey => {id}; 420 432 } 421 433 422 - /// Stores animation preferences for accessibility and motion control. 434 + /// Stores device-level animation preferences for accessibility and motion control. 435 + /// 436 + /// These preferences are intentionally global (not scoped by ownerDid) because 437 + /// they reflect device-level accessibility needs that should apply consistently 438 + /// across all user accounts. 439 + /// For instance, if a user has motion sensitivity or 440 + /// prefers reduced animations, this preference should persist when switching 441 + /// between accounts on the same device. 423 442 /// 424 443 /// Uses key-value storage for flexibility, similar to LocalSettings. 425 444 /// Keys: 'mode' (AnimationMode enum as string), 'speedMultiplier' (double as string).
+3 -1
test/src/features/dms/presentation/conversation_list_screen_test.dart
··· 9 9 import 'package:lazurite/src/features/dms/domain/dm_message.dart' as dmm; 10 10 import 'package:lazurite/src/features/dms/presentation/conversation_list_screen.dart'; 11 11 import 'package:lazurite/src/features/dms/presentation/widgets/conversation_list_item.dart'; 12 + import 'package:lazurite/src/features/dms/presentation/widgets/message_request_card.dart'; 12 13 import 'package:lazurite/src/features/dms/providers.dart'; 13 14 import 'package:lazurite/src/infrastructure/db/app_database.dart'; 14 15 import 'package:mocktail/mocktail.dart'; ··· 128 129 129 130 expect(find.text('Message Requests'), findsOneWidget); 130 131 expect(find.text('All Messages'), findsOneWidget); 131 - expect(find.byType(ConversationListItem), findsNWidgets(2)); 132 + expect(find.byType(MessageRequestCard), findsOneWidget); 133 + expect(find.byType(ConversationListItem), findsOneWidget); 132 134 }); 133 135 134 136 testWidgets('triggers refresh on pull to refresh', (tester) async {
+151
test/src/features/dms/presentation/widgets/message_request_card_test.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:lazurite/src/features/dms/domain/dm_conversation.dart'; 4 + import 'package:lazurite/src/features/dms/presentation/widgets/message_request_card.dart'; 5 + import 'package:lazurite/src/infrastructure/db/app_database.dart'; 6 + 7 + import '../../../../../helpers/pump_app.dart'; 8 + 9 + void main() { 10 + group('MessageRequestCard', () { 11 + final now = DateTime.now(); 12 + const profile = Profile( 13 + did: 'did:web:alice', 14 + handle: 'alice.bsky.social', 15 + displayName: 'Alice', 16 + ); 17 + final conversation = DmConversation( 18 + convoId: '123', 19 + members: [profile], 20 + lastMessageText: 'Hello, is this a scam?', 21 + lastMessageAt: now, 22 + unreadCount: 1, 23 + isAccepted: false, 24 + isMuted: false, 25 + ); 26 + 27 + testWidgets('renders sender display name', (tester) async { 28 + await tester.pumpApp( 29 + Scaffold( 30 + body: MessageRequestCard(conversation: conversation, onAccept: () {}, onDecline: () {}), 31 + ), 32 + ); 33 + 34 + expect(find.text('Alice'), findsOneWidget); 35 + }); 36 + 37 + testWidgets('renders sender handle', (tester) async { 38 + await tester.pumpApp( 39 + Scaffold( 40 + body: MessageRequestCard(conversation: conversation, onAccept: () {}, onDecline: () {}), 41 + ), 42 + ); 43 + 44 + expect(find.text('@alice.bsky.social'), findsOneWidget); 45 + }); 46 + 47 + testWidgets('renders message preview', (tester) async { 48 + await tester.pumpApp( 49 + Scaffold( 50 + body: MessageRequestCard(conversation: conversation, onAccept: () {}, onDecline: () {}), 51 + ), 52 + ); 53 + 54 + expect(find.text('Hello, is this a scam?'), findsOneWidget); 55 + }); 56 + 57 + testWidgets('renders accept and decline buttons', (tester) async { 58 + await tester.pumpApp( 59 + Scaffold( 60 + body: MessageRequestCard(conversation: conversation, onAccept: () {}, onDecline: () {}), 61 + ), 62 + ); 63 + 64 + expect(find.text('Accept'), findsOneWidget); 65 + expect(find.text('Decline'), findsOneWidget); 66 + }); 67 + 68 + testWidgets('calls onAccept when accept button tapped', (tester) async { 69 + bool accepted = false; 70 + await tester.pumpApp( 71 + Scaffold( 72 + body: MessageRequestCard( 73 + conversation: conversation, 74 + onAccept: () => accepted = true, 75 + onDecline: () {}, 76 + ), 77 + ), 78 + ); 79 + 80 + await tester.tap(find.text('Accept')); 81 + expect(accepted, isTrue); 82 + }); 83 + 84 + testWidgets('calls onDecline when decline button tapped', (tester) async { 85 + bool declined = false; 86 + await tester.pumpApp( 87 + Scaffold( 88 + body: MessageRequestCard( 89 + conversation: conversation, 90 + onAccept: () {}, 91 + onDecline: () => declined = true, 92 + ), 93 + ), 94 + ); 95 + 96 + await tester.tap(find.text('Decline')); 97 + expect(declined, isTrue); 98 + }); 99 + 100 + testWidgets('calls onTap when card is tapped', (tester) async { 101 + bool tapped = false; 102 + await tester.pumpApp( 103 + Scaffold( 104 + body: MessageRequestCard( 105 + conversation: conversation, 106 + onAccept: () {}, 107 + onDecline: () {}, 108 + onTap: () => tapped = true, 109 + ), 110 + ), 111 + ); 112 + 113 + await tester.tap(find.text('Alice')); 114 + expect(tapped, isTrue); 115 + }); 116 + 117 + testWidgets('uses handle when display name is null', (tester) async { 118 + const profileNoName = Profile(did: 'did:web:bob', handle: 'bob.bsky.social'); 119 + final convoNoName = conversation.copyWith(members: [profileNoName]); 120 + 121 + await tester.pumpApp( 122 + Scaffold( 123 + body: MessageRequestCard(conversation: convoNoName, onAccept: () {}, onDecline: () {}), 124 + ), 125 + ); 126 + 127 + expect(find.text('bob.bsky.social'), findsOneWidget); 128 + expect(find.text('@bob.bsky.social'), findsOneWidget); 129 + }); 130 + 131 + testWidgets('does not render message preview when null', (tester) async { 132 + final noMessage = DmConversation( 133 + convoId: '456', 134 + members: [profile], 135 + lastMessageText: null, 136 + lastMessageAt: now, 137 + unreadCount: 0, 138 + isAccepted: false, 139 + isMuted: false, 140 + ); 141 + 142 + await tester.pumpApp( 143 + Scaffold( 144 + body: MessageRequestCard(conversation: noMessage, onAccept: () {}, onDecline: () {}), 145 + ), 146 + ); 147 + 148 + expect(find.byIcon(Icons.chat_bubble_outline), findsNothing); 149 + }); 150 + }); 151 + }