+3
-3
doc/roadmap.txt
+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
+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
+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
+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
+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
+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
+
}