diff --git a/lib/main.dart b/lib/main.dart index fff3f97..5df3c30 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,6 +2,7 @@ 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/friend.dart'; import 'package:solian/providers/navigation.dart'; import 'package:solian/providers/notify.dart'; import 'package:solian/router.dart'; @@ -42,6 +43,7 @@ class SolianApp extends StatelessWidget { Provider(create: (_) => AuthProvider()), Provider(create: (_) => ChatProvider()), ChangeNotifierProvider(create: (_) => NotifyProvider()), + ChangeNotifierProvider(create: (_) => FriendProvider()), ], child: NotificationNotifier(child: child ?? Container()), ); diff --git a/lib/models/channel.dart b/lib/models/channel.dart index edd6a9f..72621f6 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -62,4 +62,48 @@ class Channel { "account_id": accountId, "realm_id": realmId, }; +} + +class ChannelMember { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + int channelId; + int accountId; + Account account; + int notify; + + ChannelMember({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.channelId, + required this.accountId, + required this.account, + required this.notify, + }); + + factory ChannelMember.fromJson(Map json) => ChannelMember( + id: json["id"], + createdAt: DateTime.parse(json["created_at"]), + updatedAt: DateTime.parse(json["updated_at"]), + deletedAt: json["deleted_at"], + channelId: json["channel_id"], + accountId: json["account_id"], + account: Account.fromJson(json["account"]), + notify: json["notify"], + ); + + Map toJson() => { + "id": id, + "created_at": createdAt.toIso8601String(), + "updated_at": updatedAt.toIso8601String(), + "deleted_at": deletedAt, + "channel_id": channelId, + "account_id": accountId, + "account": account.toJson(), + "notify": notify, + }; } \ No newline at end of file diff --git a/lib/providers/friend.dart b/lib/providers/friend.dart new file mode 100644 index 0000000..cdfa59f --- /dev/null +++ b/lib/providers/friend.dart @@ -0,0 +1,26 @@ +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'; + +class FriendProvider extends ChangeNotifier { + List friends = List.empty(); + + Future fetch(AuthProvider auth) async { + if (!await auth.isAuthorized()) return; + + var uri = getRequestUri('passport', '/api/users/me/friends?status=1'); + + var res = await auth.client!.get(uri); + if (res.statusCode == 200) { + final result = jsonDecode(utf8.decode(res.bodyBytes)) as List; + friends = result.map((x) => Friendship.fromJson(x)).toList(); + notifyListeners(); + } else { + var message = utf8.decode(res.bodyBytes); + throw Exception(message); + } + } +} \ No newline at end of file diff --git a/lib/router.dart b/lib/router.dart index 3516a41..7f95027 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -6,7 +6,8 @@ import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/chat/chat.dart'; import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/chat/manage.dart'; -import 'package:solian/screens/chat/channel/channel_editor.dart'; +import 'package:solian/screens/chat/channel/editor.dart'; +import 'package:solian/screens/chat/channel/member.dart'; import 'package:solian/screens/explore.dart'; import 'package:solian/screens/notification.dart'; import 'package:solian/screens/posts/comment_editor.dart'; @@ -46,6 +47,11 @@ final router = GoRouter( name: 'chat.channel.manage', builder: (context, state) => ChatManageScreen(channel: state.extra as Channel), ), + GoRoute( + path: '/chat/c/:channel/member', + name: 'chat.channel.member', + builder: (context, state) => ChatMemberScreen(channel: state.extra as Channel), + ), GoRoute( path: '/account', name: 'account', diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 988b172..39bca4b 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/account/avatar.dart'; import 'package:solian/widgets/common_wrapper.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -98,7 +99,7 @@ class NameCard extends StatelessWidget { Future renderAvatar(BuildContext context) async { final auth = context.read(); final profiles = await auth.getProfiles(); - return CircleAvatar(backgroundImage: NetworkImage(profiles["picture"])); + return AccountAvatar(source: profiles["picture"], direct: true); } Future renderLabel(BuildContext context) async { diff --git a/lib/screens/account/friend.dart b/lib/screens/account/friend.dart index dd3c22f..04735c6 100644 --- a/lib/screens/account/friend.dart +++ b/lib/screens/account/friend.dart @@ -7,6 +7,7 @@ import 'package:solian/models/account.dart'; import 'package:solian/models/friendship.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/account/avatar.dart'; import 'package:solian/widgets/indent_wrapper.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -59,6 +60,9 @@ class _FriendScreenState extends State { getRequestUri('passport', '/api/users/me/friends?related=$username'), ); if (res.statusCode == 200) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.friendAddDone)), + ); await fetchFriendships(); } else { var message = utf8.decode(res.bodyBytes); @@ -91,9 +95,6 @@ class _FriendScreenState extends State { }), ); if (res.statusCode == 200) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.friendAddDone)), - ); await fetchFriendships(); } else { var message = utf8.decode(res.bodyBytes); @@ -172,10 +173,6 @@ class _FriendScreenState extends State { } } - String getAvatarUrl(String uuid) { - return getRequestUri('passport', '/api/avatar/$uuid').toString(); - } - @override void initState() { super.initState(); @@ -211,9 +208,7 @@ class _FriendScreenState extends State { child: ListTile( title: Text(otherside.nick), subtitle: Text(otherside.name), - leading: CircleAvatar( - backgroundImage: NetworkImage(getAvatarUrl(otherside.avatar)), - ), + leading: AccountAvatar(source: otherside.avatar), ), onDismissed: (direction) { if (direction == DismissDirection.startToEnd) { diff --git a/lib/screens/chat/channel/channel_editor.dart b/lib/screens/chat/channel/editor.dart similarity index 100% rename from lib/screens/chat/channel/channel_editor.dart rename to lib/screens/chat/channel/editor.dart diff --git a/lib/screens/chat/channel/member.dart b/lib/screens/chat/channel/member.dart new file mode 100644 index 0000000..c46cee7 --- /dev/null +++ b/lib/screens/chat/channel/member.dart @@ -0,0 +1,143 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/account/avatar.dart'; +import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ChatMemberScreen extends StatefulWidget { + final Channel channel; + + const ChatMemberScreen({super.key, required this.channel}); + + @override + State createState() => _ChatMemberScreenState(); +} + +class _ChatMemberScreenState extends State { + bool _isSubmitting = false; + + List _members = List.empty(); + + int _selfId = 0; + + Future fetchMemberships() async { + final auth = context.read(); + final prof = await auth.getProfiles(); + if (!await auth.isAuthorized()) return; + + _selfId = prof['id']; + + var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/members'); + + var res = await auth.client!.get(uri); + if (res.statusCode == 200) { + final result = jsonDecode(utf8.decode(res.bodyBytes)) as List; + setState(() { + _members = result.map((x) => ChannelMember.fromJson(x)).toList(); + }); + } else { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + } + + Future kickMember(ChannelMember item) async { + setState(() => _isSubmitting = true); + + final auth = context.read(); + if (!await auth.isAuthorized()) { + setState(() => _isSubmitting = false); + return; + } + + var uri = getRequestUri('messaging', '/api/channels/${widget.channel.alias}/kick'); + + var res = await auth.client!.post( + uri, + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'account_name': item.account.name, + }), + ); + if (res.statusCode == 200) { + await fetchMemberships(); + } else { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + + setState(() => _isSubmitting = false); + } + + bool getKickable(ChannelMember item) { + if (_selfId != widget.channel.account.externalId) return false; + if (item.accountId == widget.channel.accountId) return false; + if (item.account.externalId == _selfId) return false; + return true; + } + + @override + void initState() { + super.initState(); + + Future.delayed(Duration.zero, () => fetchMemberships()); + } + + @override + Widget build(BuildContext context) { + return IndentWrapper( + title: AppLocalizations.of(context)!.chatMember, + noSafeArea: true, + hideDrawer: true, + child: RefreshIndicator( + onRefresh: () => fetchMemberships(), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), + ), + SliverList.builder( + itemCount: _members.length, + itemBuilder: (context, index) { + final element = _members[index]; + + final randomId = DateTime.now().microsecondsSinceEpoch >> 10; + + return Dismissible( + key: Key(randomId.toString()), + direction: getKickable(element) ? DismissDirection.startToEnd : DismissDirection.none, + background: Container( + color: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.centerLeft, + child: const Icon(Icons.remove, color: Colors.white), + ), + child: ListTile( + leading: AccountAvatar(source: element.account.avatar, direct: true), + title: Text(element.account.nick), + subtitle: Text(element.account.name), + ), + onDismissed: (_) { + kickMember(element); + }, + ); + }, + ) + ], + ), + ), + ); + } +} diff --git a/lib/screens/chat/manage.dart b/lib/screens/chat/manage.dart index 2793b1f..4ad4e73 100644 --- a/lib/screens/chat/manage.dart +++ b/lib/screens/chat/manage.dart @@ -83,7 +83,13 @@ class _ChatManageScreenState extends State { ListTile( leading: const Icon(Icons.supervisor_account), title: Text(AppLocalizations.of(context)!.chatMember), - onTap: () {}, + onTap: () { + router.pushNamed( + 'chat.channel.member', + extra: widget.channel, + pathParameters: {'channel': widget.channel.alias}, + ); + }, ), ...(isOwned ? authorizedItems : List.empty()), ], diff --git a/lib/screens/posts/comment_editor.dart b/lib/screens/posts/comment_editor.dart index 1aa19df..899dae2 100644 --- a/lib/screens/posts/comment_editor.dart +++ b/lib/screens/posts/comment_editor.dart @@ -9,6 +9,7 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; import 'package:solian/utils/service_url.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:solian/widgets/account/avatar.dart'; import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/posts/attachment_editor.dart'; @@ -145,8 +146,9 @@ class _CommentEditorScreenState extends State { subtitle: Text( AppLocalizations.of(context)!.postIdentityNotify, ), - leading: CircleAvatar( - backgroundImage: NetworkImage(userinfo["picture"]), + leading: AccountAvatar( + source: userinfo["picture"], + direct: true, ), ); } else { diff --git a/lib/screens/posts/moment_editor.dart b/lib/screens/posts/moment_editor.dart index dd36f6c..ae1b838 100644 --- a/lib/screens/posts/moment_editor.dart +++ b/lib/screens/posts/moment_editor.dart @@ -9,6 +9,7 @@ import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; import 'package:solian/utils/service_url.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:solian/widgets/account/avatar.dart'; import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/posts/attachment_editor.dart'; @@ -135,8 +136,9 @@ class _MomentEditorScreenState extends State { subtitle: Text( AppLocalizations.of(context)!.postIdentityNotify, ), - leading: CircleAvatar( - backgroundImage: NetworkImage(userinfo["picture"]), + leading: AccountAvatar( + source: userinfo["picture"], + direct: true, ), ); } else { diff --git a/lib/widgets/account/avatar.dart b/lib/widgets/account/avatar.dart new file mode 100644 index 0000000..551649e --- /dev/null +++ b/lib/widgets/account/avatar.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:solian/utils/service_url.dart'; + +class AccountAvatar extends StatelessWidget { + final String source; + final double? radius; + final bool? direct; + final Color? backgroundColor; + + const AccountAvatar({ + super.key, + required this.source, + this.radius, + this.direct, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + final detectRegex = RegExp(r'https://.*/api/avatar/'); + + if (source.isEmpty || source.replaceAll(detectRegex, '').isEmpty) { + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor, + child: const Icon(Icons.account_circle), + ); + } + if (direct == true) { + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor, + backgroundImage: NetworkImage(source), + ); + } else { + final url = getRequestUri('passport', '/api/avatar/$source').toString(); + return CircleAvatar( + radius: radius, + backgroundColor: backgroundColor, + backgroundImage: NetworkImage(url), + ); + } + } +} diff --git a/lib/widgets/chat/message.dart b/lib/widgets/chat/message.dart index 5cf0670..e4f2e89 100644 --- a/lib/widgets/chat/message.dart +++ b/lib/widgets/chat/message.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:solian/models/message.dart'; +import 'package:solian/widgets/account/avatar.dart'; import 'package:solian/widgets/chat/content.dart'; import 'package:solian/widgets/posts/content/attachment.dart'; import 'package:timeago/timeago.dart' as timeago; @@ -38,9 +39,10 @@ class ChatMessage extends StatelessWidget { child: const Icon(Icons.reply, size: 16), ), const SizedBox(width: 8), - CircleAvatar( + AccountAvatar( radius: 10, - backgroundImage: NetworkImage(item.replyTo!.sender.account.avatar), + source: item.replyTo!.sender.account.avatar, + direct: true, ), ], ), @@ -102,8 +104,9 @@ class ChatMessage extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - backgroundImage: NetworkImage(item.sender.account.avatar), + AccountAvatar( + source: item.sender.account.avatar, + direct: true, ), Expanded( child: Column( diff --git a/lib/widgets/posts/item.dart b/lib/widgets/posts/item.dart index e9c43d0..638311e 100644 --- a/lib/widgets/posts/item.dart +++ b/lib/widgets/posts/item.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:solian/models/post.dart'; +import 'package:solian/widgets/account/avatar.dart'; import 'package:solian/widgets/posts/comment_list.dart'; import 'package:solian/widgets/posts/content/article.dart'; import 'package:solian/widgets/posts/content/attachment.dart'; @@ -166,8 +167,9 @@ class _PostItemState extends State { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - backgroundImage: NetworkImage(widget.item.author.avatar), + AccountAvatar( + source: widget.item.author.avatar, + direct: true, ), Expanded( child: Column( @@ -196,8 +198,9 @@ class _PostItemState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( - backgroundImage: NetworkImage(widget.item.author.avatar), + AccountAvatar( + source: widget.item.author.avatar, + direct: true, ), Expanded( child: Column(