Compare commits

...

18 Commits

Author SHA1 Message Date
d46d584ff3 🐛 Bug fixes and optimization 2024-05-13 22:52:41 +08:00
f43f9e91f6 Better websocket reconnection and maintainer 2024-05-13 22:12:37 +08:00
b9461e5019 💄 Optimize connection experience 2024-05-12 22:29:38 +08:00
8e0e2dacfe 🐛 Fix connection issue 2024-05-12 22:17:32 +08:00
b4d1d62e9b 🚑 Fix services url issue 2024-05-12 21:27:55 +08:00
6f7ae4467c 🐛 Bug fixes and optimization 2024-05-12 20:59:33 +08:00
98547708af E2EE and Keypair 2024-05-12 20:15:12 +08:00
08d0a99b10 🐛 Bug fixes 2024-05-11 23:22:35 +08:00
5ce6543275 Message view source 2024-05-10 23:42:59 +08:00
40aa16e971 ⬆️ Support some new server stuff 2024-05-10 23:17:01 +08:00
c1d3bac0c8 🐛 Fix several known bugs 2024-05-08 22:01:06 +08:00
a4f8c65aa5 💄 Realm shortcut 2024-05-07 23:38:12 +08:00
3bcdc67285 ♻️ Better chat connection method 2024-05-07 23:07:51 +08:00
dffa0077de 💄 Better user send message experience 2024-05-07 21:49:24 +08:00
b3e266d564 🐛 Bug fixes on realm missing post editor 2024-05-07 13:06:26 +08:00
0c87bbbce1 🐛 Bug fixes on realm UI 2024-05-06 23:36:54 +08:00
ae4d9cf81a Complete the realm system 2024-05-06 23:30:30 +08:00
22c2a80650 Complete the realm system 2024-05-06 20:57:52 +08:00
62 changed files with 1772 additions and 518 deletions

View File

@@ -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"
}

View File

@@ -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": "无法解密信息,缺失解密密钥"
}

View File

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

View File

@@ -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
View 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,
};
}

View File

@@ -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,

View File

@@ -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'],

View File

@@ -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() {

View File

@@ -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;
return channel;
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;
}
}
Future<Channel> fetchChannel(String alias, String realm) async {
final Client client = Client();
_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)),
);
var uri = getRequestUri('messaging', '/api/channels/$realm/$alias');
var res = await client.get(uri);
return _channel!;
}
void disconnect() {
_channel?.sink.close(status.goingAway);
}
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();
}
}

View File

@@ -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
View 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;
}
}

View File

@@ -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;
return channel;
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;
}
}
void onRemoteMessage(model.Notification item) {
_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(item);
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 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();
}

View File

@@ -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();

View File

@@ -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(

View File

@@ -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(
...(actionItems.map(
(x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.color_lens),
title: Text(AppLocalizations.of(context)!.personalize),
leading: x.$1,
title: Text(x.$2),
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');
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;
});

View File

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

View 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),
),
),
],
),
),
);
}
}

View File

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

View File

@@ -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),

View File

@@ -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),

View File

@@ -132,7 +132,7 @@ class _ChatCallState extends State<ChatCall> {
title: AppLocalizations.of(context)!.chatCall,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
hideDrawer: true,
child: content,
body: content,
);
}

View File

@@ -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,
),
],
),
);

View File

@@ -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: [

View File

@@ -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);
setState(() {});
_chat.historyPagingController?.refresh();
} else {
final nextPageKey = pageKey + items.length;
_pagingController.appendPage(items, nextPageKey);
}
} 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,20 +230,18 @@ 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(
return Stack(
children: [
Column(
children: [
Expanded(
child: PagedListView<int, Message>(
reverse: true,
pagingController: _pagingController,
pagingController: _chat.historyPagingController!,
builderDelegate: PagedChildBuilderDelegate<Message>(
animateTransitions: true,
transitionDuration: 350.ms,
@@ -241,6 +255,7 @@ class _ChatWidgetState extends State<ChatWidget> {
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
@@ -250,12 +265,6 @@ class _ChatWidgetState extends State<ChatWidget> {
),
_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),
);
}
}

View File

@@ -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,7 +24,8 @@ class _ChatDetailScreenState extends State<ChatDetailScreen> {
void promptLeaveChannel() async {
final did = await showDialog(
context: context,
builder: (context) => ChannelDeletion(
builder: (context) =>
ChannelDeletion(
channel: widget.channel,
realm: widget.realm,
isOwned: _isOwned,
@@ -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}),
},
);
},
),

View File

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

View File

@@ -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,7 +123,29 @@ class _ExplorePostWidgetState extends State<ExplorePostWidget> {
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, Post>(
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(
@@ -125,6 +164,8 @@ class _ExplorePostWidgetState extends State<ExplorePostWidget> {
),
),
),
],
),
),
);
}

View File

@@ -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: [

View File

@@ -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()

View File

@@ -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(

View File

@@ -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,
),

View File

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

View File

@@ -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(),

View File

@@ -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);
}

View 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(),
),
],
),
),
],
),
);
}
}

View File

@@ -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),
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(

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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;
}
}

View File

@@ -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,
),
),
),
],
),
),
);
}
}

View File

@@ -1,17 +1,96 @@
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) {
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: item.content,
data: content,
shrinkWrap: true,
selectable: true,
physics: const NeverScrollableScrollPhysics(),
@@ -25,4 +104,7 @@ class ChatMessageContent extends StatelessWidget {
},
);
}
return Container();
}
}

View File

@@ -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);

View File

@@ -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),
)
],

View File

@@ -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>();

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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';

View File

@@ -54,11 +54,20 @@ class PostItemAction extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 12),
child: Text(
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(
child: FutureBuilder(

View File

@@ -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);

View File

@@ -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';

View 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;
}
}

View 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),
),
],
);
}
}

View 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},
);
}
},
);
},
);
}
}

View File

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

View File

@@ -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"

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 480 B

After

Width:  |  Height:  |  Size: 70 KiB