Chat call basis

This commit is contained in:
LittleSheep 2024-04-27 01:36:54 +08:00
parent a761b80499
commit 15c8c0fe8f
8 changed files with 330 additions and 56 deletions

View File

@ -75,6 +75,9 @@
"chatChannelDescriptionLabel": "Channel Description", "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.", "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!", "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...", "chatMessagePlaceholder": "Write a message...",
"chatMessageEditNotify": "You are about editing a message.", "chatMessageEditNotify": "You are about editing a message.",
"chatMessageReplyNotify": "You are about replying a message.", "chatMessageReplyNotify": "You are about replying a message.",

View File

@ -75,6 +75,9 @@
"chatChannelDescriptionLabel": "频道简介", "chatChannelDescriptionLabel": "频道简介",
"chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。", "chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。",
"chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!", "chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!",
"chatCall": "通话",
"chatCallOngoing": "一则通话正在进行中",
"chatCallJoin": "加入",
"chatMessagePlaceholder": "发条消息……", "chatMessagePlaceholder": "发条消息……",
"chatMessageEditNotify": "你正在编辑信息中……", "chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……", "chatMessageReplyNotify": "你正在回复消息中……",

49
lib/models/call.dart Normal file
View File

@ -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<String, dynamic> 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<String, dynamic> 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(),
};
}

View File

@ -1,8 +1,10 @@
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/screens/account.dart'; import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.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/chat.dart';
import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/chat/index.dart';
import 'package:solian/screens/chat/manage.dart'; import 'package:solian/screens/chat/manage.dart';
@ -42,6 +44,11 @@ final router = GoRouter(
name: 'chat.channel', name: 'chat.channel',
builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String), 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( GoRoute(
path: '/chat/c/:channel/manage', path: '/chat/c/:channel/manage',
name: 'chat.channel.manage', name: 'chat.channel.manage',

View File

@ -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<ChatCall> createState() => _ChatCallState();
}
class _ChatCallState extends State<ChatCall> {
String? _token;
Future<String> exchangeToken() async {
final auth = context.read<AuthProvider>();
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();
},
),
);
}
}

View File

