Better DM

This commit is contained in:
LittleSheep 2024-05-29 00:14:41 +08:00
parent c50a49f37d
commit d4cbabeb31
17 changed files with 253 additions and 187 deletions

View File

@ -15,13 +15,13 @@ class Account {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.name,
required this.nick,
required this.avatar,
required this.banner,
required this.description,
this.emailAddress,
required this.emailAddress,
this.externalId,
});

View File

@ -15,8 +15,8 @@ class Call {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
this.endedAt,
required this.deletedAt,
required this.endedAt,
required this.externalId,
required this.founderId,
required this.channelId,

View File

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/realm.dart';
@ -12,6 +10,7 @@ class Channel {
String name;
String description;
int type;
List<ChannelMember>? members;
Account account;
int accountId;
Realm? realm;
@ -24,16 +23,17 @@ class Channel {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.type,
required this.members,
required this.account,
required this.accountId,
required this.isEncrypted,
this.realm,
this.realmId,
required this.realm,
required this.realmId,
});
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
@ -45,6 +45,10 @@ class Channel {
name: json['name'],
description: json['description'],
type: json['type'],
members: json['members']
?.map((e) => ChannelMember.fromJson(e))
.toList()
.cast<ChannelMember>(),
account: Account.fromJson(json['account']),
accountId: json['account_id'],
realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null,
@ -61,21 +65,13 @@ class Channel {
'name': name,
'description': description,
'type': type,
'members': members?.map((e) => e.toJson()).toList(),
'account': account.toJson(),
'account_id': accountId,
'realm': realm?.toJson(),
'realm_id': realmId,
'is_encrypted': isEncrypted,
};
IconData get icon {
switch (type) {
case 1:
return FontAwesomeIcons.userGroup;
default:
return FontAwesomeIcons.hashtag;
}
}
}
class ChannelMember {

View File

@ -16,10 +16,10 @@ class Friendship {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.accountId,
required this.relatedId,
this.blockedBy,
required this.blockedBy,
required this.account,
required this.related,
required this.status,

View File

@ -16,25 +16,31 @@ class Notification {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.subject,
required this.content,
this.links,
required this.links,
required this.isImportant,
required this.isRealtime,
this.readAt,
this.senderId,
required this.readAt,
required this.senderId,
required this.recipientId,
});
factory Notification.fromJson(Map<String, dynamic> json) => Notification(
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']),
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'],
links: json['links'] != null ? List<Link>.from(json['links'].map((x) => Link.fromJson(x))) : List.empty(),
links: json['links'] != null
? List<Link>.from(json['links'].map((x) => Link.fromJson(x)))
: List.empty(),
isImportant: json['is_important'],
isRealtime: json['is_realtime'],
readAt: json['read_at'],
@ -49,7 +55,9 @@ class Notification {
'deleted_at': deletedAt,
'subject': subject,
'content': content,
'links': links != null ? List<dynamic>.from(links!.map((x) => x.toJson())) : List.empty(),
'links': links != null
? List<dynamic>.from(links!.map((x) => x.toJson()))
: List.empty(),
'is_important': isImportant,
'is_realtime': isRealtime,
'read_at': readAt,

View File

@ -13,11 +13,11 @@ class PersonalPage {
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.deletedAt,
required this.content,
required this.script,
required this.style,
this.links,
required this.links,
required this.accountId,
});
@ -25,7 +25,9 @@ class PersonalPage {
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: null,
content: json['content'],
script: json['script'],
style: json['style'],

View File

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/friend_select.dart';
import 'package:uuid/uuid.dart';
class ChannelProvider extends GetxController {
Future<Response> getChannel(String alias, {String realm = 'global'}) async {
@ -33,4 +36,70 @@ class ChannelProvider extends GetxController {
return resp;
}
Future<Response> createChannel(String scope, dynamic payload) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.post('/api/channels/$scope', payload);
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return resp;
}
Future<Response?> createDirectChannel(
BuildContext context, String scope) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
final related = await showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => FriendSelect(
title: 'channelOrganizeDirectHint'.tr,
),
);
if (related == null) return null;
final prof = await auth.getProfile();
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.post('/api/channels/$scope/dm', {
'alias': const Uuid().v4().replaceAll('-', '').substring(0, 12),
'name': 'DM',
'description':
'A direct message channel between @${prof.body['name']} and @${related.name}',
'related_user': related.id,
'is_encrypted': false,
});
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return resp;
}
Future<Response> updateChannel(String scope, int id, dynamic payload) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.put('/api/channels/$scope/$id', payload);
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
return resp;
}
}

View File

@ -20,7 +20,7 @@ abstract class AppRouter {
routes: [
ShellRoute(
builder: (context, state, child) =>
NavShell(state: state, child: child, showAppBar: false),
NavShell(state: state, showAppBar: false, child: child),
routes: [
GoRoute(
path: '/',

View File

@ -96,11 +96,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
CircleAvatar(
const CircleAvatar(
radius: 28,
backgroundColor: Colors.teal,
child: FaIcon(
widget.channel.icon,
FontAwesomeIcons.hashtag,
color: Colors.white,
size: 18,
),

View File

@ -1,14 +1,12 @@
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/account/friend_select.dart';
import 'package:solian/widgets/prev_page.dart';
import 'package:uuid/uuid.dart';
@ -29,11 +27,6 @@ class ChannelOrganizeScreen extends StatefulWidget {
}
class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
static Map<int, String> channelTypes = {
0: 'channelTypeCommon'.tr,
1: 'channelTypeDirect'.tr,
};
bool _isBusy = false;
final _aliasController = TextEditingController();
@ -41,38 +34,6 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
final _descriptionController = TextEditingController();
bool _isEncrypted = false;
int _channelType = 0;
List<Account> _initialMembers = List.empty(growable: true);
void selectInitialMembers() async {
final input = await showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => FriendSelect(
title: 'channelMember'.tr,
trailingBuilder: (item) {
if (_initialMembers.any((e) => e.id == item.id)) {
return const Icon(Icons.check);
} else {
return null;
}
},
),
);
if (input == null) return;
setState(() {
if (_initialMembers.any((e) => e.id == input.id)) {
_initialMembers = _initialMembers
.where((e) => e.id != input.id)
.toList(growable: true);
} else {
_initialMembers.add(input as Account);
}
});
}
void applyChannel() async {
final AuthProvider auth = Get.find();
@ -82,6 +43,8 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
setState(() => _isBusy = true);
final ChannelProvider provider = Get.find();
final client = GetConnect(maxAuthRetries: 3);
client.httpClient.baseUrl = ServiceFinder.services['messaging'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
@ -92,27 +55,21 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted,
if (_channelType == 1)
'members': _initialMembers.map((e) => e.id).toList(),
};
Response resp;
if (widget.edit != null) {
resp = await client.put(
'/api/channels/$scope/${widget.edit!.id}',
payload,
);
} else if (_channelType == 1) {
resp = await client.post('/api/channels/$scope/dm', payload);
} else {
resp = await client.post('/api/channels/$scope', payload);
}
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
} else {
AppRouter.instance.pop(resp.body);
Response? resp;
try {
if (widget.edit != null) {
resp = await provider.updateChannel(scope!, widget.edit!.id, payload);
} else {
resp = await provider.createChannel(scope!, payload);
}
} catch (e) {
context.showErrorDialog(e);
}
AppRouter.instance.pop(resp!.body);
setState(() => _isBusy = false);
}
@ -127,7 +84,6 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
_nameController.text = widget.edit!.name;
_descriptionController.text = widget.edit!.description;
_isEncrypted = widget.edit!.isEncrypted;
_channelType = widget.edit!.type;
}
}
@ -227,55 +183,6 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
).paddingSymmetric(horizontal: 16, vertical: 12),
),
const Divider(thickness: 0.3),
if (_channelType == 1 && widget.edit == null)
ListTile(
leading: const Icon(Icons.supervisor_account)
.paddingSymmetric(horizontal: 8),
title: Text('channelMember'.tr),
subtitle: _initialMembers.isNotEmpty
? Text(_initialMembers.map((e) => e.name).join(' '))
: null,
trailing: const Icon(Icons.chevron_right),
onTap: () => selectInitialMembers(),
).animate().fadeIn().slideY(
begin: 1,
end: 0,
curve: Curves.fastEaseInToSlowEaseOut,
),
ListTile(
leading: const Icon(Icons.mode).paddingSymmetric(horizontal: 8),
title: Text('channelType'.tr),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int>(
isExpanded: true,
items: channelTypes.entries
.map((item) => DropdownMenuItem<int>(
enabled: widget.edit == null ||
item.key == widget.edit?.type,
value: item.key,
child: Text(
item.value,
style: const TextStyle(
fontSize: 14,
),
),
))
.toList(),
value: _channelType,
onChanged: (int? value) {
setState(() => _channelType = value ?? 0);
},
buttonStyleData: const ButtonStyleData(
padding: EdgeInsets.only(left: 16, right: 1),
height: 40,
width: 140,
),
menuItemStyleData: const MenuItemStyleData(
height: 40,
),
),
),
),
CheckboxListTile(
title: Text('channelEncrypted'.tr),
value: _isEncrypted,

View File

@ -8,6 +8,7 @@ import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
class ContactScreen extends StatefulWidget {
@ -19,9 +20,16 @@ class ContactScreen extends StatefulWidget {
class _ContactScreenState extends State<ContactScreen> {
bool _isBusy = true;
int? _accountId;
final List<Channel> _channels = List.empty(growable: true);
getProfile() async {
final AuthProvider auth = Get.find();
final prof = await auth.getProfile();
_accountId = prof.body['id'];
}
getChannels() async {
setState(() => _isBusy = true);
@ -42,6 +50,7 @@ class _ContactScreenState extends State<ContactScreen> {
void initState() {
super.initState();
getProfile();
getChannels();
}
@ -81,17 +90,48 @@ class _ContactScreenState extends State<ContactScreen> {
forceElevated: innerBoxIsScrolled,
actions: [
const NotificationButton(),
IconButton(
PopupMenuButton(
icon: const Icon(Icons.add_circle),
onPressed: () {
AppRouter.instance
.pushNamed('channelOrganizing')
.then(
(value) {
if (value != null) getChannels();
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: ListTile(
title: Text('channelOrganizeCommon'.tr),
leading: const Icon(Icons.tag),
contentPadding:
const EdgeInsets.symmetric(horizontal: 8),
),
onTap: () {
AppRouter.instance
.pushNamed('channelOrganizing')
.then(
(value) {
if (value != null) getChannels();
},
);
},
);
},
),
PopupMenuItem(
child: ListTile(
title: Text('channelOrganizeDirect'.tr),
leading: const FaIcon(
FontAwesomeIcons.userGroup,
size: 16,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 8),
),
onTap: () {
final ChannelProvider provider = Get.find();
provider
.createDirectChannel(context, 'global')
.then((resp) {
if (resp != null) {
getChannels();
}
});
},
),
],
),
SizedBox(
width: SolianTheme.isLargeScreen(context) ? 8 : 16,
@ -115,30 +155,7 @@ class _ContactScreenState extends State<ContactScreen> {
itemCount: _channels.length,
itemBuilder: (context, index) {
final element = _channels[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.indigo,
child: FaIcon(
element.icon,
color: Colors.white,
size: 16,
),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 24),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () {
AppRouter.instance.pushNamed(
'channelChat',
pathParameters: {'alias': element.alias},
queryParameters: {
if (element.realmId != null)
'realm': element.realm!.alias,
},
);
},
);
return buildItem(element);
},
),
),
@ -152,4 +169,58 @@ class _ContactScreenState extends State<ContactScreen> {
),
);
}
Widget buildItem(Channel element) {
if (element.type == 1) {
final otherside = element.members!
.where((e) => e.account.externalId != _accountId)
.first;
return ListTile(
leading: AccountAvatar(
content: otherside.account.avatar,
bgColor: Colors.indigo,
feColor: Colors.white,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(otherside.account.name),
subtitle: Text(
'channelDirectDescription'
.trParams({'username': otherside.account.name}),
),
onTap: () {
AppRouter.instance.pushNamed(
'channelChat',
pathParameters: {'alias': element.alias},
queryParameters: {
if (element.realmId != null) 'realm': element.realm!.alias,
},
);
},
);
}
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: FaIcon(
FontAwesomeIcons.hashtag,
color: Colors.white,
size: 16,
),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () {
AppRouter.instance.pushNamed(
'channelChat',
pathParameters: {'alias': element.alias},
queryParameters: {
if (element.realmId != null) 'realm': element.realm!.alias,
},
);
},
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';

View File

@ -105,10 +105,14 @@ class SolianMessages extends Translations {
'realmPublic': 'Public Realm',
'realmCommunity': 'Community Realm',
'channelOrganizing': 'Organize a channel',
'channelOrganizeCommon': 'Create regular channel',
'channelOrganizeDirect': 'Create DM',
'channelOrganizeDirectHint': 'Choose friend to create DM',
'channelEditingNotify': 'You\'re editing channel @channel',
'channelAlias': 'Alias (Identifier)',
'channelName': 'Name',
'channelDescription': 'Description',
'channelDirectDescription': 'Direct message with @username',
'channelEncrypted': 'Encrypted Channel',
'channelMember': 'Channel member',
'channelMembers': 'Channel members',
@ -222,10 +226,14 @@ class SolianMessages extends Translations {
'realmPublic': '公开领域',
'realmCommunity': '社区领域',
'channelOrganizing': '组织频道',
'channelOrganizeCommon': '创建普通频道',
'channelOrganizeDirect': '创建私信频道',
'channelOrganizeDirectHint': '选择好友来创建私信',
'channelEditingNotify': '你正在编辑频道 @channel',
'channelAlias': '别称(标识符)',
'channelName': '显示名称',
'channelDescription': '频道简介',
'channelDirectDescription': '与 @username 的私聊',
'channelEncrypted': '加密频道',
'channelMember': '频道成员',
'channelMembers': '频道成员',

View File

@ -3,11 +3,17 @@ import 'package:solian/services.dart';
class AccountAvatar extends StatelessWidget {
final dynamic content;
final Color? color;
final Color? bgColor;
final Color? feColor;
final double? radius;
const AccountAvatar(
{super.key, required this.content, this.color, this.radius});
const AccountAvatar({
super.key,
required this.content,
this.bgColor,
this.feColor,
this.radius,
});
@override
Widget build(BuildContext context) {
@ -22,7 +28,7 @@ class AccountAvatar extends StatelessWidget {
return CircleAvatar(
key: Key('a$content'),
radius: radius,
backgroundColor: color,
backgroundColor: bgColor,
backgroundImage: !isEmpty
? NetworkImage(
direct
@ -34,6 +40,7 @@ class AccountAvatar extends StatelessWidget {
? Icon(
Icons.account_circle,
size: radius != null ? radius! * 1.2 : 24,
color: feColor,
)
: null,
);

View File

@ -19,7 +19,7 @@ class FriendSelect extends StatefulWidget {
class _FriendSelectState extends State<FriendSelect> {
int _accountId = 0;
List<Friendship> _friends = List.empty(growable: true);
final List<Friendship> _friends = List.empty(growable: true);
getFriends() async {
final AuthProvider auth = Get.find();

View File

@ -56,7 +56,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
ratio: await calculateFileAspectRatio(file),
);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
}
@ -79,7 +79,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
try {
await uploadAttachment(file, hash, ratio: ratio);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
setState(() => _isBusy = false);
@ -102,7 +102,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
try {
await uploadAttachment(file, hash);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
}
@ -136,7 +136,7 @@ class _AttachmentPublishingPopupState extends State<AttachmentPublishingPopup> {
try {
await uploadAttachment(file, hash, ratio: ratio);
} catch (err) {
this.context.showErrorDialog(err);
context.showErrorDialog(err);
}
setState(() => _isBusy = false);
@ -392,7 +392,7 @@ class _AttachmentEditingDialogState extends State<AttachmentEditingDialog> {
);
return Attachment.fromJson(resp.body);
} catch (e) {
this.context.showErrorDialog(e);
context.showErrorDialog(e);
return null;
} finally {
setState(() => _isBusy = false);
@ -406,7 +406,7 @@ class _AttachmentEditingDialogState extends State<AttachmentEditingDialog> {
await provider.deleteAttachment(widget.item.id);
widget.onDelete();
} catch (e) {
this.context.showErrorDialog(e);
context.showErrorDialog(e);
} finally {
setState(() => _isBusy = false);
}

View File

@ -1,4 +1,3 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';