diff --git a/lib/models/account.dart b/lib/models/account.dart new file mode 100644 index 0000000..349d027 --- /dev/null +++ b/lib/models/account.dart @@ -0,0 +1,59 @@ +class Account { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + String name; + String nick; + String avatar; + String banner; + String description; + String emailAddress; + int powerLevel; + int externalId; + + Account({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.name, + required this.nick, + required this.avatar, + required this.banner, + required this.description, + required this.emailAddress, + required this.powerLevel, + required this.externalId, + }); + + factory Account.fromJson(Map json) => Account( + id: json["id"], + createdAt: DateTime.parse(json["created_at"]), + updatedAt: DateTime.parse(json["updated_at"]), + deletedAt: json["deleted_at"], + name: json["name"], + nick: json["nick"], + avatar: json["avatar"], + banner: json["banner"], + description: json["description"], + emailAddress: json["email_address"], + powerLevel: json["power_level"], + externalId: json["external_id"], + ); + + Map toJson() => { + "id": id, + "created_at": createdAt.toIso8601String(), + "updated_at": updatedAt.toIso8601String(), + "deleted_at": deletedAt, + "name": name, + "nick": nick, + "avatar": avatar, + "banner": banner, + "description": description, + "email_address": emailAddress, + "power_level": powerLevel, + "external_id": externalId, + }; +} diff --git a/lib/models/message.dart b/lib/models/message.dart new file mode 100644 index 0000000..a4614fe --- /dev/null +++ b/lib/models/message.dart @@ -0,0 +1,115 @@ +import 'package:solian/models/account.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/models/post.dart'; + +class Message { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + String content; + dynamic metadata; + int type; + List? attachments; + Channel? channel; + Sender sender; + int? replyId; + Message? replyTo; + int channelId; + int senderId; + + Message({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.content, + required this.metadata, + required this.type, + this.attachments, + this.channel, + required this.sender, + required this.replyId, + required this.replyTo, + required this.channelId, + required this.senderId, + }); + + factory Message.fromJson(Map json) => Message( + id: json["id"], + createdAt: DateTime.parse(json["created_at"]), + updatedAt: DateTime.parse(json["updated_at"]), + deletedAt: json["deleted_at"], + content: json["content"], + metadata: json["metadata"], + type: json["type"], + attachments: List.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, + channelId: json["channel_id"], + senderId: json["sender_id"], + ); + + Map toJson() => { + "id": id, + "created_at": createdAt.toIso8601String(), + "updated_at": updatedAt.toIso8601String(), + "deleted_at": deletedAt, + "content": content, + "metadata": metadata, + "type": type, + "attachments": List.from(attachments?.map((x) => x.toJson()) ?? List.empty()), + "channel": channel?.toJson(), + "sender": sender.toJson(), + "reply_id": replyId, + "reply_to": replyTo?.toJson(), + "channel_id": channelId, + "sender_id": senderId, + }; +} + +class Sender { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + Account account; + int channelId; + int accountId; + int notify; + + Sender({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.account, + required this.channelId, + required this.accountId, + required this.notify, + }); + + factory Sender.fromJson(Map json) => Sender( + id: json["id"], + createdAt: DateTime.parse(json["created_at"]), + updatedAt: DateTime.parse(json["updated_at"]), + deletedAt: json["deleted_at"], + account: Account.fromJson(json["account"]), + channelId: json["channel_id"], + accountId: json["account_id"], + notify: json["notify"], + ); + + Map toJson() => { + "id": id, + "created_at": createdAt.toIso8601String(), + "updated_at": updatedAt.toIso8601String(), + "deleted_at": deletedAt, + "account": account.toJson(), + "channel_id": channelId, + "account_id": accountId, + "notify": notify, + }; +} diff --git a/lib/router.dart b/lib/router.dart index ce0e0d8..4aa73c4 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -1,6 +1,7 @@ import 'package:go_router/go_router.dart'; import 'package:solian/models/post.dart'; import 'package:solian/screens/account.dart'; +import 'package:solian/screens/chat/chat.dart'; import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/explore.dart'; import 'package:solian/screens/posts/comment_editor.dart'; @@ -19,6 +20,11 @@ final router = GoRouter( name: 'chat', builder: (context, state) => const ChatIndexScreen(), ), + GoRoute( + path: '/chat/:channel', + name: 'chat.channel', + builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String), + ), GoRoute( path: '/account', name: 'account', diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart new file mode 100644 index 0000000..2590065 --- /dev/null +++ b/lib/screens/chat/chat.dart @@ -0,0 +1,121 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/models/channel.dart'; +import 'package:solian/models/message.dart'; +import 'package:solian/models/pagination.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/chat/message.dart'; +import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:http/http.dart' as http; + +class ChatScreen extends StatefulWidget { + final String alias; + + const ChatScreen({super.key, required this.alias}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + Channel? _channelMeta; + + final PagingController _pagingController = PagingController(firstPageKey: 0); + + final http.Client _client = http.Client(); + + Future fetchMetadata(BuildContext context) async { + var uri = getRequestUri('messaging', '/api/channels/${widget.alias}'); + var res = await _client.get(uri); + if (res.statusCode == 200) { + final result = jsonDecode(utf8.decode(res.bodyBytes)); + setState(() => _channelMeta = Channel.fromJson(result)); + } else { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + } + + Future fetchMessages(int pageKey, BuildContext context) async { + final auth = context.read(); + if (!await auth.isAuthorized()) return; + + final offset = pageKey; + const take = 5; + + var uri = getRequestUri( + 'messaging', + '/api/channels/${widget.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) { + _pagingController.appendLastPage(items); + } else { + final nextPageKey = pageKey + items.length; + _pagingController.appendPage(items, nextPageKey); + } + } else { + _pagingController.error = utf8.decode(res.bodyBytes); + } + } + + bool getMessageMergeable(Message? a, Message? b) { + if (a == null || b == null) return false; + if (a.senderId != b.senderId) return false; + return a.createdAt.difference(b.createdAt).inMinutes <= 5; + } + + @override + void initState() { + Future.delayed(Duration.zero, () { + fetchMetadata(context); + }); + + _pagingController.addPageRequestListener((pageKey) => fetchMessages(pageKey, context)); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return IndentWrapper( + noSafeArea: true, + hideDrawer: true, + title: _channelMeta?.name ?? "Loading...", + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + bool isMerged = false, hasMerged = false; + if (index > 0) { + isMerged = getMessageMergeable(_pagingController.itemList?[index - 1], item); + } + if (index + 1 < (_pagingController.itemList?.length ?? 0)) { + hasMerged = getMessageMergeable(item, _pagingController.itemList?[index + 1]); + } + return Container( + padding: EdgeInsets.only( + top: !isMerged ? 8 : 0, + bottom: !hasMerged ? 8 : 0, + left: 12, + right: 12, + ), + child: ChatMessage(item: item, underMerged: isMerged), + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/chat/index.dart b/lib/screens/chat/index.dart index 452a486..3e575ce 100644 --- a/lib/screens/chat/index.dart +++ b/lib/screens/chat/index.dart @@ -4,6 +4,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/router.dart'; import 'package:solian/utils/service_url.dart'; import 'package:solian/widgets/indent_wrapper.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -64,7 +65,14 @@ class _ChatIndexScreenState extends State { ), title: Text(element.name), subtitle: Text(element.description), - onTap: () {}, + onTap: () { + router.pushNamed( + 'chat.channel', + pathParameters: { + 'channel': element.alias, + }, + ); + }, ); }, ), diff --git a/lib/screens/posts/screen.dart b/lib/screens/posts/screen.dart index 543dbba..2eb645a 100644 --- a/lib/screens/posts/screen.dart +++ b/lib/screens/posts/screen.dart @@ -44,6 +44,7 @@ class _PostScreenState extends State { @override Widget build(BuildContext context) { return IndentWrapper( + noSafeArea: true, hideDrawer: true, title: AppLocalizations.of(context)!.post, child: FutureBuilder( diff --git a/lib/widgets/chat/content.dart b/lib/widgets/chat/content.dart new file mode 100644 index 0000000..fd8ed38 --- /dev/null +++ b/lib/widgets/chat/content.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:solian/models/message.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +class ChatMessageContent extends StatelessWidget { + final Message item; + + const ChatMessageContent({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + return Markdown( + selectable: true, + data: item.content, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(0), + onTapLink: (text, href, title) async { + if (href == null) return; + await launchUrlString( + href, + mode: LaunchMode.externalApplication, + ); + }, + ); + } +} diff --git a/lib/widgets/chat/message.dart b/lib/widgets/chat/message.dart new file mode 100644 index 0000000..491fcf8 --- /dev/null +++ b/lib/widgets/chat/message.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:solian/models/message.dart'; +import 'package:solian/widgets/chat/content.dart'; +import 'package:solian/widgets/posts/content/attachment.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class ChatMessage extends StatelessWidget { + final Message item; + final bool underMerged; + + const ChatMessage({super.key, required this.item, required this.underMerged}); + + Widget renderAttachment() { + if (item.attachments != null && item.attachments!.isNotEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: AttachmentList(items: item.attachments!, provider: 'messaging'), + ); + } else { + return Container(); + } + } + + @override + Widget build(BuildContext context) { + final contentPart = Padding( + padding: const EdgeInsets.only(left: 12, right: 12, top: 2), + child: ChatMessageContent(item: item), + ); + + final userinfoPart = Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Text( + item.sender.account.nick, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 4), + Text(timeago.format(item.createdAt)) + ], + ), + ); + + if (underMerged) { + return Row( + children: [ + const SizedBox(width: 40), + Expanded(child: contentPart), + ], + ); + } else { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + backgroundImage: NetworkImage(item.sender.account.avatar), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + userinfoPart, + contentPart, + renderAttachment(), + ], + ), + ), + ], + ); + } + } +} diff --git a/lib/widgets/posts/content/attachment.dart b/lib/widgets/posts/content/attachment.dart index 5e839c0..d44b262 100644 --- a/lib/widgets/posts/content/attachment.dart +++ b/lib/widgets/posts/content/attachment.dart @@ -117,10 +117,11 @@ class _AttachmentItemState extends State { class AttachmentList extends StatelessWidget { final List items; + final String provider; - const AttachmentList({super.key, required this.items}); + const AttachmentList({super.key, required this.items, required this.provider}); - Uri getFileUri(String fileId) => getRequestUri('interactive', '/api/attachments/o/$fileId'); + Uri getFileUri(String fileId) => getRequestUri(provider, '/api/attachments/o/$fileId'); @override Widget build(BuildContext context) { diff --git a/lib/widgets/posts/item.dart b/lib/widgets/posts/item.dart index 4339fb0..d2d18db 100644 --- a/lib/widgets/posts/item.dart +++ b/lib/widgets/posts/item.dart @@ -1,7 +1,4 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/painting.dart'; -import 'package:flutter/widgets.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:solian/models/post.dart'; import 'package:solian/widgets/posts/comment_list.dart'; @@ -10,6 +7,7 @@ import 'package:solian/widgets/posts/content/attachment.dart'; import 'package:solian/widgets/posts/content/moment.dart'; import 'package:solian/widgets/posts/item_action.dart'; import 'package:solian/widgets/posts/reaction_list.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:timeago/timeago.dart' as timeago; class PostItem extends StatefulWidget { @@ -89,7 +87,7 @@ class _PostItemState extends State { widget.item.attachments!.isNotEmpty) { return Padding( padding: const EdgeInsets.only(top: 8), - child: AttachmentList(items: widget.item.attachments!), + child: AttachmentList(items: widget.item.attachments!, provider: 'interactive'), ); } else { return Container(); @@ -97,8 +95,6 @@ class _PostItemState extends State { } Widget renderReactions() { - const density = VisualDensity(horizontal: -4, vertical: -2); - return Container( height: 48, padding: const EdgeInsets.only(top: 8, left: 4, right: 4), @@ -108,7 +104,7 @@ class _PostItemState extends State { ActionChip( avatar: const Icon(Icons.comment), label: Text(widget.item.commentCount.toString()), - tooltip: 'Comment', + tooltip: AppLocalizations.of(context)!.comment, onPressed: () => viewComments(context), ), const VerticalDivider(thickness: 0.3, indent: 8, endIndent: 8), diff --git a/pubspec.lock b/pubspec.lock index a803ee2..dd57944 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://pub.dev" source: hosted - version: "1.0.6" + version: "1.0.8" dbus: dependency: transitive description: @@ -183,10 +183,10 @@ packages: dependency: "direct main" description: name: flutter_markdown - sha256: "31c12de79262b5431c5492e9c89948aa789158435f707d3519a7fdef6af28af7" + sha256: "04c4722cc36ec5af38acc38ece70d22d3c2123c61305d555750a091517bbe504" url: "https://pub.dev" source: hosted - version: "0.6.22+1" + version: "0.6.23" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -269,6 +269,22 @@ packages: url: "https://pub.dev" source: hosted version: "13.2.4" + hive: + dependency: transitive + description: + name: hive + sha256: "8dcf6db979d7933da8217edcec84e9df1bdb4e4edc7fc77dbd5aa74356d6d941" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + name: hive_flutter + sha256: dca1da446b1d808a51689fb5d0c6c9510c0a2ba01e22805d492c73b68e33eecc + url: "https://pub.dev" + source: hosted + version: "1.1.0" http: dependency: "direct main" description: @@ -297,34 +313,34 @@ packages: dependency: "direct main" description: name: image_picker - sha256: "1f498d086203360cca099d20ffea2963f48c39ce91bdd8a3b6d4a045786b02c8" + sha256: fe9ee64ccb8d599a5dfb0e21cc6652232c610bcf667af4e79b9eb175cc30a7a5 url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.1.0" image_picker_android: dependency: transitive description: name: image_picker_android - sha256: "844c6da4e4f2829dffdab97816bca09d0e0977e8dcef7450864aba4e07967a58" + sha256: "8e75431a62b7feb4fd55cb4a5c6f0ac4564460ec5dc09f9c4a0d50a5ce7c4cb9" url: "https://pub.dev" source: hosted - version: "0.8.9+6" + version: "0.8.10" image_picker_for_web: dependency: transitive description: name: image_picker_for_web - sha256: "6a1704fdd75022272e7e7a897a9068e9c2ff3cd6a66820bf3ded810633eac954" + sha256: "5d6eb13048cd47b60dbf1a5495424dea226c5faf3950e20bf8120a58efb5b5f3" url: "https://pub.dev" source: hosted - version: "3.0.3" + version: "3.0.4" image_picker_ios: dependency: transitive description: name: image_picker_ios - sha256: "917a5cadd67d052554cfb258595e54217de53fac5b52939426e26319a02e6297" + sha256: f4a6f62be96d6fd268f32a6bf8ef444cd8e3fff64d16923c6e6fe55e0c84a761 url: "https://pub.dev" source: hosted - version: "0.8.9+2" + version: "0.8.10" image_picker_linux: dependency: transitive description: @@ -878,10 +894,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "3692a459204a33e04bc94f5fb91158faf4f2c8903281ddd82915adecdb1a901d" + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.3.1" url_launcher_windows: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 854be24..037eade 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: uuid: ^4.4.0 media_kit: ^1.1.10+1 media_kit_libs_video: ^1.0.4 + hive_flutter: ^1.1.0 dev_dependencies: flutter_test: