import 'dart:async'; import 'dart:io'; import 'package:famedlysdk/famedlysdk.dart'; import 'package:file_picker/file_picker.dart'; import 'package:fluffychat/components/adaptive_page_layout.dart'; import 'package:fluffychat/components/chat_settings_popup_menu.dart'; import 'package:fluffychat/components/list_items/message.dart'; import 'package:fluffychat/components/matrix.dart'; import 'package:fluffychat/i18n/i18n.dart'; import 'package:fluffychat/utils/room_extension.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:toast/toast.dart'; import 'package:pedantic/pedantic.dart'; import 'chat_list.dart'; class ChatView extends StatelessWidget { final String id; const ChatView(this.id, {Key key}) : super(key: key); @override Widget build(BuildContext context) { // TODO: implement build return AdaptivePageLayout( primaryPage: FocusPage.SECOND, firstScaffold: ChatList( activeChat: id, ), secondScaffold: _Chat(id), ); } } class _Chat extends StatefulWidget { final String id; const _Chat(this.id, {Key key}) : super(key: key); @override _ChatState createState() => _ChatState(); } class _ChatState extends State<_Chat> { Room room; Timeline timeline; MatrixState matrix; String seenByText = ""; final ScrollController _scrollController = ScrollController(); FocusNode inputFocus = FocusNode(); Timer typingCoolDown; Timer typingTimeout; bool currentlyTyping = false; @override void initState() { _scrollController.addListener(() async { if (_scrollController.position.pixels == _scrollController.position.maxScrollExtent && timeline.events.isNotEmpty && timeline.events[timeline.events.length - 1].type != EventTypes.RoomCreate) { await timeline.requestHistory(historyCount: 100); } }); super.initState(); } void updateView() { if (!mounted) return; String seenByText = ""; if (timeline.events.isNotEmpty) { List lastReceipts = List.from(timeline.events.first.receipts); lastReceipts.removeWhere((r) => r.user.id == room.client.userID || r.user.id == timeline.events.first.senderId); if (lastReceipts.length == 1) { seenByText = I18n.of(context) .seenByUser(lastReceipts.first.user.calcDisplayname()); } else if (lastReceipts.length == 2) { seenByText = seenByText = I18n.of(context).seenByUserAndUser( lastReceipts.first.user.calcDisplayname(), lastReceipts[1].user.calcDisplayname()); } else if (lastReceipts.length > 2) { seenByText = I18n.of(context).seenByUserAndCountOthers( lastReceipts.first.user.calcDisplayname(), (lastReceipts.length - 1).toString()); } } if (timeline != null) { setState(() { this.seenByText = seenByText; }); } } Future getTimeline() async { if (timeline == null) { timeline = await room.getTimeline(onUpdate: updateView); if (timeline.events.isNotEmpty) { unawaited(room.sendReadReceipt(timeline.events.first.eventId)); } } updateView(); return true; } @override void dispose() { timeline?.sub?.cancel(); timeline = null; matrix.activeRoomId = ""; super.dispose(); } TextEditingController sendController = TextEditingController(); void send() { if (sendController.text.isEmpty) return; room.sendTextEvent(sendController.text); sendController.text = ""; } void sendFileAction(BuildContext context) async { if (kIsWeb) { return Toast.show(I18n.of(context).notSupportedInWeb, context); } File file = await FilePicker.getFile(); if (file == null) return; await matrix.tryRequestWithLoadingDialog( room.sendFileEvent( MatrixFile(bytes: await file.readAsBytes(), path: file.path), ), ); } void sendImageAction(BuildContext context) async { if (kIsWeb) { return Toast.show(I18n.of(context).notSupportedInWeb, context); } File file = await ImagePicker.pickImage( source: ImageSource.gallery, imageQuality: 50, maxWidth: 1600, maxHeight: 1600); if (file == null) return; await matrix.tryRequestWithLoadingDialog( room.sendImageEvent( MatrixFile(bytes: await file.readAsBytes(), path: file.path), ), ); } void openCameraAction(BuildContext context) async { if (kIsWeb) { return Toast.show(I18n.of(context).notSupportedInWeb, context); } File file = await ImagePicker.pickImage( source: ImageSource.camera, imageQuality: 50, maxWidth: 1600, maxHeight: 1600); if (file == null) return; await matrix.tryRequestWithLoadingDialog( room.sendImageEvent( MatrixFile(bytes: await file.readAsBytes(), path: file.path), ), ); } @override Widget build(BuildContext context) { matrix = Matrix.of(context); Client client = matrix.client; room ??= client.getRoomById(widget.id); if (room == null) { return Scaffold( appBar: AppBar( title: Text(I18n.of(context).oopsSomethingWentWrong), ), body: Center( child: Text(I18n.of(context).youAreNoLongerParticipatingInThisChat), ), ); } matrix.activeRoomId = widget.id; if (room.membership == Membership.invite) { matrix.tryRequestWithLoadingDialog(room.join()); } String typingText = ""; List typingUsers = room.typingUsers; typingUsers.removeWhere((User u) => u.id == client.userID); if (typingUsers.length == 1) { typingText = I18n.of(context).isTyping; if (typingUsers.first.id != room.directChatMatrixID) { typingText = I18n.of(context).userIsTyping(typingUsers.first.calcDisplayname()); } } else if (typingUsers.length == 2) { typingText = I18n.of(context).userAndUserAreTyping( typingUsers.first.calcDisplayname(), typingUsers[1].calcDisplayname()); } else if (typingUsers.length > 2) { typingText = I18n.of(context).userAndOthersAreTyping( typingUsers.first.calcDisplayname(), (typingUsers.length - 1).toString()); } return Scaffold( appBar: AppBar( title: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(room.getLocalizedDisplayname(context)), AnimatedContainer( duration: Duration(milliseconds: 500), height: typingText.isEmpty ? 0 : 20, child: Row( children: [ typingText.isEmpty ? Container() : Icon(Icons.edit, color: Theme.of(context).primaryColor, size: 10), SizedBox(width: 4), Text( typingText, style: TextStyle( color: Theme.of(context).primaryColor, fontStyle: FontStyle.italic, ), ), ], ), ), ], ), actions: [ChatSettingsPopupMenu(room, !room.isDirectChat)], ), body: SafeArea( child: Column( children: [ Expanded( child: FutureBuilder( future: getTimeline(), builder: (BuildContext context, snapshot) { if (!snapshot.hasData) { return Center( child: CircularProgressIndicator(), ); } if (room.notificationCount != null && room.notificationCount > 0 && timeline != null && timeline.events.isNotEmpty) { room.sendReadReceipt(timeline.events.first.eventId); } if (timeline.events.isEmpty) return Container(); return ListView.builder( reverse: true, itemCount: timeline.events.length + 1, controller: _scrollController, itemBuilder: (BuildContext context, int i) { return i == 0 ? AnimatedContainer( height: seenByText.isEmpty ? 0 : 24, duration: seenByText.isEmpty ? Duration(milliseconds: 0) : Duration(milliseconds: 500), alignment: timeline.events.first.senderId == client.userID ? Alignment.topRight : Alignment.topLeft, child: Text( seenByText, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: Theme.of(context).primaryColor, ), ), padding: EdgeInsets.only( left: 8, right: 8, bottom: 8, ), ) : Message(timeline.events[i - 1], nextEvent: i >= 2 ? timeline.events[i - 2] : null); }); }, ), ), room.canSendDefaultMessages && room.membership == Membership.join ? Container( decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.grey.withOpacity(0.2), spreadRadius: 1, blurRadius: 2, offset: Offset(0, -1), // changes position of shadow ), ], ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ kIsWeb ? Container() : PopupMenuButton( icon: Icon(Icons.add), onSelected: (String choice) async { if (choice == "file") { sendFileAction(context); } else if (choice == "image") { sendImageAction(context); } if (choice == "camera") { openCameraAction(context); } }, itemBuilder: (BuildContext context) => >[ PopupMenuItem( value: "file", child: ListTile( leading: CircleAvatar( backgroundColor: Colors.green, foregroundColor: Colors.white, child: Icon(Icons.attachment), ), title: Text(I18n.of(context).sendFile), contentPadding: EdgeInsets.all(0), ), ), PopupMenuItem( value: "image", child: ListTile( leading: CircleAvatar( backgroundColor: Colors.blue, foregroundColor: Colors.white, child: Icon(Icons.image), ), title: Text(I18n.of(context).sendImage), contentPadding: EdgeInsets.all(0), ), ), PopupMenuItem( value: "camera", child: ListTile( leading: CircleAvatar( backgroundColor: Colors.purple, foregroundColor: Colors.white, child: Icon(Icons.camera), ), title: Text(I18n.of(context).openCamera), contentPadding: EdgeInsets.all(0), ), ), ], ), SizedBox(width: 8), Expanded( child: Padding( padding: const EdgeInsets.symmetric(vertical: 4.0), child: TextField( minLines: 1, maxLines: kIsWeb ? 1 : 8, keyboardType: kIsWeb ? TextInputType.text : TextInputType.multiline, onSubmitted: (String text) { send(); FocusScope.of(context).requestFocus(inputFocus); }, focusNode: inputFocus, controller: sendController, decoration: InputDecoration( hintText: I18n.of(context).writeAMessage, border: InputBorder.none, ), onChanged: (String text) { this.typingCoolDown?.cancel(); this.typingCoolDown = Timer(Duration(seconds: 2), () { this.typingCoolDown = null; this.currentlyTyping = false; room.sendTypingInfo(false); }); this.typingTimeout ??= Timer(Duration(seconds: 30), () { this.typingTimeout = null; this.currentlyTyping = false; }); if (!this.currentlyTyping) { this.currentlyTyping = true; room.sendTypingInfo(true, timeout: Duration(seconds: 30).inMilliseconds); } }, ), )), SizedBox(width: 8), IconButton( icon: Icon(Icons.send), onPressed: () => send(), ), ], ), ) : Container(), ], ), ), ); } }