diff --git a/bruig/flutterui/bruig/lib/components/chat/active_chat.dart b/bruig/flutterui/bruig/lib/components/chat/active_chat.dart new file mode 100644 index 00000000..614cf815 --- /dev/null +++ b/bruig/flutterui/bruig/lib/components/chat/active_chat.dart @@ -0,0 +1,316 @@ +import 'package:bruig/components/attach_file.dart'; +import 'package:bruig/components/manage_gc.dart'; +import 'package:bruig/components/snackbars.dart'; +import 'package:bruig/screens/feed/feed_posts.dart'; +import 'package:bruig/models/client.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:bruig/components/profile.dart'; +import 'package:bruig/components/chat/types.dart'; +import 'package:bruig/components/chat/messages.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class ActiveChat extends StatefulWidget { + final ClientModel client; + final FocusNode editLineFocusNode; + ActiveChat(this.client, this.editLineFocusNode, {Key? key}) : super(key: key); + + @override + State createState() => _ActiveChatState(); +} + +/// TODO: Figure out a way to estimate list size to set initialOffset. +/// this way we can get rid of the "initial jump flicker" +class _ActiveChatState extends State { + ClientModel get client => widget.client; + FocusNode get editLineFocusNode => widget.editLineFocusNode; + ChatModel? chat; + late ItemScrollController _itemScrollController; + late ItemPositionsListener _itemPositionsListener; + + void clientChanged() { + var newChat = client.active; + if (newChat != chat) { + setState(() { + chat = newChat; + }); + } + } + + void sendMsg(String msg) async { + try { + await chat?.sendMsg(msg); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted && chat != null) { + var len = chat?.msgs.length; + _itemScrollController.scrollTo( + index: len! - 1, + alignment: 0.0, + curve: Curves.easeOut, + duration: + const Duration(milliseconds: 250), // a little bit smoother + ); + } + }); + } catch (exception) { + if (mounted) { + showErrorSnackbar(context, "Unable to send message: $exception"); + } + } + } + + @override + void initState() { + super.initState(); + _itemScrollController = ItemScrollController(); + _itemPositionsListener = ItemPositionsListener.create(); + chat = client.active; + client.addListener(clientChanged); + } + + @override + void didUpdateWidget(ActiveChat oldWidget) { + oldWidget.client.removeListener(clientChanged); + super.didUpdateWidget(oldWidget); + client.addListener(clientChanged); + } + + @override + void dispose() { + client.removeListener(clientChanged); + editLineFocusNode.dispose(); + super.dispose(); + } + + String nickCapitalLetter() => + chat != null && chat!.nick.isNotEmpty ? chat!.nick[0].toUpperCase() : ""; + + @override + Widget build(BuildContext context) { + if (this.chat == null) return Container(); + var chat = this.chat!; + + var profile = client.profile; + if (profile != null) { + if (chat.isGC) { + return const ManageGCScreen(); + } else { + return UserProfile(client, profile); + } + } + //editLineFocusNode.requestFocus(); + var theme = Theme.of(context); + var textColor = theme.dividerColor; + var darkTextColor = theme.indicatorColor; + var selectedBackgroundColor = theme.highlightColor; + var subMenuBorderColor = theme.canvasColor; + var hightLightTextColor = theme.focusColor; // NAME TEXT COLOR + var avatarColor = colorFromNick(chat.nick); + var avatarTextColor = + ThemeData.estimateBrightnessForColor(avatarColor) == Brightness.dark + ? hightLightTextColor + : darkTextColor; + + return Row(children: [ + Expanded( + child: Column(children: [ + Expanded( + child: Messages(chat, client.nick, client, _itemScrollController, + _itemPositionsListener), + ), + EditLine(sendMsg, chat, editLineFocusNode) + ]), + ), + Visibility( + visible: client.activeSubMenu.isNotEmpty, + child: Container( + width: 250, + decoration: BoxDecoration( + border: Border( + left: BorderSide(width: 2, color: subMenuBorderColor), + ), + ), + child: Stack(alignment: Alignment.topRight, children: [ + Column(children: [ + Container( + margin: const EdgeInsets.only(top: 20, bottom: 20), + child: CircleAvatar( + radius: 75, + backgroundColor: avatarColor, + child: Text( + nickCapitalLetter(), + style: TextStyle(color: avatarTextColor, fontSize: 75), + ), + ), + ), + Visibility( + visible: chat.isGC, + child: Text("Group Chat", + style: TextStyle(fontSize: 15, color: textColor)), + ), + Text(chat.nick, style: TextStyle(fontSize: 15, color: textColor)), + ListView.builder( + shrinkWrap: true, + itemCount: client.activeSubMenu.length, + itemBuilder: (context, index) => ListTile( + title: Text(client.activeSubMenu[index].label, + style: const TextStyle(fontSize: 11)), + onTap: () { + client.activeSubMenu[index].onSelected(context, client); + client.hideSubMenu(); + }, + hoverColor: Colors.black), + ) + ]), + Positioned( + top: 5, + right: 5, + child: Material( + color: selectedBackgroundColor.withOpacity(0), + child: IconButton( + tooltip: "Close", + hoverColor: selectedBackgroundColor, + splashRadius: 15, + iconSize: 15, + onPressed: () => client.hideSubMenu(), + icon: Icon(color: darkTextColor, Icons.close_outlined), + ), + ), + ), + ]), + ), + ) + ]); + } +} + +class EditLine extends StatefulWidget { + final SendMsg _send; + final ChatModel chat; + final FocusNode editLineFocusNode; + EditLine(this._send, this.chat, this.editLineFocusNode, {Key? key}) + : super(key: key); + + @override + State createState() => _EditLineState(); +} + +class _EditLineState extends State { + final controller = TextEditingController(); + + final FocusNode node = FocusNode(); + List embeds = []; + + @override + void initState() { + super.initState(); + controller.text = widget.chat.workingMsg; + } + + @override + void didUpdateWidget(EditLine oldWidget) { + super.didUpdateWidget(oldWidget); + var workingMsg = widget.chat.workingMsg; + if (workingMsg != controller.text) { + controller.text = workingMsg; + controller.selection = TextSelection( + baseOffset: workingMsg.length, extentOffset: workingMsg.length); + widget.editLineFocusNode.requestFocus(); + } + } + + void handleKeyPress(event) { + if (event is RawKeyUpEvent) { + bool modPressed = event.isShiftPressed || event.isControlPressed; + final val = controller.value; + if (event.data.logicalKey.keyLabel == "Enter" && !modPressed) { + final messageWithoutNewLine = + controller.text.substring(0, val.selection.start - 1) + + controller.text.substring(val.selection.start); + controller.value = const TextEditingValue( + text: "", selection: TextSelection.collapsed(offset: 0)); + final String withEmbeds = embeds.fold( + messageWithoutNewLine.trim(), (s, e) => e.replaceInString(s)); + /* + if (withEmbeds.length > 1024 * 1024) { + showErrorSnackbar(context, + "Message is larger than maximum allowed (limit: 1MiB)"); + return; + } + */ + if (withEmbeds != "") { + widget._send(withEmbeds); + widget.chat.workingMsg = ""; + setState(() { + embeds = []; + }); + } + } else { + widget.chat.workingMsg = val.text.trim(); + } + } + } + + void attachFile() async { + var res = await Navigator.of(context, rootNavigator: true) + .pushNamed(AttachFileScreen.routeName); + if (res == null) { + return; + } + var embed = res as AttachmentEmbed; + embeds.add(embed); + setState(() { + controller.text = controller.text + embed.displayString() + " "; + widget.chat.workingMsg = controller.text; + widget.editLineFocusNode.requestFocus(); + }); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var textColor = theme.focusColor; // MESSAGE TEXT COLOR + var hoverColor = theme.hoverColor; + var backgroundColor = theme.highlightColor; + var hintTextColor = theme.dividerColor; + return RawKeyboardListener( + focusNode: node, + onKey: handleKeyPress, + child: Container( + margin: const EdgeInsets.only(bottom: 5), + child: Row( + children: [ + IconButton(onPressed: attachFile, icon: Icon(Icons.attach_file)), + Expanded( + child: TextField( + autofocus: true, + focusNode: widget.editLineFocusNode, + style: TextStyle( + fontSize: 11, + color: textColor, + ), + controller: controller, + minLines: 1, + maxLines: null, + //textInputAction: TextInputAction.done, + //style: normalTextStyle, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + filled: true, + fillColor: backgroundColor, + hoverColor: hoverColor, + isDense: true, + hintText: 'Type a message', + hintStyle: TextStyle( + fontSize: 11, + color: hintTextColor, + ), + border: InputBorder.none, + ), + )), + ], + ), + ), + ); + } +} diff --git a/bruig/flutterui/bruig/lib/components/active_chat.dart b/bruig/flutterui/bruig/lib/components/chat/events.dart similarity index 71% rename from bruig/flutterui/bruig/lib/components/active_chat.dart rename to bruig/flutterui/bruig/lib/components/chat/events.dart index a8d00b27..e018283a 100644 --- a/bruig/flutterui/bruig/lib/components/active_chat.dart +++ b/bruig/flutterui/bruig/lib/components/chat/events.dart @@ -1,308 +1,25 @@ import 'dart:async'; -import 'package:bruig/components/attach_file.dart'; +import 'package:flutter/material.dart'; +import 'package:bruig/models/client.dart'; +import 'package:bruig/components/chat/types.dart'; +import 'package:golib_plugin/definitions.dart'; import 'package:bruig/components/buttons.dart'; -import 'package:bruig/components/chats_list.dart'; -import 'package:bruig/components/manage_gc.dart'; -import 'package:bruig/components/snackbars.dart'; -import 'package:bruig/screens/feed/feed_posts.dart'; import 'package:bruig/components/md_elements.dart'; import 'package:bruig/components/user_content_list.dart'; -import 'package:bruig/models/client.dart'; import 'package:bruig/models/downloads.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:golib_plugin/util.dart'; import 'package:intl/intl.dart'; -import 'package:golib_plugin/definitions.dart'; import 'package:golib_plugin/golib_plugin.dart'; import 'package:provider/provider.dart'; -import 'package:bruig/components/profile.dart'; import 'package:url_launcher/url_launcher.dart'; -import 'package:bruig/components/empty_widget.dart'; import 'package:open_filex/open_filex.dart'; import 'package:file_icon/file_icon.dart'; import 'package:bruig/components/interactive_avatar.dart'; import 'package:bruig/components/user_context_menu.dart'; - -class ActiveChat extends StatefulWidget { - final ClientModel client; - final FocusNode editLineFocusNode; - ActiveChat(this.client, this.editLineFocusNode, {Key? key}) : super(key: key); - - @override - State createState() => _ActiveChatState(); -} - -class _ActiveChatState extends State { - ClientModel get client => widget.client; - FocusNode get editLineFocusNode => widget.editLineFocusNode; - ChatModel? chat; - - void clientChanged() { - var newChat = client.active; - if (newChat != chat) { - setState(() { - chat = newChat; - }); - } - } - - void sendMsg(String msg) async { - try { - await chat?.sendMsg(msg); - } catch (exception) { - if (mounted) { - showErrorSnackbar(context, "Unable to send message: $exception"); - } - } - } - - @override - void initState() { - super.initState(); - chat = client.active; - client.addListener(clientChanged); - } - - @override - void didUpdateWidget(ActiveChat oldWidget) { - oldWidget.client.removeListener(clientChanged); - super.didUpdateWidget(oldWidget); - client.addListener(clientChanged); - } - - @override - void dispose() { - client.removeListener(clientChanged); - editLineFocusNode.dispose(); - super.dispose(); - } - - String nickCapitalLetter() => - chat != null && chat!.nick.isNotEmpty ? chat!.nick[0].toUpperCase() : ""; - - @override - Widget build(BuildContext context) { - if (this.chat == null) return Container(); - var chat = this.chat!; - - var profile = client.profile; - if (profile != null) { - if (chat.isGC) { - return const ManageGCScreen(); - } else { - return UserProfile(client, profile); - } - } - //editLineFocusNode.requestFocus(); - var theme = Theme.of(context); - var textColor = theme.dividerColor; - var darkTextColor = theme.indicatorColor; - var selectedBackgroundColor = theme.highlightColor; - var subMenuBorderColor = theme.canvasColor; - var hightLightTextColor = theme.focusColor; // NAME TEXT COLOR - var avatarColor = colorFromNick(chat.nick); - var avatarTextColor = - ThemeData.estimateBrightnessForColor(avatarColor) == Brightness.dark - ? hightLightTextColor - : darkTextColor; - - showSubMenu(String id) => chat.isGC - ? client.showSubMenu(true, id) - : client.showSubMenu(false, id); - - return Row(children: [ - Expanded( - child: Column(children: [ - Expanded(child: Messages(chat, client.nick, showSubMenu, client)), - Row(children: [ - Expanded(child: EditLine(sendMsg, chat, editLineFocusNode)) - ]) - ])), - client.activeSubMenu.isEmpty - ? const Empty() - : Container( - width: 250, - decoration: BoxDecoration( - border: Border( - left: BorderSide(width: 2, color: subMenuBorderColor))), - child: Stack(alignment: Alignment.topRight, children: [ - Column(children: [ - Container( - margin: const EdgeInsets.only(top: 20, bottom: 20), - child: CircleAvatar( - radius: 75, - backgroundColor: avatarColor, - child: Text(nickCapitalLetter(), - style: TextStyle( - color: avatarTextColor, fontSize: 75)))), - chat.isGC - ? Text("Group Chat", - style: TextStyle(fontSize: 15, color: textColor)) - : Empty(), - Text(chat.nick, - style: TextStyle(fontSize: 15, color: textColor)), - ListView.builder( - shrinkWrap: true, - itemCount: client.activeSubMenu.length, - itemBuilder: (context, index) => ListTile( - title: Text(client.activeSubMenu[index].label, - style: const TextStyle(fontSize: 11)), - onTap: () { - client.activeSubMenu[index] - .onSelected(context, client); - client.hideSubMenu(); - }, - hoverColor: Colors.black), - ) - ]), - Positioned( - top: 5, - right: 5, - child: Material( - color: selectedBackgroundColor.withOpacity(0), - child: IconButton( - tooltip: "Close", - hoverColor: selectedBackgroundColor, - splashRadius: 15, - iconSize: 15, - onPressed: () => client.hideSubMenu(), - icon: Icon( - color: darkTextColor, Icons.close_outlined)))), - ])) - ]); - } -} - -typedef SendMsg = void Function(String msg); - -class EditLine extends StatefulWidget { - final SendMsg _send; - final ChatModel chat; - final FocusNode editLineFocusNode; - EditLine(this._send, this.chat, this.editLineFocusNode, {Key? key}) - : super(key: key); - - @override - State createState() => _EditLineState(); -} - -class _EditLineState extends State { - final controller = TextEditingController(); - - final FocusNode node = FocusNode(); - List embeds = []; - - @override - void initState() { - super.initState(); - controller.text = widget.chat.workingMsg; - } - - @override - void didUpdateWidget(EditLine oldWidget) { - super.didUpdateWidget(oldWidget); - var workingMsg = widget.chat.workingMsg; - if (workingMsg != controller.text) { - controller.text = workingMsg; - controller.selection = TextSelection( - baseOffset: workingMsg.length, extentOffset: workingMsg.length); - widget.editLineFocusNode.requestFocus(); - } - } - - void handleKeyPress(event) { - if (event is RawKeyUpEvent) { - bool modPressed = event.isShiftPressed || event.isControlPressed; - final val = controller.value; - if (event.data.logicalKey.keyLabel == "Enter" && !modPressed) { - final messageWithoutNewLine = - controller.text.substring(0, val.selection.start - 1) + - controller.text.substring(val.selection.start); - controller.value = const TextEditingValue( - text: "", selection: TextSelection.collapsed(offset: 0)); - final String withEmbeds = embeds.fold( - messageWithoutNewLine.trim(), (s, e) => e.replaceInString(s)); - /* - if (withEmbeds.length > 1024 * 1024) { - showErrorSnackbar(context, - "Message is larger than maximum allowed (limit: 1MiB)"); - return; - } - */ - if (withEmbeds != "") { - widget._send(withEmbeds); - widget.chat.workingMsg = ""; - setState(() { - embeds = []; - }); - } - } else { - widget.chat.workingMsg = val.text.trim(); - } - } - } - - void attachFile() async { - var res = await Navigator.of(context, rootNavigator: true) - .pushNamed(AttachFileScreen.routeName); - if (res == null) { - return; - } - var embed = res as AttachmentEmbed; - embeds.add(embed); - setState(() { - controller.text = controller.text + embed.displayString() + " "; - widget.chat.workingMsg = controller.text; - widget.editLineFocusNode.requestFocus(); - }); - } - - @override - Widget build(BuildContext context) { - var theme = Theme.of(context); - var textColor = theme.focusColor; // MESSAGE TEXT COLOR - var hoverColor = theme.hoverColor; - var backgroundColor = theme.highlightColor; - var hintTextColor = theme.dividerColor; - return RawKeyboardListener( - focusNode: node, - onKey: handleKeyPress, - child: Container( - margin: const EdgeInsets.only(bottom: 5), - child: Row(children: [ - IconButton(onPressed: attachFile, icon: Icon(Icons.attach_file)), - Expanded( - child: TextField( - autofocus: true, - focusNode: widget.editLineFocusNode, - style: TextStyle( - fontSize: 11, - color: textColor, - ), - controller: controller, - minLines: 1, - maxLines: null, - //textInputAction: TextInputAction.done, - //style: normalTextStyle, - keyboardType: TextInputType.multiline, - decoration: InputDecoration( - filled: true, - fillColor: backgroundColor, - hoverColor: hoverColor, - isDense: true, - hintText: 'Type a message', - hintStyle: TextStyle( - fontSize: 11, - color: hintTextColor, - ), - border: InputBorder.none, - ), - )), - ]))); - } -} +import 'package:bruig/components/empty_widget.dart'; +// TODO: isolate +import 'package:bruig/screens/feed/feed_posts.dart'; class ServerEvent extends StatelessWidget { final Widget child; @@ -671,6 +388,7 @@ class _PostsListWState extends State { Text("User Posts", style: TextStyle(color: textColor)), ListView.builder( shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), itemCount: posts.length, itemBuilder: (BuildContext context, int index) { return Row( @@ -1149,14 +867,16 @@ class Event extends StatelessWidget { final ChatEventModel event; final ChatModel chat; final String nick; - final ShowSubMenuCB showSubMenu; final ClientModel client; final Function() scrollToBottom; - const Event(this.chat, this.event, this.nick, this.client, - this.scrollToBottom, this.showSubMenu, + const Event( + this.chat, this.event, this.nick, this.client, this.scrollToBottom, {Key? key}) : super(key: key); + showSubMenu(String id) => + chat.isGC ? client.showSubMenu(true, id) : client.showSubMenu(false, id); + @override Widget build(BuildContext context) { if (event.event is PM) { @@ -1239,90 +959,3 @@ class Event extends StatelessWidget { style: TextStyle(color: textColor))); } } - -class Messages extends StatefulWidget { - final ChatModel chat; - final String nick; - final ShowSubMenuCB showSubMenu; - final ClientModel client; - const Messages(this.chat, this.nick, this.showSubMenu, this.client, - {Key? key}) - : super(key: key); - - @override - State createState() => _MessagesState(); -} - -class _MessagesState extends State { - ClientModel get client => widget.client; - ChatModel get chat => widget.chat; - String get nick => widget.nick; - final ScrollController _scroller = ScrollController(); - bool _firstAutoscrollDone = false; - bool _shouldAutoscroll = false; - - void scrollToBottom() { - _scroller.jumpTo(_scroller.position.maxScrollExtent); - } - - void scrollListener() { - _firstAutoscrollDone = true; - if (_scroller.hasClients && - _scroller.position.pixels == _scroller.position.maxScrollExtent) { - _shouldAutoscroll = true; - } else { - _shouldAutoscroll = false; - } - } - - void maybeScrollToBottom() { - if (_scroller.hasClients && (_shouldAutoscroll || !_firstAutoscrollDone)) { - scrollToBottom(); - } - } - - void onChatChanged() { - setState(() { - maybeScrollToBottom(); - }); - Future.delayed(const Duration(milliseconds: 50), () { - setState(maybeScrollToBottom); - }); - } - - @override - initState() { - super.initState(); - _scroller.addListener(scrollListener); - chat.addListener(onChatChanged); - } - - @override - void didUpdateWidget(Messages oldWidget) { - super.didUpdateWidget(oldWidget); - oldWidget.chat.removeListener(onChatChanged); - chat.addListener(onChatChanged); - _firstAutoscrollDone = false; - _shouldAutoscroll = true; - Future.delayed(const Duration(milliseconds: 1), () { - setState(maybeScrollToBottom); - }); - } - - @override - dispose() { - _scroller.removeListener(scrollListener); - chat.removeListener(onChatChanged); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - var msgs = chat.msgs; // Probably inneficient to regenerate every render... - return ListView.builder( - controller: _scroller, - itemCount: msgs.length, - itemBuilder: (context, index) => Event(chat, msgs[index], nick, client, - scrollToBottom, widget.showSubMenu)); - } -} diff --git a/bruig/flutterui/bruig/lib/components/chat/input.dart b/bruig/flutterui/bruig/lib/components/chat/input.dart new file mode 100644 index 00000000..e69de29b diff --git a/bruig/flutterui/bruig/lib/components/chat/messages.dart b/bruig/flutterui/bruig/lib/components/chat/messages.dart new file mode 100644 index 00000000..34f6623f --- /dev/null +++ b/bruig/flutterui/bruig/lib/components/chat/messages.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:bruig/models/client.dart'; +import 'package:bruig/components/chat/events.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +/// TODO: make restoreScrollOffset work. +/// For some reason when trying to use PageStorage the app throws: +/// 'type 'ItemPosition' is not a subtype of type 'double?' in type cast' +class Messages extends StatefulWidget { + final ChatModel chat; + final String nick; + final ClientModel client; + final ItemScrollController itemScrollController; + final ItemPositionsListener itemPositionsListener; + const Messages(this.chat, this.nick, this.client, this.itemScrollController, + this.itemPositionsListener, + {Key? key}) + : super(key: key); + + @override + State createState() => _MessagesState(); +} + +/// Messages scroller states: +/// 1. should scroll bottom - No unread messages +/// 2. should scroll to first unread - If there's one +/// 3. should keep in the bottom - If user has reached end of scroll +class _MessagesState extends State { + ClientModel get client => widget.client; + ChatModel get chat => widget.chat; + String get nick => widget.nick; + bool shouldHoldPosition = false; + int _maxItem = 0; + late ChatModel lastChat; + + void onChatChanged() { + setState(() {}); + } + + @override + initState() { + super.initState(); + widget.itemPositionsListener.itemPositions.addListener(() { + _maxItem = widget.itemPositionsListener.itemPositions.value.isNotEmpty + ? widget.itemPositionsListener.itemPositions.value + .where((ItemPosition position) => position.itemLeadingEdge < 1) + .reduce((ItemPosition max, ItemPosition position) => + position.itemLeadingEdge > max.itemLeadingEdge + ? position + : max) + .index + : 0; + }); + chat.addListener(onChatChanged); + _maybeScrollToFirstUnread(); + _maybeScrollToBottom(); + lastChat = chat; + } + + @override + void didUpdateWidget(Messages oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.chat.removeListener(onChatChanged); + chat.addListener(onChatChanged); + var isSameChat = chat.id == lastChat.id; + var anotherSender = + chat.msgs.isNotEmpty && chat.msgs.last.source?.id != client.publicID; + var receivedNewMsg = isSameChat && anotherSender; + // user received a msg and is reading history (not on scroll maxExtent) + if (receivedNewMsg && _maxItem < lastChat.msgs.length - 2) { + shouldHoldPosition = true; + } else { + shouldHoldPosition = false; + } + _maybeScrollToFirstUnread(); + _maybeScrollToBottom(); + lastChat = chat; + } + + @override + dispose() { + chat.removeListener(onChatChanged); + super.dispose(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + widget.itemScrollController.scrollTo( + index: chat.msgs.length - 1, + alignment: 0.0, + duration: const Duration( + microseconds: 1), // a little bit smoother than a jump + ); + } + }); + } + + void _maybeScrollToBottom() { + final firstUnreadIndex = chat.firstUnreadIndex(); + if (chat.msgs.isNotEmpty && + firstUnreadIndex == -1 && + !shouldHoldPosition && + _maxItem < chat.msgs.length - 1) { + _scrollToBottom(); + } + } + + void _maybeScrollToFirstUnread() { + final firstUnreadIndex = chat.firstUnreadIndex(); + if (chat.msgs.isNotEmpty && firstUnreadIndex != -1) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (mounted) { + widget.itemScrollController.scrollTo( + index: firstUnreadIndex, + alignment: 0.0, + duration: const Duration( + microseconds: 1), // a little bit smoother than a jump + ); + } + }); + } + } + + @override + Widget build(BuildContext context) { + return ScrollablePositionedList.builder( + itemCount: chat.msgs.length, + physics: const ClampingScrollPhysics(), + itemBuilder: (context, index) { + return Event(chat, chat.msgs[index], nick, client, _scrollToBottom); + }, + itemScrollController: widget.itemScrollController, + itemPositionsListener: widget.itemPositionsListener, + ); + } +} diff --git a/bruig/flutterui/bruig/lib/components/chat/types.dart b/bruig/flutterui/bruig/lib/components/chat/types.dart new file mode 100644 index 00000000..03640d89 --- /dev/null +++ b/bruig/flutterui/bruig/lib/components/chat/types.dart @@ -0,0 +1,5 @@ +import 'package:bruig/models/client.dart'; + +typedef SendMsg = void Function(String msg); +typedef MakeActiveCB = void Function(ChatModel? c); +typedef ShowSubMenuCB = void Function(String); diff --git a/bruig/flutterui/bruig/lib/components/chats_list.dart b/bruig/flutterui/bruig/lib/components/chats_list.dart index 66ad7035..643a6f9b 100644 --- a/bruig/flutterui/bruig/lib/components/chats_list.dart +++ b/bruig/flutterui/bruig/lib/components/chats_list.dart @@ -7,9 +7,7 @@ import 'package:golib_plugin/golib_plugin.dart'; import 'package:bruig/components/interactive_avatar.dart'; import 'package:file_picker/file_picker.dart'; import 'package:bruig/components/user_context_menu.dart'; - -typedef MakeActiveCB = void Function(ChatModel? c); -typedef ShowSubMenuCB = void Function(String); +import 'package:bruig/components/chat/types.dart'; class _ChatHeadingW extends StatefulWidget { final ChatModel chat; diff --git a/bruig/flutterui/bruig/lib/components/user_content_list.dart b/bruig/flutterui/bruig/lib/components/user_content_list.dart index bcf330d3..e94e4fec 100644 --- a/bruig/flutterui/bruig/lib/components/user_content_list.dart +++ b/bruig/flutterui/bruig/lib/components/user_content_list.dart @@ -167,6 +167,7 @@ class UserContentListW extends StatelessWidget { Widget build(BuildContext context) { return ListView.builder( shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), itemCount: content.files.length, itemBuilder: (BuildContext context, int index) { return Container( diff --git a/bruig/flutterui/bruig/lib/models/client.dart b/bruig/flutterui/bruig/lib/models/client.dart index 2683f886..a252c096 100644 --- a/bruig/flutterui/bruig/lib/models/client.dart +++ b/bruig/flutterui/bruig/lib/models/client.dart @@ -107,6 +107,17 @@ class ChatModel extends ChangeNotifier { notifyListeners(); } + // return the first unread msg index and -1 if there aren't + // unread msgs + int firstUnreadIndex() { + for (int i = 0; i < _msgs.length; i++) { + if (_msgs[i].firstUnread) { + return i; + } + } + return -1; + } + List _msgs = []; UnmodifiableListView get msgs => UnmodifiableListView(_msgs); void append(ChatEventModel msg) { diff --git a/bruig/flutterui/bruig/lib/screens/chats.dart b/bruig/flutterui/bruig/lib/screens/chats.dart index e1ba6a34..1865d0d8 100644 --- a/bruig/flutterui/bruig/lib/screens/chats.dart +++ b/bruig/flutterui/bruig/lib/screens/chats.dart @@ -9,7 +9,7 @@ import 'package:flutter/material.dart'; import 'package:golib_plugin/definitions.dart'; import 'package:golib_plugin/golib_plugin.dart'; import 'package:provider/provider.dart'; -import '../components/active_chat.dart'; +import 'package:bruig/components/chat/active_chat.dart'; class ChatsScreenTitle extends StatelessWidget { const ChatsScreenTitle({super.key}); @@ -130,7 +130,7 @@ class _InviteNeededPage extends StatelessWidget { const SizedBox(height: 34), Text(''' Bison Relay does not rely on a central server for user accounts, so to chat -with someone else you need to exchange an invitation with them. This is +with someone else you need to exchange an invitation with them. This is just a file that should be sent via some other secure transfer method. After the invitation is accepted, you'll be able to chat with them, and if they diff --git a/bruig/flutterui/bruig/macos/Podfile b/bruig/flutterui/bruig/macos/Podfile index fe733905..049abe29 100644 --- a/bruig/flutterui/bruig/macos/Podfile +++ b/bruig/flutterui/bruig/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.13' +platform :osx, '10.14' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/bruig/flutterui/bruig/macos/Podfile.lock b/bruig/flutterui/bruig/macos/Podfile.lock index be7f6fd8..8ef90f29 100644 --- a/bruig/flutterui/bruig/macos/Podfile.lock +++ b/bruig/flutterui/bruig/macos/Podfile.lock @@ -2,18 +2,29 @@ PODS: - FlutterMacOS (1.0.0) - golib_plugin (0.0.1): - FlutterMacOS - - path_provider_macos (0.0.1): + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter - FlutterMacOS - screen_retriever (0.0.1): - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS - window_manager (0.2.0): - FlutterMacOS DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - golib_plugin (from `Flutter/ephemeral/.symlinks/plugins/golib_plugin/macos`) - - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos`) - screen_retriever (from `Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`) EXTERNAL SOURCES: @@ -21,20 +32,29 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral golib_plugin: :path: Flutter/ephemeral/.symlinks/plugins/golib_plugin/macos - path_provider_macos: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/macos screen_retriever: :path: Flutter/ephemeral/.symlinks/plugins/screen_retriever/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/macos + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos window_manager: :path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos SPEC CHECKSUMS: - FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 golib_plugin: de2bc86bb31074cedbbfee2cdfe0effe2e208738 - path_provider_macos: 3c0c3b4b0d4a76d2bf989a913c2de869c5641a19 + package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce + path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 screen_retriever: 59634572a57080243dd1bf715e55b6c54f241a38 + shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca + url_launcher_macos: c04e4fa86382d4f94f6b38f14625708be3ae52e2 window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8 -PODFILE CHECKSUM: a884f6dd3f7494f3892ee6c81feea3a3abbf9153 +PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 COCOAPODS: 1.11.3 diff --git a/bruig/flutterui/bruig/macos/Runner.xcodeproj/project.pbxproj b/bruig/flutterui/bruig/macos/Runner.xcodeproj/project.pbxproj index 93d507cf..df8df2fa 100644 --- a/bruig/flutterui/bruig/macos/Runner.xcodeproj/project.pbxproj +++ b/bruig/flutterui/bruig/macos/Runner.xcodeproj/project.pbxproj @@ -412,7 +412,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -499,7 +499,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -546,7 +546,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.13; + MACOSX_DEPLOYMENT_TARGET = 10.14; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/bruig/flutterui/bruig/pubspec.lock b/bruig/flutterui/bruig/pubspec.lock index 50f49764..ce97f953 100644 --- a/bruig/flutterui/bruig/pubspec.lock +++ b/bruig/flutterui/bruig/pubspec.lock @@ -519,6 +519,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.5" + scrollable_positioned_list: + dependency: "direct main" + description: + name: scrollable_positioned_list + sha256: ca7fcaa743db712d4f7b1580526f494d0093c77a721a65705ee51fbeac7a2bd3 + url: "https://pub.dev" + source: hosted + version: "0.3.5" shared_preferences: dependency: "direct main" description: diff --git a/bruig/flutterui/bruig/pubspec.yaml b/bruig/flutterui/bruig/pubspec.yaml index b84e54d5..a8db8024 100644 --- a/bruig/flutterui/bruig/pubspec.yaml +++ b/bruig/flutterui/bruig/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: file_icon: ^1.0.0 package_info_plus: ^3.0.3 open_filex: ^4.3.2 + scrollable_positioned_list: ^0.3.5 msix_config: display_name: Bison Relay GUI