From 73777fe74e7fea16a21d0a8e38056a3d94d1c804 Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sun, 2 Mar 2025 22:53:14 +0800
Subject: [PATCH] :lipstick: Optimize attachment view

---
 assets/translations/en-US.json              |   3 +-
 assets/translations/zh-CN.json              |   3 +-
 assets/translations/zh-HK.json              |   3 +-
 assets/translations/zh-TW.json              |   3 +-
 lib/widgets/account/account_popover.dart    | 106 ++++++++++----------
 lib/widgets/attachment/attachment_zoom.dart | 101 +++++++++++++------
 lib/widgets/chat/chat_message_input.dart    |  98 +++++++-----------
 lib/widgets/post/publisher_popover.dart     |  43 +++++++-
 8 files changed, 203 insertions(+), 157 deletions(-)

diff --git a/assets/translations/en-US.json b/assets/translations/en-US.json
index 9e79500..04e668c 100644
--- a/assets/translations/en-US.json
+++ b/assets/translations/en-US.json
@@ -752,5 +752,6 @@
   "screenAccountBadges": "Badges",
   "accountBadges": "Badges",
   "accountBadgesDescription": "View and manage your badges.",
-  "badgeActivated": "Activated badge {}."
+  "badgeActivated": "Activated badge {}.",
+  "viewDetailedAttachment": "Details"
 }
diff --git a/assets/translations/zh-CN.json b/assets/translations/zh-CN.json
index 7d34dc4..a5881b8 100644
--- a/assets/translations/zh-CN.json
+++ b/assets/translations/zh-CN.json
@@ -750,5 +750,6 @@
   "screenAccountBadges": "徽章",
   "accountBadges": "徽章",
   "accountBadgesDescription": "查看并管理你的徽章。",
-  "badgeActivated": "已佩戴徽章 {}。"
+  "badgeActivated": "已佩戴徽章 {}。",
+  "viewDetailedAttachment": "查看附件详情"
 }
diff --git a/assets/translations/zh-HK.json b/assets/translations/zh-HK.json
index b4b0c3d..a322e9d 100644
--- a/assets/translations/zh-HK.json
+++ b/assets/translations/zh-HK.json
@@ -750,5 +750,6 @@
   "screenAccountBadges": "徽章",
   "accountBadges": "徽章",
   "accountBadgesDescription": "查看並管理你的徽章。",
-  "badgeActivated": "已佩戴徽章 {}。"
+  "badgeActivated": "已佩戴徽章 {}。",
+  "viewDetailedAttachment": "查看附件詳情"
 }
diff --git a/assets/translations/zh-TW.json b/assets/translations/zh-TW.json
index eaa1247..dbc2327 100644
--- a/assets/translations/zh-TW.json
+++ b/assets/translations/zh-TW.json
@@ -750,5 +750,6 @@
   "screenAccountBadges": "徽章",
   "accountBadges": "徽章",
   "accountBadgesDescription": "查看並管理你的徽章。",
-  "badgeActivated": "已佩戴徽章 {}。"
+  "badgeActivated": "已佩戴徽章 {}。",
+  "viewDetailedAttachment": "查看附件詳情"
 }
diff --git a/lib/widgets/account/account_popover.dart b/lib/widgets/account/account_popover.dart
index af6e98c..5eec0de 100644
--- a/lib/widgets/account/account_popover.dart
+++ b/lib/widgets/account/account_popover.dart
@@ -72,34 +72,36 @@ class AccountPopoverCard extends StatelessWidget {
             const Gap(8)
           ],
         ).padding(horizontal: 16),
