diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index 63ab6c6..7e41bf0 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -75,6 +75,9 @@ "chatChannelDescriptionLabel": "Channel Description", "chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.", "chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!", + "chatCall": "Call", + "chatCallOngoing": "A call is ongoing", + "chatCallJoin": "Join", "chatMessagePlaceholder": "Write a message...", "chatMessageEditNotify": "You are about editing a message.", "chatMessageReplyNotify": "You are about replying a message.", diff --git a/lib/i18n/app_zh.arb b/lib/i18n/app_zh.arb index 6bc5291..46a63f1 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -75,6 +75,9 @@ "chatChannelDescriptionLabel": "频道简介", "chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。", "chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!", + "chatCall": "通话", + "chatCallOngoing": "一则通话正在进行中", + "chatCallJoin": "加入", "chatMessagePlaceholder": "发条消息……", "chatMessageEditNotify": "你正在编辑信息中……", "chatMessageReplyNotify": "你正在回复消息中……", diff --git a/lib/models/call.dart b/lib/models/call.dart new file mode 100644 index 0000000..7288dd7 --- /dev/null +++ b/lib/models/call.dart @@ -0,0 +1,49 @@ +import 'package:solian/models/channel.dart'; + +class Call { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + DateTime? endedAt; + String externalId; + int founderId; + int channelId; + Channel channel; + + Call({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + this.endedAt, + required this.externalId, + required this.founderId, + required this.channelId, + required this.channel, + }); + + factory Call.fromJson(Map json) => Call( + id: json["id"], + createdAt: DateTime.parse(json["created_at"]), + updatedAt: DateTime.parse(json["updated_at"]), + deletedAt: json["deleted_at"], + endedAt: json["ended_at"] != null ? DateTime.parse(json["ended_at"]) : null, + externalId: json["external_id"], + founderId: json["founder_id"], + channelId: json["channel_id"], + channel: Channel.fromJson(json["channel"]), + ); + + Map toJson() => { + "id": id, + "created_at": createdAt.toIso8601String(), + "updated_at": updatedAt.toIso8601String(), + "deleted_at": deletedAt, + "ended_at": endedAt?.toIso8601String(), + "external_id": externalId, + "founder_id": founderId, + "channel_id": channelId, + "channel": channel.toJson(), + }; +} \ No newline at end of file diff --git a/lib/router.dart b/lib/router.dart index 7f95027..39d1f90 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,8 +1,10 @@ import 'package:go_router/go_router.dart'; +import 'package:solian/models/call.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/post.dart'; import 'package:solian/screens/account.dart'; import 'package:solian/screens/account/friend.dart'; +import 'package:solian/screens/chat/call.dart'; import 'package:solian/screens/chat/chat.dart'; import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/chat/manage.dart'; @@ -42,6 +44,11 @@ final router = GoRouter( name: 'chat.channel', builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String), ), + GoRoute( + path: '/chat/c/:channel/call', + name: 'chat.channel.call', + builder: (context, state) => ChatCall(call: state.extra as Call), + ), GoRoute( path: '/chat/c/:channel/manage', name: 'chat.channel.manage', diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart new file mode 100644 index 0000000..5b44e75 --- /dev/null +++ b/lib/screens/chat/call.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/models/call.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/router.dart'; +import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ChatCall extends StatefulWidget { + final Call call; + + const ChatCall({super.key, required this.call}); + + @override + State createState() => _ChatCallState(); +} + +class _ChatCallState extends State { + String? _token; + + Future exchangeToken() async { + final auth = context.read(); + if (!await auth.isAuthorized()) { + router.pop(); + throw Error(); + } + + var uri = getRequestUri('messaging', '/api/channels/${widget.call.channel.alias}/calls/ongoing/token'); + + var res = await auth.client!.post(uri); + if (res.statusCode == 200) { + final result = jsonDecode(utf8.decode(res.bodyBytes)); + _token = result['token']; + return _token!; + } else { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + throw Exception(message); + } + } + + @override + Widget build(BuildContext context) { + return IndentWrapper( + title: AppLocalizations.of(context)!.chatCall, + noSafeArea: true, + hideDrawer: true, + child: FutureBuilder( + future: exchangeToken(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == null) { + return const Center(child: CircularProgressIndicator()); + } + + print(snapshot.data!); + return Container(); + }, + ), + ); + } +} diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index 8121fa2..a6d7b92 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -1,12 +1,15 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; +import 'package:solian/models/call.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/message.dart'; import 'package:solian/models/pagination.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/router.dart'; import 'package:solian/utils/service_url.dart'; import 'package:solian/widgets/chat/channel_action.dart'; import 'package:solian/widgets/chat/maintainer.dart'; @@ -14,6 +17,7 @@ import 'package:solian/widgets/chat/message.dart'; import 'package:solian/widgets/chat/message_action.dart'; import 'package:solian/widgets/chat/message_editor.dart'; import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:http/http.dart' as http; class ChatScreen extends StatefulWidget { @@ -26,6 +30,7 @@ class ChatScreen extends StatefulWidget { } class _ChatScreenState extends State { + Call? _ongoingCall; Channel? _channelMeta; final PagingController _pagingController = PagingController(firstPageKey: 0); @@ -48,6 +53,24 @@ class _ChatScreenState extends State { } } + Future fetchCall() async { + var uri = getRequestUri('messaging', '/api/channels/${widget.alias}/calls/ongoing'); + var res = await _client.get(uri); + if (res.statusCode == 200) { + final result = jsonDecode(utf8.decode(res.bodyBytes)); + setState(() => _ongoingCall = Call.fromJson(result)); + return _ongoingCall; + } else if (res.statusCode != 404) { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + throw Exception(message); + } else { + return null; + } + } + Future fetchMessages(int pageKey, BuildContext context) async { final auth = context.read(); if (!await auth.isAuthorized()) return; @@ -124,6 +147,7 @@ class _ChatScreenState extends State { void initState() { Future.delayed(Duration.zero, () { fetchMetadata(); + fetchCall(); }); _pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context)); @@ -133,12 +157,61 @@ class _ChatScreenState extends State { @override Widget build(BuildContext context) { + Widget chatHistoryBuilder(context, item, index) { + bool isMerged = false, hasMerged = false; + if (index > 0) { + hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item); + } + if (index + 1 < (_pagingController.itemList?.length ?? 0)) { + isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]); + } + return InkWell( + child: Container( + padding: EdgeInsets.only( + top: !isMerged ? 8 : 0, + bottom: !hasMerged ? 8 : 0, + left: 12, + right: 12, + ), + child: ChatMessage( + key: Key('m${item.id}'), + item: item, + underMerged: isMerged, + ), + ), + onLongPress: () => viewActions(item), + ); + } + + final callBanner = MaterialBanner( + padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20), + leading: const Icon(Icons.call_received), + backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9), + dividerColor: const Color.fromARGB(1, 0, 0, 0), + content: Text(AppLocalizations.of(context)!.chatCallOngoing), + actions: [ + TextButton( + child: Text(AppLocalizations.of(context)!.chatCallJoin), + onPressed: () { + router.pushNamed( + 'chat.channel.call', + extra: _ongoingCall, + pathParameters: {'channel': widget.alias}, + ); + }, + ), + ], + ); + return IndentWrapper( hideDrawer: true, title: _channelMeta?.name ?? "Loading...", - appBarActions: [ - _channelMeta != null ? ChannelAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()) : Container(), - ], + appBarActions: _channelMeta != null + ? [ + ChannelCallAction(call: _ongoingCall, channel: _channelMeta!, onUpdate: () => fetchMetadata()), + ChannelManageAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()), + ] + : [], child: FutureBuilder( future: fetchMetadata(), builder: (context, snapshot) { @@ -148,56 +221,39 @@ class _ChatScreenState extends State { return ChatMaintainer( channel: snapshot.data!, - child: Column( + child: Stack( children: [ - Expanded( - child: PagedListView( - reverse: true, - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - noItemsFoundIndicatorBuilder: (_) => Container(), - itemBuilder: (context, item, index) { - bool isMerged = false, hasMerged = false; - if (index > 0) { - hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item); - } - if (index + 1 < (_pagingController.itemList?.length ?? 0)) { - isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]); - } - return InkWell( - child: Container( - padding: EdgeInsets.only( - top: !isMerged ? 8 : 0, - bottom: !hasMerged ? 8 : 0, - left: 12, - right: 12, - ), - child: ChatMessage( - key: Key('m${item.id}'), - item: item, - underMerged: isMerged, - ), - ), - onLongPress: () => viewActions(item), - ); - }, + Column( + children: [ + Expanded( + child: PagedListView( + reverse: true, + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + noItemsFoundIndicatorBuilder: (_) => Container(), + itemBuilder: chatHistoryBuilder, + ), + ), ), - ), - ), - ChatMessageEditor( - channel: widget.alias, - editing: _editingItem, - replying: _replyingItem, - onReset: () => setState(() { - _editingItem = null; - _replyingItem = null; - }), + ChatMessageEditor( + channel: widget.alias, + editing: _editingItem, + replying: _replyingItem, + onReset: () => setState(() { + _editingItem = null; + _replyingItem = null; + }), + ), + ], ), + _ongoingCall != null ? callBanner.animate().slideY() : Container(), ], ), onInsertMessage: (message) => addMessage(message), onUpdateMessage: (message) => updateMessage(message), onDeleteMessage: (message) => deleteMessage(message), + onCallStarted: (call) => setState(() => _ongoingCall = call), + onCallEnded: () => setState(() => _ongoingCall = null), ); }, ), diff --git a/lib/widgets/chat/channel_action.dart b/lib/widgets/chat/channel_action.dart index 98ac384..5482f57 100644 --- a/lib/widgets/chat/channel_action.dart +++ b/lib/widgets/chat/channel_action.dart @@ -1,14 +1,93 @@ -import 'package:flutter/material.dart'; -import 'package:solian/models/channel.dart'; -import 'package:solian/router.dart'; +import 'dart:convert'; -class ChannelAction extends StatelessWidget { +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/models/call.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/router.dart'; +import 'package:solian/utils/service_url.dart'; + +class ChannelCallAction extends StatefulWidget { + final Call? call; final Channel channel; final Function onUpdate; - ChannelAction({super.key, required this.channel, required this.onUpdate}); + const ChannelCallAction({super.key, this.call, required this.channel, required this.onUpdate}); - final FocusNode _focusNode = FocusNode(); + @override + State createState() => _ChannelCallActionState(); +} + +class _ChannelCallActionState extends State { + bool _isSubmitting = false; + + Future makeCall() async { + setState(() => _isSubmitting = true); + + final auth = context.read(); + if (!await auth.isAuthorized()) { + setState(() => _isSubmitting = false); + return; + } + + var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/calls'); + + var res = await auth.client!.post(uri); + if (res.statusCode != 200) { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + + setState(() => _isSubmitting = false); + } + + Future endsCall() async { + setState(() => _isSubmitting = true); + + final auth = context.read(); + if (!await auth.isAuthorized()) { + setState(() => _isSubmitting = false); + return; + } + + var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/calls/ongoing'); + + var res = await auth.client!.delete(uri); + if (res.statusCode != 200) { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + + setState(() => _isSubmitting = false); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: _isSubmitting + ? null + : () { + if (widget.call == null) { + makeCall(); + } else { + endsCall(); + } + }, + icon: widget.call == null ? const Icon(Icons.call) : const Icon(Icons.call_end), + ); + } +} + +class ChannelManageAction extends StatelessWidget { + final Channel channel; + final Function onUpdate; + + const ChannelManageAction({super.key, required this.channel, required this.onUpdate}); @override Widget build(BuildContext context) { @@ -19,16 +98,14 @@ class ChannelAction extends StatelessWidget { extra: channel, pathParameters: {'channel': channel.alias}, ); - switch(result) { + switch (result) { case 'disposed': - if(router.canPop()) router.pop('refresh'); + if (router.canPop()) router.pop('refresh'); case 'refresh': onUpdate(); } }, - focusNode: _focusNode, - style: TextButton.styleFrom(shape: const CircleBorder()), - icon: const Icon(Icons.more_horiz), + icon: const Icon(Icons.settings), ); } } diff --git a/lib/widgets/chat/maintainer.dart b/lib/widgets/chat/maintainer.dart index d661e45..23f0e95 100644 --- a/lib/widgets/chat/maintainer.dart +++ b/lib/widgets/chat/maintainer.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:solian/models/call.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/message.dart'; import 'package:solian/models/packet.dart'; @@ -15,6 +16,8 @@ class ChatMaintainer extends StatefulWidget { final Function(Message val) onInsertMessage; final Function(Message val) onUpdateMessage; final Function(Message val) onDeleteMessage; + final Function(Call val) onCallStarted; + final Function() onCallEnded; const ChatMaintainer({ super.key, @@ -23,6 +26,8 @@ class ChatMaintainer extends StatefulWidget { required this.onInsertMessage, required this.onUpdateMessage, required this.onDeleteMessage, + required this.onCallStarted, + required this.onCallEnded, }); @override @@ -60,6 +65,14 @@ class _ChatMaintainerState extends State { final payload = Message.fromJson(result.payload!); if (payload.channelId == widget.channel.id) widget.onDeleteMessage(payload); break; + case 'calls.new': + final payload = Call.fromJson(result.payload!); + if (payload.channelId == widget.channel.id) widget.onCallStarted(payload); + break; + case 'calls.end': + final payload = Call.fromJson(result.payload!); + if (payload.channelId == widget.channel.id) widget.onCallEnded(); + break; } }, onError: (_, __) => connect(),