diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index a90d828e..6fb8664b 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -6,10 +6,14 @@ additional functionality it is fine to subclass or reimplement FlutterApplication and put your custom class here. --> + + + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true" + > value..extraData['image'] = getRandomPicUrl(value)); return Scaffold( - body: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only( - top: 34, - bottom: 20, - ), - child: Center( - child: SvgPicture.asset( - 'assets/logo.svg', - height: 40, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 34, + bottom: 20, + ), + child: Center( + child: SvgPicture.asset( + 'assets/logo.svg', + height: 40, + ), ), ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 13.0), - child: Text( - 'Welcome to Stream Chat', + Padding( + padding: const EdgeInsets.only(bottom: 13.0), + child: Text( + 'Welcome to Stream Chat', + style: TextStyle( + fontSize: 22, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + 'Select a user to try the Flutter SDK:', style: TextStyle( - fontSize: 22, + fontSize: 14.5, color: Colors.black, - fontWeight: FontWeight.bold, ), ), - ), - Text( - 'Select a user to try the Flutter SDK:', - style: TextStyle( - fontSize: 14.5, - color: Colors.black, - ), - ), - Expanded( - child: ListView.separated( - separatorBuilder: (context, i) { - return Container( - width: double.infinity, - color: Colors.black12, - height: 1, - ); - }, - itemCount: users.length + 1, - itemBuilder: (context, i) { - return [ - ...users.entries.map((entry) { - final token = entry.key; - final user = entry.value; - return ListTile( - onTap: () async { - showDialog( - barrierDismissible: false, - context: context, - builder: (context) => Center( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Colors.white, - ), - height: 100, - width: 100, - child: Center( - child: CircularProgressIndicator(), + Expanded( + child: ListView.separated( + separatorBuilder: (context, i) { + return Container( + width: double.infinity, + color: Colors.black12, + height: 1, + ); + }, + itemCount: users.length + 1, + itemBuilder: (context, i) { + return [ + ...users.entries.map((entry) { + final token = entry.key; + final user = entry.value; + return ListTile( + onTap: () async { + showDialog( + barrierDismissible: false, + context: context, + builder: (context) => Center( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Colors.white, + ), + height: 100, + width: 100, + child: Center( + child: CircularProgressIndicator(), + ), ), ), + ); + + final secureStorage = FlutterSecureStorage(); + final client = StreamChat.of(context).client; + + await client.setUser( + user, + token, + ); + + secureStorage.write( + key: kStreamApiKey, + value: kDefaultStreamApiKey, + ); + secureStorage.write( + key: kStreamUserId, + value: user.id, + ); + secureStorage.write( + key: kStreamToken, + value: token, + ); + + if (!kIsWeb) { + initNotifications(client); + } + + Navigator.pop(context); + await Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) { + return StreamChat( + client: client, + child: ChannelListPage(), + ); + }, + ), + ); + }, + leading: UserAvatar( + user: user, + constraints: BoxConstraints.tight( + Size.fromRadius(20), ), - ); - - final secureStorage = FlutterSecureStorage(); - final client = StreamChat.of(context).client; - - await client.setUser( - user, - token, - ); - - secureStorage.write( - key: kStreamApiKey, - value: kDefaultStreamApiKey, - ); - secureStorage.write( - key: kStreamUserId, - value: user.id, - ); - secureStorage.write( - key: kStreamToken, - value: token, - ); - - if (!kIsWeb) { - initNotifications(client); - } - - Navigator.pop(context); - await Navigator.pushReplacement( + ), + title: Text( + user.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('Stream test account'), + trailing: SvgPicture.asset( + 'assets/icon_arrow_right.svg', + height: 24, + width: 24, + ), + ); + }), + ListTile( + onTap: () { + Navigator.push( context, MaterialPageRoute( - builder: (context) { - return StreamChat( - client: client, - child: ChannelListPage(), - ); - }, + builder: (context) => AdvancedOptionsPage(), ), ); }, - leading: UserAvatar( - user: user, - constraints: BoxConstraints.tight( - Size.fromRadius(20), + leading: CircleAvatar( + child: Icon( + StreamIcons.settings, + color: Colors.black, ), + backgroundColor: + StreamChatTheme.of(context).secondaryColor, ), title: Text( - user.name, + 'Advanced Options', style: TextStyle(fontWeight: FontWeight.bold), ), - subtitle: Text('Stream test account'), + subtitle: Text('Custom settings'), trailing: SvgPicture.asset( 'assets/icon_arrow_right.svg', height: 24, width: 24, + clipBehavior: Clip.none, ), - ); - }), - ListTile( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AdvancedOptionsPage(), - ), - ); - }, - leading: CircleAvatar( - child: Icon( - StreamIcons.settings, - color: Colors.black, - ), - backgroundColor: - StreamChatTheme.of(context).secondaryColor, ), - title: Text( - 'Advanced Options', - style: TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text('Custom settings'), - trailing: SvgPicture.asset( - 'assets/icon_arrow_right.svg', - height: 24, - width: 24, - clipBehavior: Clip.none, - ), - ), - ][i]; - }, + ][i]; + }, + ), ), - ), - StreamVersion(), - ], + StreamVersion(), + ], + ), ), ); } diff --git a/example/lib/main.dart b/example/lib/main.dart index 1da02072..2e3f43e2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -64,83 +64,85 @@ class ChannelListPage extends StatelessWidget { final user = StreamChat.of(context).user; return Scaffold( drawer: Drawer( - child: Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).viewPadding.top + 8, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only( - bottom: 20.0, - left: 8, - ), - child: Row( - children: [ - UserAvatar( - user: user, - showOnlineStatus: false, - constraints: BoxConstraints.tight(Size.fromRadius(20)), - ), - Padding( - padding: const EdgeInsets.only(left: 16.0), - child: Text( - user.name, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, + child: SafeArea( + child: Padding( + padding: EdgeInsets.only( + top: MediaQuery.of(context).viewPadding.top + 8, + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only( + bottom: 20.0, + left: 8, + ), + child: Row( + children: [ + UserAvatar( + user: user, + showOnlineStatus: false, + constraints: BoxConstraints.tight(Size.fromRadius(20)), + ), + Padding( + padding: const EdgeInsets.only(left: 16.0), + child: Text( + user.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), ), ), + ], + ), + ), + ListTile( + leading: Icon(StreamIcons.edit), + title: Text( + 'New direct message', + style: TextStyle( + fontSize: 14.5, ), - ], - ), - ), - ListTile( - leading: Icon(StreamIcons.edit), - title: Text( - 'New direct message', - style: TextStyle( - fontSize: 14.5, ), ), - ), - ListTile( - leading: Icon(StreamIcons.group), - title: Text( - 'New group', - style: TextStyle( - fontSize: 14.5, + ListTile( + leading: Icon(StreamIcons.group), + title: Text( + 'New group', + style: TextStyle( + fontSize: 14.5, + ), ), ), - ), - Expanded( - child: Container( - alignment: Alignment.bottomCenter, - child: ListTile( - onTap: () async { - await StreamChat.of(context).client.disconnect(); + Expanded( + child: Container( + alignment: Alignment.bottomCenter, + child: ListTile( + onTap: () async { + await StreamChat.of(context).client.disconnect(); - final secureStorage = FlutterSecureStorage(); - await secureStorage.deleteAll(); - Navigator.pop(context); - await Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => ChooseUserPage(), + final secureStorage = FlutterSecureStorage(); + await secureStorage.deleteAll(); + Navigator.pop(context); + await Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => ChooseUserPage(), + ), + ); + }, + leading: Icon(StreamIcons.user), + title: Text( + 'Sign out', + style: TextStyle( + fontSize: 14.5, ), - ); - }, - leading: Icon(StreamIcons.user), - title: Text( - 'Sign out', - style: TextStyle( - fontSize: 14.5, ), ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 85fd549e..8fc2dadb 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: example description: A new Flutter project. -version: 1.0.57+59 +version: 1.0.59+61 environment: sdk: ">=2.2.2 <3.0.0" diff --git a/lib/src/media_list_view.dart b/lib/src/media_list_view.dart new file mode 100644 index 00000000..627a480c --- /dev/null +++ b/lib/src/media_list_view.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:media_gallery/media_gallery.dart'; +import 'package:stream_chat_flutter/src/stream_icons.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class MediaListView extends StatefulWidget { + final List selectedIds; + final void Function(Media media) onSelect; + + const MediaListView({ + Key key, + this.selectedIds = const [], + this.onSelect, + }) : super(key: key); + @override + _MediaListViewState createState() => _MediaListViewState(); +} + +class _MediaListViewState extends State { + final _media = []; + final ScrollController _scrollController = ScrollController(); + + @override + Widget build(BuildContext context) { + return GridView.builder( + itemCount: _media.length, + controller: _scrollController, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), + itemBuilder: ( + context, + position, + ) { + final media = _media.elementAt(position); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 1.0), + child: InkWell( + child: Stack( + children: [ + AspectRatio( + aspectRatio: 1.0, + child: FadeInImage( + placeholder: MemoryImage(kTransparentImage), + image: MediaThumbnailProvider( + media: media, + ), + fit: BoxFit.cover, + ), + ), + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + duration: Duration(milliseconds: 300), + opacity: widget.selectedIds.any((id) => id == media.id) + ? 1.0 + : 0.0, + child: Container( + color: Colors.black.withOpacity(0.5), + alignment: Alignment.topRight, + padding: const EdgeInsets.only( + top: 8, + right: 8, + ), + child: CircleAvatar( + maxRadius: 12.0, + backgroundColor: Colors.white, + child: Icon( + StreamIcons.check, + color: Colors.black, + ), + ), + ), + ), + ), + ), + if (media.mediaType == MediaType.video) + Positioned( + left: 8, + bottom: 10, + child: SvgPicture.asset( + 'svgs/video_call_icon.svg', + package: 'stream_chat_flutter', + ), + ), + ], + ), + onTap: () { + if (widget.onSelect != null) { + widget.onSelect(media); + } + }, + ), + ); + }, + ); + } + + @override + void initState() { + super.initState(); + _getMedia(); + } + + void _getMedia() async { + final List collections = + await MediaGallery.listMediaCollections( + mediaTypes: [ + MediaType.video, + MediaType.image, + ], + ); + + if (collections.isEmpty) { + return; + } + final collection = collections.firstWhere( + (element) => element.isAllCollection, + orElse: () => collections.first, + ); + + final videoPage = await collection.getMedias( + mediaType: MediaType.video, + take: 500, + ); + final imagePage = await collection.getMedias( + mediaType: MediaType.image, + take: 500, + ); + + final allItems = [ + ...videoPage.items, + ...imagePage.items, + ]..sort(( + a, + b, + ) => + b.creationDate.compareTo(a.creationDate)); + + setState(() { + _media.addAll(allItems); + }); + } +} diff --git a/lib/src/message_actions_modal.dart b/lib/src/message_actions_modal.dart index 00354a49..16c48e25 100644 --- a/lib/src/message_actions_modal.dart +++ b/lib/src/message_actions_modal.dart @@ -45,14 +45,15 @@ class MessageActionsModal extends StatelessWidget { @override Widget build(BuildContext context) { - var size = MediaQuery.of(context).size; - var user = StreamChat.of(context).user; + final size = MediaQuery.of(context).size; + final user = StreamChat.of(context).user; - var roughMaxSize = 2 * size.width / 3; - var roughSentenceSize = + final roughMaxSize = 2 * size.width / 3; + final roughSentenceSize = message.text.length * messageTheme.messageText.fontSize * 1.2; - var divFactor = - roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize); + final divFactor = message.attachments?.isNotEmpty == true + ? 1 + : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); return GestureDetector( behavior: HitTestBehavior.translucent, diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 89891ace..bac36ef7 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math'; import 'package:emojis/emoji.dart'; @@ -8,10 +9,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:http_parser/http_parser.dart'; +import 'package:http_parser/http_parser.dart' as httpParser; import 'package:image_picker/image_picker.dart'; +import 'package:media_gallery/media_gallery.dart'; import 'package:mime/mime.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/media_list_view.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; @@ -168,7 +172,7 @@ class MessageInputState extends State { final _imagePicker = ImagePicker(); bool _inputEnabled = true; bool _messageIsPresent = false; - bool _typingStarted = false; + bool _animateContainer = true; bool _commandEnabled = false; OverlayEntry _commandsOverlay, _mentionsOverlay, _emojiOverlay; Iterable _emojiNames; @@ -176,6 +180,9 @@ class MessageInputState extends State { Command _chosenCommand; bool _actionsShrunk = false; bool _sendAsDm = false; + bool _openFilePickerSection = false; + int _filePickerIndex = 0; + double _filePickerSize = 250.0; /// The editing controller passed to the input TextField TextEditingController textEditingController; @@ -201,6 +208,7 @@ class MessageInputState extends State { padding: const EdgeInsets.symmetric(horizontal: 8.0), child: _buildDmCheckbox(), ), + _buildFilePickerSection(), ], ), ), @@ -210,7 +218,7 @@ class MessageInputState extends State { Flex _buildTextField(BuildContext context) { return Flex( direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!_commandEnabled) _buildExpandActionsButton(), if (widget.actionsLocation == ActionsLocation.left) @@ -370,25 +378,17 @@ class MessageInputState extends State { _emojiOverlay?.remove(); _emojiOverlay = null; - _checkCommands(s.trimLeft(), context); + _checkCommands(s, context); _checkMentions(s, context); _checkEmoji(s, context); }, - onTap: () { - setState(() { - _typingStarted = true; - }); - }, style: Theme.of(context).textTheme.bodyText2, autofocus: false, textAlignVertical: TextAlignVertical.center, decoration: InputDecoration( - hintText: - (_commandEnabled && _chosenCommand.name == 'giphy') - ? 'Search GIFs' - : 'Write a message', + hintText: _getHint(), prefixText: _commandEnabled ? null : ' ', border: OutlineInputBorder( borderSide: BorderSide(color: Colors.transparent)), @@ -440,6 +440,16 @@ class MessageInputState extends State { ); } + String _getHint() { + if (_commandEnabled && _chosenCommand.name == 'giphy') { + return 'Search GIFs'; + } + if (_attachments.isNotEmpty) { + return 'Add a command or send'; + } + return 'Write a message'; + } + void _checkEmoji(String s, BuildContext context) { if (textEditingController.selection.isCollapsed && (s.isNotEmpty && s[textEditingController.selection.start - 1] == ':' || @@ -548,8 +558,10 @@ class MessageInputState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Icon(StreamIcons.lightning, - color: StreamChatTheme.of(context).accentColor), + child: Icon( + StreamIcons.lightning, + color: StreamChatTheme.of(context).accentColor, + ), ), Text( 'Instant Commands', @@ -604,6 +616,261 @@ class MessageInputState extends State { }); } + Widget _buildFilePickerSection() { + return AnimatedContainer( + duration: _animateContainer ? Duration(milliseconds: 300) : Duration.zero, + height: _openFilePickerSection ? _filePickerSize : 0, + child: Material( + color: Color(0xFFF2F2F2), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + icon: Icon( + StreamIcons.picture, + color: _filePickerIndex == 0 + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.5), + ), + onPressed: () { + setState(() { + _filePickerIndex = 0; + }); + }, + ), + IconButton( + icon: Icon( + StreamIcons.folder, + color: _filePickerIndex == 1 + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.5), + ), + onPressed: () { + pickFile(DefaultAttachmentTypes.file, false); + }, + ), + IconButton( + icon: Icon( + StreamIcons.camera, + color: _filePickerIndex == 2 + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.5), + ), + onPressed: () { + pickFile(DefaultAttachmentTypes.image, true); + }, + ), + ], + ), + GestureDetector( + onVerticalDragUpdate: (update) { + setState(() { + _animateContainer = false; + _filePickerSize = (_filePickerSize - update.delta.dy).clamp( + 240.0, + MediaQuery.of(context).size.height / 1.7, + ); + }); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + child: Container( + width: double.infinity, + child: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: 40.0, + height: 4.0, + decoration: BoxDecoration( + color: Color(0xFFF2F2F2), + borderRadius: BorderRadius.circular(4.0), + ), + ), + ), + ), + ), + ), + ), + if (_openFilePickerSection) + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.0), + ), + child: _buildPickerSection(), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPickerSection() { + switch (_filePickerIndex) { + case 0: + return FutureBuilder( + future: Platform.isAndroid + ? Permission.storage.status + : Permission.photos.status, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + + if (snapshot.data.isGranted) { + return MediaListView( + selectedIds: _attachments.map((e) => e.id).toList(), + onSelect: (media) { + if (!_attachments + .any((element) => element.id == media.id)) { + _addAttachment(media); + } else { + _attachments + .removeWhere((element) => element.id == media.id); + setState(() {}); + } + }, + ); + } + + return InkWell( + onTap: () async { + var status = await (Platform.isAndroid + ? Permission.storage.status + : Permission.photos.status); + print('status: ${status}'); + if (status.isPermanentlyDenied || status.isDenied) { + if (await openAppSettings()) { + setState(() {}); + } + } else { + status = await (Platform.isAndroid + ? Permission.storage + : Permission.photos) + .request(); + if (status.isGranted) { + setState(() {}); + } + } + }, + child: Container( + color: Color(0xFFF2F2F2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + 'svgs/icon_picture_empty_state.svg', + package: 'stream_chat_flutter', + height: 140, + color: StreamChatTheme.of(context).accentColor, + ), + Center( + child: Text( + 'Allow access to your gallery', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: StreamChatTheme.of(context).accentColor, + ), + ), + ), + ], + ), + ), + ); + }); + break; + case 1: + break; + case 2: + break; + } + } + + void _addAttachment(Media medium) async { + final mediaFile = await medium.getFile(); + final thumbBytes = await medium.getThumbnail(); + + final file = PlatformFile( + path: mediaFile.path, + bytes: mediaFile.readAsBytesSync(), + ); + + final thumbFile = PlatformFile( + bytes: thumbBytes, + name: '${file.name ?? file.path?.split('/')?.last}_thumbnail.jpeg', + ); + + setState(() { + _inputEnabled = true; + }); + + if (file == null) { + return; + } + + final channel = StreamChannel.of(context).channel; + final attachment = _SendingAttachment( + file: file, + thumbFile: thumbFile, + attachment: Attachment( + localUri: file.path != null ? Uri.parse(file.path) : null, + type: medium.mediaType == MediaType.image ? 'image' : 'video', + ), + id: medium.id, + ); + + setState(() { + _attachments.add(attachment); + }); + + final thumbUrl = await _uploadImage( + thumbFile, + channel, + ); + + final url = await _uploadAttachment( + file, + medium.mediaType == MediaType.image + ? DefaultAttachmentTypes.image + : DefaultAttachmentTypes.video, + channel); + + final fileType = medium.mediaType == MediaType.image + ? DefaultAttachmentTypes.image + : DefaultAttachmentTypes.video; + + if (fileType == DefaultAttachmentTypes.image) { + attachment.attachment = attachment.attachment.copyWith( + imageUrl: url, + thumbUrl: thumbUrl, + ); + } else { + attachment.attachment = attachment.attachment.copyWith( + assetUrl: url, + thumbUrl: thumbUrl, + ); + } + + setState(() { + attachment.uploaded = true; + }); + } + CircleAvatar _buildGiphyIcon() { if (kIsWeb) { return CircleAvatar( @@ -857,24 +1124,11 @@ class MessageInputState extends State { _commandsOverlay = null; } - Gradient _getGradient(BuildContext context) { - if (_typingStarted) { - if (widget.editMessage == null) { - return StreamChatTheme.of(context).channelTheme.inputGradient; - } - return LinearGradient( - colors: [Colors.lightGreen, Colors.green], - ); - } else { - return null; - } - } - Widget _buildAttachments() { return _attachments.isEmpty ? Container() : LimitedBox( - maxHeight: 76.0, + maxHeight: 104.0, child: ListView( scrollDirection: Axis.horizontal, children: _attachments @@ -888,8 +1142,8 @@ class MessageInputState extends State { AspectRatio( aspectRatio: 1.0, child: Container( - height: 50, - width: 50, + height: 104, + width: 104, child: _buildAttachment(attachment), ), ), @@ -934,11 +1188,12 @@ class MessageInputState extends State { _attachments.remove(attachment); }); }, - fillColor: Colors.white.withOpacity(.5), + fillColor: Colors.black.withOpacity(.5), child: Center( child: Icon( - Icons.close, + StreamIcons.close, size: 15, + color: Colors.white, ), ), ), @@ -970,9 +1225,26 @@ class MessageInputState extends State { ); break; case 'video': - return Container( - child: Icon(Icons.videocam), - color: Colors.black26, + return Stack( + children: [ + Container( + child: attachment.thumbFile != null + ? Image.memory( + attachment.thumbFile.bytes, + fit: BoxFit.cover, + ) + : Icon(Icons.videocam), + color: Colors.black26, + ), + Positioned( + left: 8, + bottom: 10, + child: SvgPicture.asset( + 'svgs/video_call_icon.svg', + package: 'stream_chat_flutter', + ), + ), + ], ); break; default: @@ -984,26 +1256,24 @@ class MessageInputState extends State { } Widget _buildCommandButton() { - return Center( - child: InkWell( - child: Padding( - padding: const EdgeInsets.only( - left: 4.0, right: 8.0, top: 8.0, bottom: 8.0), - child: Icon( - StreamIcons.lightning, - color: Color(0xFF000000).withAlpha(128), - ), + return InkWell( + child: Padding( + padding: + const EdgeInsets.only(left: 4.0, right: 8.0, top: 8.0, bottom: 8.0), + child: Icon( + StreamIcons.lightning, + color: Color(0xFF000000).withAlpha(128), ), - onTap: () { - if (_commandsOverlay == null) { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); - } else { - _commandsOverlay?.remove(); - _commandsOverlay = null; - } - }, ), + onTap: () { + if (_commandsOverlay == null) { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + } else { + _commandsOverlay?.remove(); + _commandsOverlay = null; + } + }, ); } @@ -1016,11 +1286,37 @@ class MessageInputState extends State { EdgeInsets.only(left: 8.0, right: padding, top: 8.0, bottom: 8.0), child: Icon( StreamIcons.attach, - color: Color(0xFF000000).withAlpha(128), + color: _openFilePickerSection + ? StreamChatTheme.of(context).accentColor + : Color(0xFF000000).withAlpha(128), ), ), - onTap: () { - showAttachmentModal(); + onTap: () async { + _emojiOverlay?.remove(); + _emojiOverlay = null; + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + + if (_openFilePickerSection) { + setState(() { + _animateContainer = true; + _openFilePickerSection = false; + _filePickerSize = 250.0; + }); + } else { + final status = await (Platform.isAndroid + ? Permission.storage.status + : Permission.photos.status); + if (status.isUndetermined) { + await (Platform.isAndroid + ? Permission.storage + : Permission.photos) + .request(); + } + showAttachmentModal(); + } }, ), ); @@ -1032,73 +1328,79 @@ class MessageInputState extends State { _focusNode.unfocus(); } - showModalBottomSheet( - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), + if (!kIsWeb) { + setState(() { + _openFilePickerSection = true; + }); + } else { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), ), - ), - context: context, - isScrollControlled: true, - builder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - 'Add a file', - style: TextStyle( - fontWeight: FontWeight.bold, + context: context, + isScrollControlled: true, + builder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + 'Add a file', + style: TextStyle( + fontWeight: FontWeight.bold, + ), ), ), - ), - ListTile( - leading: Icon(Icons.image), - title: Text('Upload a photo'), - onTap: () { - pickFile(DefaultAttachmentTypes.image, false); - Navigator.pop(context); - }, - ), - ListTile( - leading: Icon(Icons.video_library), - title: Text('Upload a video'), - onTap: () { - pickFile(DefaultAttachmentTypes.video, false); - Navigator.pop(context); - }, - ), - if (!kIsWeb) ListTile( - leading: Icon(Icons.camera_alt), - title: Text('Photo from camera'), + leading: Icon(Icons.image), + title: Text('Upload a photo'), onTap: () { - pickFile(DefaultAttachmentTypes.image, true); + pickFile(DefaultAttachmentTypes.image, false); Navigator.pop(context); }, ), - if (!kIsWeb) ListTile( - leading: Icon(Icons.videocam), - title: Text('Video from camera'), + leading: Icon(Icons.video_library), + title: Text('Upload a video'), onTap: () { - pickFile(DefaultAttachmentTypes.video, true); + pickFile(DefaultAttachmentTypes.video, false); Navigator.pop(context); }, ), - ListTile( - leading: Icon(Icons.insert_drive_file), - title: Text('Upload a file'), - onTap: () { - pickFile(DefaultAttachmentTypes.file, false); - Navigator.pop(context); - }, - ), - ], - ); - }); + if (!kIsWeb) + ListTile( + leading: Icon(Icons.camera_alt), + title: Text('Photo from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.image, true); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: Icon(Icons.videocam), + title: Text('Video from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.video, true); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.insert_drive_file), + title: Text('Upload a file'), + onTap: () { + pickFile(DefaultAttachmentTypes.file, false); + Navigator.pop(context); + }, + ), + ], + ); + }); + } } /// Add an attachment to the sending message @@ -1228,7 +1530,9 @@ class MessageInputState extends State { MultipartFile.fromBytes( bytes, filename: filename, - contentType: MediaType.parse(lookupMimeType(filename)), + contentType: filename != null + ? httpParser.MediaType.parse(lookupMimeType(filename)) + : null, ), ); return res.file; @@ -1241,7 +1545,7 @@ class MessageInputState extends State { MultipartFile.fromBytes( bytes, filename: filename, - contentType: MediaType.parse(lookupMimeType(filename)), + contentType: httpParser.MediaType.parse(lookupMimeType(filename)), ), ); return res.file; @@ -1332,7 +1636,6 @@ class MessageInputState extends State { setState(() { _messageIsPresent = false; - _typingStarted = false; _commandEnabled = false; }); @@ -1405,42 +1708,9 @@ class MessageInputState extends State { if (!kIsWeb) { _keyboardListener = KeyboardVisibility.onChange.listen((visible) { if (visible) { - if (_commandsOverlay != null) { - if (textEditingController.text.trimLeft().startsWith('/')) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); - }); - } - } - - if (_mentionsOverlay != null) { - if (textEditingController.text.contains('@')) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _mentionsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_mentionsOverlay); - }); - } - } - - if (_emojiOverlay != null) { - if (textEditingController.text.contains(':')) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _emojiOverlay = _buildEmojiOverlay(); - Overlay.of(context).insert(_emojiOverlay); - }); - } - } - } else { - if (_commandsOverlay != null) { - _commandsOverlay.remove(); - } - if (_mentionsOverlay != null) { - _mentionsOverlay.remove(); - } - if (_emojiOverlay != null) { - _emojiOverlay.remove(); - } + _checkCommands(textEditingController.text, context); + _checkMentions(textEditingController.text, context); + _checkEmoji(textEditingController.text, context); } }); } @@ -1450,12 +1720,17 @@ class MessageInputState extends State { if (widget.editMessage != null || widget.initialMessage != null) { _parseExistingMessage(widget.editMessage ?? widget.initialMessage); } + + _focusNode.addListener(() { + if (_focusNode.hasFocus) { + _openFilePickerSection = false; + } + }); } void _parseExistingMessage(Message message) { textEditingController.text = message.text; - _typingStarted = true; _messageIsPresent = true; message.attachments?.forEach((attachment) { @@ -1488,13 +1763,17 @@ class MessageInputState extends State { class _SendingAttachment { PlatformFile file; + PlatformFile thumbFile; Attachment attachment; bool uploaded; + String id; _SendingAttachment({ this.file, + this.thumbFile, this.attachment, this.uploaded = false, + this.id, }); } @@ -1503,3 +1782,44 @@ extension StringExtension on String { return "${this[0].toUpperCase()}${this.substring(1)}"; } } + +/// Represents a 2-tuple, or pair. +class Tuple2 { + /// Returns the first item of the tuple + final T1 item1; + + /// Returns the second item of the tuple + final T2 item2; + + /// Creates a new tuple value with the specified items. + const Tuple2(this.item1, this.item2); + + /// Create a new tuple value with the specified list [items]. + factory Tuple2.fromList(List items) { + if (items.length != 2) { + throw ArgumentError('items must have length 2'); + } + + return Tuple2(items[0] as T1, items[1] as T2); + } + + /// Returns a tuple with the first item set to the specified value. + Tuple2 withItem1(T1 v) => Tuple2(v, item2); + + /// Returns a tuple with the second item set to the specified value. + Tuple2 withItem2(T2 v) => Tuple2(item1, v); + + /// Creates a [List] containing the items of this [Tuple2]. + /// + /// The elements are in item order. The list is variable-length + /// if [growable] is true. + List toList({bool growable = false}) => + List.from([item1, item2], growable: growable); + + @override + String toString() => '[$item1, $item2]'; + + @override + bool operator ==(Object other) => + other is Tuple2 && other.item1 == item1 && other.item2 == item2; +} diff --git a/lib/src/message_reactions_modal.dart b/lib/src/message_reactions_modal.dart index fc3f68a4..d68bd769 100644 --- a/lib/src/message_reactions_modal.dart +++ b/lib/src/message_reactions_modal.dart @@ -36,14 +36,15 @@ class MessageReactionsModal extends StatelessWidget { @override Widget build(BuildContext context) { - var size = MediaQuery.of(context).size; - var user = StreamChat.of(context).user; + final size = MediaQuery.of(context).size; + final user = StreamChat.of(context).user; - var roughMaxSize = 2 * size.width / 3; - var roughSentenceSize = + final roughMaxSize = 2 * size.width / 3; + final roughSentenceSize = message.text.length * messageTheme.messageText.fontSize * 1.2; - var divFactor = - roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize); + final divFactor = message.attachments?.isNotEmpty == true + ? 1 + : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); return GestureDetector( behavior: HitTestBehavior.translucent, diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index fd6286a2..4f551655 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -585,14 +585,22 @@ class _MessageWidgetState extends State { widget.message, attachment, ); - return wrapAttachmentWidget(context, attachmentWidget, - attachment: attachment); + return wrapAttachmentWidget( + context, + attachmentWidget, + attachment: attachment, + ); })?.toList() ?? []; } - Padding wrapAttachmentWidget(BuildContext context, Widget attachmentWidget, - {Attachment attachment}) { + Padding wrapAttachmentWidget( + BuildContext context, + Widget attachmentWidget, { + Attachment attachment, + }) { + final attachmentShape = + widget.attachmentShape ?? widget.shape ?? _getDefaultShape(context); return Padding( padding: EdgeInsets.only( bottom: 4, @@ -605,13 +613,12 @@ class _MessageWidgetState extends State { ? Colors.white : _getBackgroundColor(), clipBehavior: Clip.hardEdge, - shape: widget.attachmentShape ?? - widget.shape ?? - _getDefaultShape(context), + shape: attachmentShape, child: Padding( padding: widget.attachmentPadding, - child: ClipRRect( - borderRadius: BorderRadius.circular(6), + child: Material( + clipBehavior: Clip.hardEdge, + shape: attachmentShape, child: Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, @@ -809,7 +816,8 @@ class _MessageWidgetState extends State { ), ), if (widget.message.attachments - .any((element) => element.ogScrapeUrl != null)) + ?.any((element) => element.ogScrapeUrl != null) == + true) _buildUrlAttachment(), ], ), @@ -836,7 +844,8 @@ class _MessageWidgetState extends State { } if (widget.message.attachments - .any((element) => element.ogScrapeUrl != null)) { + ?.any((element) => element.ogScrapeUrl != null) == + true) { return Color(0xFFE9F2FF); } diff --git a/pubspec.yaml b/pubspec.yaml index a3a1f55a..ccbc8463 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,9 @@ dependencies: flutter_slidable: ^0.5.4 carousel_slider: ^2.2.1 clipboard: ^0.1.2+8 + media_gallery: ^0.1.5 + permission_handler: ^5.0.1+1 + transparent_image: ^1.0.0 flutter: assets: diff --git a/svgs/icon_picture_empty_state.svg b/svgs/icon_picture_empty_state.svg new file mode 100644 index 00000000..135772d0 --- /dev/null +++ b/svgs/icon_picture_empty_state.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svgs/video_call_icon.svg b/svgs/video_call_icon.svg new file mode 100644 index 00000000..1c3832d6 --- /dev/null +++ b/svgs/video_call_icon.svg @@ -0,0 +1,3 @@ + + +