-        const Gap(16),
-        Wrap(
-          children: data.badges
-              .map(
-                (ele) => Tooltip(
-              richMessage: TextSpan(
-                children: [
-                  TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
-                  if (ele.metadata['title'] != null)
-                    TextSpan(
-                      text: '\n${ele.metadata['title']}',
-                      style: const TextStyle(fontWeight: FontWeight.bold),
+        if (data.badges.isNotEmpty) const Gap(12),
+        if (data.badges.isNotEmpty)
+          Wrap(
+            spacing: 4,
+            children: data.badges
+                .map(
+                  (ele) => Tooltip(
+                    richMessage: TextSpan(
+                      children: [
+                        TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
+                        if (ele.metadata['title'] != null)
+                          TextSpan(
+                            text: '\n${ele.metadata['title']}',
+                            style: const TextStyle(fontWeight: FontWeight.bold),
+                          ),
+                        TextSpan(text: '\n'),
+                        TextSpan(
+                          text: DateFormat.yMEd().format(ele.createdAt),
+                        ),
+                      ],
+                    ),
+                    child: Icon(
+                      kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
+                      color: kBadgesMeta[ele.type]?.$3,
+                      fill: 1,
                     ),
-                  TextSpan(text: '\n'),
-                  TextSpan(
-                    text: DateFormat.yMEd().format(ele.createdAt),
                   ),
-                ],
-              ),
-              child: Icon(
-                kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
-                color: kBadgesMeta[ele.type]?.$3,
-                fill: 1,
-              ),
-            ),
-          )
-              .toList(),
-        ).padding(horizontal: 24),
+                )
+                .toList(),
+          ).padding(horizontal: 24),
         const Gap(8),
         Row(
           crossAxisAlignment: CrossAxisAlignment.center,
@@ -127,33 +129,33 @@ class AccountPopoverCard extends StatelessWidget {
             final SnAccountStatusInfo? status =
                 snapshot.hasData ? SnAccountStatusInfo.fromJson(snapshot.data!.data) : null;
             return Row(
-                children: [
-                  Icon(
-                    Symbols.circle,
-                    fill: 1,
-                    size: 16,
-                    color: (status?.isOnline ?? false) ? Colors.green : Colors.grey,
-                  ).padding(all: 4),
-                  const Gap(8),
+              children: [
+                Icon(
+                  Symbols.circle,
+                  fill: 1,
+                  size: 16,
+                  color: (status?.isOnline ?? false) ? Colors.green : Colors.grey,
+                ).padding(all: 4),
+                const Gap(8),
+                Text(
+                  status != null
+                      ? status.isOnline
+                          ? 'accountStatusOnline'.tr()
+                          : 'accountStatusOffline'.tr()
+                      : 'loading'.tr(),
+                ),
+                if (status != null && !status.isOnline && status.lastSeenAt != null)
                   Text(
-                    status != null
-                        ? status.isOnline
-                            ? 'accountStatusOnline'.tr()
-                            : 'accountStatusOffline'.tr()
-                        : 'loading'.tr(),
-                  ),
-                  if (status != null && !status.isOnline && status.lastSeenAt != null)
-                    Text(
-                      'accountStatusLastSeen'.tr(args: [
-                        status.lastSeenAt != null
-                            ? RelativeTime(context).format(
-                                status.lastSeenAt!.toLocal(),
-                              )
-                            : 'unknown',
-                      ]),
-                    ).padding(left: 6).opacity(0.75),
-                ],
-              ).padding(horizontal: 24);
+                    'accountStatusLastSeen'.tr(args: [
+                      status.lastSeenAt != null
+                          ? RelativeTime(context).format(
+                              status.lastSeenAt!.toLocal(),
+                            )
+                          : 'unknown',
+                    ]),
+                  ).padding(left: 6).opacity(0.75),
+              ],
+            ).padding(horizontal: 24);
           },
         ),
         // Bottom padding
diff --git a/lib/widgets/attachment/attachment_zoom.dart b/lib/widgets/attachment/attachment_zoom.dart
index 8be522d..3eee61d 100644
--- a/lib/widgets/attachment/attachment_zoom.dart
+++ b/lib/widgets/attachment/attachment_zoom.dart
@@ -1,4 +1,5 @@
 import 'dart:io';
+import 'dart:math' show max;
 
 import 'package:dio/dio.dart';
 import 'package:dismissible_page/dismissible_page.dart';
