Compare commits
18 Commits
0b9439262c
...
archived/v
Author | SHA1 | Date | |
---|---|---|---|
d46d584ff3 | |||
f43f9e91f6 | |||
b9461e5019 | |||
8e0e2dacfe | |||
b4d1d62e9b | |||
6f7ae4467c | |||
98547708af | |||
08d0a99b10 | |||
5ce6543275 | |||
40aa16e971 | |||
c1d3bac0c8 | |||
a4f8c65aa5 | |||
3bcdc67285 | |||
dffa0077de | |||
b3e266d564 | |||
0c87bbbce1 | |||
ae4d9cf81a | |||
22c2a80650 |
@@ -28,6 +28,7 @@
|
||||
"birthday": "Birthday",
|
||||
"password": "Password",
|
||||
"next": "Next",
|
||||
"join": "Join",
|
||||
"edit": "Edit",
|
||||
"apply": "Apply",
|
||||
"delete": "Delete",
|
||||
@@ -37,6 +38,8 @@
|
||||
"cancel": "Cancel",
|
||||
"report": "Report",
|
||||
"reply": "Reply",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"settings": "Settings",
|
||||
"errorHappened": "An Error Occurred",
|
||||
"notification": "Notification",
|
||||
@@ -55,6 +58,11 @@
|
||||
"friendAddDone": "Friend request sent, go reach your friend!",
|
||||
"personalize": "Personalize",
|
||||
"personalizeApplied": "Your account information has been updated, some fields may take a while to fully applied.",
|
||||
"keypair": "Keypair",
|
||||
"keypairGenerated": "A new set of keypair has been generated. Automatically set it to active.",
|
||||
"keypairSecretCode": "Secret Code",
|
||||
"keypairImportHint": "You can paste the exported secret code here to import all keys in it.",
|
||||
"keypairExportHint": "You can copy the exported secret code to other device to import all keys into it. Do not share it with anybody else!",
|
||||
"reaction": "Reaction",
|
||||
"reactVerb": "React",
|
||||
"post": "Post",
|
||||
@@ -75,6 +83,7 @@
|
||||
"postEditNotify": "You are about editing a post that already published.",
|
||||
"reactionAdded": "Your reaction has been added.",
|
||||
"reactionRemoved": "Your reaction has been removed.",
|
||||
"shortcutsEmpty": "Shortcuts are empty, looks like you didn't go anywhere recently...",
|
||||
"realmNew": "New Realm",
|
||||
"realmNewCreate": "Create a realm",
|
||||
"realmNewJoin": "Join a exists realm",
|
||||
@@ -87,12 +96,17 @@
|
||||
"realmDescriptionLabel": "Realm Description",
|
||||
"realmPublicLabel": "It's public",
|
||||
"realmCommunityLabel": "It's community realm",
|
||||
"realmMember": "Member",
|
||||
"realmManage": "Realm Manage",
|
||||
"chatNew": "New Chat",
|
||||
"chatNewCreate": "Create a channel",
|
||||
"chatNewJoin": "Join a exists channel",
|
||||
"chatDetail": "Chat Details",
|
||||
"chatMember": "Member",
|
||||
"chatNotifySetting": "Notify Settings",
|
||||
"chatChannelUnavailable": "Channel Unavailable",
|
||||
"chatChannelUnavailableCaptionWithRealm": "You didn't join the channel, but looks like you able to join to, would you want to have a try?",
|
||||
"chatChannelUnavailableCaption": "You didn't join the channel, so you cannot access the information of this channel.",
|
||||
"chatChannelUsage": "Channel",
|
||||
"chatChannelUsageCaption": "Channel is place to talk with people, one or a lot.",
|
||||
"chatChannelOrganize": "Organize a channel",
|
||||
@@ -100,6 +114,7 @@
|
||||
"chatChannelAliasLabel": "Channel Alias",
|
||||
"chatChannelNameLabel": "Channel Name",
|
||||
"chatChannelDescriptionLabel": "Channel Description",
|
||||
"chatChannelEncryptedLabel": "Encrypted Channel",
|
||||
"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",
|
||||
@@ -117,7 +132,13 @@
|
||||
"chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.",
|
||||
"chatCallChangeSpeaker": "Change Speaker",
|
||||
"chatMessagePlaceholder": "Write a message...",
|
||||
"chatMessageEncryptedPlaceholder": "Write a encrypted message...",
|
||||
"chatMessageSending": "Now delivering your messages...",
|
||||
"chatMessageEditNotify": "You are about editing a message.",
|
||||
"chatMessageReplyNotify": "You are about replying a message.",
|
||||
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!"
|
||||
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!",
|
||||
"chatMessageViewSource": "View source",
|
||||
"chatMessageUnableDecryptWaiting": "Waiting for encryption key...",
|
||||
"chatMessageUnableDecryptUnsupported": "Unable to decrypt the message, encryption algorithm unsupported",
|
||||
"chatMessageUnableDecryptMissing": "Unable to decrypt the message, missing encryption key"
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@
|
||||
"appName": "Solar Network",
|
||||
"explore": "探索",
|
||||
"chat": "聊天",
|
||||
"realm": "领域",
|
||||
"account": "账号",
|
||||
"riskDetection": "风险监测",
|
||||
"signIn": "登录",
|
||||
@@ -27,6 +28,7 @@
|
||||
"birthday": "生日",
|
||||
"password": "密码",
|
||||
"next": "下一步",
|
||||
"join": "加入",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"action": "操作",
|
||||
@@ -36,6 +38,8 @@
|
||||
"exit": "离开",
|
||||
"report": "举报",
|
||||
"reply": "回复",
|
||||
"export": "导出",
|
||||
"import": "导入",
|
||||
"settings": "设置",
|
||||
"errorHappened": "发生了错误",
|
||||
"notification": "通知",
|
||||
@@ -54,6 +58,11 @@
|
||||
"friendAddDone": "好友请求已发送,快告诉你的朋友吧!",
|
||||
"personalize": "个性化",
|
||||
"personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。",
|
||||
"keypair": "密钥对",
|
||||
"keypairGenerated": "已生成一套新的密钥对,并且设为活跃的密钥。",
|
||||
"keypairSecretCode": "神秘代码",
|
||||
"keypairImportHint": "你可以将别的设备导出的神秘代码粘贴到这里来导入其中的所有密钥。",
|
||||
"keypairExportHint": "你可以将这个导出的神秘代码到你的别的设备来导入这个设备所包含的密钥,但绝对不要发送给其他人!",
|
||||
"reaction": "反应",
|
||||
"reactVerb": "作出反应",
|
||||
"post": "帖子",
|
||||
@@ -74,10 +83,28 @@
|
||||
"postEditNotify": "你正在修改一个已经发布了的帖子。",
|
||||
"reactionAdded": "你的反应已被添加。",
|
||||
"reactionRemoved": "你的反应已被移除。",
|
||||
"shortcutsEmpty": "快捷方式空空如也,看起来最近你没去哪里呀~",
|
||||
"realmNew": "新领域",
|
||||
"realmNewCreate": "创建新领域",
|
||||
"realmNewJoin": "加入现有领域",
|
||||
"realmUsage": "领域",
|
||||
"realmUsageCaption": "领域是一个地方给你来组织帖子、文章、聊天频道的,好好利用领域打造一个绝妙的专属于你的社区吧!",
|
||||
"realmEstablish": "部署领域",
|
||||
"realmEditNotify": "你正在修改一个现有领域……",
|
||||
"realmAliasLabel": "领域别名",
|
||||
"realmNameLabel": "领域名称",
|
||||
"realmDescriptionLabel": "领域简介",
|
||||
"realmPublicLabel": "公共领域",
|
||||
"realmCommunityLabel": "社区领域(任何人均可加入)",
|
||||
"realmMember": "成员",
|
||||
"realmManage": "领域管理",
|
||||
"chatNew": "新聊天",
|
||||
"chatDetail": "聊天详情",
|
||||
"chatMember": "成员",
|
||||
"chatNotifySetting": "通知设定",
|
||||
"chatChannelUnavailable": "频道不可用",
|
||||
"chatChannelUnavailableCaptionWithRealm": "你没加入该频道,但是看起来你能加入本频道,你想加入吗?",
|
||||
"chatChannelUnavailableCaption": "你没加入该频道,所以你无法读取本频道的信息。",
|
||||
"chatNewCreate": "新建频道",
|
||||
"chatNewJoin": "加入已有频道",
|
||||
"chatChannelUsage": "频道",
|
||||
@@ -87,6 +114,7 @@
|
||||
"chatChannelAliasLabel": "频道别名",
|
||||
"chatChannelNameLabel": "频道名称",
|
||||
"chatChannelDescriptionLabel": "频道简介",
|
||||
"chatChannelEncryptedLabel": "加密频道",
|
||||
"chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。",
|
||||
"chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!",
|
||||
"chatCall": "通话",
|
||||
@@ -104,7 +132,13 @@
|
||||
"chatCallDisconnect": "断开连接",
|
||||
"chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。",
|
||||
"chatMessagePlaceholder": "发条消息……",
|
||||
"chatMessageEncryptedPlaceholder": "发条加密信息……",
|
||||
"chatMessageSending": "正在送出你的信息……",
|
||||
"chatMessageEditNotify": "你正在编辑信息中……",
|
||||
"chatMessageReplyNotify": "你正在回复消息中……",
|
||||
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!"
|
||||
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!",
|
||||
"chatMessageViewSource": "查看原始信息",
|
||||
"chatMessageUnableDecryptWaiting": "正在等待解密密钥……",
|
||||
"chatMessageUnableDecryptUnsupported": "无法解密信息,不支持加密的算法",
|
||||
"chatMessageUnableDecryptMissing": "无法解密信息,缺失解密密钥"
|
||||
}
|
||||
|
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/providers/friend.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:solian/providers/navigation.dart';
|
||||
import 'package:solian/providers/notify.dart';
|
||||
import 'package:solian/providers/realm.dart';
|
||||
@@ -12,7 +13,7 @@ import 'package:solian/utils/timeago.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/utils/video_player.dart';
|
||||
import 'package:solian/widgets/chat/call/call_overlay.dart';
|
||||
import 'package:solian/widgets/notification_notifier.dart';
|
||||
import 'package:solian/widgets/provider_init.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -45,13 +46,16 @@ class SolianApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(create: (_) => NotifyProvider()),
|
||||
ChangeNotifierProvider(create: (_) => FriendProvider()),
|
||||
ChangeNotifierProvider(create: (_) => RealmProvider()),
|
||||
ChangeNotifierProvider(create: (_) => KeypairProvider()),
|
||||
],
|
||||
child: Overlay(
|
||||
initialEntries: [
|
||||
OverlayEntry(builder: (context) {
|
||||
return ScaffoldMessenger(
|
||||
child: Scaffold(
|
||||
body: NotificationNotifier(child: child ?? Container()),
|
||||
body: ProviderInitializer(
|
||||
child: child ?? Container(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
@@ -8,12 +8,13 @@ class Channel {
|
||||
String alias;
|
||||
String name;
|
||||
String description;
|
||||
dynamic members;
|
||||
dynamic calls;
|
||||
int type;
|
||||
Account account;
|
||||
int accountId;
|
||||
int? realmId;
|
||||
bool isEncrypted;
|
||||
|
||||
bool isAvailable = false;
|
||||
|
||||
Channel({
|
||||
required this.id,
|
||||
@@ -23,11 +24,10 @@ class Channel {
|
||||
required this.alias,
|
||||
required this.name,
|
||||
required this.description,
|
||||
this.members,
|
||||
this.calls,
|
||||
required this.type,
|
||||
required this.account,
|
||||
required this.accountId,
|
||||
required this.isEncrypted,
|
||||
this.realmId,
|
||||
});
|
||||
|
||||
@@ -39,12 +39,11 @@ class Channel {
|
||||
alias: json['alias'],
|
||||
name: json['name'],
|
||||
description: json['description'],
|
||||
members: json['members'],
|
||||
calls: json['calls'],
|
||||
type: json['type'],
|
||||
account: Account.fromJson(json['account']),
|
||||
accountId: json['account_id'],
|
||||
realmId: json['realm_id'],
|
||||
isEncrypted: json['is_encrypted'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
@@ -55,12 +54,11 @@ class Channel {
|
||||
'alias': alias,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'members': members,
|
||||
'calls': calls,
|
||||
'type': type,
|
||||
'account': account,
|
||||
'account_id': accountId,
|
||||
'realm_id': realmId,
|
||||
'is_encrypted': isEncrypted,
|
||||
};
|
||||
}
|
||||
|
||||
|
32
lib/models/keypair.dart
Normal file
32
lib/models/keypair.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
class Keypair {
|
||||
final String id;
|
||||
final String algorithm;
|
||||
final String publicKey;
|
||||
final String? privateKey;
|
||||
|
||||
final bool isOwned;
|
||||
|
||||
Keypair({
|
||||
required this.id,
|
||||
required this.algorithm,
|
||||
required this.publicKey,
|
||||
required this.privateKey,
|
||||
this.isOwned = false,
|
||||
});
|
||||
|
||||
factory Keypair.fromJson(Map<String, dynamic> json) => Keypair(
|
||||
id: json['id'],
|
||||
algorithm: json['algorithm'],
|
||||
publicKey: json['public_key'],
|
||||
privateKey: json['private_key'],
|
||||
isOwned: json['is_owned'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'algorithm': algorithm,
|
||||
'public_key': publicKey,
|
||||
'private_key': privateKey,
|
||||
'is_owned': isOwned,
|
||||
};
|
||||
}
|
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
@@ -7,9 +9,9 @@ class Message {
|
||||
DateTime createdAt;
|
||||
DateTime updatedAt;
|
||||
DateTime? deletedAt;
|
||||
String content;
|
||||
dynamic metadata;
|
||||
int type;
|
||||
String rawContent;
|
||||
Map<String, dynamic>? metadata;
|
||||
String type;
|
||||
List<Attachment>? attachments;
|
||||
Channel? channel;
|
||||
Sender sender;
|
||||
@@ -18,12 +20,18 @@ class Message {
|
||||
int channelId;
|
||||
int senderId;
|
||||
|
||||
bool isSending = false;
|
||||
|
||||
Map<String, dynamic> get decodedContent {
|
||||
return jsonDecode(utf8.fuse(base64).decode(rawContent));
|
||||
}
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.deletedAt,
|
||||
required this.content,
|
||||
required this.rawContent,
|
||||
required this.metadata,
|
||||
required this.type,
|
||||
this.attachments,
|
||||
@@ -40,18 +48,14 @@ class Message {
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
updatedAt: DateTime.parse(json['updated_at']),
|
||||
deletedAt: json['deleted_at'],
|
||||
content: json['content'],
|
||||
rawContent: json['content'],
|
||||
metadata: json['metadata'],
|
||||
type: json['type'],
|
||||
attachments: List<Attachment>.from(
|
||||
json['attachments']?.map((x) => Attachment.fromJson(x)) ??
|
||||
List.empty()),
|
||||
attachments: List<Attachment>.from(json['attachments']?.map((x) => Attachment.fromJson(x)) ?? List.empty()),
|
||||
channel: Channel.fromJson(json['channel']),
|
||||
sender: Sender.fromJson(json['sender']),
|
||||
replyId: json['reply_id'],
|
||||
replyTo: json['reply_to'] != null
|
||||
? Message.fromJson(json['reply_to'])
|
||||
: null,
|
||||
replyTo: json['reply_to'] != null ? Message.fromJson(json['reply_to']) : null,
|
||||
channelId: json['channel_id'],
|
||||
senderId: json['sender_id'],
|
||||
);
|
||||
@@ -61,11 +65,10 @@ class Message {
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
'deleted_at': deletedAt,
|
||||
'content': content,
|
||||
'content': rawContent,
|
||||
'metadata': metadata,
|
||||
'type': type,
|
||||
'attachments': List<dynamic>.from(
|
||||
attachments?.map((x) => x.toJson()) ?? List.empty()),
|
||||
'attachments': List<dynamic>.from(attachments?.map((x) => x.toJson()) ?? List.empty()),
|
||||
'channel': channel?.toJson(),
|
||||
'sender': sender.toJson(),
|
||||
'reply_id': replyId,
|
||||
|
@@ -28,9 +28,9 @@ class Notification {
|
||||
});
|
||||
|
||||
factory Notification.fromJson(Map<String, dynamic> json) => Notification(
|
||||
id: json['id'],
|
||||
createdAt: DateTime.parse(json['created_at']),
|
||||
updatedAt: DateTime.parse(json['updated_at']),
|
||||
id: json['id'] ?? 0,
|
||||
createdAt: json['created_at'] == null ? DateTime.now() : DateTime.parse(json['created_at']),
|
||||
updatedAt: json['updated_at'] == null ? DateTime.now() : DateTime.parse(json['updated_at']),
|
||||
deletedAt: json['deleted_at'],
|
||||
subject: json['subject'],
|
||||
content: json['content'],
|
||||
|
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:oauth2/oauth2.dart' as oauth2;
|
||||
import 'package:solian/utils/http.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
|
||||
class AuthProvider extends ChangeNotifier {
|
||||
AuthProvider() {
|
||||
|
@@ -3,56 +3,173 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:permission_handler/permission_handler.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';
|
||||
import 'package:solian/models/pagination.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/chat/call/exts.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/status.dart' as status;
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class ChatProvider extends ChangeNotifier {
|
||||
bool isOpened = false;
|
||||
bool isCallShown = false;
|
||||
|
||||
Call? ongoingCall;
|
||||
Channel? focusChannel;
|
||||
String? focusChannelRealm;
|
||||
ChatCallInstance? currentCall;
|
||||
|
||||
Future<WebSocketChannel?> connect(AuthProvider auth) async {
|
||||
PagingController<int, Message>? historyPagingController;
|
||||
|
||||
IOWebSocketChannel? _channel;
|
||||
|
||||
Future<IOWebSocketChannel?> connect(
|
||||
AuthProvider auth, {
|
||||
Function(bool status)? onStateUpdated,
|
||||
noRetry = false,
|
||||
}) async {
|
||||
if (auth.client == null) await auth.loadClient();
|
||||
if (!await auth.isAuthorized()) return null;
|
||||
|
||||
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
|
||||
if (_channel != null && (_channel!.innerWebSocket?.readyState ?? 0) < 2) {
|
||||
return _channel;
|
||||
}
|
||||
|
||||
var ori = getRequestUri('messaging', '/api/ws');
|
||||
var uri = Uri(
|
||||
scheme: ori.scheme.replaceFirst('http', 'ws'),
|
||||
host: ori.host,
|
||||
port: ori.port,
|
||||
path: ori.path,
|
||||
queryParameters: {'tk': Uri.encodeComponent(auth.client!.currentToken!)},
|
||||
);
|
||||
|
||||
final channel = WebSocketChannel.connect(uri);
|
||||
await channel.ready;
|
||||
try {
|
||||
_channel = IOWebSocketChannel.connect(uri);
|
||||
if (onStateUpdated != null) onStateUpdated(true);
|
||||
await _channel!.ready;
|
||||
if (onStateUpdated != null) onStateUpdated(false);
|
||||
} catch (e) {
|
||||
if (!noRetry) {
|
||||
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
|
||||
connect(auth, noRetry: true);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return channel;
|
||||
_channel!.stream.listen(
|
||||
(event) {
|
||||
final result = NetworkPackage.fromJson(jsonDecode(event));
|
||||
if (focusChannel == null || historyPagingController == null) return;
|
||||
switch (result.method) {
|
||||
case 'messages.new':
|
||||
final payload = Message.fromJson(result.payload!);
|
||||
if (payload.channelId == focusChannel?.id) {
|
||||
historyPagingController?.itemList?.insert(0, payload);
|
||||
}
|
||||
break;
|
||||
case 'messages.update':
|
||||
final payload = Message.fromJson(result.payload!);
|
||||
if (payload.channelId == focusChannel?.id) {
|
||||
historyPagingController?.itemList =
|
||||
historyPagingController?.itemList?.map((x) => x.id == payload.id ? payload : x).toList();
|
||||
}
|
||||
break;
|
||||
case 'messages.burnt':
|
||||
final payload = Message.fromJson(result.payload!);
|
||||
if (payload.channelId == focusChannel?.id) {
|
||||
historyPagingController?.itemList =
|
||||
historyPagingController?.itemList?.where((x) => x.id != payload.id).toList();
|
||||
}
|
||||
break;
|
||||
case 'calls.new':
|
||||
final payload = Call.fromJson(result.payload!);
|
||||
if (payload.channelId == focusChannel?.id) {
|
||||
setOngoingCall(payload);
|
||||
}
|
||||
break;
|
||||
case 'calls.end':
|
||||
final payload = Call.fromJson(result.payload!);
|
||||
if (payload.channelId == focusChannel?.id) {
|
||||
setOngoingCall(null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (_, __) => Future.delayed(const Duration(seconds: 3), () => connect(auth)),
|
||||
onDone: () => Future.delayed(const Duration(seconds: 1), () => connect(auth)),
|
||||
);
|
||||
|
||||
return _channel!;
|
||||
}
|
||||
|
||||
Future<Channel> fetchChannel(String alias, String realm) async {
|
||||
final Client client = Client();
|
||||
void disconnect() {
|
||||
_channel?.sink.close(status.goingAway);
|
||||
}
|
||||
|
||||
var uri = getRequestUri('messaging', '/api/channels/$realm/$alias');
|
||||
var res = await client.get(uri);
|
||||
Future<void> fetchMessages(int pageKey, BuildContext context) async {
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) return;
|
||||
if (focusChannel == null || focusChannelRealm == null) return;
|
||||
|
||||
final offset = pageKey;
|
||||
const take = 10;
|
||||
|
||||
var uri = getRequestUri(
|
||||
'messaging',
|
||||
'/api/channels/$focusChannelRealm/${focusChannel!.alias}/messages?take=$take&offset=$offset',
|
||||
);
|
||||
|
||||
var res = await auth.client!.get(uri);
|
||||
if (res.statusCode == 200) {
|
||||
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
|
||||
final items = result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty();
|
||||
final isLastPage = (result.count - pageKey) < take;
|
||||
if (isLastPage || result.data == null) {
|
||||
historyPagingController!.appendLastPage(items);
|
||||
} else {
|
||||
final nextPageKey = pageKey + items.length;
|
||||
historyPagingController!.appendPage(items, nextPageKey);
|
||||
}
|
||||
} else if (res.statusCode == 403) {
|
||||
historyPagingController!.appendLastPage([]);
|
||||
} else {
|
||||
historyPagingController!.error = utf8.decode(res.bodyBytes);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Channel> fetchChannel(BuildContext context, AuthProvider auth, String alias, String realm) async {
|
||||
if (focusChannel != null) {
|
||||
unFocus();
|
||||
}
|
||||
|
||||
var uri = getRequestUri('messaging', '/api/channels/$realm/$alias/availability');
|
||||
var res = await auth.client!.get(uri);
|
||||
if (res.statusCode == 200 || res.statusCode == 403) {
|
||||
final result = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
focusChannel = Channel.fromJson(result);
|
||||
focusChannel?.isAvailable = res.statusCode == 200;
|
||||
focusChannelRealm = realm;
|
||||
|
||||
if (historyPagingController == null) {
|
||||
historyPagingController = PagingController(firstPageKey: 0);
|
||||
historyPagingController?.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
|
||||
return focusChannel!;
|
||||
} else {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
@@ -111,6 +228,8 @@ class ChatProvider extends ChangeNotifier {
|
||||
void unFocus() {
|
||||
currentCall = null;
|
||||
focusChannel = null;
|
||||
historyPagingController?.dispose();
|
||||
historyPagingController = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/models/friendship.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
|
||||
class FriendProvider extends ChangeNotifier {
|
||||
List<Friendship> friends = List.empty();
|
||||
|
151
lib/providers/keypair.dart
Normal file
151
lib/providers/keypair.dart
Normal file
@@ -0,0 +1,151 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:encrypt/encrypt.dart' as encrypt;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:solian/models/keypair.dart';
|
||||
import 'package:solian/models/packet.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
class KeypairProvider extends ChangeNotifier {
|
||||
static const storage = FlutterSecureStorage();
|
||||
static const encryptIV = 'WT7s~><Ae?YrJd)D';
|
||||
|
||||
WebSocketChannel? channel;
|
||||
|
||||
String? activeKeyId;
|
||||
Map<String, Keypair> keys = {};
|
||||
List<String> requestingKeys = List.empty(growable: true);
|
||||
|
||||
KeypairProvider() {
|
||||
loadKeys();
|
||||
}
|
||||
|
||||
void loadKeys() async {
|
||||
final result = await storage.read(key: 'keypair');
|
||||
if (result != null) {
|
||||
jsonDecode(result).values.forEach((x) {
|
||||
keys[x['id']] = Keypair.fromJson(x);
|
||||
});
|
||||
activeKeyId = await storage.read(key: 'keypairActive');
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void saveKeys() async {
|
||||
await storage.write(key: 'keypair', value: jsonEncode(keys));
|
||||
if (activeKeyId != null) {
|
||||
await storage.write(key: 'keypairActive', value: activeKeyId);
|
||||
}
|
||||
}
|
||||
|
||||
void receiveKeypair(Keypair kp) {
|
||||
print('received ${kp.id}');
|
||||
keys[kp.id] = kp;
|
||||
requestingKeys.remove(kp.id);
|
||||
notifyListeners();
|
||||
saveKeys();
|
||||
}
|
||||
|
||||
Keypair? provideKeypair(String id) {
|
||||
return keys[id];
|
||||
}
|
||||
|
||||
void importKeys(String code) {
|
||||
final result = jsonDecode(utf8.fuse(base64).decode(code)).map((x) => Keypair.fromJson(x)).toList();
|
||||
for (final item in result) {
|
||||
if (item is Keypair) {
|
||||
keys[item.id] = item;
|
||||
}
|
||||
}
|
||||
saveKeys();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setActiveKey(String id) {
|
||||
if (keys[id] == null) return;
|
||||
activeKeyId = id;
|
||||
saveKeys();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearKeys() {
|
||||
keys = {};
|
||||
storage.delete(key: 'keypairActive');
|
||||
saveKeys();
|
||||
}
|
||||
|
||||
void requestKey(String id, String algorithm, int uid) {
|
||||
if (channel == null) return;
|
||||
if (requestingKeys.contains(id)) return;
|
||||
|
||||
print('requested $id');
|
||||
|
||||
channel!.sink.add(jsonEncode(
|
||||
NetworkPackage(method: 'kex.request', payload: {
|
||||
'request_id': const Uuid().v4(),
|
||||
'keypair_id': id,
|
||||
'algorithm': algorithm,
|
||||
'owner_id': uid,
|
||||
'deadline': 3,
|
||||
}).toJson(),
|
||||
));
|
||||
|
||||
requestingKeys.add(id);
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
requestingKeys.remove(id);
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String? encodeViaAESKey(String keypairId, String content) {
|
||||
if (keys[keypairId] == null) {
|
||||
return null;
|
||||
} else if (keys[keypairId]?.algorithm != 'aes') {
|
||||
throw Exception('invalid algorithm');
|
||||
}
|
||||
|
||||
final kp = keys[keypairId]!;
|
||||
final iv = encrypt.IV.fromUtf8(encryptIV);
|
||||
final key = encrypt.Key.fromBase64(kp.publicKey);
|
||||
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
|
||||
return encryptor.encryptBytes(utf8.encode(content), iv: iv).base64;
|
||||
}
|
||||
|
||||
String? decodeViaAESKey(String keypairId, String encrypted) {
|
||||
if (keys[keypairId] == null) {
|
||||
return null;
|
||||
} else if (keys[keypairId]?.algorithm != 'aes') {
|
||||
throw Exception('invalid algorithm');
|
||||
}
|
||||
|
||||
final kp = keys[keypairId]!;
|
||||
final iv = encrypt.IV.fromUtf8(encryptIV);
|
||||
final key = encrypt.Key.fromBase64(kp.publicKey);
|
||||
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
|
||||
return utf8.decode(encryptor.decryptBytes(encrypt.Encrypted.fromBase64(encrypted), iv: iv));
|
||||
}
|
||||
|
||||
Keypair generateAESKey() {
|
||||
final random = Random.secure();
|
||||
final values = List<int>.generate(32, (i) => random.nextInt(256));
|
||||
final key = Uint8List.fromList(values);
|
||||
|
||||
final kp = Keypair(
|
||||
id: const Uuid().v4(),
|
||||
algorithm: 'aes',
|
||||
publicKey: base64.encode(key),
|
||||
privateKey: null,
|
||||
isOwned: true,
|
||||
);
|
||||
|
||||
keys[kp.id] = kp;
|
||||
|
||||
return kp;
|
||||
}
|
||||
}
|
@@ -4,21 +4,22 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:solian/models/keypair.dart';
|
||||
import 'package:solian/models/packet.dart';
|
||||
import 'package:solian/models/pagination.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/models/notification.dart' as model;
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/status.dart' as status;
|
||||
import 'dart:math' as math;
|
||||
|
||||
class NotifyProvider extends ChangeNotifier {
|
||||
bool isOpened = false;
|
||||
int unreadAmount = 0;
|
||||
|
||||
List<model.Notification> notifications = List.empty(growable: true);
|
||||
|
||||
final FlutterLocalNotificationsPlugin localNotify =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
final FlutterLocalNotificationsPlugin localNotify = FlutterLocalNotificationsPlugin();
|
||||
|
||||
NotifyProvider() {
|
||||
initNotify();
|
||||
@@ -32,10 +33,8 @@ class NotifyProvider extends ChangeNotifier {
|
||||
DarwinNotificationCategory('general'),
|
||||
],
|
||||
);
|
||||
const linuxSettings =
|
||||
LinuxInitializationSettings(defaultActionName: 'Open notification');
|
||||
const InitializationSettings initializationSettings =
|
||||
InitializationSettings(
|
||||
const linuxSettings = LinuxInitializationSettings(defaultActionName: 'Open notification');
|
||||
const InitializationSettings initializationSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: darwinSettings,
|
||||
macOS: darwinSettings,
|
||||
@@ -58,42 +57,96 @@ class NotifyProvider extends ChangeNotifier {
|
||||
var uri = getRequestUri('passport', '/api/notifications?skip=0&take=25');
|
||||
var res = await auth.client!.get(uri);
|
||||
if (res.statusCode == 200) {
|
||||
final result =
|
||||
PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
|
||||
notifications =
|
||||
result.data?.map((x) => model.Notification.fromJson(x)).toList() ??
|
||||
List.empty(growable: true);
|
||||
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
|
||||
notifications = result.data?.map((x) => model.Notification.fromJson(x)).toList() ?? List.empty(growable: true);
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<WebSocketChannel?> connect(AuthProvider auth) async {
|
||||
IOWebSocketChannel? _channel;
|
||||
|
||||
Future<IOWebSocketChannel?> connect(
|
||||
AuthProvider auth, {
|
||||
Keypair? Function(String id)? onKexRequest,
|
||||
Function(Keypair kp)? onKexProvide,
|
||||
Function(bool status)? onStateUpdated,
|
||||
bool noRetry = false,
|
||||
}) async {
|
||||
if (auth.client == null) await auth.loadClient();
|
||||
if (!await auth.isAuthorized()) return null;
|
||||
|
||||
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
|
||||
|
||||
var ori = getRequestUri('passport', '/api/notifications/listen');
|
||||
if (_channel != null && (_channel!.innerWebSocket?.readyState ?? 0) < 2) {
|
||||
return _channel;
|
||||
}
|
||||
var ori = getRequestUri('passport', '/api/ws');
|
||||
var uri = Uri(
|
||||
scheme: ori.scheme.replaceFirst('http', 'ws'),
|
||||
host: ori.host,
|
||||
port: ori.port,
|
||||
path: ori.path,
|
||||
queryParameters: {
|
||||
'tk': Uri.encodeComponent(auth.client!.currentToken!)
|
||||
},
|
||||
queryParameters: {'tk': Uri.encodeComponent(auth.client!.currentToken!)},
|
||||
);
|
||||
|
||||
final channel = WebSocketChannel.connect(uri);
|
||||
await channel.ready;
|
||||
try {
|
||||
_channel = IOWebSocketChannel.connect(uri);
|
||||
if (onStateUpdated != null) onStateUpdated(true);
|
||||
await _channel!.ready;
|
||||
if (onStateUpdated != null) onStateUpdated(false);
|
||||
} catch (e) {
|
||||
if (!noRetry) {
|
||||
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
|
||||
connect(auth, noRetry: true);
|
||||
} else {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
return channel;
|
||||
_channel!.stream.listen(
|
||||
(event) {
|
||||
final result = NetworkPackage.fromJson(jsonDecode(event));
|
||||
switch (result.method) {
|
||||
case 'notifications.new':
|
||||
if (result.payload == null) break;
|
||||
final notification = model.Notification.fromJson(result.payload!);
|
||||
unreadAmount++;
|
||||
notifications.add(notification);
|
||||
notifyListeners();
|
||||
notifyMessage(notification.subject, notification.content);
|
||||
break;
|
||||
case 'kex.request':
|
||||
if (onKexRequest == null || result.payload == null) break;
|
||||
final resp = onKexRequest(result.payload!['keypair_id']);
|
||||
if (resp == null) break;
|
||||
_channel!.sink.add(jsonEncode(
|
||||
NetworkPackage(method: 'kex.provide', payload: {
|
||||
'request_id': result.payload!['request_id'],
|
||||
'keypair_id': resp.id,
|
||||
'public_key': resp.publicKey,
|
||||
'algorithm': resp.algorithm,
|
||||
}).toJson(),
|
||||
));
|
||||
break;
|
||||
case 'kex.provide':
|
||||
if (onKexProvide == null || result.payload == null) break;
|
||||
onKexProvide(Keypair(
|
||||
id: result.payload!['keypair_id'],
|
||||
algorithm: result.payload?['algorithm'] ?? 'aes',
|
||||
publicKey: result.payload!['public_key'],
|
||||
privateKey: result.payload?['private_key'],
|
||||
));
|
||||
break;
|
||||
}
|
||||
},
|
||||
onError: (_, __) => Future.delayed(const Duration(seconds: 3), () => connect(auth)),
|
||||
onDone: () => Future.delayed(const Duration(seconds: 1), () => connect(auth)),
|
||||
);
|
||||
|
||||
return _channel!;
|
||||
}
|
||||
|
||||
void onRemoteMessage(model.Notification item) {
|
||||
unreadAmount++;
|
||||
notifications.add(item);
|
||||
notifyListeners();
|
||||
void disconnect() {
|
||||
_channel?.sink.close(status.goingAway);
|
||||
}
|
||||
|
||||
void notifyMessage(String title, String body) {
|
||||
@@ -130,7 +183,7 @@ class NotifyProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearRealtime() {
|
||||
void clearRealtimeNotifications() {
|
||||
notifications = notifications.where((x) => !x.isRealtime).toList();
|
||||
notifyListeners();
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
|
||||
class RealmProvider with ChangeNotifier {
|
||||
List<Realm> realms = List.empty();
|
||||
|
@@ -6,6 +6,7 @@ import 'package:solian/models/post.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/screens/account.dart';
|
||||
import 'package:solian/screens/account/friend.dart';
|
||||
import 'package:solian/screens/account/keypair.dart';
|
||||
import 'package:solian/screens/account/personalize.dart';
|
||||
import 'package:solian/screens/auth/signup.dart';
|
||||
import 'package:solian/screens/chat/call.dart';
|
||||
@@ -21,8 +22,10 @@ import 'package:solian/screens/posts/moment_editor.dart';
|
||||
import 'package:solian/screens/posts/screen.dart';
|
||||
import 'package:solian/screens/auth/signin.dart';
|
||||
import 'package:solian/screens/realms/realm.dart';
|
||||
import 'package:solian/screens/realms/realm_manage.dart';
|
||||
import 'package:solian/screens/realms/realm_editor.dart';
|
||||
import 'package:solian/screens/realms/realm_list.dart';
|
||||
import 'package:solian/screens/realms/realm_member.dart';
|
||||
import 'package:solian/screens/users/userinfo.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/empty.dart';
|
||||
@@ -103,6 +106,24 @@ abstract class SolianRouter {
|
||||
realm: state.uri.queryParameters['realm'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/realms/:realm/manage',
|
||||
name: 'realms.manage',
|
||||
builder: (context, state) => RealmManageScreen(realm: state.extra as Realm),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/realms/:realm/member',
|
||||
name: 'realms.member',
|
||||
builder: (context, state) => RealmMemberScreen(realm: state.extra as Realm),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/realms/:realm/posts/publish/moments',
|
||||
name: 'realms.posts.moments.editor',
|
||||
builder: (context, state) => MomentEditorScreen(
|
||||
editing: state.extra as Post?,
|
||||
realm: state.pathParameters['realm']
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/realms/:realm/posts/:dataset/:alias',
|
||||
name: 'realms.posts.details',
|
||||
@@ -167,7 +188,7 @@ abstract class SolianRouter {
|
||||
name: 'chat.channel.editor',
|
||||
builder: (context, state) => ChannelEditorScreen(
|
||||
editing: state.extra as Channel?,
|
||||
realm: state.uri.queryParameters['realm'],
|
||||
realm: state.uri.queryParameters['realm'] ?? 'global',
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
@@ -230,6 +251,11 @@ abstract class SolianRouter {
|
||||
name: 'account.personalize',
|
||||
builder: (context, state) => const PersonalizeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/account/keypair',
|
||||
name: 'account.keypair',
|
||||
builder: (context, state) => const KeypairScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
|
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:solian/providers/notify.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
@@ -14,9 +17,8 @@ class AccountScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.account,
|
||||
noSafeArea: true,
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
child: AccountScreenWidget(
|
||||
body: AccountScreenWidget(
|
||||
onSelect: (item) {
|
||||
SolianRouter.router.pushNamed(item);
|
||||
},
|
||||
@@ -50,6 +52,13 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.watch<AuthProvider>();
|
||||
final keypair = context.read<KeypairProvider>();
|
||||
|
||||
final actionItems = [
|
||||
(const Icon(Icons.color_lens), AppLocalizations.of(context)!.personalize, 'account.personalize'),
|
||||
(const Icon(Icons.diversity_1), AppLocalizations.of(context)!.friend, 'account.friend'),
|
||||
(const Icon(Icons.key), AppLocalizations.of(context)!.keypair, 'account.keypair'),
|
||||
];
|
||||
|
||||
if (_isAuthorized) {
|
||||
return Column(
|
||||
@@ -58,28 +67,25 @@ class _AccountScreenWidgetState extends State<AccountScreenWidget> {
|
||||
padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
|
||||
child: NameCard(),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: const Icon(Icons.color_lens),
|
||||
title: Text(AppLocalizations.of(context)!.personalize),
|
||||
onTap: () {
|
||||
widget.onSelect('account.personalize');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: const Icon(Icons.diversity_1),
|
||||
title: Text(AppLocalizations.of(context)!.friend),
|
||||
onTap: () {
|
||||
widget.onSelect('account.friend');
|
||||
},
|
||||
),
|
||||
...(actionItems.map(
|
||||
(x) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: x.$1,
|
||||
title: Text(x.$2),
|
||||
onTap: () {
|
||||
widget.onSelect(x.$3);
|
||||
},
|
||||
),
|
||||
)),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
|
||||
leading: const Icon(Icons.logout),
|
||||
title: Text(AppLocalizations.of(context)!.signOut),
|
||||
onTap: () {
|
||||
auth.signoff();
|
||||
keypair.clearKeys();
|
||||
context.read<NotifyProvider>().disconnect();
|
||||
context.read<ChatProvider>().disconnect();
|
||||
setState(() {
|
||||
_isAuthorized = false;
|
||||
});
|
||||
|
@@ -5,7 +5,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/friendship.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
@@ -18,9 +18,8 @@ class FriendScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.friend,
|
||||
noSafeArea: true,
|
||||
hideDrawer: true,
|
||||
child: const FriendScreenWidget(),
|
||||
body: const FriendScreenWidget(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
191
lib/screens/account/keypair.dart
Normal file
191
lib/screens/account/keypair.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/keypair.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class KeypairScreen extends StatelessWidget {
|
||||
const KeypairScreen({super.key});
|
||||
|
||||
Widget getIcon(KeypairProvider provider, Keypair item) {
|
||||
if (item.id == provider.activeKeyId) {
|
||||
return const Icon(Icons.check_box);
|
||||
} else if (item.isOwned) {
|
||||
return const Icon(Icons.check_box_outlined);
|
||||
} else {
|
||||
return const Icon(Icons.key);
|
||||
}
|
||||
}
|
||||
|
||||
void importKeys(BuildContext context) async {
|
||||
final controller = TextEditingController();
|
||||
final input = await showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.import),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.keypairImportHint),
|
||||
const SizedBox(height: 18),
|
||||
TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: AppLocalizations.of(context)!.keypairSecretCode,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(AppLocalizations.of(context)!.next),
|
||||
onPressed: () {
|
||||
Navigator.pop(context, controller.text);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
|
||||
|
||||
if (input == null || input.isEmpty) return;
|
||||
|
||||
context.read<KeypairProvider>().importKeys(input);
|
||||
}
|
||||
|
||||
void exportKeys(BuildContext context) {
|
||||
showModalBottomSheet(context: context, builder: (context) => const KeypairExportWidget());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final keypair = context.watch<KeypairProvider>();
|
||||
final keys = keypair.keys.values.toList();
|
||||
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.keypair,
|
||||
hideDrawer: true,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.generating_tokens),
|
||||
onPressed: () {
|
||||
final result = keypair.generateAESKey();
|
||||
keypair.setActiveKey(result.id);
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.keypairGenerated),
|
||||
));
|
||||
},
|
||||
),
|
||||
appBarActions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.upload),
|
||||
tooltip: AppLocalizations.of(context)!.import,
|
||||
onPressed: () => importKeys(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download),
|
||||
tooltip: AppLocalizations.of(context)!.export,
|
||||
onPressed: () => exportKeys(context),
|
||||
),
|
||||
],
|
||||
body: ListView.builder(
|
||||
itemCount: keys.length,
|
||||
itemBuilder: (context, index) {
|
||||
final element = keys[index];
|
||||
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
|
||||
return Dismissible(
|
||||
key: Key(randomId.toString()),
|
||||
background: Container(
|
||||
color: Colors.teal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: const Icon(Icons.check, color: Colors.white),
|
||||
),
|
||||
direction: keypair.activeKeyId != element.id && element.isOwned
|
||||
? DismissDirection.horizontal
|
||||
: DismissDirection.none,
|
||||
child: ListTile(
|
||||
leading: getIcon(keypair, element),
|
||||
title: Text('${element.algorithm.toUpperCase()} Key'),
|
||||
subtitle: Text(element.id.toUpperCase()),
|
||||
),
|
||||
onDismissed: (_) {
|
||||
keypair.setActiveKey(element.id);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class KeypairExportWidget extends StatelessWidget {
|
||||
const KeypairExportWidget({super.key});
|
||||
|
||||
String getEncodedContent(BuildContext context) {
|
||||
final keypair = context.read<KeypairProvider>();
|
||||
return utf8.fuse(base64).encode(jsonEncode(
|
||||
keypair.keys.values.map((x) => x.toJson()).toList(),
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 640,
|
||||
child: ListView(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 20, top: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.export,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 20),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.keypairExportHint,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Markdown(
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: '```\n${getEncodedContent(context)}\n```',
|
||||
padding: const EdgeInsets.all(0),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
codeblockPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -8,7 +8,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
@@ -22,7 +22,7 @@ class PersonalizeScreen extends StatelessWidget {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.personalize,
|
||||
hideDrawer: true,
|
||||
child: const PersonalizeScreenWidget(),
|
||||
body: const PersonalizeScreenWidget(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -2,8 +2,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/providers/notify.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -21,6 +23,8 @@ class SignInScreen extends StatelessWidget {
|
||||
final password = _passwordController.value.text;
|
||||
if (username.isEmpty || password.isEmpty) return;
|
||||
auth.signin(context, username, password).then((_) {
|
||||
context.read<ChatProvider>().connect(auth);
|
||||
context.read<NotifyProvider>().connect(auth);
|
||||
SolianRouter.router.pop(true);
|
||||
}).catchError((e) {
|
||||
List<String> messages = e.toString().split('\n');
|
||||
@@ -64,7 +68,7 @@ class SignInScreen extends StatelessWidget {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.signIn,
|
||||
hideDrawer: true,
|
||||
child: Center(
|
||||
body: Center(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
|
@@ -3,7 +3,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -71,7 +71,7 @@ class SignUpScreen extends StatelessWidget {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.signUp,
|
||||
hideDrawer: true,
|
||||
child: Center(
|
||||
body: Center(
|
||||
child: Container(
|
||||
width: MediaQuery.of(context).size.width * 0.6,
|
||||
constraints: const BoxConstraints(maxWidth: 360),
|
||||
|
@@ -132,7 +132,7 @@ class _ChatCallState extends State<ChatCall> {
|
||||
title: AppLocalizations.of(context)!.chatCall,
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
hideDrawer: true,
|
||||
child: content,
|
||||
body: content,
|
||||
);
|
||||
}
|
||||
|
||||
|
@@ -7,7 +7,7 @@ import 'package:provider/provider.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';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
@@ -15,9 +15,9 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChannelEditorScreen extends StatefulWidget {
|
||||
final Channel? editing;
|
||||
final String? realm;
|
||||
final String realm;
|
||||
|
||||
const ChannelEditorScreen({super.key, this.editing, this.realm});
|
||||
const ChannelEditorScreen({super.key, this.editing, this.realm = 'global'});
|
||||
|
||||
@override
|
||||
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
|
||||
@@ -28,6 +28,8 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
|
||||
bool _isEncrypted = false;
|
||||
|
||||
bool _isSubmitting = false;
|
||||
|
||||
Future<void> applyChannel(BuildContext context) async {
|
||||
@@ -39,9 +41,10 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
final scope = widget.realm.isNotEmpty ? widget.realm : 'global';
|
||||
final uri = widget.editing == null
|
||||
? getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}')
|
||||
: getRequestUri('messaging', '/api/channels/${widget.realm ?? 'global'}/${widget.editing!.id}');
|
||||
? getRequestUri('messaging', '/api/channels/$scope')
|
||||
: getRequestUri('messaging', '/api/channels/$scope/${widget.editing!.id}');
|
||||
|
||||
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
|
||||
req.headers['Content-Type'] = 'application/json';
|
||||
@@ -49,6 +52,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
'alias': _aliasController.value.text.toLowerCase(),
|
||||
'name': _nameController.value.text,
|
||||
'description': _descriptionController.value.text,
|
||||
'is_encrypted': _isEncrypted,
|
||||
});
|
||||
|
||||
var res = await Response.fromStream(await auth.client!.send(req));
|
||||
@@ -57,7 +61,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
context.showErrorDialog(message);
|
||||
} else {
|
||||
if (SolianRouter.router.canPop()) {
|
||||
SolianRouter.router.pop(true);
|
||||
SolianRouter.router.pop(_aliasController.value.text.toLowerCase());
|
||||
}
|
||||
}
|
||||
setState(() => _isSubmitting = false);
|
||||
@@ -79,6 +83,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
_aliasController.text = widget.editing!.alias;
|
||||
_nameController.text = widget.editing!.name;
|
||||
_descriptionController.text = widget.editing!.description;
|
||||
_isEncrypted = widget.editing!.isEncrypted;
|
||||
}
|
||||
|
||||
super.initState();
|
||||
@@ -102,6 +107,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
|
||||
return IndentScaffold(
|
||||
hideDrawer: true,
|
||||
showSafeArea: true,
|
||||
title: AppLocalizations.of(context)!.chatChannelOrganize,
|
||||
appBarActions: <Widget>[
|
||||
TextButton(
|
||||
@@ -109,7 +115,7 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
body: Column(
|
||||
children: [
|
||||
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
|
||||
widget.editing != null ? editingBanner : Container(),
|
||||
@@ -176,6 +182,15 @@ class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3),
|
||||
CheckboxListTile(
|
||||
title: Text(AppLocalizations.of(context)!.chatChannelEncryptedLabel),
|
||||
value: _isEncrypted,
|
||||
onChanged: (widget.editing?.isEncrypted ?? false) ? null : (newValue) {
|
||||
setState(() => _isEncrypted = newValue ?? false);
|
||||
},
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/account/friend_picker.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
@@ -141,7 +141,6 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.chatMember,
|
||||
noSafeArea: true,
|
||||
hideDrawer: true,
|
||||
appBarActions: [
|
||||
IconButton(
|
||||
@@ -149,7 +148,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
|
||||
onPressed: () => promptAddMember(),
|
||||
),
|
||||
],
|
||||
child: RefreshIndicator(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => fetchMemberships(),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
|
@@ -5,17 +5,16 @@ 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/message.dart';
|
||||
import 'package:solian/models/pagination.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/chat/channel_action.dart';
|
||||
import 'package:solian/widgets/chat/chat_maintainer.dart';
|
||||
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/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
@@ -27,11 +26,13 @@ class ChatScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final chat = context.watch<ChatProvider>();
|
||||
|
||||
return IndentScaffold(
|
||||
title: chat.focusChannel?.name ?? 'Loading...',
|
||||
hideDrawer: true,
|
||||
showSafeArea: true,
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
appBarActions: chat.focusChannel != null
|
||||
? [
|
||||
@@ -39,16 +40,16 @@ class ChatScreen extends StatelessWidget {
|
||||
call: chat.ongoingCall,
|
||||
channel: chat.focusChannel!,
|
||||
realm: realm,
|
||||
onUpdate: () => chat.fetchChannel(chat.focusChannel!.alias, realm),
|
||||
onUpdate: () => chat.fetchChannel(context, auth, chat.focusChannel!.alias, realm),
|
||||
),
|
||||
ChannelManageAction(
|
||||
channel: chat.focusChannel!,
|
||||
realm: realm,
|
||||
onUpdate: () => chat.fetchChannel(chat.focusChannel!.alias, realm),
|
||||
onUpdate: () => chat.fetchChannel(context, auth, chat.focusChannel!.alias, realm),
|
||||
),
|
||||
]
|
||||
: [],
|
||||
child: ChatWidget(
|
||||
body: ChatWidget(
|
||||
alias: alias,
|
||||
realm: realm,
|
||||
),
|
||||
@@ -69,35 +70,26 @@ class ChatWidget extends StatefulWidget {
|
||||
class _ChatWidgetState extends State<ChatWidget> {
|
||||
bool _isReady = false;
|
||||
|
||||
final PagingController<int, Message> _pagingController = PagingController(firstPageKey: 0);
|
||||
|
||||
late final ChatProvider _chat;
|
||||
|
||||
Future<void> fetchMessages(int pageKey, BuildContext context) async {
|
||||
Future<void> joinChannel() async {
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) return;
|
||||
|
||||
final offset = pageKey;
|
||||
const take = 10;
|
||||
|
||||
var uri = getRequestUri(
|
||||
'messaging',
|
||||
'/api/channels/${widget.realm}/${widget.alias}/messages?take=$take&offset=$offset',
|
||||
'/api/channels/${widget.realm}/${widget.alias}/members/me',
|
||||
);
|
||||
|
||||
var res = await auth.client!.get(uri);
|
||||
var res = await auth.client!.post(uri);
|
||||
if (res.statusCode == 200) {
|
||||
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
|
||||
final items = result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty();
|
||||
final isLastPage = (result.count - pageKey) < take;
|
||||
if (isLastPage || result.data == null) {
|
||||
_pagingController.appendLastPage(items);
|
||||
} else {
|
||||
final nextPageKey = pageKey + items.length;
|
||||
_pagingController.appendPage(items, nextPageKey);
|
||||
}
|
||||
setState(() {});
|
||||
_chat.historyPagingController?.refresh();
|
||||
} else {
|
||||
_pagingController.error = utf8.decode(res.bodyBytes);
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message).then((_) {
|
||||
SolianRouter.router.pop();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,25 +97,7 @@ class _ChatWidgetState extends State<ChatWidget> {
|
||||
if (a?.replyTo != null) return false;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.senderId != b.senderId) return false;
|
||||
return a.createdAt.difference(b.createdAt).inMinutes <= 5;
|
||||
}
|
||||
|
||||
void addMessage(Message item) {
|
||||
setState(() {
|
||||
_pagingController.itemList?.insert(0, item);
|
||||
});
|
||||
}
|
||||
|
||||
void updateMessage(Message item) {
|
||||
setState(() {
|
||||
_pagingController.itemList = _pagingController.itemList?.map((x) => x.id == item.id ? item : x).toList();
|
||||
});
|
||||
}
|
||||
|
||||
void deleteMessage(Message item) {
|
||||
setState(() {
|
||||
_pagingController.itemList = _pagingController.itemList?.where((x) => x.id != item.id).toList();
|
||||
});
|
||||
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
|
||||
}
|
||||
|
||||
Message? _editingItem;
|
||||
@@ -134,6 +108,7 @@ class _ChatWidgetState extends State<ChatWidget> {
|
||||
context: context,
|
||||
builder: (context) => ChatMessageAction(
|
||||
channel: widget.alias,
|
||||
realm: widget.realm,
|
||||
item: item,
|
||||
onEdit: () => setState(() {
|
||||
_editingItem = item;
|
||||
@@ -145,15 +120,56 @@ class _ChatWidgetState extends State<ChatWidget> {
|
||||
);
|
||||
}
|
||||
|
||||
void showUnavailableDialog() {
|
||||
final content = widget.realm == 'global'
|
||||
? AppLocalizations.of(context)!.chatChannelUnavailableCaption
|
||||
: AppLocalizations.of(context)!.chatChannelUnavailableCaptionWithRealm;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.chatChannelUnavailable),
|
||||
content: Text(content),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text(AppLocalizations.of(context)!.cancel),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
SolianRouter.router.pop();
|
||||
},
|
||||
),
|
||||
...(widget.realm != 'global'
|
||||
? [
|
||||
TextButton(
|
||||
child: Text(AppLocalizations.of(context)!.join),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
joinChannel();
|
||||
},
|
||||
),
|
||||
]
|
||||
: [])
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
|
||||
|
||||
super.initState();
|
||||
|
||||
Future.delayed(Duration.zero, () {
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final auth = context.read<AuthProvider>();
|
||||
|
||||
await _chat.connect(auth);
|
||||
|
||||
_chat.fetchOngoingCall(widget.alias, widget.realm);
|
||||
_chat.fetchChannel(widget.alias, widget.realm);
|
||||
_chat.fetchChannel(context, auth, widget.alias, widget.realm).then((result) {
|
||||
if (result.isAvailable == false) {
|
||||
showUnavailableDialog();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,10 +178,10 @@ class _ChatWidgetState extends State<ChatWidget> {
|
||||
Widget chatHistoryBuilder(context, item, index) {
|
||||
bool isMerged = false, hasMerged = false;
|
||||
if (index > 0) {
|
||||
hasMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item);
|
||||
hasMerged = getMessageMergeable(_chat.historyPagingController?.itemList?[index - 1], item);
|
||||
}
|
||||
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
|
||||
isMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]);
|
||||
if (index + 1 < (_chat.historyPagingController?.itemList?.length ?? 0)) {
|
||||
isMerged = getMessageMergeable(item, _chat.historyPagingController?.itemList?[index + 1]);
|
||||
}
|
||||
return InkWell(
|
||||
child: Container(
|
||||
@@ -214,48 +230,41 @@ class _ChatWidgetState extends State<ChatWidget> {
|
||||
],
|
||||
);
|
||||
|
||||
if (_chat.focusChannel == null) {
|
||||
if (_chat.focusChannel == null || _chat.historyPagingController == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return ChatMaintainer(
|
||||
channel: _chat.focusChannel!,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PagedListView<int, Message>(
|
||||
reverse: true,
|
||||
pagingController: _pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate<Message>(
|
||||
animateTransitions: true,
|
||||
transitionDuration: 350.ms,
|
||||
itemBuilder: chatHistoryBuilder,
|
||||
noItemsFoundIndicatorBuilder: (_) => Container(),
|
||||
),
|
||||
return Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PagedListView<int, Message>(
|
||||
reverse: true,
|
||||
pagingController: _chat.historyPagingController!,
|
||||
builderDelegate: PagedChildBuilderDelegate<Message>(
|
||||
animateTransitions: true,
|
||||
transitionDuration: 350.ms,
|
||||
itemBuilder: chatHistoryBuilder,
|
||||
noItemsFoundIndicatorBuilder: (_) => Container(),
|
||||
),
|
||||
),
|
||||
ChatMessageEditor(
|
||||
realm: widget.realm,
|
||||
channel: widget.alias,
|
||||
editing: _editingItem,
|
||||
replying: _replyingItem,
|
||||
onReset: () => setState(() {
|
||||
_editingItem = null;
|
||||
_replyingItem = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
_chat.ongoingCall != null ? callBanner.animate().slideY() : Container(),
|
||||
],
|
||||
),
|
||||
onInsertMessage: (message) => addMessage(message),
|
||||
onUpdateMessage: (message) => updateMessage(message),
|
||||
onDeleteMessage: (message) => deleteMessage(message),
|
||||
onCallStarted: (call) => _chat.setOngoingCall(call),
|
||||
onCallEnded: () => _chat.setOngoingCall(null),
|
||||
),
|
||||
ChatMessageEditor(
|
||||
realm: widget.realm,
|
||||
channel: widget.alias,
|
||||
editing: _editingItem,
|
||||
replying: _replyingItem,
|
||||
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
|
||||
onReset: () => setState(() {
|
||||
_editingItem = null;
|
||||
_replyingItem = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
_chat.ongoingCall != null ? callBanner.animate().slideY() : Container(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/chat/channel_deletion.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
@@ -23,11 +24,12 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
void promptLeaveChannel() async {
|
||||
final did = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => ChannelDeletion(
|
||||
channel: widget.channel,
|
||||
realm: widget.realm,
|
||||
isOwned: _isOwned,
|
||||
),
|
||||
builder: (context) =>
|
||||
ChannelDeletion(
|
||||
channel: widget.channel,
|
||||
realm: widget.realm,
|
||||
isOwned: _isOwned,
|
||||
),
|
||||
);
|
||||
if (did == true && SolianRouter.router.canPop()) {
|
||||
SolianRouter.router.pop('disposed');
|
||||
@@ -50,14 +52,23 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final chat = context.read<ChatProvider>();
|
||||
|
||||
final authorizedItems = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: Text(AppLocalizations.of(context)!.settings),
|
||||
onTap: () async {
|
||||
SolianRouter.router.pushNamed('chat.channel.editor', extra: widget.channel).then((did) {
|
||||
if (did == true) {
|
||||
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
|
||||
SolianRouter.router
|
||||
.pushNamed(
|
||||
'chat.channel.editor',
|
||||
extra: widget.channel,
|
||||
queryParameters: widget.realm != 'global' ? {'realm': widget.realm} : {},
|
||||
)
|
||||
.then((resp) {
|
||||
if (resp != null) {
|
||||
chat.fetchChannel(context, auth, resp as String, widget.realm);
|
||||
}
|
||||
});
|
||||
},
|
||||
@@ -67,8 +78,7 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.chatDetail,
|
||||
hideDrawer: true,
|
||||
noSafeArea: true,
|
||||
child: Column(
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
@@ -82,8 +92,14 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(widget.channel.name, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(widget.channel.description, style: Theme.of(context).textTheme.bodySmall),
|
||||
Text(widget.channel.name, style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodyLarge),
|
||||
Text(widget.channel.description, style: Theme
|
||||
.of(context)
|
||||
.textTheme
|
||||
.bodySmall),
|
||||
]),
|
||||
)
|
||||
],
|
||||
@@ -103,9 +119,12 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
|
||||
title: Text(AppLocalizations.of(context)!.chatMember),
|
||||
onTap: () {
|
||||
SolianRouter.router.pushNamed(
|
||||
'chat.channel.member',
|
||||
widget.realm == 'global' ? 'chat.channel.member' : 'realms.chat.channel.member',
|
||||
extra: widget.channel,
|
||||
pathParameters: {'channel': widget.channel.alias},
|
||||
pathParameters: {
|
||||
'channel': widget.channel.alias,
|
||||
...(widget.realm == 'global' ? {} : {'realm': widget.realm}),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
@@ -4,8 +4,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/chat/chat_new.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
@@ -23,7 +24,7 @@ class ChatListScreen extends StatelessWidget {
|
||||
title: AppLocalizations.of(context)!.chat,
|
||||
appBarActions: const [NotificationButton()],
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
child: const ChatListWidget(),
|
||||
body: const ChatListWidget(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +86,7 @@ class _ChatListWidgetState extends State<ChatListWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final chat = context.watch<ChatProvider>();
|
||||
|
||||
return Scaffold(
|
||||
floatingActionButton: FutureBuilder(
|
||||
@@ -122,17 +124,10 @@ class _ChatListWidgetState extends State<ChatListWidget> {
|
||||
title: Text(element.name),
|
||||
subtitle: Text(element.description),
|
||||
onTap: () async {
|
||||
String? result;
|
||||
if (SolianRouter.currentRoute.name == 'chat.channel') {
|
||||
result = await SolianRouter.router.pushReplacementNamed(
|
||||
widget.realm == null ? 'chat.channel' : 'realms.chat.channel',
|
||||
pathParameters: {
|
||||
'channel': element.alias,
|
||||
...(widget.realm == null ? {} : {'realm': widget.realm!}),
|
||||
},
|
||||
);
|
||||
if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) {
|
||||
chat.fetchChannel(context, auth, element.alias, widget.realm!);
|
||||
} else {
|
||||
result = await SolianRouter.router.pushNamed(
|
||||
SolianRouter.router.pushNamed(
|
||||
widget.realm == null ? 'chat.channel' : 'realms.chat.channel',
|
||||
pathParameters: {
|
||||
'channel': element.alias,
|
||||
@@ -140,10 +135,6 @@ class _ChatListWidgetState extends State<ChatListWidget> {
|
||||
},
|
||||
);
|
||||
}
|
||||
switch (result) {
|
||||
case 'refresh':
|
||||
fetchChannels();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
|
@@ -1,16 +1,19 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/pagination.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/realm.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/realms/realm_shortcuts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:solian/widgets/notification_notifier.dart';
|
||||
import 'package:solian/widgets/posts/post.dart';
|
||||
@@ -21,19 +24,23 @@ class ExplorePostScreen extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IndentScaffold(
|
||||
noSafeArea: true,
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
appBarActions: const [NotificationButton()],
|
||||
title: AppLocalizations.of(context)!.explore,
|
||||
child: const ExplorePostWidget(),
|
||||
body: const ExplorePostWidget(showRealmShortcuts: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExplorePostWidget extends StatefulWidget {
|
||||
final String? realm;
|
||||
final bool showRealmShortcuts;
|
||||
|
||||
const ExplorePostWidget({super.key, this.realm});
|
||||
const ExplorePostWidget({
|
||||
super.key,
|
||||
this.realm,
|
||||
this.showRealmShortcuts = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExplorePostWidget> createState() => _ExplorePostWidgetState();
|
||||
@@ -75,6 +82,16 @@ class _ExplorePostWidgetState extends State<ExplorePostWidget> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
Future.delayed(Duration.zero, () async {
|
||||
if (widget.showRealmShortcuts) {
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (auth.client == null) {
|
||||
await auth.loadClient();
|
||||
}
|
||||
context.read<RealmProvider>().fetch(auth);
|
||||
}
|
||||
});
|
||||
|
||||
_pagingController.addPageRequestListener((pageKey) => fetchFeed(pageKey));
|
||||
}
|
||||
|
||||
@@ -91,8 +108,8 @@ class _ExplorePostWidgetState extends State<ExplorePostWidget> {
|
||||
child: const Icon(Icons.edit),
|
||||
onPressed: () async {
|
||||
final did = await SolianRouter.router.pushNamed(
|
||||
'posts.moments.editor',
|
||||
queryParameters: {'realm': widget.realm},
|
||||
widget.realm == null ? 'posts.moments.editor' : 'realms.posts.moments.editor',
|
||||
pathParameters: widget.realm == null ? {} : {'realm': widget.realm!},
|
||||
);
|
||||
if (did == true) _pagingController.refresh();
|
||||
},
|
||||
@@ -106,24 +123,48 @@ class _ExplorePostWidgetState extends State<ExplorePostWidget> {
|
||||
onRefresh: () => Future.sync(
|
||||
() => _pagingController.refresh(),
|
||||
),
|
||||
child: PagedListView<int, Post>(
|
||||
pagingController: _pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate<Post>(
|
||||
itemBuilder: (context, item, index) => PostItem(
|
||||
item: item,
|
||||
onUpdate: () => _pagingController.refresh(),
|
||||
onTap: () {
|
||||
SolianRouter.router.pushNamed(
|
||||
widget.realm == null ? 'posts.details' : 'realms.posts.details',
|
||||
pathParameters: {
|
||||
'alias': item.alias,
|
||||
'dataset': item.dataset,
|
||||
...(widget.realm == null ? {} : {'realm': widget.realm!}),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
widget.showRealmShortcuts
|
||||
? SliverToBoxAdapter(
|
||||
child: FutureBuilder(
|
||||
future: auth.isAuthorized(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data != true) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: const Material(
|
||||
elevation: 8,
|
||||
child: SizedBox(height: 120, child: RealmShortcuts()),
|
||||
).animate().fade().slideY(begin: -1, end: 0, curve: Curves.fastEaseInToSlowEaseOut),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: SliverToBoxAdapter(child: Container()),
|
||||
PagedSliverList<int, Post>(
|
||||
pagingController: _pagingController,
|
||||
builderDelegate: PagedChildBuilderDelegate<Post>(
|
||||
itemBuilder: (context, item, index) => PostItem(
|
||||
item: item,
|
||||
onUpdate: () => _pagingController.refresh(),
|
||||
onTap: () {
|
||||
SolianRouter.router.pushNamed(
|
||||
widget.realm == null ? 'posts.details' : 'realms.posts.details',
|
||||
pathParameters: {
|
||||
'alias': item.alias,
|
||||
'dataset': item.dataset,
|
||||
...(widget.realm == null ? {} : {'realm': widget.realm!}),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@@ -5,7 +5,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/notify.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -37,7 +37,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
markList.add(element.id);
|
||||
}
|
||||
|
||||
nty.clearRealtime();
|
||||
nty.clearRealtimeNotifications();
|
||||
|
||||
if(markList.isNotEmpty) {
|
||||
var uri = getRequestUri('passport', '/api/notifications/batch/read');
|
||||
@@ -73,10 +73,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
final nty = context.watch<NotifyProvider>();
|
||||
|
||||
return IndentScaffold(
|
||||
noSafeArea: true,
|
||||
hideDrawer: true,
|
||||
title: AppLocalizations.of(context)!.notification,
|
||||
child: RefreshIndicator(
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => nty.fetch(auth),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
|
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
@@ -122,6 +122,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
|
||||
|
||||
return IndentScaffold(
|
||||
hideDrawer: true,
|
||||
showSafeArea: true,
|
||||
title: AppLocalizations.of(context)!.newComment,
|
||||
appBarActions: <Widget>[
|
||||
TextButton(
|
||||
@@ -129,7 +130,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
|
||||
child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
body: Column(
|
||||
children: [
|
||||
_isSubmitting
|
||||
? const LinearProgressIndicator().animate().scaleX()
|
||||
|
@@ -7,7 +7,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
@@ -16,8 +16,9 @@ import 'package:solian/widgets/posts/attachment_editor.dart';
|
||||
|
||||
class MomentEditorScreen extends StatefulWidget {
|
||||
final Post? editing;
|
||||
final String? realm;
|
||||
|
||||
const MomentEditorScreen({super.key, this.editing});
|
||||
const MomentEditorScreen({super.key, this.editing, this.realm});
|
||||
|
||||
@override
|
||||
State<MomentEditorScreen> createState() => _MomentEditorScreenState();
|
||||
@@ -61,6 +62,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
|
||||
'alias': _alias,
|
||||
'content': _textController.value.text,
|
||||
'attachments': _attachments,
|
||||
'realm': widget.realm,
|
||||
});
|
||||
|
||||
var res = await Response.fromStream(await auth.client!.send(req));
|
||||
@@ -111,6 +113,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
|
||||
);
|
||||
|
||||
return IndentScaffold(
|
||||
showSafeArea: true,
|
||||
hideDrawer: true,
|
||||
title: AppLocalizations.of(context)!.newMoment,
|
||||
appBarActions: <Widget>[
|
||||
@@ -119,7 +122,7 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
|
||||
child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
body: Column(
|
||||
children: [
|
||||
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
|
||||
FutureBuilder(
|
||||
|
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:solian/widgets/posts/comment_list.dart';
|
||||
@@ -21,9 +21,8 @@ class PostScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.post,
|
||||
noSafeArea: true,
|
||||
hideDrawer: true,
|
||||
child: PostScreenWidget(
|
||||
body: PostScreenWidget(
|
||||
dataset: dataset,
|
||||
alias: alias,
|
||||
),
|
||||
|
@@ -6,7 +6,6 @@ import 'package:solian/providers/realm.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/chat/chat_list.dart';
|
||||
import 'package:solian/screens/explore.dart';
|
||||
import 'package:solian/screens/realms/realm_member.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
|
||||
@@ -23,7 +22,6 @@ class RealmScreen extends StatelessWidget {
|
||||
return IndentScaffold(
|
||||
title: realm.focusRealm?.name ?? 'Loading...',
|
||||
hideDrawer: true,
|
||||
noSafeArea: true,
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
appBarActions: realm.focusRealm != null
|
||||
? [
|
||||
@@ -41,7 +39,7 @@ class RealmScreen extends StatelessWidget {
|
||||
},
|
||||
)
|
||||
: null,
|
||||
child: RealmWidget(
|
||||
body: RealmWidget(
|
||||
alias: alias,
|
||||
),
|
||||
);
|
||||
@@ -82,7 +80,7 @@ class _RealmWidgetState extends State<RealmWidget> {
|
||||
}
|
||||
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
TabBar(
|
||||
@@ -90,7 +88,6 @@ class _RealmWidgetState extends State<RealmWidget> {
|
||||
tabs: const [
|
||||
Tab(icon: Icon(Icons.newspaper)),
|
||||
Tab(icon: Icon(Icons.message)),
|
||||
Tab(icon: Icon(Icons.supervisor_account))
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
@@ -98,11 +95,6 @@ class _RealmWidgetState extends State<RealmWidget> {
|
||||
children: [
|
||||
ExplorePostWidget(realm: widget.alias),
|
||||
ChatListWidget(realm: widget.alias),
|
||||
_realm.focusRealm != null
|
||||
? RealmMemberWidget(realm: _realm.focusRealm!)
|
||||
: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
@@ -127,8 +119,9 @@ class RealmManageAction extends StatelessWidget {
|
||||
return IconButton(
|
||||
onPressed: () async {
|
||||
final did = await SolianRouter.router.pushNamed(
|
||||
'realms.editor',
|
||||
'realms.manage',
|
||||
extra: realm,
|
||||
pathParameters: {'realm': realm.alias},
|
||||
);
|
||||
if (did == true) onUpdate();
|
||||
},
|
||||
|
@@ -7,7 +7,8 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -110,6 +111,7 @@ class _RealmEditorScreenState extends State<RealmEditorScreen> {
|
||||
|
||||
return IndentScaffold(
|
||||
hideDrawer: true,
|
||||
showSafeArea: true,
|
||||
title: AppLocalizations.of(context)!.realmEstablish,
|
||||
appBarActions: <Widget>[
|
||||
TextButton(
|
||||
@@ -117,7 +119,8 @@ class _RealmEditorScreenState extends State<RealmEditorScreen> {
|
||||
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
|
||||
),
|
||||
],
|
||||
child: Column(
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
body: Column(
|
||||
children: [
|
||||
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
|
||||
widget.editing != null ? editingBanner : Container(),
|
||||
|
@@ -18,12 +18,12 @@ class RealmListScreen extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final realm = context.watch<RealmProvider>();
|
||||
|
||||
return realm.focusRealm == null
|
||||
return realm.focusRealm == null || !SolianTheme.isLargeScreen(context)
|
||||
? IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.realm,
|
||||
appBarActions: const [NotificationButton()],
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
child: const RealmListWidget(),
|
||||
body: const RealmListWidget(),
|
||||
)
|
||||
: RealmScreen(alias: realm.focusRealm!.alias);
|
||||
}
|
||||
|
118
lib/screens/realms/realm_manage.dart
Normal file
118
lib/screens/realms/realm_manage.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/widgets/realms/realm_deletion.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class RealmManageScreen extends StatefulWidget {
|
||||
final Realm realm;
|
||||
|
||||
const RealmManageScreen({super.key, required this.realm});
|
||||
|
||||
@override
|
||||
State<RealmManageScreen> createState() => _RealmManageScreenState();
|
||||
}
|
||||
|
||||
class _RealmManageScreenState extends State<RealmManageScreen> {
|
||||
bool _isOwned = false;
|
||||
|
||||
void promptLeaveChannel() async {
|
||||
final did = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => RealmDeletion(
|
||||
realm: widget.realm,
|
||||
isOwned: _isOwned,
|
||||
),
|
||||
);
|
||||
if (did == true && SolianRouter.router.canPop()) {
|
||||
SolianRouter.router.pop('disposed');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
Future.delayed(Duration.zero, () async {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final prof = await auth.getProfiles();
|
||||
|
||||
setState(() {
|
||||
_isOwned = prof['id'] == widget.realm.accountId;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authorizedItems = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings),
|
||||
title: Text(AppLocalizations.of(context)!.settings),
|
||||
onTap: () async {
|
||||
SolianRouter.router.pushNamed('realms.editor', extra: widget.realm).then((did) {
|
||||
if (did == true) {
|
||||
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.realmManage,
|
||||
hideDrawer: true,
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: Colors.teal,
|
||||
child: Icon(Icons.tag, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(widget.realm.name, style: Theme.of(context).textTheme.bodyLarge),
|
||||
Text(widget.realm.description, style: Theme.of(context).textTheme.bodySmall),
|
||||
]),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.supervisor_account),
|
||||
title: Text(AppLocalizations.of(context)!.chatMember),
|
||||
onTap: () {
|
||||
SolianRouter.router.pushNamed(
|
||||
'realms.member',
|
||||
extra: widget.realm,
|
||||
pathParameters: {'realm': widget.realm.alias},
|
||||
);
|
||||
},
|
||||
),
|
||||
...(_isOwned ? authorizedItems : List.empty()),
|
||||
const Divider(thickness: 0.3),
|
||||
ListTile(
|
||||
leading: _isOwned ? const Icon(Icons.delete) : const Icon(Icons.exit_to_app),
|
||||
title: Text(_isOwned ? AppLocalizations.of(context)!.delete : AppLocalizations.of(context)!.exit),
|
||||
onTap: () => promptLeaveChannel(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -6,21 +6,24 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/account/friend_picker.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/widgets/scaffold.dart';
|
||||
|
||||
class RealmMemberWidget extends StatefulWidget {
|
||||
class RealmMemberScreen extends StatefulWidget {
|
||||
final Realm realm;
|
||||
|
||||
const RealmMemberWidget({super.key, required this.realm});
|
||||
const RealmMemberScreen({super.key, required this.realm});
|
||||
|
||||
@override
|
||||
State<RealmMemberWidget> createState() => _RealmMemberWidgetState();
|
||||
State<RealmMemberScreen> createState() => _RealmMemberScreenState();
|
||||
}
|
||||
|
||||
class _RealmMemberWidgetState extends State<RealmMemberWidget> {
|
||||
class _RealmMemberScreenState extends State<RealmMemberScreen> {
|
||||
bool _isSubmitting = false;
|
||||
|
||||
List<RealmMember> _members = List.empty();
|
||||
@@ -136,11 +139,16 @@ class _RealmMemberWidgetState extends State<RealmMemberWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton(
|
||||
child: const Icon(Icons.add),
|
||||
onPressed: () => promptAddMember(),
|
||||
),
|
||||
return IndentScaffold(
|
||||
title: AppLocalizations.of(context)!.realmMember,
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
hideDrawer: true,
|
||||
appBarActions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () => promptAddMember(),
|
||||
),
|
||||
],
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => fetchMemberships(),
|
||||
child: CustomScrollView(
|
||||
|
@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:solian/models/account.dart';
|
||||
import 'package:solian/models/personal_page.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/account/account_avatar.dart';
|
||||
import 'package:solian/widgets/account/personal_page_content.dart';
|
||||
@@ -65,8 +65,7 @@ class _UserInfoScreenState extends State<UserInfoScreen> {
|
||||
title: _userinfo?.nick ?? 'Loading...',
|
||||
fixedAppBarColor: SolianTheme.isLargeScreen(context),
|
||||
hideDrawer: true,
|
||||
noSafeArea: true,
|
||||
child: FutureBuilder(
|
||||
body: FutureBuilder(
|
||||
future: fetchUserinfo(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
|
||||
class HttpClient extends http.BaseClient {
|
||||
final bool isUnauthorizedRetry;
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/utils/platform.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
|
||||
class AccountAvatar extends StatelessWidget {
|
||||
final String source;
|
||||
|
@@ -7,7 +7,7 @@ import 'package:solian/models/channel.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
class ChannelCallAction extends StatefulWidget {
|
||||
|
@@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/channel.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
class ChannelDeletion extends StatefulWidget {
|
||||
|
@@ -1,99 +0,0 @@
|
||||
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';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class ChatMaintainer extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Channel channel;
|
||||
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,
|
||||
required this.child,
|
||||
required this.channel,
|
||||
required this.onInsertMessage,
|
||||
required this.onUpdateMessage,
|
||||
required this.onDeleteMessage,
|
||||
required this.onCallStarted,
|
||||
required this.onCallEnded,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatMaintainer> createState() => _ChatMaintainerState();
|
||||
}
|
||||
|
||||
class _ChatMaintainerState extends State<ChatMaintainer> {
|
||||
void connect() {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
|
||||
final notify = ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.connectingServer),
|
||||
duration: const Duration(minutes: 1),
|
||||
),
|
||||
);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
final chat = context.read<ChatProvider>();
|
||||
|
||||
chat.connect(auth).then((snapshot) {
|
||||
snapshot!.stream.listen(
|
||||
(event) {
|
||||
final result = NetworkPackage.fromJson(jsonDecode(event));
|
||||
switch (result.method) {
|
||||
case 'messages.new':
|
||||
final payload = Message.fromJson(result.payload!);
|
||||
if (payload.channelId == widget.channel.id) widget.onInsertMessage(payload);
|
||||
break;
|
||||
case 'messages.update':
|
||||
final payload = Message.fromJson(result.payload!);
|
||||
if (payload.channelId == widget.channel.id) widget.onUpdateMessage(payload);
|
||||
break;
|
||||
case 'messages.burnt':
|
||||
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(),
|
||||
onDone: () => connect(),
|
||||
);
|
||||
|
||||
notify.close();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
Future.delayed(Duration.zero, () {
|
||||
connect();
|
||||
});
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
@@ -1,4 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
@@ -7,6 +11,7 @@ import 'package:solian/widgets/chat/message_deletion.dart';
|
||||
|
||||
class ChatMessageAction extends StatelessWidget {
|
||||
final String channel;
|
||||
final String realm;
|
||||
final Message item;
|
||||
final Function? onEdit;
|
||||
final Function? onReply;
|
||||
@@ -15,6 +20,7 @@ class ChatMessageAction extends StatelessWidget {
|
||||
super.key,
|
||||
required this.channel,
|
||||
required this.item,
|
||||
this.realm = 'global',
|
||||
this.onEdit,
|
||||
this.onReply,
|
||||
});
|
||||
@@ -24,7 +30,7 @@ class ChatMessageAction extends StatelessWidget {
|
||||
final auth = context.read<AuthProvider>();
|
||||
|
||||
return SizedBox(
|
||||
height: 320,
|
||||
height: 400,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -38,7 +44,7 @@ class ChatMessageAction extends StatelessWidget {
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
Text(
|
||||
'Message ID #${item.id.toString().padLeft(8, '0')}',
|
||||
'#${item.id.toString().padLeft(8, '0')}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
@@ -67,6 +73,7 @@ class ChatMessageAction extends StatelessWidget {
|
||||
builder: (context) => ChatMessageDeletionDialog(
|
||||
item: item,
|
||||
channel: channel,
|
||||
realm: realm,
|
||||
),
|
||||
).then((did) {
|
||||
if (did == true && Navigator.canPop(context)) {
|
||||
@@ -79,9 +86,7 @@ class ChatMessageAction extends StatelessWidget {
|
||||
|
||||
return ListView(
|
||||
children: [
|
||||
...(snapshot.data['id'] == item.sender.account.externalId
|
||||
? authorizedItems
|
||||
: List.empty()),
|
||||
...(snapshot.data['id'] == item.sender.account.externalId ? authorizedItems : List.empty()),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply),
|
||||
title: Text(AppLocalizations.of(context)!.reply),
|
||||
@@ -89,6 +94,17 @@ class ChatMessageAction extends StatelessWidget {
|
||||
if (onReply != null) onReply!();
|
||||
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.code),
|
||||
title: Text(AppLocalizations.of(context)!.chatMessageViewSource),
|
||||
onTap: () {
|
||||
if (Navigator.canPop(context)) Navigator.pop(context);
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => ChatMessageSourceWidget(item: item),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
@@ -105,3 +121,90 @@ class ChatMessageAction extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChatMessageSourceWidget extends StatelessWidget {
|
||||
final Message item;
|
||||
|
||||
const ChatMessageSourceWidget({super.key, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 640,
|
||||
child: ListView(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 20, top: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.chatMessageViewSource,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
|
||||
child: Text(
|
||||
'Raw content',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Markdown(
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: '```\n${item.rawContent}\n```',
|
||||
padding: const EdgeInsets.all(0),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
|
||||
child: Text(
|
||||
'Decoded content',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Markdown(
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: '```\n${const JsonEncoder.withIndent(' ').convert(item.decodedContent)}\n```',
|
||||
padding: const EdgeInsets.all(0),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
code: GoogleFonts.robotoMono(
|
||||
backgroundColor: Theme.of(context).cardTheme.color ?? Theme.of(context).cardColor,
|
||||
fontSize: Theme.of(context).textTheme.bodyMedium!.fontSize! * 0.85,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
|
||||
child: Text(
|
||||
'Entire message',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Markdown(
|
||||
selectable: true,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
data: '```\n${const JsonEncoder.withIndent(' ').convert(item)}\n```',
|
||||
padding: const EdgeInsets.all(0),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
code: GoogleFonts.robotoMono(
|
||||
backgroundColor: Theme.of(context).cardTheme.color ?? Theme.of(context).cardColor,
|
||||
fontSize: Theme.of(context).textTheme.bodyMedium!.fontSize! * 0.85,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,28 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class ChatMessageContent extends StatelessWidget {
|
||||
class ChatMessageContent extends StatefulWidget {
|
||||
final Message item;
|
||||
|
||||
const ChatMessageContent({super.key, required this.item});
|
||||
|
||||
@override
|
||||
State<ChatMessageContent> createState() => _ChatMessageContentState();
|
||||
}
|
||||
|
||||
class _ChatMessageContentState extends State<ChatMessageContent> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Markdown(
|
||||
data: item.content,
|
||||
shrinkWrap: true,
|
||||
selectable: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(0),
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null) return;
|
||||
await launchUrlString(
|
||||
href,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
final feColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.65);
|
||||
|
||||
final waitingKeyHint = Row(
|
||||
children: [
|
||||
Icon(Icons.key, color: feColor, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.chatMessageUnableDecryptWaiting,
|
||||
style: TextStyle(color: feColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
final missingKeyHint = Row(
|
||||
children: [
|
||||
Icon(Icons.key_off_outlined, color: feColor, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.chatMessageUnableDecryptMissing,
|
||||
style: TextStyle(color: feColor),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (widget.item.type == 'm.text') {
|
||||
String? content;
|
||||
switch (widget.item.decodedContent['algorithm']) {
|
||||
case 'plain':
|
||||
content = widget.item.decodedContent['value'];
|
||||
case 'aes':
|
||||
final keypair = context.watch<KeypairProvider>();
|
||||
if (keypair.keys[widget.item.decodedContent['keypair_id']] == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
keypair.requestKey(
|
||||
widget.item.decodedContent['keypair_id'],
|
||||
widget.item.decodedContent['algorithm'],
|
||||
widget.item.sender.account.externalId!,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
content = keypair.decodeViaAESKey(
|
||||
widget.item.decodedContent['keypair_id'],
|
||||
widget.item.decodedContent['value'],
|
||||
)!;
|
||||
break;
|
||||
}
|
||||
|
||||
if (keypair.requestingKeys.contains(widget.item.decodedContent['keypair_id'])) {
|
||||
return waitingKeyHint.animate().swap(builder: (context, _) {
|
||||
return missingKeyHint;
|
||||
}, delay: 3000.ms);
|
||||
}
|
||||
}
|
||||
|
||||
if (content == null) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(Icons.key_off, color: feColor, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
AppLocalizations.of(context)!.chatMessageUnableDecryptUnsupported,
|
||||
style: TextStyle(color: feColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Markdown(
|
||||
data: content,
|
||||
shrinkWrap: true,
|
||||
selectable: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(0),
|
||||
onTapLink: (text, href, title) async {
|
||||
if (href == null) return;
|
||||
await launchUrlString(
|
||||
href,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
@@ -5,22 +5,23 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
class ChatMessageDeletionDialog extends StatefulWidget {
|
||||
final String channel;
|
||||
final String realm;
|
||||
final Message item;
|
||||
|
||||
const ChatMessageDeletionDialog({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.channel,
|
||||
this.realm = 'global'
|
||||
});
|
||||
|
||||
@override
|
||||
State<ChatMessageDeletionDialog> createState() =>
|
||||
_ChatMessageDeletionDialogState();
|
||||
State<ChatMessageDeletionDialog> createState() => _ChatMessageDeletionDialogState();
|
||||
}
|
||||
|
||||
class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> {
|
||||
@@ -30,8 +31,8 @@ class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> {
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) return;
|
||||
|
||||
final uri = getRequestUri('messaging',
|
||||
'/api/channels/global/${widget.channel}/messages/${widget.item.id}');
|
||||
final uri =
|
||||
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages/${widget.item.id}');
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final res = await auth.client!.delete(uri);
|
||||
|
@@ -1,13 +1,16 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:easy_debounce/easy_debounce.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/message.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
import 'package:solian/widgets/posts/attachment_editor.dart';
|
||||
import 'package:badges/badges.dart' as badge;
|
||||
@@ -17,11 +20,13 @@ class ChatMessageEditor extends StatefulWidget {
|
||||
final String realm;
|
||||
final Message? editing;
|
||||
final Message? replying;
|
||||
final bool isEncrypted;
|
||||
final Function? onReset;
|
||||
|
||||
const ChatMessageEditor({
|
||||
super.key,
|
||||
required this.channel,
|
||||
required this.isEncrypted,
|
||||
this.realm = 'global',
|
||||
this.editing,
|
||||
this.replying,
|
||||
@@ -36,7 +41,8 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
final _textController = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
|
||||
bool _isSubmitting = false;
|
||||
final List<int> _pendingMessages = List.empty(growable: true);
|
||||
|
||||
int? _prevEditingId;
|
||||
|
||||
List<Attachment> _attachments = List.empty(growable: true);
|
||||
@@ -52,9 +58,22 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(BuildContext context) async {
|
||||
if (_isSubmitting) return;
|
||||
Map<String, dynamic> buildContentBody(String content) {
|
||||
final keypair = context.read<KeypairProvider>();
|
||||
if (keypair.activeKeyId == null || keypair.keys[keypair.activeKeyId] == null) {
|
||||
final kp = keypair.generateAESKey();
|
||||
keypair.setActiveKey(kp.id);
|
||||
return buildContentBody(content);
|
||||
}
|
||||
|
||||
return {
|
||||
'value': widget.isEncrypted ? keypair.encodeViaAESKey(keypair.activeKeyId!, content) : content,
|
||||
'keypair_id': widget.isEncrypted ? keypair.activeKeyId : null,
|
||||
'algorithm': widget.isEncrypted ? 'aes' : 'plain',
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> sendMessage(BuildContext context) async {
|
||||
_focusNode.requestFocus();
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
@@ -67,20 +86,31 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
|
||||
req.headers['Content-Type'] = 'application/json';
|
||||
req.body = jsonEncode(<String, dynamic>{
|
||||
'content': _textController.value.text,
|
||||
'type': 'm.text',
|
||||
'content': buildContentBody(_textController.value.text),
|
||||
'attachments': _attachments,
|
||||
'reply_to': widget.replying?.id,
|
||||
});
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
reset();
|
||||
|
||||
final messageMarkId = DateTime.now().microsecondsSinceEpoch >> 10;
|
||||
final messageDebounceId = 'm-pending$messageMarkId';
|
||||
|
||||
EasyDebounce.debounce(messageDebounceId, 350.ms, () {
|
||||
setState(() => _pendingMessages.add(messageMarkId));
|
||||
});
|
||||
|
||||
var res = await Response.fromStream(await auth.client!.send(req));
|
||||
if (res.statusCode != 200) {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
} else {
|
||||
reset();
|
||||
}
|
||||
setState(() => _isSubmitting = false);
|
||||
|
||||
EasyDebounce.cancel(messageDebounceId);
|
||||
if (_pendingMessages.isNotEmpty) {
|
||||
setState(() => _pendingMessages.remove(messageMarkId));
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
@@ -94,8 +124,11 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
if (widget.editing != null && _prevEditingId != widget.editing!.id) {
|
||||
setState(() {
|
||||
_prevEditingId = widget.editing!.id;
|
||||
_textController.text = widget.editing!.content;
|
||||
_attachments = widget.editing!.attachments ?? List.empty(growable: true);
|
||||
|
||||
if (widget.editing!.type == 'm.text') {
|
||||
_textController.text = widget.editing!.decodedContent['value'];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -113,6 +146,15 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sendingBanner = MaterialBanner(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
|
||||
leading: const Icon(Icons.schedule_send),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
|
||||
dividerColor: const Color.fromARGB(1, 0, 0, 0),
|
||||
content: Text('${AppLocalizations.of(context)!.chatMessageSending} (${_pendingMessages.length})'),
|
||||
actions: const [SizedBox()],
|
||||
);
|
||||
|
||||
final editingBanner = MaterialBanner(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
|
||||
leading: const Icon(Icons.edit_note),
|
||||
@@ -143,6 +185,18 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_pendingMessages.isNotEmpty
|
||||
? sendingBanner
|
||||
.animate()
|
||||
.scaleY(
|
||||
begin: 0,
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
)
|
||||
.slideY(
|
||||
begin: 1,
|
||||
curve: Curves.fastEaseInToSlowEaseOut,
|
||||
)
|
||||
: Container(),
|
||||
widget.editing != null ? editingBanner : Container(),
|
||||
widget.replying != null ? replyingBanner : Container(),
|
||||
Container(
|
||||
@@ -162,7 +216,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
position: badge.BadgePosition.custom(top: -2, end: 8),
|
||||
child: TextButton(
|
||||
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)),
|
||||
onPressed: !_isSubmitting ? () => viewAttachments(context) : null,
|
||||
onPressed: () => viewAttachments(context),
|
||||
child: const Icon(Icons.attach_file),
|
||||
),
|
||||
),
|
||||
@@ -174,7 +228,9 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
autocorrect: true,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration.collapsed(
|
||||
hintText: AppLocalizations.of(context)!.chatMessagePlaceholder,
|
||||
hintText: widget.isEncrypted
|
||||
? AppLocalizations.of(context)!.chatMessageEncryptedPlaceholder
|
||||
: AppLocalizations.of(context)!.chatMessagePlaceholder,
|
||||
),
|
||||
onSubmitted: (_) => sendMessage(context),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
@@ -182,7 +238,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
|
||||
),
|
||||
TextButton(
|
||||
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)),
|
||||
onPressed: !_isSubmitting ? () => sendMessage(context) : null,
|
||||
onPressed: () => sendMessage(context),
|
||||
child: const Icon(Icons.send),
|
||||
)
|
||||
],
|
||||
|
@@ -1,76 +1,12 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/notify.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/models/notification.dart' as model;
|
||||
import 'package:badges/badges.dart' as badge;
|
||||
|
||||
class NotificationNotifier extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const NotificationNotifier({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<NotificationNotifier> createState() => _NotificationNotifierState();
|
||||
}
|
||||
|
||||
class _NotificationNotifierState extends State<NotificationNotifier> {
|
||||
void connect() async {
|
||||
final notify = ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.connectingServer),
|
||||
duration: const Duration(minutes: 1),
|
||||
),
|
||||
);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
final nty = context.read<NotifyProvider>();
|
||||
|
||||
if (await auth.isAuthorized()) {
|
||||
nty.fetch(auth);
|
||||
nty.connect(auth).then((snapshot) {
|
||||
snapshot!.stream.listen(
|
||||
(event) {
|
||||
final result = model.Notification.fromJson(jsonDecode(event));
|
||||
nty.onRemoteMessage(result);
|
||||
nty.notifyMessage(result.subject, result.content);
|
||||
},
|
||||
onError: (_, __) => connect(),
|
||||
onDone: () => connect(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
notify.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
Future.delayed(Duration.zero, () {
|
||||
connect();
|
||||
});
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationButton extends StatefulWidget {
|
||||
class NotificationButton extends StatelessWidget {
|
||||
const NotificationButton({super.key});
|
||||
|
||||
@override
|
||||
State<NotificationButton> createState() => _NotificationButtonState();
|
||||
}
|
||||
|
||||
class _NotificationButtonState extends State<NotificationButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final nty = context.watch<NotifyProvider>();
|
||||
|
@@ -11,7 +11,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/file.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
|
@@ -9,7 +9,7 @@ import 'package:http/http.dart' as http;
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/screens/posts/comment_editor.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/posts/post.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
|
@@ -4,7 +4,7 @@ import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:markdown/markdown.dart' as markdown;
|
||||
import 'package:solian/utils/platform.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class ArticleContent extends StatelessWidget {
|
||||
|
@@ -4,7 +4,7 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:solian/utils/platform.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:flutter_carousel_widget/flutter_carousel_widget.dart';
|
||||
import 'package:solian/widgets/posts/attachment_screen.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
@@ -54,10 +54,19 @@ class PostItemAction extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 12),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.action,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.action,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
Text(
|
||||
'#${item.id.toString().padLeft(8, '0')}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
@@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/post.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
class ItemDeletionDialog extends StatefulWidget {
|
||||
@@ -29,8 +29,7 @@ class _ItemDeletionDialogState extends State<ItemDeletionDialog> {
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) return;
|
||||
|
||||
final uri =
|
||||
getRequestUri('interactive', '/api/p/moments/${widget.item.id}');
|
||||
final uri = getRequestUri('interactive', '/api/p/${widget.item.modelType}s/${widget.item.id}');
|
||||
|
||||
setState(() => _isSubmitting = true);
|
||||
final res = await auth.client!.delete(uri);
|
||||
|
@@ -5,7 +5,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/models/reaction.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/service_url.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
|
90
lib/widgets/provider_init.dart
Normal file
90
lib/widgets/provider_init.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/chat.dart';
|
||||
import 'package:solian/providers/keypair.dart';
|
||||
import 'package:solian/providers/notify.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
class ProviderInitializer extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const ProviderInitializer({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<ProviderInitializer> createState() => _ProviderInitializerState();
|
||||
}
|
||||
|
||||
class _ProviderInitializerState extends State<ProviderInitializer> {
|
||||
void showConnectionStatus(bool status) {
|
||||
if (status) {
|
||||
showConnectionSnackbar();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
}
|
||||
}
|
||||
|
||||
void showConnectionSnackbar() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.connectingServer),
|
||||
duration: const Duration(minutes: 1),
|
||||
));
|
||||
}
|
||||
|
||||
void connect() async {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final nty = context.read<NotifyProvider>();
|
||||
final chat = context.read<ChatProvider>();
|
||||
final keypair = context.read<KeypairProvider>();
|
||||
|
||||
final notify = ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.connectingServer),
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
if (await auth.isAuthorized()) {
|
||||
if (auth.client == null) {
|
||||
await auth.loadClient();
|
||||
}
|
||||
|
||||
nty.connect(
|
||||
auth,
|
||||
onKexRequest: keypair.provideKeypair,
|
||||
onKexProvide: keypair.receiveKeypair,
|
||||
onStateUpdated: showConnectionStatus
|
||||
).then((value) {
|
||||
keypair.channel = value;
|
||||
});
|
||||
chat.connect(auth, onStateUpdated: showConnectionStatus);
|
||||
|
||||
nty.fetch(auth);
|
||||
|
||||
Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
nty.connect(auth, onStateUpdated: showConnectionStatus);
|
||||
chat.connect(auth, onStateUpdated: showConnectionStatus);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
context.showErrorDialog(e);
|
||||
}
|
||||
|
||||
notify.close();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.delayed(Duration.zero, () => connect());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
101
lib/widgets/realms/realm_deletion.dart
Normal file
101
lib/widgets/realms/realm_deletion.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:solian/models/realm.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/utils/services_url.dart';
|
||||
import 'package:solian/widgets/exts.dart';
|
||||
|
||||
class RealmDeletion extends StatefulWidget {
|
||||
final Realm realm;
|
||||
final bool isOwned;
|
||||
|
||||
const RealmDeletion({
|
||||
super.key,
|
||||
required this.realm,
|
||||
required this.isOwned,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RealmDeletion> createState() => _RealmDeletionState();
|
||||
}
|
||||
|
||||
class _RealmDeletionState extends State<RealmDeletion> {
|
||||
bool _isSubmitting = false;
|
||||
|
||||
Future<void> deleteChannel() async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
var res = await auth.client!.delete(
|
||||
getRequestUri('passport', '/api/realms/${widget.realm.alias}'),
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
Future<void> leaveChannel() async {
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
final auth = context.read<AuthProvider>();
|
||||
if (!await auth.isAuthorized()) {
|
||||
setState(() => _isSubmitting = false);
|
||||
return;
|
||||
}
|
||||
|
||||
var res = await auth.client!.delete(
|
||||
getRequestUri('passport', '/api/realms/${widget.realm.alias}/members/me'),
|
||||
);
|
||||
if (res.statusCode != 200) {
|
||||
var message = utf8.decode(res.bodyBytes);
|
||||
context.showErrorDialog(message);
|
||||
} else if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
|
||||
setState(() => _isSubmitting = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = widget.isOwned
|
||||
? AppLocalizations.of(context)!.chatChannelDeleteConfirm
|
||||
: AppLocalizations.of(context)!.chatChannelLeaveConfirm;
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(AppLocalizations.of(context)!.confirmation),
|
||||
content: Text(content),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context)!.confirmCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () {
|
||||
if (widget.isOwned) {
|
||||
deleteChannel();
|
||||
} else {
|
||||
leaveChannel();
|
||||
}
|
||||
},
|
||||
child: Text(AppLocalizations.of(context)!.confirmOkay),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
72
lib/widgets/realms/realm_shortcuts.dart
Normal file
72
lib/widgets/realms/realm_shortcuts.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:solian/providers/auth.dart';
|
||||
import 'package:solian/providers/realm.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
class RealmShortcuts extends StatelessWidget {
|
||||
const RealmShortcuts({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = context.read<AuthProvider>();
|
||||
final realm = context.watch<RealmProvider>();
|
||||
|
||||
if (realm.realms.isEmpty) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.shortcutsEmpty,
|
||||
style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: realm.realms.length,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
final element = realm.realms[index];
|
||||
|
||||
return InkWell(
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: Colors.teal,
|
||||
child: Icon(Icons.supervised_user_circle, color: Colors.white),
|
||||
),
|
||||
),
|
||||
Text(element.name, textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () async {
|
||||
if (SolianTheme.isLargeScreen(context)) {
|
||||
await realm.fetchSingle(auth, element.alias);
|
||||
SolianRouter.router.pushNamed('realms');
|
||||
} else {
|
||||
SolianRouter.router.pushNamed(
|
||||
'realms.details',
|
||||
pathParameters: {'realm': element.alias},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,45 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:solian/router.dart';
|
||||
import 'package:solian/utils/theme.dart';
|
||||
import 'package:solian/widgets/navigation_drawer.dart';
|
||||
|
||||
class IndentScaffold extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final Widget? body;
|
||||
final Widget? floatingActionButton;
|
||||
final Widget? appBarLeading;
|
||||
final List<Widget>? appBarActions;
|
||||
final bool noSafeArea;
|
||||
final bool hideDrawer;
|
||||
final bool showSafeArea;
|
||||
final bool fixedAppBarColor;
|
||||
final String title;
|
||||
|
||||
const IndentScaffold({
|
||||
super.key,
|
||||
this.child,
|
||||
this.body,
|
||||
required this.title,
|
||||
this.floatingActionButton,
|
||||
this.appBarLeading,
|
||||
this.appBarActions,
|
||||
this.hideDrawer = false,
|
||||
this.showSafeArea = false,
|
||||
this.fixedAppBarColor = false,
|
||||
this.noSafeArea = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final content = child ?? Container();
|
||||
final backButton = IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () {
|
||||
if (SolianRouter.router.canPop()) {
|
||||
SolianRouter.router.pop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final drawerButton = Builder(
|
||||
builder: (context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.menu),
|
||||
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
|
||||
onPressed: () {
|
||||
Scaffold.of(context).openDrawer();
|
||||
},
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
leading: appBarLeading,
|
||||
leading: appBarLeading ?? (hideDrawer ? backButton : drawerButton),
|
||||
actions: appBarActions,
|
||||
centerTitle: false,
|
||||
elevation: fixedAppBarColor ? 4 : null,
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
floatingActionButton: floatingActionButton,
|
||||
drawer: !hideDrawer ? const SolianNavigationDrawer() : null,
|
||||
drawerScrimColor: SolianTheme.isLargeScreen(context) ? Colors.transparent : null,
|
||||
body: noSafeArea ? content : SafeArea(child: content),
|
||||
body: showSafeArea ? SafeArea(child: body ?? Container()) : body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
46
pubspec.lock
46
pubspec.lock
@@ -17,6 +17,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.0"
|
||||
asn1lib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: asn1lib
|
||||
sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.3"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -33,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
basic_utils:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: basic_utils
|
||||
sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.7.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -209,6 +225,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
easy_debounce:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: easy_debounce
|
||||
sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.3"
|
||||
encrypt:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: encrypt
|
||||
sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.3"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -469,6 +501,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "13.2.4"
|
||||
google_fonts:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_fonts
|
||||
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
hive:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -953,10 +993,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pointycastle
|
||||
sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744"
|
||||
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.9.0"
|
||||
version: "3.9.1"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1396,4 +1436,4 @@ packages:
|
||||
version: "3.1.2"
|
||||
sdks:
|
||||
dart: ">=3.3.3 <4.0.0"
|
||||
flutter: ">=3.19.0"
|
||||
flutter: ">=3.19.2"
|
||||
|
@@ -71,6 +71,10 @@ dependencies:
|
||||
package_info_plus: ^7.0.0
|
||||
cached_network_image: ^3.3.1
|
||||
desktop_drop: ^0.4.4
|
||||
easy_debounce: ^2.0.3
|
||||
google_fonts: ^6.2.1
|
||||
basic_utils: ^5.7.0
|
||||
encrypt: ^5.0.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
BIN
web/favicon.png
Normal file → Executable file
BIN
web/favicon.png
Normal file → Executable file
Binary file not shown.
Before Width: | Height: | Size: 480 B After Width: | Height: | Size: 70 KiB |
Reference in New Issue
Block a user