@ -1,12 +1,15 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/message.dart';
import 'package:solian/models/pagination.dart'; import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/channel_action.dart'; import 'package:solian/widgets/chat/channel_action.dart';
import 'package:solian/widgets/chat/maintainer.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_action.dart';
import 'package:solian/widgets/chat/message_editor.dart'; import 'package:solian/widgets/chat/message_editor.dart';
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/indent_wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
class ChatScreen extends StatefulWidget { class ChatScreen extends StatefulWidget {
@ -26,6 +30,7 @@ class ChatScreen extends StatefulWidget {
} }
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
Call? _ongoingCall;
Channel? _channelMeta; Channel? _channelMeta;
final PagingController<int, Message> _pagingController = PagingController(firstPageKey: 0); final PagingController<int, Message> _pagingController = PagingController(firstPageKey: 0);
@ -48,6 +53,24 @@ class _ChatScreenState extends State<ChatScreen> {
} }
} }
Future<Call?> 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<void> fetchMessages(int pageKey, BuildContext context) async { Future<void> fetchMessages(int pageKey, BuildContext context) async {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return; if (!await auth.isAuthorized()) return;
@ -124,6 +147,7 @@ class _ChatScreenState extends State<ChatScreen> {
void initState() { void initState() {
Future.delayed(Duration.zero, () { Future.delayed(Duration.zero, () {
fetchMetadata(); fetchMetadata();
fetchCall();
}); });
_pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context)); _pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
@ -133,12 +157,61 @@ class _ChatScreenState extends State<ChatScreen> {
@override @override
Widget build(BuildContext context) { 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( return IndentWrapper(
hideDrawer: true, hideDrawer: true,
title: _channelMeta?.name ?? "Loading...", title: _channelMeta?.name ?? "Loading...",
appBarActions: [ appBarActions: _channelMeta != null
_channelMeta != null ? ChannelAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()) : Container(), ? [
], ChannelCallAction(call: _ongoingCall, channel: _channelMeta!, onUpdate: () => fetchMetadata()),
ChannelManageAction(channel: _channelMeta!, onUpdate: () => fetchMetadata()),
]
: [],
child: FutureBuilder( child: FutureBuilder(
future: fetchMetadata(), future: fetchMetadata(),
builder: (context, snapshot) { builder: (context, snapshot) {
@ -148,56 +221,39 @@ class _ChatScreenState extends State<ChatScreen> {
return ChatMaintainer( return ChatMaintainer(
channel: snapshot.data!, channel: snapshot.data!,
child: Column( child: Stack(
children: [ children: [
Expanded( Column(
child: PagedListView<int, Message>( children: [
reverse: true, Expanded(
pagingController: _pagingController, child: PagedListView<int, Message>(
builderDelegate: PagedChildBuilderDelegate<Message>( reverse: true,
noItemsFoundIndicatorBuilder: (_) => Container(), pagingController: _pagingController,
itemBuilder: (context, item, index) { builderDelegate: PagedChildBuilderDelegate<Message>(
bool isMerged = false, hasMerged = false; noItemsFoundIndicatorBuilder: (_) => Container(),
if (index > 0) { itemBuilder: chatHistoryBuilder,
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),
);
},
), ),
), ChatMessageEditor(
), channel: widget.alias,
ChatMessageEditor( editing: _editingItem,
channel: widget.alias, replying: _replyingItem,
editing: _editingItem, onReset: () => setState(() {
replying: _replyingItem, _editingItem = null;
onReset: () => setState(() { _replyingItem = null;
_editingItem = null; }),
_replyingItem = null; ),
}), ],
), ),
_ongoingCall != null ? callBanner.animate().slideY() : Container(),
], ],
), ),
onInsertMessage: (message) => addMessage(message), onInsertMessage: (message) => addMessage(message),
onUpdateMessage: (message) => updateMessage(message), onUpdateMessage: (message) => updateMessage(message),
onDeleteMessage: (message) => deleteMessage(message), onDeleteMessage: (message) => deleteMessage(message),
onCallStarted: (call) => setState(() => _ongoingCall = call),
onCallEnded: () => setState(() => _ongoingCall = null),
); );
}, },
), ),

View File

@ -1,14 +1,93 @@
import 'package:flutter/material.dart'; import 'dart:convert';
import 'package:solian/models/channel.dart';
import 'package:solian/router.dart';
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 Channel channel;
final Function onUpdate; 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<ChannelCallAction> createState() => _ChannelCallActionState();
}
class _ChannelCallActionState extends State<ChannelCallAction> {
bool _isSubmitting = false;
Future<void> makeCall() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
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<void> endsCall() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -19,16 +98,14 @@ class ChannelAction extends StatelessWidget {
extra: channel, extra: channel,
pathParameters: {'channel': channel.alias}, pathParameters: {'channel': channel.alias},
); );
switch(result) { switch (result) {
case 'disposed': case 'disposed':
if(router.canPop()) router.pop('refresh'); if (router.canPop()) router.pop('refresh');
case 'refresh': case 'refresh':
onUpdate(); onUpdate();
} }
}, },
focusNode: _focusNode, icon: const Icon(Icons.settings),
style: TextButton.styleFrom(shape: const CircleBorder()),
icon: const Icon(Icons.more_horiz),
); );
} }
} }

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart'; import 'package:solian/models/message.dart';
import 'package:solian/models/packet.dart'; import 'package:solian/models/packet.dart';
@ -15,6 +16,8 @@ class ChatMaintainer extends StatefulWidget {
final Function(Message val) onInsertMessage; final Function(Message val) onInsertMessage;
final Function(Message val) onUpdateMessage; final Function(Message val) onUpdateMessage;
final Function(Message val) onDeleteMessage; final Function(Message val) onDeleteMessage;
final Function(Call val) onCallStarted;
final Function() onCallEnded;
const ChatMaintainer({ const ChatMaintainer({
super.key, super.key,
@ -23,6 +26,8 @@ class ChatMaintainer extends StatefulWidget {
required this.onInsertMessage, required this.onInsertMessage,
required this.onUpdateMessage, required this.onUpdateMessage,
required this.onDeleteMessage, required this.onDeleteMessage,
required this.onCallStarted,
required this.onCallEnded,
}); });
@override @override
@ -60,6 +65,14 @@ class _ChatMaintainerState extends State<ChatMaintainer> {
final payload = Message.fromJson(result.payload!); final payload = Message.fromJson(result.payload!);
if (payload.channelId == widget.channel.id) widget.onDeleteMessage(payload); if (payload.channelId == widget.channel.id) widget.onDeleteMessage(payload);
break; 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(), onError: (_, __) => connect(),