@@ -43,6 +44,9 @@ class AttachmentZoomView extends StatefulWidget {
 class _AttachmentZoomViewState extends State<AttachmentZoomView> {
   late final PageController _pageController = PageController(initialPage: widget.initialIndex ?? 0);
 
+  bool _showOverlay = true;
+  bool _dismissable = true;
+
   void _updatePage() {
     setState(() {
       if (_isCompletedDownload) {
@@ -146,7 +150,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
       onDismissed: () {
         Navigator.of(context).pop();
       },
-      direction: DismissiblePageDismissDirection.none,
+      direction: _dismissable ? DismissiblePageDismissDirection.multi : DismissiblePageDismissDirection.none,
       backgroundColor: Colors.transparent,
       isFullScreen: true,
       child: GestureDetector(
@@ -163,6 +167,9 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
                     child: PhotoView(
                       key: Key('attachment-detail-${widget.data.first.rid}-$heroTag'),
                       backgroundDecoration: BoxDecoration(color: Colors.transparent),
+                      scaleStateChangedCallback: (scaleState) {
+                        setState(() => _dismissable = scaleState == PhotoViewScaleState.initial);
+                      },
                       imageProvider: UniversalImage.provider(
                         sn.getAttachmentUrl(widget.data.first.rid),
                       ),
@@ -172,7 +179,10 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
 
                 return PhotoViewGallery.builder(
                   pageController: _pageController,
-                  scrollPhysics: const BouncingScrollPhysics(),
+                  enableRotation: true,
+                  scaleStateChangedCallback: (scaleState) {
+                    setState(() => _dismissable = scaleState == PhotoViewScaleState.initial);
+                  },
                   builder: (context, idx) {
                     final heroTag = widget.heroTags?.elementAt(idx) ?? uuid.v4();
                     return PhotoViewGalleryPageOptions(
@@ -197,6 +207,27 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
                   backgroundDecoration: BoxDecoration(color: Colors.transparent),
                 );
               }),
+              Positioned(
+                top: max(MediaQuery.of(context).padding.top, 8),
+                left: 14,
+                child: IgnorePointer(
+                  ignoring: !_showOverlay,
+                  child: IconButton(
+                    constraints: const BoxConstraints(),
+                    icon: const Icon(Icons.close),
+                    style: ButtonStyle(
+                      backgroundColor: MaterialStateProperty.all(
+                        Theme.of(context).colorScheme.surface.withOpacity(0.5),
+                      ),
+                    ),
+                    onPressed: () {
+                      Navigator.of(context).pop();
+                    },
+                  )
+                      .opacity(_showOverlay ? 1 : 0, animate: true)
+                      .animate(const Duration(milliseconds: 300), Curves.easeInOut),
+                ),
+              ),
               Align(
                 alignment: Alignment.bottomCenter,
                 child: IgnorePointer(
@@ -214,7 +245,9 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
                     ),
                   ),
                 ),
-              ),
+              )
+                  .opacity(_showOverlay ? 1 : 0, animate: true)
+                  .animate(const Duration(milliseconds: 300), Curves.easeInOut),
               Positioned(
                 left: 16,
                 right: 16,
@@ -318,16 +351,6 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
                                   ]),
                                   style: metaTextStyle,
                                 ).padding(right: 2),
-                              if (item.metadata['exif']?['ISO'] != null)
-                                Text(
-                                  'ISO${item.metadata['exif']?['ISO']}',
-                                  style: metaTextStyle,
-                                ).padding(right: 2),
-                              if (item.metadata['exif']?['Aperture'] != null)
-                                Text(
-                                  'f/${item.metadata['exif']?['Aperture']}',
-                                  style: metaTextStyle,
-                                ).padding(right: 2),
                               if (item.metadata['exif']?['Megapixels'] != null &&
                                   item.metadata['exif']?['Model'] != null)
                                 Text(
@@ -344,29 +367,44 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
                                   '${item.metadata['width']}x${item.metadata['height']}',
                                   style: metaTextStyle,
                                 ),
-                              if (item.metadata['ratio'] != null)
-                                Text(
-                                  (item.metadata['ratio'] as num).toStringAsFixed(2),
-                                  style: metaTextStyle,
-                                ),
-                              Text(
-                                item.mimetype,
-                                style: metaTextStyle,
-                              ),
                             ],
                           ),
                         ),
+                        const Gap(4),
+                        InkWell(
+                          onTap: () {
+                            _showDetail = true;
+                            showModalBottomSheet(
+                              context: context,
+                              builder: (context) => _AttachmentZoomDetailPopup(
+                                data: widget.data
+                                    .elementAt(widget.data.length > 1 ? _pageController.page?.round() ?? 0 : 0),
+                              ),
+                            ).then((_) {
+                              _showDetail = false;
+                            });
+                          },
+                          child: Text(
+                            'viewDetailedAttachment'.tr(),
+                            style: metaTextStyle.copyWith(decoration: TextDecoration.underline),
+                          ),
+                        ),
                       ],
                     );
                   }),
-                ),
+                )
+                    .opacity(_showOverlay ? 1 : 0, animate: true)
+                    .animate(const Duration(milliseconds: 300), Curves.easeInOut),
               ),
             ],
           ),
         ),
+        onTap: () {
+          setState(() => _showOverlay = !_showOverlay);
+        },
         onVerticalDragUpdate: (details) {
           if (_showDetail) return;
-          if (details.delta.dy <= -40) {
+          if (details.delta.dy <= -20) {
             _showDetail = true;
             showModalBottomSheet(
               context: context,
@@ -378,9 +416,6 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
             });
           }
         },
-        onTap: () {
-          Navigator.of(context).pop();
-        },
       ),
     );
   }
@@ -480,14 +515,14 @@ class _AttachmentZoomDetailPopup extends StatelessWidget {
                     ),
                   tableGap,
                   ...(data.metadata['exif']?.keys.map((k) => TableRow(
-                        children: [
-                          TableCell(child: Text(k).padding(right: 16)),
-                          TableCell(child: Text(data.metadata['exif'][k].toString())),
-                        ],
-                      )) ??
+                            children: [
+                              TableCell(child: Text(k).padding(right: 16)),
+                              TableCell(child: Text(data.metadata['exif'][k].toString())),
+                            ],
+                          )) ??
                       []),
                 ],
-              ).padding(horizontal: 20, vertical: 8),
+              ).padding(horizontal: 20, vertical: 8, bottom: MediaQuery.of(context).padding.bottom),
             ),
           ),
         ],
diff --git a/lib/widgets/chat/chat_message_input.dart b/lib/widgets/chat/chat_message_input.dart
index 87402db..3652b5f 100644
--- a/lib/widgets/chat/chat_message_input.dart
+++ b/lib/widgets/chat/chat_message_input.dart
@@ -28,8 +28,7 @@ class ChatMessageInput extends StatefulWidget {
   final ChatMessageController controller;
   final SnChannelMember? otherMember;
 
-  const ChatMessageInput(
-      {super.key, required this.controller, this.otherMember});
+  const ChatMessageInput({super.key, required this.controller, this.otherMember});
 
   @override
   State<ChatMessageInput> createState() => ChatMessageInputState();
@@ -46,20 +45,12 @@ class ChatMessageInputState extends State<ChatMessageInput> {
 
   final HotKey _pasteHotKey = HotKey(
     key: PhysicalKeyboardKey.keyV,
-    modifiers: [
-      (!kIsWeb && Platform.isMacOS)
-          ? HotKeyModifier.meta
-          : HotKeyModifier.control
-    ],
+    modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control],
     scope: HotKeyScope.inapp,
   );
   final HotKey _newLineHotKey = HotKey(
     key: PhysicalKeyboardKey.enter,
-    modifiers: [
-      (!kIsWeb && Platform.isMacOS)
-          ? HotKeyModifier.meta
-          : HotKeyModifier.control
-    ],
+    modifiers: [(!kIsWeb && Platform.isMacOS) ? HotKeyModifier.meta : HotKeyModifier.control],
     scope: HotKeyScope.inapp,
   );
 
@@ -109,8 +100,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
   void setEdit(SnChatMessage? value) {
     _contentController.text = value?.body['text'] ?? '';
     _attachments.clear();
-    _attachments.addAll(
-        value?.preload?.attachments?.map((e) => PostWriteMedia(e)) ?? []);
+    _attachments.addAll(value?.preload?.attachments?.map((e) => PostWriteMedia(e)) ?? []);
     setState(() => _editingMessage = value);
   }
 
@@ -149,9 +139,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
           media.name,
           'messaging',
           null,
-          mimetype: media.raw != null && media.type == SnMediaType.image
-              ? 'image/png'
-              : null,
+          mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
         );
 
         final item = await attach.chunkedUploadParts(
@@ -183,10 +171,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
     widget.controller.sendMessage(
       _editingMessage != null ? 'messages.edit' : 'messages.new',
       _contentController.text,
-      attachments: _attachments
-          .where((e) => e.attachment != null)
-          .map((e) => e.attachment!.rid)
-          .toList(),
+      attachments: _attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
       relatedId: _editingMessage?.id,
       quoteId: _replyingMessage?.id,
       editingMessage: _editingMessage,
@@ -247,15 +232,12 @@ class ChatMessageInputState extends State<ChatMessageInput> {
           TweenAnimationBuilder<double>(
             tween: Tween(begin: 0, end: _progress),
             duration: Duration(milliseconds: 300),
-            builder: (context, value, _) =>
-                LinearProgressIndicator(value: value, minHeight: 2),
+            builder: (context, value, _) => LinearProgressIndicator(value: value, minHeight: 2),
           )
         else if (_isBusy)
           const LinearProgressIndicator(value: null, minHeight: 2),
         Padding(
-          padding: _attachments.isNotEmpty
-              ? const EdgeInsets.only(top: 8)
-              : EdgeInsets.zero,
+          padding: _attachments.isNotEmpty ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
           child: PostMediaPendingList(
             attachments: _attachments,
             isBusy: _isBusy,
@@ -267,8 +249,9 @@ class ChatMessageInputState extends State<ChatMessageInput> {
             },
             onUpdateBusy: (state) => setState(() => _isBusy = state),
           ),
-        ).height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true).animate(
-            const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
+        )
+            .height(_attachments.isNotEmpty ? 80 + 8 : 0, animate: true)
+            .animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
         SingleChildScrollView(
           physics: const NeverScrollableScrollPhysics(),
           child: _replyingMessage != null
@@ -289,8 +272,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
                       const Gap(8),
                       Expanded(
                         child: Text(
-                          _replyingMessage?.body['text'] ??
-                              '${_replyingMessage?.sender.nick}',
+                          _replyingMessage?.body['text'] ?? '${_replyingMessage?.sender.nick}',
                           maxLines: 1,
                           overflow: TextOverflow.ellipsis,
                         ),
@@ -307,8 +289,9 @@ class ChatMessageInputState extends State<ChatMessageInput> {
                   ).padding(vertical: 8),
                 )
               : const SizedBox.shrink(),
-        ).height(_replyingMessage != null ? 38 : 0, animate: true).animate(
-            const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
+        )
+            .height(_replyingMessage != null ? 38 : 0, animate: true)
+            .animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
         SingleChildScrollView(
           physics: const NeverScrollableScrollPhysics(),
           child: _editingMessage != null
@@ -329,8 +312,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
                       const Gap(8),
                       Expanded(
                         child: Text(
-                          _editingMessage?.body['text'] ??
-                              '${_editingMessage?.sender.nick}',
+                          _editingMessage?.body['text'] ?? '${_editingMessage?.sender.nick}',
                           maxLines: 1,
                           overflow: TextOverflow.ellipsis,
                         ),
@@ -348,8 +330,9 @@ class ChatMessageInputState extends State<ChatMessageInput> {
                   ).padding(vertical: 8),
                 )
               : const SizedBox.shrink(),
-        ).height(_editingMessage != null ? 38 : 0, animate: true).animate(
-            const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
+        )
+            .height(_editingMessage != null ? 38 : 0, animate: true)
+            .animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
         Container(
           padding: EdgeInsets.symmetric(horizontal: 16),
           constraints: BoxConstraints(minHeight: 56, maxHeight: 240),
@@ -366,14 +349,11 @@ class ChatMessageInputState extends State<ChatMessageInput> {
                         ? 'fieldChatMessageDirect'.tr(args: [
                             '@${ud.getAccountFromCache(widget.otherMember?.accountId)?.name}',
                           ])
-                        : 'fieldChatMessage'.tr(args: [
-                            widget.controller.channel?.name ?? 'loading'.tr()
-                          ]),
+                        : 'fieldChatMessage'.tr(args: [widget.controller.channel?.name ?? 'loading'.tr()]),
                     border: InputBorder.none,
                   ),
                   textInputAction: TextInputAction.send,
-                  onTapOutside: (_) =>
-                      FocusManager.instance.primaryFocus?.unfocus(),
+                  onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
                   onSubmitted: (_) {
                     if (_isBusy) return;
                     _sendMessage();
@@ -388,8 +368,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
                   Symbols.mood,
                   color: Theme.of(context).colorScheme.primary,
                 ),
-                visualDensity:
-                    const VisualDensity(horizontal: -4, vertical: -4),
+                visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
                 padding: EdgeInsets.zero,
                 constraints: const BoxConstraints(),
                 onPressed: () {
@@ -409,8 +388,7 @@ class ChatMessageInputState extends State<ChatMessageInput> {
                   Symbols.send,
                   color: Theme.of(context).colorScheme.primary,
                 ),
-                visualDensity:
-                    const VisualDensity(horizontal: -4, vertical: -4),
+                visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
                 padding: EdgeInsets.zero,
                 constraints: const BoxConstraints(),
               ),
@@ -427,8 +405,7 @@ class _StickerPicker extends StatelessWidget {
   final Function? onDismiss;
   final Function(String)? onInsert;
 
-  const _StickerPicker(
-      {this.onDismiss, required this.originalText, this.onInsert});
+  const _StickerPicker({this.onDismiss, required this.originalText, this.onInsert});
 
   @override
   Widget build(BuildContext context) {
@@ -439,8 +416,9 @@ class _StickerPicker extends StatelessWidget {
       },
       child: Container(
         constraints: BoxConstraints(
-            maxWidth: min(360, MediaQuery.of(context).size.width),
-            maxHeight: 240),
+          maxWidth: min(360, MediaQuery.of(context).size.width - 40),
+          maxHeight: 240,
+        ),
         child: Material(
           elevation: 8,
           borderRadius: const BorderRadius.all(Radius.circular(8)),
@@ -453,10 +431,8 @@ class _StickerPicker extends StatelessWidget {
                     return <Widget>[
                       Container(
                         margin: EdgeInsets.only(bottom: 8),
-                        padding:
-                            EdgeInsets.symmetric(horizontal: 8, vertical: 4),
-                        color:
-                            Theme.of(context).colorScheme.surfaceContainerHigh,
+                        padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+                        color: Theme.of(context).colorScheme.surfaceContainerHigh,
                         child: Column(
                           mainAxisSize: MainAxisSize.min,
                           crossAxisAlignment: CrossAxisAlignment.start,
@@ -468,8 +444,7 @@ class _StickerPicker extends StatelessWidget {
                       ),
                       GridView.builder(
                         physics: const NeverScrollableScrollPhysics(),
-                        padding:
-                            const EdgeInsets.only(left: 8, right: 8, bottom: 8),
+                        padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
                         shrinkWrap: true,
                         gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
                           maxCrossAxisExtent: 48,
@@ -492,8 +467,7 @@ class _StickerPicker extends StatelessWidget {
                               richMessage: TextSpan(
                                 children: [
                                   TextSpan(
-                                      text:
-                                          ':${element.pack.prefix}${element.alias}:\n',
+                                      text: ':${element.pack.prefix}${element.alias}:\n',
                                       style: GoogleFonts.robotoMono()),
                                   TextSpan(text: element.name).bold(),
                                 ],
@@ -502,15 +476,11 @@ class _StickerPicker extends StatelessWidget {
                                 width: 48,
                                 height: 48,
                                 decoration: BoxDecoration(
-                                  borderRadius: const BorderRadius.all(
-                                      Radius.circular(8)),
-                                  color: Theme.of(context)
-                                      .colorScheme
-                                      .surfaceContainerHigh,
+                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
+                                  color: Theme.of(context).colorScheme.surfaceContainerHigh,
                                 ),
                                 child: ClipRRect(
-                                  borderRadius: const BorderRadius.all(
-                                      Radius.circular(8)),
+                                  borderRadius: const BorderRadius.all(Radius.circular(8)),
                                   child: UniversalImage(
                                     sn.getAttachmentUrl(element.attachment.rid),
                                     width: 48,
diff --git a/lib/widgets/post/publisher_popover.dart b/lib/widgets/post/publisher_popover.dart
index 81cefec..d29decd 100644
--- a/lib/widgets/post/publisher_popover.dart
+++ b/lib/widgets/post/publisher_popover.dart
@@ -6,17 +6,24 @@ import 'package:material_symbols_icons/symbols.dart';
 import 'package:provider/provider.dart';
 import 'package:styled_widget/styled_widget.dart';
 import 'package:surface/providers/sn_network.dart';
+import 'package:surface/providers/user_directory.dart';
 import 'package:surface/types/post.dart';
 import 'package:surface/widgets/account/account_image.dart';
 import 'package:surface/widgets/universal_image.dart';
 
+import '../../screens/account/profile_page.dart' show kBadgesMeta;
+
 class PublisherPopoverCard extends StatelessWidget {
   final SnPublisher data;
+
   const PublisherPopoverCard({super.key, required this.data});
 
   @override
   Widget build(BuildContext context) {
     final sn = context.read<SnNetworkProvider>();
+    final ud = context.read<UserDirectoryProvider>();
+
+    final user = data.type == 0 ? ud.getAccountFromCache(data.accountId) : null;
 
     return Column(
       crossAxisAlignment: CrossAxisAlignment.start,
@@ -41,6 +48,7 @@ class PublisherPopoverCard extends StatelessWidget {
             AccountImage(
               content: data.avatar,
               radius: 20,
+              borderRadius: data.type == 1 ? 8 : 20,
             ),
             Gap(16),
             Expanded(
@@ -68,6 +76,36 @@ class PublisherPopoverCard extends StatelessWidget {
             const Gap(8)
           ],
         ).padding(horizontal: 16),
+        if (user != null && user.badges.isNotEmpty) const Gap(16),
+        if (user != null && user.badges.isNotEmpty)
+          Wrap(
+            spacing: 4,
+            children: user.badges
+                .map(
+                  (ele) => Tooltip(
+                    richMessage: TextSpan(
+                      children: [
+                        TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
+                        if (ele.metadata['title'] != null)
+                          TextSpan(
+                            text: '\n${ele.metadata['title']}',
+                            style: const TextStyle(fontWeight: FontWeight.bold),
+                          ),
+                        TextSpan(text: '\n'),
+                        TextSpan(
+                          text: DateFormat.yMEd().format(ele.createdAt),
+                        ),
+                      ],
+                    ),
+                    child: Icon(
+                      kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
+                      color: kBadgesMeta[ele.type]?.$3,
+                      fill: 1,
+                    ),
+                  ),
+                )
+                .toList(),
+          ).padding(horizontal: 24),
         const Gap(16),
         Row(
           children: [
@@ -108,10 +146,7 @@ class PublisherPopoverCard extends StatelessWidget {
                 mainAxisSize: MainAxisSize.min,
                 crossAxisAlignment: CrossAxisAlignment.center,
                 children: [
-                  Text('publisherTotalDownvote')
-                      .tr()
-                      .fontSize(13)
-                      .opacity(0.75),
+                  Text('publisherTotalDownvote').tr().fontSize(13).opacity(0.75),
                   Text(data.totalDownvote.toString()),
                 ],
               ),