Skip to content

Commit 0733d3b

Browse files
committed
feat: mention
1 parent fc08994 commit 0733d3b

6 files changed

Lines changed: 419 additions & 16 deletions

File tree

lib/models/user_profile.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,4 +181,14 @@ def hello():
181181
avatar: null,
182182
);
183183
}
184+
static UserProfile? findByUsername(String username) {
185+
const knownUids = ['1', '2', '3', '4', '5'];
186+
for (final uid in knownUids) {
187+
final profile = getDemoProfile(uid);
188+
if (profile.username.toLowerCase() == username.toLowerCase()) {
189+
return profile;
190+
}
191+
}
192+
return null;
193+
}
184194
}

lib/screens/chat_detail_screen.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import '../widgets/message_bubble.dart';
1010
import '../widgets/chat_input_bar.dart';
1111
import '../routes/app_routes.dart';
1212
import '../l10n/app_localizations.dart';
13+
import '../widgets/mention_text_field.dart';
1314
import 'chat_room_settings_screen.dart';
1415

1516

@@ -30,6 +31,7 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
3031
final ScrollController _scrollController = ScrollController();
3132
final List<ChatMessage> _messages = [];
3233
ChatRoom? _currentRoom;
34+
List<MentionUser> _mentionUsers = [];
3335
bool _isInitialized = false;
3436

3537
@override
@@ -57,6 +59,9 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
5759
final allRooms = ChatDemoData.getDemoChatRooms();
5860
final allContacts = ChatDemoData.getDemoContacts();
5961
final l10n = AppLocalizations.of(context)!;
62+
_mentionUsers = allContacts
63+
.map((c) => MentionUser(id: c.id, username: c.name, avatarUrl: c.avatar))
64+
.toList();
6065
_currentRoom = allRooms.firstWhere(
6166
(room) => room.id == widget.roomId,
6267
orElse: () {
@@ -397,6 +402,7 @@ void main() {
397402
controller: _messageController,
398403
onSend: _sendMessage,
399404
onFilePicked: _sendMediaMessage,
405+
mentionUsers: _mentionUsers,
400406
),
401407
],
402408
),

lib/screens/forum_post_compose_screen.dart

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import 'package:flutter/material.dart';
22
import '../l10n/app_localizations.dart';
3+
import '../models/chat_model.dart';
34
import '../models/user_profile.dart';
45
import '../widgets/account/profile_picture.dart';
6+
import '../widgets/mention_text_field.dart';
57

68
class ForumPostComposeSheet extends StatefulWidget {
79
final String forumId;
@@ -41,6 +43,7 @@ class _ForumPostComposeSheetState extends State<ForumPostComposeSheet> {
4143
final _titleController = TextEditingController();
4244
final _contentController = TextEditingController();
4345
final _formKey = GlobalKey<FormState>();
46+
late final List<MentionUser> _mentionUsers;
4447

4548
final _currentUser = UserProfileDemoData.getDemoProfile('1');
4649

@@ -50,6 +53,9 @@ class _ForumPostComposeSheetState extends State<ForumPostComposeSheet> {
5053
if (widget.initialContent != null) {
5154
_contentController.text = widget.initialContent!;
5255
}
56+
_mentionUsers = ChatDemoData.getDemoContacts()
57+
.map((c) => MentionUser(id: c.id, username: c.name, avatarUrl: c.avatar))
58+
.toList();
5359
}
5460

5561
@override
@@ -155,8 +161,9 @@ class _ForumPostComposeSheetState extends State<ForumPostComposeSheet> {
155161
if (!widget.isReply)
156162
const SizedBox(height: 4),
157163
// Content field
158-
TextFormField(
164+
MentionTextField(
159165
controller: _contentController,
166+
mentionUsers: _mentionUsers,
160167
style: Theme.of(context).textTheme.bodyLarge,
161168
maxLines: null,
162169
minLines: 6,
@@ -169,12 +176,6 @@ class _ForumPostComposeSheetState extends State<ForumPostComposeSheet> {
169176
vertical: 8,
170177
),
171178
),
172-
validator: (value) {
173-
if (value == null || value.isEmpty) {
174-
return l10n.forumPostContentRequired;
175-
}
176-
return null;
177-
},
178179
),
179180
],
180181
),
@@ -306,16 +307,21 @@ class _ForumPostComposeSheetState extends State<ForumPostComposeSheet> {
306307
}
307308

308309
void _submit() {
309-
if (_formKey.currentState!.validate()) {
310-
final l10n = AppLocalizations.of(context)!;
310+
final l10n = AppLocalizations.of(context)!;
311+
if (!_formKey.currentState!.validate()) return;
312+
if (_contentController.text.trim().isEmpty) {
311313
ScaffoldMessenger.of(context).showSnackBar(
312-
SnackBar(
313-
content: Text(
314-
widget.isReply ? l10n.forumCommentSuccess : l10n.forumPostSuccess,
315-
),
316-
),
314+
SnackBar(content: Text(l10n.forumPostContentRequired)),
317315
);
318-
Navigator.pop(context, true);
316+
return;
319317
}
318+
ScaffoldMessenger.of(context).showSnackBar(
319+
SnackBar(
320+
content: Text(
321+
widget.isReply ? l10n.forumCommentSuccess : l10n.forumPostSuccess,
322+
),
323+
),
324+
);
325+
Navigator.pop(context, true);
320326
}
321327
}

lib/widgets/chat_input_bar.dart

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,20 @@ import 'package:file_picker/file_picker.dart';
77
import 'package:mime/mime.dart';
88
import '../l10n/app_localizations.dart';
99
import '../models/message_model.dart';
10+
import 'mention_text_field.dart';
1011

1112
class ChatInputBar extends StatefulWidget {
1213
final TextEditingController controller;
1314
final VoidCallback onSend;
1415
final Function(PlatformFile file, MessageType type)? onFilePicked;
16+
final List<MentionUser> mentionUsers;
1517

1618
const ChatInputBar({
1719
super.key,
1820
required this.controller,
1921
required this.onSend,
2022
this.onFilePicked,
23+
this.mentionUsers = const [],
2124
});
2225

2326
@override
@@ -120,8 +123,9 @@ class _ChatInputBarState extends State<ChatInputBar> {
120123
],
121124
),
122125
Expanded(
123-
child: TextField(
126+
child: MentionTextField(
124127
controller: widget.controller,
128+
mentionUsers: widget.mentionUsers,
125129
maxLines: 5,
126130
minLines: 1,
127131
keyboardType: TextInputType.multiline,

lib/widgets/markdown_renderer.dart

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import 'package:flutter_math_fork/flutter_math.dart';
99
import 'package:markdown/markdown.dart' as md;
1010
import 'package:photo_view/photo_view.dart';
1111
import '../l10n/app_localizations.dart';
12+
import '../models/user_profile.dart';
13+
import 'account/profile_picture.dart';
1214

1315
/// Markdown Renderer
1416
class MarkdownRenderer extends StatelessWidget {
@@ -118,6 +120,8 @@ class MarkdownRenderer extends StatelessWidget {
118120
'code': CustomCodeBuilder(),
119121
// LaTeX 支持
120122
'latex': LatexBuilder(),
123+
// @mention 支持
124+
'mention': MentionChipBuilder(),
121125
},
122126
// 自定义图片
123127
imageBuilder: (uri, title, alt) {
@@ -140,6 +144,7 @@ class MarkdownRenderer extends StatelessWidget {
140144
extensionSet: md.ExtensionSet(
141145
md.ExtensionSet.gitHubFlavored.blockSyntaxes,
142146
[
147+
MentionInlineSyntax(),
143148
md.EmojiSyntax(),
144149
...md.ExtensionSet.gitHubFlavored.inlineSyntaxes,
145150
LatexSyntax(),
@@ -274,6 +279,77 @@ class _CodeBlockWidget extends StatelessWidget {
274279
}
275280
}
276281

282+
// @mention
283+
class MentionInlineSyntax extends md.InlineSyntax {
284+
MentionInlineSyntax() : super(r'@([A-Za-z0-9_]+)(?=\s|$)');
285+
286+
@override
287+
bool onMatch(md.InlineParser parser, Match match) {
288+
final username = match[1]!;
289+
if (UserProfileDemoData.findByUsername(username) == null) {
290+
parser.addNode(md.Text('@$username'));
291+
return true;
292+
}
293+
final element = md.Element.text('mention', '@$username');
294+
element.attributes['username'] = username;
295+
parser.addNode(element);
296+
return true;
297+
}
298+
}
299+
300+
class MentionChipBuilder extends MarkdownElementBuilder {
301+
@override
302+
Widget? visitElementAfter(md.Element element, TextStyle? preferredStyle) {
303+
final username = element.attributes['username'] ??
304+
element.textContent.replaceFirst('@', '');
305+
final profile = UserProfileDemoData.findByUsername(username);
306+
return _MentionChipWidget(username: username, profile: profile);
307+
}
308+
}
309+
310+
class _MentionChipWidget extends StatelessWidget {
311+
final String username;
312+
final UserProfile? profile;
313+
314+
const _MentionChipWidget({required this.username, this.profile});
315+
316+
@override
317+
Widget build(BuildContext context) {
318+
final colorScheme = Theme.of(context).colorScheme;
319+
final avatarUrl = profile?.avatar;
320+
return Container(
321+
margin: const EdgeInsets.symmetric(horizontal: 1),
322+
padding: const EdgeInsets.fromLTRB(4, 2, 6, 2),
323+
decoration: BoxDecoration(
324+
color: colorScheme.primaryContainer,
325+
borderRadius: BorderRadius.circular(12),
326+
),
327+
child: Row(
328+
mainAxisSize: MainAxisSize.min,
329+
children: [
330+
_buildAvatar(avatarUrl, colorScheme),
331+
const SizedBox(width: 4),
332+
Text(
333+
'@$username',
334+
style: TextStyle(
335+
fontSize: 13,
336+
fontWeight: FontWeight.w500,
337+
color: colorScheme.onPrimaryContainer,
338+
),
339+
),
340+
],
341+
),
342+
);
343+
}
344+
345+
Widget _buildAvatar(String? avatarUrl, ColorScheme colorScheme) {
346+
return ProfilePictureWidget(
347+
avatarUrl: avatarUrl,
348+
radius: 8,
349+
);
350+
}
351+
}
352+
277353
/// LaTeX 语法解析器
278354
class LatexSyntax extends md.InlineSyntax {
279355
LatexSyntax() : super(r'\$\$(.+?)\$\$|\$(.+?)\$');

0 commit comments

Comments
 (0)