940 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			940 lines
		
	
	
		
			36 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'package:dio/dio.dart';
 | 
						|
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:go_router/go_router.dart';
 | 
						|
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
						|
import 'package:freezed_annotation/freezed_annotation.dart';
 | 
						|
import 'package:gap/gap.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/models/chat.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/screens/chat/chat.dart';
 | 
						|
import 'package:island/widgets/account/account_pfc.dart';
 | 
						|
import 'package:island/widgets/account/account_picker.dart';
 | 
						|
import 'package:island/widgets/account/status.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/app_scaffold.dart';
 | 
						|
import 'package:island/widgets/content/cloud_files.dart';
 | 
						|
import 'package:island/widgets/content/sheet.dart';
 | 
						|
import 'package:material_symbols_icons/symbols.dart';
 | 
						|
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
						|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
import 'package:island/pods/database.dart';
 | 
						|
import 'package:island/screens/chat/search_messages.dart';
 | 
						|
 | 
						|
part 'room_detail.freezed.dart';
 | 
						|
part 'room_detail.g.dart';
 | 
						|
 | 
						|
@riverpod
 | 
						|
Future<int> totalMessagesCount(Ref ref, String roomId) async {
 | 
						|
  final database = ref.watch(databaseProvider);
 | 
						|
  return database.getTotalMessagesForRoom(roomId);
 | 
						|
}
 | 
						|
 | 
						|
class ChatDetailScreen extends HookConsumerWidget {
 | 
						|
  final String id;
 | 
						|
  const ChatDetailScreen({super.key, required this.id});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final roomState = ref.watch(chatroomProvider(id));
 | 
						|
    final roomIdentity = ref.watch(chatroomIdentityProvider(id));
 | 
						|
    final totalMessages = ref.watch(totalMessagesCountProvider(id));
 | 
						|
 | 
						|
    const kNotifyLevelText = [
 | 
						|
      'chatNotifyLevelAll',
 | 
						|
      'chatNotifyLevelMention',
 | 
						|
      'chatNotifyLevelNone',
 | 
						|
    ];
 | 
						|
 | 
						|
    void setNotifyLevel(int level) async {
 | 
						|
      try {
 | 
						|
        final client = ref.watch(apiClientProvider);
 | 
						|
        await client.patch(
 | 
						|
          '/sphere/chat/$id/members/me/notify',
 | 
						|
          data: {'notify_level': level},
 | 
						|
        );
 | 
						|
        ref.invalidate(chatroomIdentityProvider(id));
 | 
						|
        if (context.mounted) {
 | 
						|
          showSnackBar(
 | 
						|
            'chatNotifyLevelUpdated'.tr(args: [kNotifyLevelText[level].tr()]),
 | 
						|
          );
 | 
						|
        }
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    void setChatBreak(DateTime until) async {
 | 
						|
      try {
 | 
						|
        final client = ref.watch(apiClientProvider);
 | 
						|
        await client.patch(
 | 
						|
          '/sphere/chat/$id/members/me/notify',
 | 
						|
          data: {'break_until': until.toUtc().toIso8601String()},
 | 
						|
        );
 | 
						|
        ref.invalidate(chatroomProvider(id));
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    void showNotifyLevelBottomSheet(SnChatMember identity) {
 | 
						|
      showModalBottomSheet(
 | 
						|
        isScrollControlled: true,
 | 
						|
        context: context,
 | 
						|
        builder:
 | 
						|
            (context) => SheetScaffold(
 | 
						|
              height: 320,
 | 
						|
              titleText: 'chatNotifyLevel'.tr(),
 | 
						|
              child: Column(
 | 
						|
                mainAxisSize: MainAxisSize.min,
 | 
						|
                children: [
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatNotifyLevelAll').tr(),
 | 
						|
                    subtitle: const Text('chatNotifyLevelDescription').tr(),
 | 
						|
                    leading: const Icon(Icons.notifications_active),
 | 
						|
                    selected: identity.notify == 0,
 | 
						|
                    onTap: () {
 | 
						|
                      setNotifyLevel(0);
 | 
						|
                      Navigator.pop(context);
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatNotifyLevelMention').tr(),
 | 
						|
                    subtitle: const Text('chatNotifyLevelDescription').tr(),
 | 
						|
                    leading: const Icon(Icons.alternate_email),
 | 
						|
                    selected: identity.notify == 1,
 | 
						|
                    onTap: () {
 | 
						|
                      setNotifyLevel(1);
 | 
						|
                      Navigator.pop(context);
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatNotifyLevelNone').tr(),
 | 
						|
                    subtitle: const Text('chatNotifyLevelDescription').tr(),
 | 
						|
                    leading: const Icon(Icons.notifications_off),
 | 
						|
                    selected: identity.notify == 2,
 | 
						|
                    onTap: () {
 | 
						|
                      setNotifyLevel(2);
 | 
						|
                      Navigator.pop(context);
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    void showChatBreakDialog() {
 | 
						|
      final now = DateTime.now();
 | 
						|
      final durationController = TextEditingController();
 | 
						|
 | 
						|
      showDialog(
 | 
						|
        context: context,
 | 
						|
        builder:
 | 
						|
            (context) => AlertDialog(
 | 
						|
              title: const Text('chatBreak').tr(),
 | 
						|
              content: Column(
 | 
						|
                mainAxisSize: MainAxisSize.min,
 | 
						|
                children: [
 | 
						|
                  const Text('chatBreakDescription').tr(),
 | 
						|
                  const Gap(16),
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatBreakClearButton').tr(),
 | 
						|
                    subtitle: const Text('chatBreakClear').tr(),
 | 
						|
                    leading: const Icon(Icons.notifications_active),
 | 
						|
                    onTap: () {
 | 
						|
                      setChatBreak(now);
 | 
						|
                      Navigator.pop(context);
 | 
						|
                      if (context.mounted) {
 | 
						|
                        showSnackBar('chatBreakCleared'.tr());
 | 
						|
                      }
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatBreak5m').tr(),
 | 
						|
                    subtitle: const Text(
 | 
						|
                      'chatBreakHour',
 | 
						|
                    ).tr(args: ['chatBreak5m'.tr()]),
 | 
						|
                    leading: const Icon(Symbols.circle),
 | 
						|
                    onTap: () {
 | 
						|
                      setChatBreak(now.add(const Duration(minutes: 5)));
 | 
						|
                      Navigator.pop(context);
 | 
						|
                      if (context.mounted) {
 | 
						|
                        showSnackBar('chatBreakSet'.tr(args: ['5m']));
 | 
						|
                      }
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatBreak10m').tr(),
 | 
						|
                    subtitle: const Text(
 | 
						|
                      'chatBreakHour',
 | 
						|
                    ).tr(args: ['chatBreak10m'.tr()]),
 | 
						|
                    leading: const Icon(Symbols.circle),
 | 
						|
                    onTap: () {
 | 
						|
                      setChatBreak(now.add(const Duration(minutes: 10)));
 | 
						|
                      Navigator.pop(context);
 | 
						|
                      if (context.mounted) {
 | 
						|
                        showSnackBar('chatBreakSet'.tr(args: ['10m']));
 | 
						|
                      }
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatBreak15m').tr(),
 | 
						|
                    subtitle: const Text(
 | 
						|
                      'chatBreakHour',
 | 
						|
                    ).tr(args: ['chatBreak15m'.tr()]),
 | 
						|
                    leading: const Icon(Symbols.timer_3),
 | 
						|
                    onTap: () {
 | 
						|
                      setChatBreak(now.add(const Duration(minutes: 15)));
 | 
						|
                      Navigator.pop(context);
 | 
						|
                      if (context.mounted) {
 | 
						|
                        showSnackBar('chatBreakSet'.tr(args: ['15m']));
 | 
						|
                      }
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  ListTile(
 | 
						|
                    title: const Text('chatBreak30m').tr(),
 | 
						|
                    subtitle: const Text(
 | 
						|
                      'chatBreakHour',
 | 
						|
                    ).tr(args: ['chatBreak30m'.tr()]),
 | 
						|
                    leading: const Icon(Symbols.timer),
 | 
						|
                    onTap: () {
 | 
						|
                      setChatBreak(now.add(const Duration(minutes: 30)));
 | 
						|
                      Navigator.pop(context);
 | 
						|
                      if (context.mounted) {
 | 
						|
                        showSnackBar('chatBreakSet'.tr(args: ['30m']));
 | 
						|
                      }
 | 
						|
                    },
 | 
						|
                  ),
 | 
						|
                  const Gap(8),
 | 
						|
                  TextField(
 | 
						|
                    controller: durationController,
 | 
						|
                    decoration: InputDecoration(
 | 
						|
                      labelText: 'chatBreakCustomMinutes'.tr(),
 | 
						|
                      hintText: 'chatBreakEnterMinutes'.tr(),
 | 
						|
                      border: const OutlineInputBorder(),
 | 
						|
                      suffixIcon: IconButton(
 | 
						|
                        icon: const Icon(Icons.check),
 | 
						|
                        onPressed: () {
 | 
						|
                          final minutes = int.tryParse(durationController.text);
 | 
						|
                          if (minutes != null && minutes > 0) {
 | 
						|
                            setChatBreak(now.add(Duration(minutes: minutes)));
 | 
						|
                            Navigator.pop(context);
 | 
						|
                            if (context.mounted) {
 | 
						|
                              showSnackBar(
 | 
						|
                                'chatBreakSet'.tr(args: ['${minutes}m']),
 | 
						|
                              );
 | 
						|
                            }
 | 
						|
                          }
 | 
						|
                        },
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                    keyboardType: TextInputType.number,
 | 
						|
                    onTapOutside:
 | 
						|
                        (_) => FocusManager.instance.primaryFocus?.unfocus(),
 | 
						|
                  ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
              actions: [
 | 
						|
                TextButton(
 | 
						|
                  onPressed: () => Navigator.pop(context),
 | 
						|
                  child: const Text('cancel').tr(),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
      );
 | 
						|
    }
 | 
						|
 | 
						|
    const iconShadow = Shadow(
 | 
						|
      color: Colors.black54,
 | 
						|
      blurRadius: 5.0,
 | 
						|
      offset: Offset(1.0, 1.0),
 | 
						|
    );
 | 
						|
 | 
						|
    return AppScaffold(
 | 
						|
      body: roomState.when(
 | 
						|
        loading: () => const Center(child: CircularProgressIndicator()),
 | 
						|
        error:
 | 
						|
            (error, _) => Center(
 | 
						|
              child: Text('errorGeneric'.tr(args: [error.toString()])),
 | 
						|
            ),
 | 
						|
        data:
 | 
						|
            (currentRoom) => CustomScrollView(
 | 
						|
              slivers: [
 | 
						|
                SliverAppBar(
 | 
						|
                  expandedHeight: 180,
 | 
						|
                  pinned: true,
 | 
						|
                  leading: PageBackButton(shadows: [iconShadow]),
 | 
						|
                  flexibleSpace: FlexibleSpaceBar(
 | 
						|
                    background:
 | 
						|
                        (currentRoom!.type == 1 &&
 | 
						|
                                currentRoom.background?.id != null)
 | 
						|
                            ? CloudImageWidget(
 | 
						|
                              fileId: currentRoom.background!.id,
 | 
						|
                            )
 | 
						|
                            : (currentRoom.type == 1 &&
 | 
						|
                                currentRoom.members!.length == 1 &&
 | 
						|
                                currentRoom
 | 
						|
                                        .members!
 | 
						|
                                        .first
 | 
						|
                                        .account
 | 
						|
                                        .profile
 | 
						|
                                        .background
 | 
						|
                                        ?.id !=
 | 
						|
                                    null)
 | 
						|
                            ? CloudImageWidget(
 | 
						|
                              fileId:
 | 
						|
                                  currentRoom
 | 
						|
                                      .members!
 | 
						|
                                      .first
 | 
						|
                                      .account
 | 
						|
                                      .profile
 | 
						|
                                      .background!
 | 
						|
                                      .id,
 | 
						|
                            )
 | 
						|
                            : currentRoom.background?.id != null
 | 
						|
                            ? CloudImageWidget(
 | 
						|
                              fileId: currentRoom.background!.id,
 | 
						|
                              fit: BoxFit.cover,
 | 
						|
                            )
 | 
						|
                            : Container(
 | 
						|
                              color:
 | 
						|
                                  Theme.of(context).appBarTheme.backgroundColor,
 | 
						|
                            ),
 | 
						|
                    title: Text(
 | 
						|
                      (currentRoom.type == 1 && currentRoom.name == null)
 | 
						|
                          ? currentRoom.members!
 | 
						|
                              .map((e) => e.account.nick)
 | 
						|
                              .join(', ')
 | 
						|
                          : currentRoom.name!,
 | 
						|
                      style: TextStyle(
 | 
						|
                        color: Theme.of(context).appBarTheme.foregroundColor,
 | 
						|
                        shadows: [iconShadow],
 | 
						|
                      ),
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
                  actions: [
 | 
						|
                    IconButton(
 | 
						|
                      icon: const Icon(Icons.people, shadows: [iconShadow]),
 | 
						|
                      onPressed: () {
 | 
						|
                        showModalBottomSheet(
 | 
						|
                          isScrollControlled: true,
 | 
						|
                          context: context,
 | 
						|
                          builder:
 | 
						|
                              (context) => _ChatMemberListSheet(roomId: id),
 | 
						|
                        );
 | 
						|
                      },
 | 
						|
                    ),
 | 
						|
                    _ChatRoomActionMenu(id: id, iconShadow: iconShadow),
 | 
						|
                    const Gap(8),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
                SliverToBoxAdapter(
 | 
						|
                  child: Column(
 | 
						|
                    crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                    children: [
 | 
						|
                      Text(
 | 
						|
                        currentRoom.description ?? 'descriptionNone'.tr(),
 | 
						|
                        style: const TextStyle(fontSize: 16),
 | 
						|
                      ).padding(all: 24),
 | 
						|
                      const Divider(height: 1),
 | 
						|
                      roomIdentity.when(
 | 
						|
                        data:
 | 
						|
                            (identity) => Column(
 | 
						|
                              crossAxisAlignment: CrossAxisAlignment.start,
 | 
						|
                              children: [
 | 
						|
                                ListTile(
 | 
						|
                                  contentPadding: EdgeInsets.symmetric(
 | 
						|
                                    horizontal: 24,
 | 
						|
                                  ),
 | 
						|
                                  leading: const Icon(Symbols.notifications),
 | 
						|
                                  trailing: const Icon(Symbols.chevron_right),
 | 
						|
                                  title: const Text('chatNotifyLevel').tr(),
 | 
						|
                                  subtitle: Text(
 | 
						|
                                    kNotifyLevelText[identity!.notify].tr(),
 | 
						|
                                  ),
 | 
						|
                                  onTap:
 | 
						|
                                      () =>
 | 
						|
                                          showNotifyLevelBottomSheet(identity),
 | 
						|
                                ),
 | 
						|
                                ListTile(
 | 
						|
                                  contentPadding: EdgeInsets.symmetric(
 | 
						|
                                    horizontal: 24,
 | 
						|
                                  ),
 | 
						|
                                  leading: const Icon(Icons.timer),
 | 
						|
                                  trailing: const Icon(Symbols.chevron_right),
 | 
						|
                                  title: const Text('chatBreak').tr(),
 | 
						|
                                  subtitle:
 | 
						|
                                      identity.breakUntil != null &&
 | 
						|
                                              identity.breakUntil!.isAfter(
 | 
						|
                                                DateTime.now(),
 | 
						|
                                              )
 | 
						|
                                          ? Text(
 | 
						|
                                            DateFormat(
 | 
						|
                                              'yyyy-MM-dd HH:mm',
 | 
						|
                                            ).format(identity.breakUntil!),
 | 
						|
                                          )
 | 
						|
                                          : const Text('chatBreakNone').tr(),
 | 
						|
                                  onTap: () => showChatBreakDialog(),
 | 
						|
                                ),
 | 
						|
                                ListTile(
 | 
						|
                                  contentPadding: EdgeInsets.symmetric(
 | 
						|
                                    horizontal: 24,
 | 
						|
                                  ),
 | 
						|
                                  leading: const Icon(Icons.search),
 | 
						|
                                  trailing: const Icon(Symbols.chevron_right),
 | 
						|
                                  title: const Text('searchMessages').tr(),
 | 
						|
                                  subtitle: totalMessages.when(
 | 
						|
                                    data:
 | 
						|
                                        (count) => Text(
 | 
						|
                                          'messagesCount'.tr(
 | 
						|
                                            args: [count.toString()],
 | 
						|
                                          ),
 | 
						|
                                        ),
 | 
						|
                                    loading:
 | 
						|
                                        () => const CircularProgressIndicator(),
 | 
						|
                                    error:
 | 
						|
                                        (err, stack) => Text(
 | 
						|
                                          'errorGeneric'.tr(
 | 
						|
                                            args: [err.toString()],
 | 
						|
                                          ),
 | 
						|
                                        ),
 | 
						|
                                  ),
 | 
						|
                                  onTap: () async {
 | 
						|
                                    final result = await context.pushNamed(
 | 
						|
                                      'searchMessages',
 | 
						|
                                      pathParameters: {'id': id},
 | 
						|
                                    );
 | 
						|
                                    if (result is SearchMessagesResult) {
 | 
						|
                                      // Navigate back to room screen with message to jump to
 | 
						|
                                      if (context.mounted) {
 | 
						|
                                        context.pop(result);
 | 
						|
                                      }
 | 
						|
                                    }
 | 
						|
                                  },
 | 
						|
                                ),
 | 
						|
                              ],
 | 
						|
                            ),
 | 
						|
                        error: (_, _) => const SizedBox.shrink(),
 | 
						|
                        loading: () => const SizedBox.shrink(),
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _ChatRoomActionMenu extends HookConsumerWidget {
 | 
						|
  final String id;
 | 
						|
  final Shadow iconShadow;
 | 
						|
 | 
						|
  const _ChatRoomActionMenu({required this.id, required this.iconShadow});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final chatIdentity = ref.watch(chatroomIdentityProvider(id));
 | 
						|
 | 
						|
    return PopupMenuButton(
 | 
						|
      icon: Icon(Icons.more_vert, shadows: [iconShadow]),
 | 
						|
      itemBuilder:
 | 
						|
          (context) => [
 | 
						|
            if ((chatIdentity.value?.role ?? 0) >= 50)
 | 
						|
              PopupMenuItem(
 | 
						|
                onTap: () {
 | 
						|
                  context.pushReplacementNamed(
 | 
						|
                    'chatEdit',
 | 
						|
                    pathParameters: {'id': id},
 | 
						|
                  );
 | 
						|
                },
 | 
						|
                child: Row(
 | 
						|
                  children: [
 | 
						|
                    Icon(
 | 
						|
                      Icons.edit,
 | 
						|
                      color: Theme.of(context).colorScheme.onSecondaryContainer,
 | 
						|
                    ),
 | 
						|
                    const Gap(12),
 | 
						|
                    const Text('editChatRoom').tr(),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
              ),
 | 
						|
            if ((chatIdentity.value?.role ?? 0) >= 100)
 | 
						|
              PopupMenuItem(
 | 
						|
                child: Row(
 | 
						|
                  children: [
 | 
						|
                    const Icon(Icons.delete, color: Colors.red),
 | 
						|
                    const Gap(12),
 | 
						|
                    const Text(
 | 
						|
                      'deleteChatRoom',
 | 
						|
                      style: TextStyle(color: Colors.red),
 | 
						|
                    ).tr(),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
                onTap: () {
 | 
						|
                  showConfirmAlert(
 | 
						|
                    'deleteChatRoomHint'.tr(),
 | 
						|
                    'deleteChatRoom'.tr(),
 | 
						|
                  ).then((confirm) async {
 | 
						|
                    if (confirm) {
 | 
						|
                      final client = ref.watch(apiClientProvider);
 | 
						|
                      await client.delete('/sphere/chat/$id');
 | 
						|
                      ref.invalidate(chatroomsJoinedProvider);
 | 
						|
                      if (context.mounted) {
 | 
						|
                        context.pop();
 | 
						|
                      }
 | 
						|
                    }
 | 
						|
                  });
 | 
						|
                },
 | 
						|
              )
 | 
						|
            else
 | 
						|
              PopupMenuItem(
 | 
						|
                child: Row(
 | 
						|
                  children: [
 | 
						|
                    Icon(
 | 
						|
                      Icons.exit_to_app,
 | 
						|
                      color: Theme.of(context).colorScheme.error,
 | 
						|
                    ),
 | 
						|
                    const Gap(12),
 | 
						|
                    Text(
 | 
						|
                      'leaveChatRoom',
 | 
						|
                      style: TextStyle(
 | 
						|
                        color: Theme.of(context).colorScheme.error,
 | 
						|
                      ),
 | 
						|
                    ).tr(),
 | 
						|
                  ],
 | 
						|
                ),
 | 
						|
                onTap: () {
 | 
						|
                  showConfirmAlert(
 | 
						|
                    'leaveChatRoomHint'.tr(),
 | 
						|
                    'leaveChatRoom'.tr(),
 | 
						|
                  ).then((confirm) async {
 | 
						|
                    if (confirm) {
 | 
						|
                      final client = ref.watch(apiClientProvider);
 | 
						|
                      await client.delete('/sphere/chat/$id/members/me');
 | 
						|
                      ref.invalidate(chatroomsJoinedProvider);
 | 
						|
                      if (context.mounted) {
 | 
						|
                        context.pop();
 | 
						|
                      }
 | 
						|
                    }
 | 
						|
                  });
 | 
						|
                },
 | 
						|
              ),
 | 
						|
          ],
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
@freezed
 | 
						|
sealed class ChatRoomMemberState with _$ChatRoomMemberState {
 | 
						|
  const factory ChatRoomMemberState({
 | 
						|
    required List<SnChatMember> members,
 | 
						|
    required bool isLoading,
 | 
						|
    required int total,
 | 
						|
    String? error,
 | 
						|
  }) = _ChatRoomMemberState;
 | 
						|
}
 | 
						|
 | 
						|
final chatMemberStateProvider = StateNotifierProvider.family<
 | 
						|
  ChatMemberNotifier,
 | 
						|
  ChatRoomMemberState,
 | 
						|
  String
 | 
						|
>((ref, roomId) {
 | 
						|
  final apiClient = ref.watch(apiClientProvider);
 | 
						|
  return ChatMemberNotifier(apiClient, roomId);
 | 
						|
});
 | 
						|
 | 
						|
class ChatMemberNotifier extends StateNotifier<ChatRoomMemberState> {
 | 
						|
  final String roomId;
 | 
						|
  final Dio _apiClient;
 | 
						|
 | 
						|
  ChatMemberNotifier(this._apiClient, this.roomId)
 | 
						|
    : super(const ChatRoomMemberState(members: [], isLoading: false, total: 0));
 | 
						|
 | 
						|
  Future<void> loadMore({int offset = 0, int take = 20}) async {
 | 
						|
    if (state.isLoading) return;
 | 
						|
    if (state.total > 0 && state.members.length >= state.total) return;
 | 
						|
 | 
						|
    state = state.copyWith(isLoading: true, error: null);
 | 
						|
 | 
						|
    try {
 | 
						|
      final response = await _apiClient.get(
 | 
						|
        '/sphere/chat/$roomId/members',
 | 
						|
        queryParameters: {'offset': offset, 'take': take},
 | 
						|
      );
 | 
						|
 | 
						|
      final total = int.parse(response.headers.value('X-Total') ?? '0');
 | 
						|
      final List<dynamic> data = response.data;
 | 
						|
      final members = data.map((e) => SnChatMember.fromJson(e)).toList();
 | 
						|
 | 
						|
      state = state.copyWith(
 | 
						|
        members: [...state.members, ...members],
 | 
						|
        total: total,
 | 
						|
        isLoading: false,
 | 
						|
      );
 | 
						|
    } catch (e) {
 | 
						|
      state = state.copyWith(error: e.toString(), isLoading: false);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  void reset() {
 | 
						|
    state = const ChatRoomMemberState(members: [], isLoading: false, total: 0);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
@riverpod
 | 
						|
class ChatMemberListNotifier extends _$ChatMemberListNotifier
 | 
						|
    with CursorPagingNotifierMixin<SnChatMember> {
 | 
						|
  @override
 | 
						|
  Future<CursorPagingData<SnChatMember>> build(String roomId) {
 | 
						|
    return fetch();
 | 
						|
  }
 | 
						|
 | 
						|
  @override
 | 
						|
  Future<CursorPagingData<SnChatMember>> fetch({String? cursor}) async {
 | 
						|
    final offset = cursor == null ? 0 : int.parse(cursor);
 | 
						|
    final take = 20;
 | 
						|
 | 
						|
    final apiClient = ref.watch(apiClientProvider);
 | 
						|
    final response = await apiClient.get(
 | 
						|
      '/sphere/chat/$roomId/members',
 | 
						|
      queryParameters: {'offset': offset, 'take': take, 'withStatus': true},
 | 
						|
    );
 | 
						|
 | 
						|
    final total = int.parse(response.headers.value('X-Total') ?? '0');
 | 
						|
    final List<dynamic> data = response.data;
 | 
						|
    final members = data.map((e) => SnChatMember.fromJson(e)).toList();
 | 
						|
 | 
						|
    // Calculate next cursor based on total count
 | 
						|
    final nextOffset = offset + members.length;
 | 
						|
    final String? nextCursor =
 | 
						|
        nextOffset < total ? nextOffset.toString() : null;
 | 
						|
 | 
						|
    return CursorPagingData(
 | 
						|
      items: members,
 | 
						|
      nextCursor: nextCursor,
 | 
						|
      hasMore: members.length < total,
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _ChatMemberListSheet extends HookConsumerWidget {
 | 
						|
  final String roomId;
 | 
						|
  const _ChatMemberListSheet({required this.roomId});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final memberListProvider = chatMemberListNotifierProvider(roomId);
 | 
						|
 | 
						|
    // For backward compatibility and to show total count in the header
 | 
						|
    final memberState = ref.watch(chatMemberStateProvider(roomId));
 | 
						|
    final memberNotifier = ref.read(chatMemberStateProvider(roomId).notifier);
 | 
						|
 | 
						|
    final roomIdentity = ref.watch(chatroomIdentityProvider(roomId));
 | 
						|
 | 
						|
    useEffect(() {
 | 
						|
      Future(() {
 | 
						|
        memberNotifier.loadMore();
 | 
						|
      });
 | 
						|
      return null;
 | 
						|
    }, []);
 | 
						|
 | 
						|
    Future<void> invitePerson() async {
 | 
						|
      final result = await showModalBottomSheet(
 | 
						|
        context: context,
 | 
						|
        useRootNavigator: true,
 | 
						|
        isScrollControlled: true,
 | 
						|
        builder: (context) => const AccountPickerSheet(),
 | 
						|
      );
 | 
						|
      if (result == null) return;
 | 
						|
      try {
 | 
						|
        final apiClient = ref.watch(apiClientProvider);
 | 
						|
        await apiClient.post(
 | 
						|
          '/sphere/chat/invites/$roomId',
 | 
						|
          data: {'related_user_id': result.id, 'role': 0},
 | 
						|
        );
 | 
						|
        // Refresh both providers
 | 
						|
        memberNotifier.reset();
 | 
						|
        await memberNotifier.loadMore();
 | 
						|
        ref.invalidate(memberListProvider);
 | 
						|
      } catch (err) {
 | 
						|
        showErrorAlert(err);
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    return Container(
 | 
						|
      constraints: BoxConstraints(
 | 
						|
        maxHeight: MediaQuery.of(context).size.height * 0.8,
 | 
						|
      ),
 | 
						|
      child: Column(
 | 
						|
        children: [
 | 
						|
          Padding(
 | 
						|
            padding: EdgeInsets.only(top: 16, left: 20, right: 16, bottom: 12),
 | 
						|
            child: Row(
 | 
						|
              children: [
 | 
						|
                Text(
 | 
						|
                  'members'.plural(memberState.total),
 | 
						|
                  style: Theme.of(context).textTheme.headlineSmall?.copyWith(
 | 
						|
                    fontWeight: FontWeight.w600,
 | 
						|
                    letterSpacing: -0.5,
 | 
						|
                  ),
 | 
						|
                ),
 | 
						|
                const Spacer(),
 | 
						|
                IconButton(
 | 
						|
                  icon: const Icon(Symbols.person_add),
 | 
						|
                  onPressed: invitePerson,
 | 
						|
                  style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
 | 
						|
                ),
 | 
						|
                IconButton(
 | 
						|
                  icon: const Icon(Symbols.refresh),
 | 
						|
                  onPressed: () {
 | 
						|
                    // Refresh both providers
 | 
						|
                    memberNotifier.reset();
 | 
						|
                    memberNotifier.loadMore();
 | 
						|
                    ref.invalidate(memberListProvider);
 | 
						|
                  },
 | 
						|
                ),
 | 
						|
                IconButton(
 | 
						|
                  icon: const Icon(Symbols.close),
 | 
						|
                  onPressed: () => Navigator.pop(context),
 | 
						|
                  style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
          const Divider(height: 1),
 | 
						|
          Expanded(
 | 
						|
            child: PagingHelperView(
 | 
						|
              provider: memberListProvider,
 | 
						|
              futureRefreshable: memberListProvider.future,
 | 
						|
              notifierRefreshable: memberListProvider.notifier,
 | 
						|
              contentBuilder: (data, widgetCount, endItemView) {
 | 
						|
                return ListView.builder(
 | 
						|
                  itemCount: widgetCount,
 | 
						|
                  itemBuilder: (context, index) {
 | 
						|
                    if (index == data.items.length) {
 | 
						|
                      return endItemView;
 | 
						|
                    }
 | 
						|
 | 
						|
                    final member = data.items[index];
 | 
						|
                    return ListTile(
 | 
						|
                      contentPadding: EdgeInsets.only(left: 16, right: 12),
 | 
						|
                      leading: AccountPfcGestureDetector(
 | 
						|
                        uname: member.account.name,
 | 
						|
                        child: ProfilePictureWidget(
 | 
						|
                          fileId: member.account.profile.picture?.id,
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                      title: Row(
 | 
						|
                        spacing: 6,
 | 
						|
                        children: [
 | 
						|
                          Flexible(child: Text(member.account.nick)),
 | 
						|
                          if (member.status != null)
 | 
						|
                            AccountStatusLabel(
 | 
						|
                              status: member.status!,
 | 
						|
                              maxLines: 1,
 | 
						|
                              overflow: TextOverflow.ellipsis,
 | 
						|
                            ),
 | 
						|
                          if (member.joinedAt == null)
 | 
						|
                            const Icon(Symbols.pending_actions, size: 20),
 | 
						|
                        ],
 | 
						|
                      ),
 | 
						|
                      subtitle: Row(
 | 
						|
                        children: [
 | 
						|
                          Text(
 | 
						|
                            member.role >= 100
 | 
						|
                                ? 'permissionOwner'
 | 
						|
                                : member.role >= 50
 | 
						|
                                ? 'permissionModerator'
 | 
						|
                                : 'permissionMember',
 | 
						|
                          ).tr(),
 | 
						|
                          Text('·').bold().padding(horizontal: 6),
 | 
						|
                          Expanded(child: Text("@${member.account.name}")),
 | 
						|
                        ],
 | 
						|
                      ),
 | 
						|
                      trailing: Row(
 | 
						|
                        mainAxisSize: MainAxisSize.min,
 | 
						|
                        children: [
 | 
						|
                          if ((roomIdentity.value?.role ?? 0) >= 50)
 | 
						|
                            IconButton(
 | 
						|
                              icon: const Icon(Symbols.edit),
 | 
						|
                              onPressed: () {
 | 
						|
                                showModalBottomSheet(
 | 
						|
                                  isScrollControlled: true,
 | 
						|
                                  context: context,
 | 
						|
                                  builder:
 | 
						|
                                      (context) => _ChatMemberRoleSheet(
 | 
						|
                                        roomId: roomId,
 | 
						|
                                        member: member,
 | 
						|
                                      ),
 | 
						|
                                ).then((value) {
 | 
						|
                                  if (value != null) {
 | 
						|
                                    // Refresh both providers
 | 
						|
                                    memberNotifier.reset();
 | 
						|
                                    memberNotifier.loadMore();
 | 
						|
                                    ref.invalidate(memberListProvider);
 | 
						|
                                  }
 | 
						|
                                });
 | 
						|
                              },
 | 
						|
                            ),
 | 
						|
                          if ((roomIdentity.value?.role ?? 0) >= 50)
 | 
						|
                            IconButton(
 | 
						|
                              icon: const Icon(Symbols.delete),
 | 
						|
                              onPressed: () {
 | 
						|
                                showConfirmAlert(
 | 
						|
                                  'removeChatMemberHint'.tr(),
 | 
						|
                                  'removeChatMember'.tr(),
 | 
						|
                                ).then((confirm) async {
 | 
						|
                                  if (confirm != true) return;
 | 
						|
                                  try {
 | 
						|
                                    final apiClient = ref.watch(
 | 
						|
                                      apiClientProvider,
 | 
						|
                                    );
 | 
						|
                                    await apiClient.delete(
 | 
						|
                                      '/sphere/chat/$roomId/members/${member.accountId}',
 | 
						|
                                    );
 | 
						|
                                    // Refresh both providers
 | 
						|
                                    memberNotifier.reset();
 | 
						|
                                    memberNotifier.loadMore();
 | 
						|
                                    ref.invalidate(memberListProvider);
 | 
						|
                                  } catch (err) {
 | 
						|
                                    showErrorAlert(err);
 | 
						|
                                  }
 | 
						|
                                });
 | 
						|
                              },
 | 
						|
                            ),
 | 
						|
                        ],
 | 
						|
                      ),
 | 
						|
                    );
 | 
						|
                  },
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
          ),
 | 
						|
        ],
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class _ChatMemberRoleSheet extends HookConsumerWidget {
 | 
						|
  final String roomId;
 | 
						|
  final SnChatMember member;
 | 
						|
 | 
						|
  const _ChatMemberRoleSheet({required this.roomId, required this.member});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    final roleController = useTextEditingController(
 | 
						|
      text: member.role.toString(),
 | 
						|
    );
 | 
						|
 | 
						|
    return Container(
 | 
						|
      padding: EdgeInsets.only(
 | 
						|
        bottom: MediaQuery.of(context).viewInsets.bottom,
 | 
						|
      ),
 | 
						|
      child: SafeArea(
 | 
						|
        child: Column(
 | 
						|
          mainAxisSize: MainAxisSize.min,
 | 
						|
          crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
          children: [
 | 
						|
            Padding(
 | 
						|
              padding: EdgeInsets.only(
 | 
						|
                top: 16,
 | 
						|
                left: 20,
 | 
						|
                right: 16,
 | 
						|
                bottom: 12,
 | 
						|
              ),
 | 
						|
              child: Row(
 | 
						|
                children: [
 | 
						|
                  Text(
 | 
						|
                    'memberRoleEdit'.tr(args: [member.account.name]),
 | 
						|
                    style: Theme.of(context).textTheme.headlineSmall?.copyWith(
 | 
						|
                      fontWeight: FontWeight.w600,
 | 
						|
                      letterSpacing: -0.5,
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
                  const Spacer(),
 | 
						|
                  IconButton(
 | 
						|
                    icon: const Icon(Symbols.close),
 | 
						|
                    onPressed: () => Navigator.pop(context),
 | 
						|
                    style: IconButton.styleFrom(
 | 
						|
                      minimumSize: const Size(36, 36),
 | 
						|
                    ),
 | 
						|
                  ),
 | 
						|
                ],
 | 
						|
              ),
 | 
						|
            ),
 | 
						|
            const Divider(height: 1),
 | 
						|
            Column(
 | 
						|
              crossAxisAlignment: CrossAxisAlignment.stretch,
 | 
						|
              children: [
 | 
						|
                Autocomplete<int>(
 | 
						|
                  optionsBuilder: (TextEditingValue textEditingValue) {
 | 
						|
                    if (textEditingValue.text.isEmpty) {
 | 
						|
                      return const [100, 50, 0];
 | 
						|
                    }
 | 
						|
                    final int? value = int.tryParse(textEditingValue.text);
 | 
						|
                    if (value == null) return const [100, 50, 0];
 | 
						|
                    return [100, 50, 0].where(
 | 
						|
                      (option) =>
 | 
						|
                          option.toString().contains(textEditingValue.text),
 | 
						|
                    );
 | 
						|
                  },
 | 
						|
                  onSelected: (int selection) {
 | 
						|
                    roleController.text = selection.toString();
 | 
						|
                  },
 | 
						|
                  fieldViewBuilder: (
 | 
						|
                    context,
 | 
						|
                    controller,
 | 
						|
                    focusNode,
 | 
						|
                    onFieldSubmitted,
 | 
						|
                  ) {
 | 
						|
                    return TextField(
 | 
						|
                      controller: controller,
 | 
						|
                      focusNode: focusNode,
 | 
						|
                      keyboardType: TextInputType.number,
 | 
						|
                      decoration: InputDecoration(
 | 
						|
                        labelText: 'memberRole'.tr(),
 | 
						|
                        helperText: 'memberRoleHint'.tr(),
 | 
						|
                      ),
 | 
						|
                      onTapOutside: (event) => focusNode.unfocus(),
 | 
						|
                    );
 | 
						|
                  },
 | 
						|
                ),
 | 
						|
                const Gap(16),
 | 
						|
                FilledButton.icon(
 | 
						|
                  onPressed: () async {
 | 
						|
                    try {
 | 
						|
                      final newRole = int.parse(roleController.text);
 | 
						|
                      if (newRole < 0 || newRole > 100) {
 | 
						|
                        throw 'roleValidationHint'.tr();
 | 
						|
                      }
 | 
						|
 | 
						|
                      final apiClient = ref.read(apiClientProvider);
 | 
						|
                      await apiClient.patch(
 | 
						|
                        '/sphere/chat/$roomId/members/${member.accountId}/role',
 | 
						|
                        data: newRole,
 | 
						|
                      );
 | 
						|
 | 
						|
                      if (context.mounted) Navigator.pop(context, true);
 | 
						|
                    } catch (err) {
 | 
						|
                      showErrorAlert(err);
 | 
						|
                    }
 | 
						|
                  },
 | 
						|
                  icon: const Icon(Symbols.save),
 | 
						|
                  label: const Text('saveChanges').tr(),
 | 
						|
                ),
 | 
						|
              ],
 | 
						|
            ).padding(vertical: 16, horizontal: 24),
 | 
						|
          ],
 | 
						|
        ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |