import 'dart:async'; import 'dart:convert'; import 'dart:developer'; import 'package:dio/dio.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/controllers/chat_message_controller.dart'; import 'package:surface/providers/channel.dart'; import 'package:surface/providers/chat_call.dart'; import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/websocket.dart'; import 'package:surface/types/chat.dart'; import 'package:surface/widgets/chat/call/call_prejoin.dart'; import 'package:surface/widgets/chat/chat_message.dart'; import 'package:surface/widgets/chat/chat_message_input.dart'; import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/loading_indicator.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import '../../providers/user_directory.dart'; import '../../providers/userinfo.dart'; class ChatRoomScreen extends StatefulWidget { final String scope; final String alias; const ChatRoomScreen({super.key, required this.scope, required this.alias}); @override State createState() => _ChatRoomScreenState(); } class _ChatRoomScreenState extends State { bool _isBusy = false; bool _isCalling = false; SnChannel? _channel; SnChannelMember? _otherMember; SnChatCall? _ongoingCall; final GlobalKey _inputGlobalKey = GlobalKey(); late final ChatMessageController _messageController; StreamSubscription? _wsSubscription; Future _fetchChannel() async { setState(() => _isBusy = true); try { final chan = context.read(); _channel = await chan.getChannel('${widget.scope}:${widget.alias}'); if (!mounted || _channel == null) return; final ud = context.read(); final ua = context.read(); if (_channel!.type == 1) { await ud.listAccount( _channel!.members ?.cast() .map((ele) => ele?.accountId) .where((ele) => ele != null && ele != ua.user?.id) .toSet() ?? {}, ); _otherMember = _channel!.members?.cast().firstWhere( (ele) => ele?.accountId != ua.user?.id, orElse: () => null, ); } } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isBusy = false); } } Future _fetchOngoingCall() async { setState(() => _isCalling = true); try { final sn = context.read(); final resp = await sn.client.get( '/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing', options: Options( validateStatus: (status) => status != null && status < 500, receiveTimeout: const Duration(seconds: 60), sendTimeout: const Duration(seconds: 60), ), ); if (resp.statusCode == 200) { _ongoingCall = SnChatCall.fromJson(resp.data); } } catch (err) { if (!mounted) return; print((err as DioException).response?.data); context.showErrorDialog(err); } finally { setState(() => _isCalling = false); } } Future _makeCall() async { setState(() => _isCalling = true); try { final sn = context.read(); final resp = await sn.client.post( '/cgi/im/channels/${_messageController.channel!.keyPath}/calls', options: Options( sendTimeout: const Duration(seconds: 30), receiveTimeout: const Duration(seconds: 30), ), ); } catch (err) { if (!mounted) return; if (_ongoingCall == null) { // ignore the error because the call is already ongoing context.showErrorDialog(err); } } finally { setState(() => _isCalling = false); } } Future _endCall() async { setState(() => _isCalling = true); try { final sn = context.read(); await sn.client.delete( '/cgi/im/channels/${_messageController.channel!.keyPath}/calls/ongoing', ); } catch (err) { if (!mounted) return; context.showErrorDialog(err); } finally { setState(() => _isCalling = false); } } Future _onCallJoin() async { await showModalBottomSheet( context: context, builder: (context) => ChatCallPrejoinPopup( ongoingCall: _ongoingCall!, channel: _channel!, onJoin: _onCallResume, ), ); } void _onCallResume() { GoRouter.of(context).pushNamed( 'chatCallRoom', pathParameters: { 'scope': _channel!.realm!.alias, 'alias': _channel!.alias, }, ); } bool _checkMessageMergeable(SnChatMessage? a, SnChatMessage? b) { if (a == null || b == null) return false; if (a.sender.accountId != b.sender.accountId) return false; return a.createdAt.difference(b.createdAt).inMinutes <= 3; } @override void initState() { super.initState(); _messageController = ChatMessageController(context); _fetchChannel().then((_) async { await _messageController.initialize(_channel!); await _messageController.checkUpdate(); await _fetchOngoingCall(); }); final ws = context.read(); _wsSubscription = ws.stream.stream.listen((event) { switch (event.method) { case 'calls.new': final payload = SnChatCall.fromJson(event.payload!); if (payload.channelId == _channel?.id) { setState(() => _ongoingCall = payload); } break; case 'calls.end': final payload = SnChatCall.fromJson(event.payload!); if (payload.channelId == _channel?.id) { setState(() => _ongoingCall = null); } break; } }); } @override void dispose() { _wsSubscription?.cancel(); _messageController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final call = context.watch(); final ud = context.read(); return Scaffold( appBar: AppBar( title: Text( _channel?.type == 1 ? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name : _channel?.name ?? 'loading'.tr(), ), actions: [ IconButton( icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end), onPressed: _isCalling ? null : _ongoingCall == null ? _makeCall : _endCall, ), IconButton( icon: const Icon(Symbols.more_vert), onPressed: () { GoRouter.of(context).pushNamed('channelDetail', pathParameters: { 'scope': widget.scope, 'alias': widget.alias, }).then((value) { if (value == false && context.mounted) { Navigator.pop(context, true); } else if (value != null && context.mounted) { _fetchChannel(); } }); }, ), const Gap(8), ], ), body: ListenableBuilder( listenable: _messageController, builder: (context, _) { return Column( children: [ LoadingIndicator(isActive: _isBusy), SingleChildScrollView( physics: const NeverScrollableScrollPhysics(), child: MaterialBanner( dividerColor: Colors.transparent, leading: const Icon(Symbols.call_received), content: Text('callOngoingNotice').tr().padding(top: 2), actions: [ if (call.current == null) TextButton( onPressed: _onCallJoin, child: Text('callJoin').tr(), ) else if (call.current?.channelId == _channel?.id) TextButton( onPressed: _onCallResume, child: Text('callResume').tr(), ) ], ), ) .height(_ongoingCall != null ? 54 : 0, animate: true) .animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn), if (_messageController.isPending) Expanded( child: const CircularProgressIndicator().center(), ), if (!_messageController.isPending) Expanded( child: InfiniteList( reverse: true, padding: const EdgeInsets.only( left: 12, right: 12, top: 12, ), hasReachedMax: _messageController.isAllLoaded, itemCount: _messageController.messages.length, isLoading: _messageController.isLoading, onFetchData: () { _messageController.loadMessages(); }, itemBuilder: (context, idx) { final message = _messageController.messages[idx]; bool canMerge = false, canMergePrevious = false; if (idx > 0) { canMergePrevious = _checkMessageMergeable( _messageController.messages[idx - 1], _messageController.messages[idx], ); } if (idx + 1 < _messageController.messages.length) { canMerge = _checkMessageMergeable( _messageController.messages[idx], _messageController.messages[idx + 1], ); } return Align( alignment: Alignment.centerLeft, child: Container( constraints: BoxConstraints(maxWidth: 480), child: ChatMessage( data: message, isMerged: canMerge, hasMerged: canMergePrevious, isPending: _messageController.unconfirmedMessages.contains(message.uuid), onReply: (value) { _inputGlobalKey.currentState?.setReply(value); }, onEdit: (value) { _inputGlobalKey.currentState?.setEdit(value); }, onDelete: (value) { _inputGlobalKey.currentState?.deleteMessage(value); }, ), ), ); }, ), ), if (!_messageController.isPending) Material( elevation: 2, child: ChatMessageInput( key: _inputGlobalKey, otherMember: _otherMember, controller: _messageController, ).padding(bottom: MediaQuery.of(context).padding.bottom), ), ], ); }, ), ); } }