From 81d508390864b915d209fcfa488a3d408001caa5 Mon Sep 17 00:00:00 2001
From: LittleSheep <littlesheep.code@hotmail.com>
Date: Sat, 17 May 2025 23:13:08 +0800
Subject: [PATCH] :sparkles: DM groups

---
 lib/models/chat.dart                 |   4 +-
 lib/models/chat.freezed.dart         |  26 ++---
 lib/models/chat.g.dart               |   4 +-
 lib/screens/auth/captcha.web.dart    |   1 +
 lib/screens/chat/chat.dart           | 128 +++++++++-------------
 lib/screens/chat/room.dart           |  20 ++--
 lib/screens/chat/room_detail.dart    |  20 +++-
 lib/screens/realm/realms.dart        |   6 +-
 lib/screens/wallet.dart              |   7 +-
 lib/widgets/content/cloud_files.dart | 158 +++++++++++++++++++++++++++
 lib/widgets/response.dart            |   6 +-
 pubspec.lock                         |  16 +--
 pubspec.yaml                         |   2 +-
 13 files changed, 276 insertions(+), 122 deletions(-)

diff --git a/lib/models/chat.dart b/lib/models/chat.dart
index 621df2d..9d54c41 100644
--- a/lib/models/chat.dart
+++ b/lib/models/chat.dart
@@ -10,8 +10,8 @@ part 'chat.g.dart';
 abstract class SnChatRoom with _$SnChatRoom {
   const factory SnChatRoom({
     required String id,
-    required String name,
-    required String description,
+    required String? name,
+    required String? description,
     required int type,
     required bool isPublic,
     required String? pictureId,
diff --git a/lib/models/chat.freezed.dart b/lib/models/chat.freezed.dart
index 1cf5137..c46e2d6 100644
--- a/lib/models/chat.freezed.dart
+++ b/lib/models/chat.freezed.dart
@@ -16,7 +16,7 @@ T _$identity<T>(T value) => value;
 /// @nodoc
 mixin _$SnChatRoom {
 
- String get id; String get name; String get description; int get type; bool get isPublic; String? get pictureId; SnCloudFile? get picture; String? get backgroundId; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members;
+ String get id; String? get name; String? get description; int get type; bool get isPublic; String? get pictureId; SnCloudFile? get picture; String? get backgroundId; SnCloudFile? get background; String? get realmId; SnRealm? get realm; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt; List<SnChatMember>? get members;
 /// Create a copy of SnChatRoom
 /// with the given fields replaced by the non-null parameter values.
 @JsonKey(includeFromJson: false, includeToJson: false)
@@ -49,7 +49,7 @@ abstract mixin class $SnChatRoomCopyWith<$Res>  {
   factory $SnChatRoomCopyWith(SnChatRoom value, $Res Function(SnChatRoom) _then) = _$SnChatRoomCopyWithImpl;
 @useResult
 $Res call({
- String id, String name, String description, int type, bool isPublic, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
+ String id, String? name, String? description, int type, bool isPublic, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
 });
 
 
@@ -66,12 +66,12 @@ class _$SnChatRoomCopyWithImpl<$Res>
 
 /// Create a copy of SnChatRoom
 /// with the given fields replaced by the non-null parameter values.
-@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? description = null,Object? type = null,Object? isPublic = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
+@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
   return _then(_self.copyWith(
 id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
-as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
-as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
-as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
+as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
+as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
+as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
 as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable
 as bool,pictureId: freezed == pictureId ? _self.pictureId : pictureId // ignore: cast_nullable_to_non_nullable
 as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
@@ -134,8 +134,8 @@ class _SnChatRoom implements SnChatRoom {
   factory _SnChatRoom.fromJson(Map<String, dynamic> json) => _$SnChatRoomFromJson(json);
 
 @override final  String id;
-@override final  String name;
-@override final  String description;
+@override final  String? name;
+@override final  String? description;
 @override final  int type;
 @override final  bool isPublic;
 @override final  String? pictureId;
@@ -190,7 +190,7 @@ abstract mixin class _$SnChatRoomCopyWith<$Res> implements $SnChatRoomCopyWith<$
   factory _$SnChatRoomCopyWith(_SnChatRoom value, $Res Function(_SnChatRoom) _then) = __$SnChatRoomCopyWithImpl;
 @override @useResult
 $Res call({
- String id, String name, String description, int type, bool isPublic, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
+ String id, String? name, String? description, int type, bool isPublic, String? pictureId, SnCloudFile? picture, String? backgroundId, SnCloudFile? background, String? realmId, SnRealm? realm, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt, List<SnChatMember>? members
 });
 
 
@@ -207,12 +207,12 @@ class __$SnChatRoomCopyWithImpl<$Res>
 
 /// Create a copy of SnChatRoom
 /// with the given fields replaced by the non-null parameter values.
-@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? description = null,Object? type = null,Object? isPublic = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
+@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = freezed,Object? description = freezed,Object? type = null,Object? isPublic = null,Object? pictureId = freezed,Object? picture = freezed,Object? backgroundId = freezed,Object? background = freezed,Object? realmId = freezed,Object? realm = freezed,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,Object? members = freezed,}) {
   return _then(_SnChatRoom(
 id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
-as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
-as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
-as String,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
+as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
+as String?,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
+as String?,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
 as int,isPublic: null == isPublic ? _self.isPublic : isPublic // ignore: cast_nullable_to_non_nullable
 as bool,pictureId: freezed == pictureId ? _self.pictureId : pictureId // ignore: cast_nullable_to_non_nullable
 as String?,picture: freezed == picture ? _self.picture : picture // ignore: cast_nullable_to_non_nullable
diff --git a/lib/models/chat.g.dart b/lib/models/chat.g.dart
index cf559b0..08bd0a1 100644
--- a/lib/models/chat.g.dart
+++ b/lib/models/chat.g.dart
@@ -8,8 +8,8 @@ part of 'chat.dart';
 
 _SnChatRoom _$SnChatRoomFromJson(Map<String, dynamic> json) => _SnChatRoom(
   id: json['id'] as String,
-  name: json['name'] as String,
-  description: json['description'] as String,
+  name: json['name'] as String?,
+  description: json['description'] as String?,
   type: (json['type'] as num).toInt(),
   isPublic: json['is_public'] as bool,
   pictureId: json['picture_id'] as String?,
diff --git a/lib/screens/auth/captcha.web.dart b/lib/screens/auth/captcha.web.dart
index 7d060d0..78b6fa4 100644
--- a/lib/screens/auth/captcha.web.dart
+++ b/lib/screens/auth/captcha.web.dart
@@ -23,6 +23,7 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
         final message = event.data as String;
         if (message.startsWith("captcha_tk=")) {
           String token = message.replaceFirst("captcha_tk=", "");
+          // ignore: use_build_context_synchronously
           if (context.mounted) Navigator.pop(context, token);
         }
       }
diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart
index 4d8810c..3377d48 100644
--- a/lib/screens/chat/chat.dart
+++ b/lib/screens/chat/chat.dart
@@ -4,7 +4,6 @@ import 'package:croppy/croppy.dart' hide cropImage;
 import 'package:dio/dio.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:gap/gap.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -48,20 +47,27 @@ class ChatRoomListTile extends StatelessWidget {
   Widget build(BuildContext context) {
     return ListTile(
       leading:
-          isDirect
-              ? ProfilePictureWidget(
-                fileId: room.members!.first.account.profile.pictureId,
+          (isDirect && room.pictureId == null)
+              ? SplitAvatarWidget(
+                filesId:
+                    room.members!
+                        .map((e) => e.account.profile.pictureId)
+                        .toList(),
               )
               : room.pictureId == null
-              ? CircleAvatar(child: Text(room.name[0].toUpperCase()))
+              ? CircleAvatar(child: Text(room.name![0].toUpperCase()))
               : ProfilePictureWidget(fileId: room.pictureId),
-      title: Text(isDirect ? room.members!.first.account.nick : room.name),
+      title: Text(
+        (isDirect && room.name == null)
+            ? room.members!.map((e) => e.account.nick).join(', ')
+            : room.name!,
+      ),
       subtitle:
           subtitle != null
               ? subtitle!
-              : isDirect
-              ? Text('@${room.members!.first.account.name}')
-              : Text(room.description),
+              : (isDirect && room.description == null)
+              ? Text(room.members!.map((e) => '@${e.account.name}').join(', '))
+              : Text(room.description ?? 'descriptionNone'.tr()),
       trailing: trailing,
       onTap:
           onTap ??
@@ -82,8 +88,6 @@ Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async {
       .toList();
 }
 
-final chatFabKey = GlobalKey<ExpandableFabState>();
-
 @RoutePage()
 class ChatListScreen extends HookConsumerWidget {
   const ChatListScreen({super.key});
@@ -139,69 +143,41 @@ class ChatListScreen extends HookConsumerWidget {
           const Gap(8),
         ],
       ),
-      floatingActionButtonLocation: ExpandableFab.location,
-      floatingActionButton: ExpandableFab(
-        key: chatFabKey,
-        distance: 75,
-        type: ExpandableFabType.up,
-        childrenAnimation: ExpandableFabAnimation.none,
-        overlayStyle: ExpandableFabOverlayStyle(
-          color: Theme.of(
-            context,
-          ).colorScheme.surface.withAlpha((255 * 0.5).round()),
-        ),
-        openButtonBuilder: RotateFloatingActionButtonBuilder(
-          child: const Icon(Icons.add),
-          fabSize: ExpandableFabSize.regular,
-          foregroundColor:
-              Theme.of(context).floatingActionButtonTheme.foregroundColor,
-          backgroundColor:
-              Theme.of(context).floatingActionButtonTheme.backgroundColor,
-        ),
-        closeButtonBuilder: DefaultFloatingActionButtonBuilder(
-          child: const Icon(Icons.close),
-          fabSize: ExpandableFabSize.regular,
-          foregroundColor:
-              Theme.of(context).floatingActionButtonTheme.foregroundColor,
-          backgroundColor:
-              Theme.of(context).floatingActionButtonTheme.backgroundColor,
-        ),
-        children: [
-          Row(
-            children: [
-              Text('createChatRoom').tr(),
-              const Gap(20),
-              FloatingActionButton(
-                heroTag: null,
-                tooltip: 'createChatRoom'.tr(),
-                onPressed: () {
-                  chatFabKey.currentState?.toggle();
-                  context.pushRoute(NewChatRoute()).then((value) {
-                    if (value != null) {
-                      ref.invalidate(chatroomsJoinedProvider);
-                    }
-                  });
-                },
-                child: const Icon(Symbols.chat_add_on),
-              ),
-            ],
-          ),
-          Row(
-            children: [
-              Text('createDirectMessage').tr(),
-              const Gap(20),
-              FloatingActionButton(
-                heroTag: null,
-                tooltip: 'createDirectMessage'.tr(),
-                onPressed: () {
-                  chatFabKey.currentState?.toggle();
-                  createDirectMessage();
-                },
-                child: const Icon(Symbols.communication),
-              ),
-            ],
-          ),
-        ],
+      floatingActionButton: FloatingActionButton(
+        onPressed: () {
+          showModalBottomSheet(
+            context: context,
+            builder:
+                (context) => Column(
+                  mainAxisSize: MainAxisSize.min,
+                  crossAxisAlignment: CrossAxisAlignment.stretch,
+                  children: [
+                    ListTile(
+                      title: Text('createChatRoom').tr(),
+                      leading: const Icon(Symbols.add),
+                      onTap: () {
+                        Navigator.pop(context);
+                        context.pushRoute(NewChatRoute()).then((value) {
+                          if (value != null) {
+                            ref.invalidate(chatroomsJoinedProvider);
+                          }
+                        });
+                      },
+                    ),
+                    ListTile(
+                      title: Text('createDirectMessage').tr(),
+                      leading: const Icon(Symbols.person),
+                      onTap: () {
+                        Navigator.pop(context);
+                        createDirectMessage();
+                      },
+                    ),
+                    Gap(MediaQuery.of(context).padding.bottom + 16),
+                  ],
+                ),
+          );
+        },
+        child: const Icon(Symbols.add),
       ),
       body: chats.when(
         data:
@@ -281,8 +257,8 @@ class EditChatScreen extends HookConsumerWidget {
 
     useEffect(() {
       if (chat.value != null) {
-        nameController.text = chat.value!.name;
-        descriptionController.text = chat.value!.description;
+        nameController.text = chat.value!.name ?? '';
+        descriptionController.text = chat.value!.description ?? '';
         picture.value = chat.value!.picture;
         background.value = chat.value!.background;
         currentRealm.value = joinedRealms.value?.firstWhereOrNull(
diff --git a/lib/screens/chat/room.dart b/lib/screens/chat/room.dart
index a5f617e..3da5a94 100644
--- a/lib/screens/chat/room.dart
+++ b/lib/screens/chat/room.dart
@@ -442,10 +442,12 @@ class ChatRoomScreen extends HookConsumerWidget {
                     height: 26,
                     width: 26,
                     child:
-                        room!.type == 1
-                            ? ProfilePictureWidget(
-                              fileId:
-                                  room.members!.first.account.profile.pictureId,
+                        (room!.type == 1 && room.pictureId == null)
+                            ? SplitAvatarWidget(
+                              filesId:
+                                  room.members!
+                                      .map((e) => e.account.profile.pictureId)
+                                      .toList(),
                             )
                             : room.pictureId != null
                             ? ProfilePictureWidget(
@@ -454,15 +456,15 @@ class ChatRoomScreen extends HookConsumerWidget {
                             )
                             : CircleAvatar(
                               child: Text(
-                                room.name[0].toUpperCase(),
+                                room.name![0].toUpperCase(),
                                 style: const TextStyle(fontSize: 12),
                               ),
                             ),
                   ),
                   Text(
-                    room.type == 1
-                        ? room.members!.first.account.nick
-                        : room.name,
+                    (room.type == 1 && room.name == null)
+                        ? room.members!.map((e) => e.account.nick).join(', ')
+                        : room.name!,
                   ).fontSize(19),
                 ],
               ),
@@ -763,7 +765,7 @@ class _ChatInput extends StatelessWidget {
                               ? 'chatDirectMessageHint'.tr(
                                 args: [chatRoom.members!.first.account.nick],
                               )
-                              : 'chatMessageHint'.tr(args: [chatRoom.name]),
+                              : 'chatMessageHint'.tr(args: [chatRoom.name!]),
                       border: InputBorder.none,
                       isDense: true,
                       contentPadding: const EdgeInsets.symmetric(
diff --git a/lib/screens/chat/room_detail.dart b/lib/screens/chat/room_detail.dart
index 7df73c5..64b95d2 100644
--- a/lib/screens/chat/room_detail.dart
+++ b/lib/screens/chat/room_detail.dart
@@ -54,14 +54,20 @@ class ChatDetailScreen extends HookConsumerWidget {
                   leading: PageBackButton(shadows: [iconShadow]),
                   flexibleSpace: FlexibleSpaceBar(
                     background:
-                        currentRoom!.type == 1 &&
+                        (currentRoom!.type == 1 &&
+                                currentRoom.backgroundId != null)
+                            ? CloudImageWidget(
+                              fileId: currentRoom.backgroundId!,
+                            )
+                            : (currentRoom.type == 1 &&
+                                currentRoom.members!.length == 1 &&
                                 currentRoom
                                         .members!
                                         .first
                                         .account
                                         .profile
                                         .backgroundId !=
-                                    null
+                                    null)
                             ? CloudImageWidget(
                               fileId:
                                   currentRoom
@@ -81,9 +87,11 @@ class ChatDetailScreen extends HookConsumerWidget {
                                   Theme.of(context).appBarTheme.backgroundColor,
                             ),
                     title: Text(
-                      currentRoom.type == 1
-                          ? currentRoom.members!.first.account.nick
-                          : currentRoom.name,
+                      (currentRoom.type == 1 && currentRoom.name == null)
+                          ? currentRoom.members!
+                              .map((e) => e.account.name)
+                              .join(', ')
+                          : currentRoom.name!,
                       style: TextStyle(
                         color: Theme.of(context).appBarTheme.foregroundColor,
                         shadows: [iconShadow],
@@ -114,7 +122,7 @@ class ChatDetailScreen extends HookConsumerWidget {
                       crossAxisAlignment: CrossAxisAlignment.start,
                       children: [
                         Text(
-                          currentRoom.description,
+                          currentRoom.description ?? 'descriptionNone'.tr(),
                           style: const TextStyle(fontSize: 16),
                         ),
                       ],
diff --git a/lib/screens/realm/realms.dart b/lib/screens/realm/realms.dart
index b2d4173..ebf7924 100644
--- a/lib/screens/realm/realms.dart
+++ b/lib/screens/realm/realms.dart
@@ -73,7 +73,11 @@ class RealmListScreen extends HookConsumerWidget {
         heroTag: Key("realms-page-fab"),
         child: const Icon(Symbols.add),
         onPressed: () {
-          context.router.push(NewRealmRoute());
+          context.router.push(NewRealmRoute()).then((value) {
+            if (value != null) {
+              ref.invalidate(realmsJoinedProvider);
+            }
+          });
         },
       ),
       body: RefreshIndicator(
diff --git a/lib/screens/wallet.dart b/lib/screens/wallet.dart
index e1cf132..8b11070 100644
--- a/lib/screens/wallet.dart
+++ b/lib/screens/wallet.dart
@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:island/models/wallet.dart';
 import 'package:island/pods/network.dart';
 import 'package:island/widgets/app_scaffold.dart';
+import 'package:island/widgets/response.dart';
 import 'package:material_symbols_icons/symbols.dart';
 import 'package:riverpod_annotation/riverpod_annotation.dart';
 import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -149,7 +150,11 @@ class WalletScreen extends HookConsumerWidget {
             ],
           );
         },
-        error: (error, stackTrace) => Center(child: Text('Error: $error')),
+        error:
+            (error, stackTrace) => ResponseErrorWidget(
+              error: error,
+              onRetry: () => ref.invalidate(walletCurrentProvider),
+            ),
         loading: () => const Center(child: CircularProgressIndicator()),
       ),
     );
diff --git a/lib/widgets/content/cloud_files.dart b/lib/widgets/content/cloud_files.dart
index b0e4701..9b0f300 100644
--- a/lib/widgets/content/cloud_files.dart
+++ b/lib/widgets/content/cloud_files.dart
@@ -64,6 +64,14 @@ class CloudImageWidget extends ConsumerWidget {
       child: UniversalImage(uri: uri, blurHash: blurHash),
     );
   }
+
+  static ImageProvider provider({
+    required String fileId,
+    required String serverUrl,
+  }) {
+    final uri = '$serverUrl/files/$fileId';
+    return CachedNetworkImageProvider(uri);
+  }
 }
 
 class ProfilePictureWidget extends ConsumerWidget {
@@ -104,3 +112,153 @@ class ProfilePictureWidget extends ConsumerWidget {
     );
   }
 }
+
+class SplitAvatarWidget extends ConsumerWidget {
+  final List<String?> filesId;
+  final double radius;
+  final IconData fallbackIcon;
+  final Color? fallbackColor;
+
+  const SplitAvatarWidget({
+    super.key,
+    required this.filesId,
+    this.radius = 20,
+    this.fallbackIcon = Symbols.account_circle,
+    this.fallbackColor,
+  });
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    if (filesId.isEmpty) {
+      return ProfilePictureWidget(
+        fileId: null,
+        radius: radius,
+        fallbackIcon: fallbackIcon,
+        fallbackColor: fallbackColor,
+      );
+    }
+    if (filesId.length == 1) {
+      return ProfilePictureWidget(
+        fileId: filesId[0],
+        radius: radius,
+        fallbackIcon: fallbackIcon,
+        fallbackColor: fallbackColor,
+      );
+    }
+
+    return ClipRRect(
+      borderRadius: BorderRadius.all(Radius.circular(radius)),
+      child: Container(
+        width: radius * 2,
+        height: radius * 2,
+        color: Theme.of(context).colorScheme.primaryContainer,
+        child: Stack(
+          children: [
+            if (filesId.length == 2)
+              Row(
+                children: [
+                  Expanded(
+                    child: _buildQuadrant(context, filesId[0], ref, radius),
+                  ),
+                  Expanded(
+                    child: _buildQuadrant(context, filesId[1], ref, radius),
+                  ),
+                ],
+              )
+            else if (filesId.length == 3)
+              Row(
+                children: [
+                  Column(
+                    children: [
+                      Expanded(
+                        child: _buildQuadrant(context, filesId[0], ref, radius),
+                      ),
+                      Expanded(
+                        child: _buildQuadrant(context, filesId[1], ref, radius),
+                      ),
+                    ],
+                  ),
+                  Expanded(
+                    child: _buildQuadrant(context, filesId[2], ref, radius),
+                  ),
+                ],
+              )
+            else ...[
+              Positioned(
+                top: 0,
+                left: 0,
+                child: _buildQuadrant(context, filesId[0], ref, radius),
+              ),
+              Positioned(
+                top: 0,
+                right: 0,
+                child: _buildQuadrant(context, filesId[1], ref, radius),
+              ),
+              Positioned(
+                bottom: 0,
+                left: 0,
+                child: _buildQuadrant(context, filesId[2], ref, radius),
+              ),
+              Positioned(
+                bottom: 0,
+                right: 0,
+                child:
+                    filesId.length > 4
+                        ? Container(
+                          width: radius,
+                          height: radius,
+                          color: Theme.of(context).colorScheme.primaryContainer,
+                          child: Center(
+                            child: Text(
+                              '+${filesId.length - 3}',
+                              style: TextStyle(
+                                fontSize: radius * 0.4,
+                                color:
+                                    Theme.of(
+                                      context,
+                                    ).colorScheme.onPrimaryContainer,
+                              ),
+                            ),
+                          ),
+                        )
+                        : _buildQuadrant(context, filesId[3], ref, radius),
+              ),
+            ],
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildQuadrant(
+    BuildContext context,
+    String? fileId,
+    WidgetRef ref,
+    double radius,
+  ) {
+    if (fileId == null) {
+      return Container(
+        width: radius,
+        height: radius,
+        color: Theme.of(context).colorScheme.primaryContainer,
+        child:
+            Icon(
+              fallbackIcon,
+              size: radius * 0.6,
+              color:
+                  fallbackColor ??
+                  Theme.of(context).colorScheme.onPrimaryContainer,
+            ).center(),
+      );
+    }
+
+    final serverUrl = ref.watch(serverUrlProvider);
+    final uri = '$serverUrl/files/$fileId';
+
+    return SizedBox(
+      width: radius,
+      height: radius,
+      child: CachedNetworkImage(imageUrl: uri, fit: BoxFit.cover),
+    );
+  }
+}
diff --git a/lib/widgets/response.dart b/lib/widgets/response.dart
index ac74d60..36263aa 100644
--- a/lib/widgets/response.dart
+++ b/lib/widgets/response.dart
@@ -4,7 +4,7 @@ import 'package:gap/gap.dart';
 import 'package:material_symbols_icons/symbols.dart';
 
 class ResponseErrorWidget extends StatelessWidget {
-  final Error error;
+  final dynamic error;
   final VoidCallback onRetry;
   const ResponseErrorWidget({
     super.key,
@@ -18,13 +18,13 @@ class ResponseErrorWidget extends StatelessWidget {
       mainAxisAlignment: MainAxisAlignment.center,
       children: [
         const Icon(Symbols.error_outline, size: 48),
-        const Gap(16),
+        const Gap(4),
         Text(
           error.toString(),
           textAlign: TextAlign.center,
           style: const TextStyle(color: Color(0xFF757575)),
         ),
-        const SizedBox(height: 16),
+        const Gap(8),
         TextButton(onPressed: onRetry, child: const Text('retry').tr()),
       ],
     );
diff --git a/pubspec.lock b/pubspec.lock
index 743e025..3a6c839 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -81,6 +81,14 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "10.0.1"
+  avatar_stack:
+    dependency: "direct main"
+    description:
+      name: avatar_stack
+      sha256: "354527ba139956fd6439e2c49199d8298d72afdaa6c4cd6f37f26b97faf21f7e"
+      url: "https://pub.dev"
+    source: hosted
+    version: "3.0.0"
   bitsdojo_window:
     dependency: "direct main"
     description:
@@ -630,14 +638,6 @@ packages:
       url: "https://pub.dev"
     source: hosted
     version: "3.4.1"
-  flutter_expandable_fab:
-    dependency: "direct main"
-    description:
-      name: flutter_expandable_fab
-      sha256: c2936d398169166064d025df91a3bb417109a859e725d9b80c6ef7f04e01b6ab
-      url: "https://pub.dev"
-    source: hosted
-    version: "2.5.1"
   flutter_highlight:
     dependency: "direct main"
     description:
diff --git a/pubspec.yaml b/pubspec.yaml
index f5bed0e..76a7382 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -88,7 +88,6 @@ dependencies:
   drift_flutter: ^0.2.4
   path: ^1.9.1
   collection: ^1.19.1
-  flutter_expandable_fab: ^2.5.0
   markdown_editor_plus: ^0.2.15
   croppy: ^1.3.6
   table_calendar: ^3.1.3
@@ -96,6 +95,7 @@ dependencies:
   dropdown_button2: ^2.3.9
   riverpod_paging_utils: ^0.8.0
   crypto: ^3.0.6
+  avatar_stack: ^3.0.0
 
 dev_dependencies:
   flutter_test: