Merge branch 'feature/new-ui' into feature/theme

This commit is contained in:
Salvatore Giordano
2020-12-23 12:22:58 +01:00
39 changed files with 2506 additions and 1109 deletions
+36 -36
View File
@@ -69,40 +69,26 @@ class ChannelBottomSheet extends StatelessWidget {
),
),
Divider(),
StreamBuilder<bool>(
stream: channel.isMutedStream,
initialData: channel.isMuted,
builder: (context, snapshot) {
return ListTile(
leading: StreamSvgIcon.mute(
size: 22,
color: StreamChatTheme.of(context).primaryIconTheme.color,
),
title: Text('Mute ${channel.isGroup ? 'group' : 'user'}'),
trailing: Switch(
onChanged: (bool muted) async {
if (muted) {
await channel.mute();
} else {
await channel.unmute();
}
},
value: snapshot.data,
),
);
}),
Divider(),
if (channel.isGroup && !channel.isDistinct)
ListTile(
leading: StreamSvgIcon.userRemove(
size: 22,
color: Colors.black,
size: 24,
color: Color(0xff7A7A7A),
),
title: Text(
'Leave Group',
style: TextStyle(fontWeight: FontWeight.bold),
),
title: Text('Leave Group'),
onTap: () async {
final confirm = await showConfirmationDialog(
context,
'Do you want to leave the group?',
title: 'Leave Group',
okText: 'LEAVE',
question: 'Are you sure you want to leave this group?',
cancelText: 'CANCEL',
icon: StreamSvgIcon.userRemove(
color: Colors.red,
),
);
if (confirm == true) {
await channel
@@ -111,11 +97,17 @@ class ChannelBottomSheet extends StatelessWidget {
}
},
),
if (!channel.isGroup && !channel.isDistinct)
if ([
'admin',
'owner',
].contains(channel.state.members
.firstWhere((m) => m.userId == channel.client.state.user.id,
orElse: () => null)
?.role))
ListTile(
leading: Icon(
Icons.delete_outline,
leading: StreamSvgIcon.delete(
color: Color(0xFFFF3742),
size: 24,
),
title: Text(
'Delete chat',
@@ -124,14 +116,22 @@ class ChannelBottomSheet extends StatelessWidget {
),
),
onTap: () async {
final confirm = await showConfirmationDialog(
final res = await showConfirmationDialog(
context,
'Do you want to delete the chat?',
title: 'Delete Conversation',
okText: 'DELETE',
question:
'Are you sure you want to delete this conversation?',
cancelText: 'CANCEL',
icon: StreamSvgIcon.delete(
color: Colors.red,
),
);
if (confirm == true) {
await channel
.removeMembers([StreamChat.of(context).user.id]);
Navigator.pop(context);
var channel = StreamChannel.of(context).channel;
if (res == true) {
await channel.delete().then((value) {
Navigator.pop(context);
});
}
},
),
+29 -1
View File
@@ -5,8 +5,10 @@ import 'package:stream_chat_flutter/src/channel_info.dart';
import 'package:stream_chat_flutter/src/channel_name.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import '../stream_chat_flutter.dart';
import './channel_name.dart';
import 'channel_image.dart';
import 'chat_info_screen.dart';
import 'stream_channel.dart';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_header.png)
@@ -97,7 +99,33 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
padding: const EdgeInsets.only(right: 10.0),
child: Center(
child: ChannelImage(
onTap: onImageTap,
onTap: onImageTap ??
() async {
if (channel.memberCount == 2 && channel.isDistinct) {
final currentUser = StreamChat.of(context).user;
final otherUser = channel.state.members.firstWhere(
(element) => element.user.id != currentUser.id,
orElse: () => null,
);
if (otherUser != null) {
final pop = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => StreamChannel(
channel: channel,
child: ChatInfoScreen(
user: otherUser.user,
),
),
),
);
if (pop == true) {
Navigator.pop(context);
}
}
}
},
),
),
),
+22 -33
View File
@@ -528,44 +528,33 @@ class _ChannelListViewState extends State<ChannelListView>
);
},
),
IconSlideAction(
color: backgroundColor,
iconWidget: StreamSvgIcon.mute(),
onTap: () async {
if (!channel.isMuted) {
await channel.mute();
} else {
await channel.unmute();
}
},
),
if (channel.isGroup && !channel.isDistinct)
if ([
'admin',
'owner',
].contains(channel.state.members
.firstWhere(
(m) => m.userId == channel.client.state.user.id,
orElse: () => null)
?.role))
IconSlideAction(
color: backgroundColor,
iconWidget: StreamSvgIcon.userRemove(),
iconWidget: StreamSvgIcon.delete(
color: Color(0xFFFF3742),
),
onTap: () async {
final confirm = await showConfirmationDialog(
final res = await showConfirmationDialog(
context,
'Do you want to leave the group?',
title: 'Delete Conversation',
okText: 'DELETE',
question:
'Are you sure you want to delete this conversation?',
cancelText: 'CANCEL',
icon: StreamSvgIcon.delete(
color: Color(0xFFFF3742),
),
);
if (confirm == true) {
await channel
.removeMembers([StreamChat.of(context).user.id]);
}
},
),
if (!channel.isGroup && !channel.isDistinct)
IconSlideAction(
color: backgroundColor,
icon: Icons.delete_outline,
onTap: () async {
final confirm = await showConfirmationDialog(
context,
'Do you want to delete the chat?',
);
if (confirm == true) {
await channel
.removeMembers([StreamChat.of(context).user.id]);
if (res == true) {
await channel.delete();
}
},
),
+24 -1
View File
@@ -7,6 +7,7 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import '../stream_chat_flutter.dart';
import 'channel_name.dart';
import 'channel_unread_indicator.dart';
import 'chat_info_screen.dart';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview_paint.png)
@@ -63,7 +64,29 @@ class ChannelPreview extends StatelessWidget {
}
},
leading: ChannelImage(
onTap: onImageTap,
onTap: onImageTap ??
() {
if (channel.memberCount == 2 && channel.isDistinct) {
final currentUser = StreamChat.of(context).user;
final otherUser = channel.state.members.firstWhere(
(element) => element.user.id != currentUser.id,
orElse: () => null,
);
if (otherUser != null) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => StreamChannel(
channel: channel,
child: ChatInfoScreen(
user: otherUser.user,
),
),
),
);
}
}
},
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
+4 -7
View File
@@ -85,7 +85,7 @@ class ChannelsBlocState extends State<ChannelsBloc>
paginationParams.offset == null ||
paginationParams.offset == 0;
final oldChannels = List<Channel>.from(channels ?? []);
client
await client
.queryChannels(
filter: filter,
sort: sortOptions,
@@ -93,21 +93,18 @@ class ChannelsBlocState extends State<ChannelsBloc>
paginationParams: paginationParams,
onlyOffline: onlyOffline,
)
.listen((channels) {
.then((channels) {
if (clear) {
_channelsController.add(channels);
} else {
final l = oldChannels + channels;
_channelsController.add(l);
}
}, onDone: () {
_queryChannelsLoadingController.sink.add(false);
}, onError: (err, stackTrace) {
print(err);
print(stackTrace);
_queryChannelsLoadingController.addError(err, stackTrace);
});
} catch (err, stackTrace) {
print(err);
print(stackTrace);
_queryChannelsLoadingController.addError(err, stackTrace);
}
}
+574
View File
@@ -0,0 +1,574 @@
import 'package:emojis/emojis.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import '../stream_chat_flutter.dart';
/// Detail screen for a 1:1 chat correspondence
class ChatInfoScreen extends StatefulWidget {
/// User in consideration
final User user;
const ChatInfoScreen({Key key, this.user}) : super(key: key);
@override
_ChatInfoScreenState createState() => _ChatInfoScreenState();
}
class _ChatInfoScreenState extends State<ChatInfoScreen> {
@override
Widget build(BuildContext context) {
final channel = StreamChannel.of(context).channel;
return Scaffold(
backgroundColor: Color(0xFFe6e6e6),
body: ListView(
children: [
_buildUserHeader(),
SizedBox(
height: 8.0,
),
_buildOptionListTiles(),
SizedBox(
height: 8.0,
),
if ([
'admin',
'owner',
].contains(channel.state.members
.firstWhere((m) => m.userId == channel.client.state.user.id,
orElse: () => null)
?.role))
_buildDeleteListTile(),
],
),
);
}
Widget _buildUserHeader() {
return Material(
color: Colors.white,
child: SafeArea(
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: UserAvatar(
user: widget.user,
constraints: BoxConstraints(
maxWidth: 72.0,
maxHeight: 72.0,
),
borderRadius: BorderRadius.circular(36.0),
showOnlineStatus: false,
),
),
//SizedBox(height: 4.0),
Text(
widget.user.name,
style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold),
),
SizedBox(height: 7.0),
_buildConnectedTitleState(),
SizedBox(height: 15.0),
_OptionListTile(
title: '@${widget.user.id}',
trailing: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
widget.user.name,
style: TextStyle(
color: Colors.black.withOpacity(0.5), fontSize: 16.0),
),
),
onTap: () {},
),
],
),
Positioned(
top: 21,
left: 16,
child: InkWell(
child: StreamSvgIcon.left(),
onTap: () {
Navigator.of(context).pop();
},
),
),
],
),
),
);
}
Widget _buildOptionListTiles() {
var channel = StreamChannel.of(context);
return Column(
children: [
// _OptionListTile(
// title: 'Notifications',
// leading: StreamSvgIcon.Icon_notification(
// size: 24.0,
// color: Colors.black.withOpacity(0.5),
// ),
// trailing: CupertinoSwitch(
// value: true,
// onChanged: (val) {},
// ),
// onTap: () {},
// ),
StreamBuilder<bool>(
stream: StreamChannel.of(context).channel.isMutedStream,
builder: (context, snapshot) {
return _OptionListTile(
title: 'Mute user',
leading: StreamSvgIcon.mute(
size: 23.0,
color: Colors.black.withOpacity(0.5),
),
trailing: snapshot.data == null
? CircularProgressIndicator()
: CupertinoSwitch(
value: snapshot.data,
onChanged: (val) {
if (snapshot.data) {
channel.channel.unmute();
} else {
channel.channel.mute();
}
},
),
onTap: () {},
);
}),
// _OptionListTile(
// title: 'Block User',
// leading: StreamSvgIcon.Icon_user_delete(
// size: 24.0,
// color: Colors.black.withOpacity(0.5),
// ),
// trailing: CupertinoSwitch(
// value: widget.user.banned,
// onChanged: (val) {
// if (widget.user.banned) {
// channel.channel.shadowBan(widget.user.id, {});
// } else {
// channel.channel.unbanUser(widget.user.id);
// }
// },
// ),
// onTap: () {},
// ),
_OptionListTile(
title: 'Photos & Videos',
leading: StreamSvgIcon.pictures(
size: 32.0,
color: Colors.black.withOpacity(0.5),
),
trailing: StreamSvgIcon.right(),
onTap: () {
Navigator.push(context,
MaterialPageRoute(builder: (context) => _MediaDisplayScreen()));
},
),
_OptionListTile(
title: 'Files',
leading: StreamSvgIcon.files(
size: 32.0,
color: Colors.black.withOpacity(0.5),
),
trailing: StreamSvgIcon.right(),
onTap: () {
Navigator.push(context,
MaterialPageRoute(builder: (context) => _FileDisplayScreen()));
},
),
_OptionListTile(
title: 'Shared groups',
leading: StreamSvgIcon.Icon_group(
size: 24.0,
color: Colors.black.withOpacity(0.5),
),
trailing: StreamSvgIcon.right(),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => _SharedGroupsScreen(
StreamChat.of(context).user, widget.user)));
},
),
],
);
}
Widget _buildDeleteListTile() {
return _OptionListTile(
title: 'Delete',
leading: StreamSvgIcon.delete(
color: Colors.red,
size: 24.0,
),
onTap: () {
_showDeleteDialog();
},
titleColor: Colors.red,
);
}
void _showDeleteDialog() async {
final res = await showConfirmationDialog(
context,
title: 'Delete Conversation',
okText: 'DELETE',
question: 'Are you sure you want to delete this conversation?',
cancelText: 'CANCEL',
icon: StreamSvgIcon.delete(
color: Colors.red,
),
);
var channel = StreamChannel.of(context).channel;
if (res == true) {
await channel.delete().then((value) {
Navigator.pop(context);
});
}
}
Widget _buildConnectedTitleState() {
var alternativeWidget;
final otherMember = widget.user;
if (otherMember != null) {
if (otherMember.online) {
alternativeWidget = Text(
'Online',
style: TextStyle(color: Colors.black.withOpacity(0.5)),
);
} else {
alternativeWidget = Text(
'Last seen ${Jiffy(otherMember.lastActive).fromNow()}',
style: TextStyle(color: Colors.black.withOpacity(0.5)),
);
}
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.user.online)
Material(
type: MaterialType.circle,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
constraints: BoxConstraints.tightFor(
width: 28,
height: 12,
),
child: Material(
shape: CircleBorder(),
color: Color(0xff20E070),
),
),
color: Colors.white,
),
alternativeWidget,
],
);
}
}
class _OptionListTile extends StatelessWidget {
final String title;
final StreamSvgIcon leading;
final Widget trailing;
final VoidCallback onTap;
final Color titleColor;
_OptionListTile({
this.title,
this.leading,
this.trailing,
this.onTap,
this.titleColor,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
color: Color(0xffe6e6e6),
height: 2.0,
),
Material(
color: Colors.white,
child: Container(
height: 56.0,
child: InkWell(
onTap: onTap,
child: Row(
children: [
if (leading != null)
Expanded(
child: Center(child: leading),
),
if (leading == null)
SizedBox(
width: 16.0,
),
Expanded(
flex: 4,
child: Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600, color: titleColor),
)),
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Align(
alignment: Alignment.centerRight,
child: trailing ?? Container(),
),
),
),
],
),
),
),
),
],
);
}
}
class _SharedGroupsScreen extends StatefulWidget {
final User mainUser;
final User otherUser;
_SharedGroupsScreen(this.mainUser, this.otherUser);
@override
__SharedGroupsScreenState createState() => __SharedGroupsScreenState();
}
class __SharedGroupsScreenState extends State<_SharedGroupsScreen> {
@override
Widget build(BuildContext context) {
var chat = StreamChat.of(context);
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
brightness: Theme.of(context).brightness,
elevation: 1,
centerTitle: true,
title: Text(
'Shared Groups',
style: TextStyle(color: Colors.black, fontSize: 16.0),
),
leading: Center(
child: InkWell(
onTap: () {
Navigator.of(context).pop();
},
child: Container(
child: StreamSvgIcon.left(
color: Colors.black,
size: 24.0,
),
width: 24.0,
height: 24.0,
),
),
),
backgroundColor: StreamChatTheme.of(context).primaryColor,
),
body: FutureBuilder<List<Channel>>(
future: chat.client.queryChannels(
filter: {
r'$and': [
{
'members': {
r'$in': [widget.otherUser.id],
},
},
{
'members': {
r'$in': [widget.mainUser.id],
},
}
],
},
),
builder: (context, snapshot) {
if (snapshot.data == null) {
return Center(
child: CircularProgressIndicator(),
);
}
return ListView.builder(
itemCount: snapshot.data.length,
itemBuilder: (context, position) {
return StreamChannel(
channel: snapshot.data[position],
child: _buildListTile(snapshot.data[position]),
);
},
);
},
),
);
}
Widget _buildListTile(Channel channel) {
var extraData = channel.extraData;
var members = channel.state.members;
var textStyle = TextStyle(fontSize: 14.0, fontWeight: FontWeight.bold);
return Container(
height: 64.0,
child: LayoutBuilder(builder: (context, constraints) {
String title;
if (extraData['name'] == null) {
final otherMembers = members.where(
(member) => member.userId != StreamChat.of(context).user.id);
if (otherMembers.isNotEmpty) {
final maxWidth = constraints.maxWidth;
final maxChars = maxWidth / textStyle.fontSize;
var currentChars = 0;
final currentMembers = <Member>[];
otherMembers.forEach((element) {
final newLength = currentChars + element.user.name.length;
if (newLength < maxChars) {
currentChars = newLength;
currentMembers.add(element);
}
});
final exceedingMembers =
otherMembers.length - currentMembers.length;
title =
'${currentMembers.map((e) => e.user.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}';
} else {
title = 'No title';
}
} else {
title = extraData['name'];
}
return Column(
children: [
Expanded(
child: Row(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: ChannelImage(
channel: channel,
constraints:
BoxConstraints(maxWidth: 40.0, maxHeight: 40.0),
),
),
Expanded(
child: Text(
title,
style: textStyle,
)),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${channel.memberCount} members',
style: TextStyle(color: Colors.black.withOpacity(0.5)),
),
)
],
),
),
Container(
height: 1.0,
color: Color(0xffe6e6e6),
),
],
);
}),
);
}
}
class _MediaDisplayScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
brightness: Theme.of(context).brightness,
elevation: 1,
centerTitle: true,
title: Text(
'Photos & Videos',
style: TextStyle(color: Colors.black, fontSize: 16.0),
),
leading: Center(
child: InkWell(
onTap: () {
Navigator.of(context).pop();
},
child: Container(
child: StreamSvgIcon.left(
color: Colors.black,
size: 24.0,
),
width: 24.0,
height: 24.0,
),
),
),
backgroundColor: StreamChatTheme.of(context).primaryColor,
),
);
}
}
class _FileDisplayScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
brightness: Theme.of(context).brightness,
elevation: 1,
centerTitle: true,
title: Text(
'Files',
style: TextStyle(color: Colors.black, fontSize: 16.0),
),
leading: Center(
child: InkWell(
onTap: () {
Navigator.of(context).pop();
},
child: Container(
child: StreamSvgIcon.left(
color: Colors.black,
size: 24.0,
),
width: 24.0,
height: 24.0,
),
),
),
backgroundColor: StreamChatTheme.of(context).primaryColor,
),
);
}
}
+135 -12
View File
@@ -1,38 +1,79 @@
import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/utils.dart';
import 'package:video_compress/video_compress.dart';
import 'package:video_player/video_player.dart';
import 'media_utils.dart';
class FileAttachment extends StatelessWidget {
enum FileAttachmentType { local, online }
class FileAttachment extends StatefulWidget {
final Attachment attachment;
final Size size;
final Widget trailing;
final FileAttachmentType attachmentType;
final PlatformFile file;
const FileAttachment({
Key key,
@required this.attachment,
this.size,
this.trailing,
this.attachmentType = FileAttachmentType.online,
this.file,
}) : super(key: key);
@override
_FileAttachmentState createState() => _FileAttachmentState();
}
class _FileAttachmentState extends State<FileAttachment> {
VideoPlayerController _controller;
Future<void> _initializeVideoPlayerFuture;
@override
void initState() {
super.initState();
if (MediaUtils.getMimeType(widget.attachment.title).type == 'video') {
if (widget.attachmentType == FileAttachmentType.online) {
_controller = VideoPlayerController.network(
widget.attachment.assetUrl,
);
} else {
_controller = VideoPlayerController.file(
File.fromRawPath(widget.file.bytes),
);
}
_initializeVideoPlayerFuture = _controller.initialize();
}
}
@override
Widget build(BuildContext context) {
return Material(
child: Container(
width: size?.width ?? 100,
width: widget.size?.width ?? 100,
height: 56.0,
margin: trailing != null ? EdgeInsets.only(top: 4.0) : null,
margin: widget.trailing != null ? EdgeInsets.only(top: 4.0) : null,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: trailing != null ? BorderRadius.circular(16.0) : null,
border: trailing != null
borderRadius:
widget.trailing != null ? BorderRadius.circular(16.0) : null,
border: widget.trailing != null
? Border.fromBorderSide(BorderSide(color: Color(0xFFE6E6E6)))
: null,
),
child: Row(
children: [
Container(
child: _getFileTypeImage(attachment.extraData['mime_type']),
child: _getFileTypeImage(),
height: 40.0,
width: 33.33,
margin: EdgeInsets.all(8.0),
@@ -46,7 +87,7 @@ class FileAttachment extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
attachment?.title ?? 'File',
widget.attachment?.title ?? 'File',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14.0,
@@ -58,7 +99,7 @@ class FileAttachment extends StatelessWidget {
height: 3.0,
),
Text(
'${attachment.extraData['file_size'] ?? 'N/A'} bytes',
'${_getSizeText(widget.attachment.extraData['file_size'])}',
style: TextStyle(
color: Colors.black.withOpacity(0.5),
fontSize: 14.0,
@@ -69,13 +110,13 @@ class FileAttachment extends StatelessWidget {
),
Column(
children: [
trailing ??
widget.trailing ??
IconButton(
icon: StreamSvgIcon.cloud_download(
color: Colors.black,
),
onPressed: () {
launchURL(context, attachment.assetUrl);
launchURL(context, widget.attachment.assetUrl);
},
),
],
@@ -117,8 +158,76 @@ class FileAttachment extends StatelessWidget {
);
}
StreamSvgIcon _getFileTypeImage(String type) {
switch (type) {
Widget _getFileTypeImage() {
if ((MediaUtils.getMimeType(widget.attachment.title).type == 'image')) {
switch (widget.attachmentType) {
case FileAttachmentType.local:
return Image.memory(
widget.file.bytes,
fit: BoxFit.cover,
);
break;
case FileAttachmentType.online:
return CachedNetworkImage(
imageUrl: widget.attachment.imageUrl ??
widget.attachment.assetUrl ??
widget.attachment.thumbUrl,
fit: BoxFit.cover,
progressIndicatorBuilder: (context, _, progress) {
return Center(
child: Container(
width: 20.0,
height: 20.0,
child: CircularProgressIndicator(
backgroundColor: StreamChatTheme.of(context).accentColor,
),
),
);
},
);
break;
}
}
if ((MediaUtils.getMimeType(widget.attachment.title).type == 'video')) {
switch (widget.attachmentType) {
case FileAttachmentType.local:
return FutureBuilder<File>(
future: VideoCompress.getFileThumbnail(widget.file.path),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Image.asset(
'images/placeholder.png',
package: 'stream_chat_flutter',
);
}
return Image.file(
snapshot.data,
fit: BoxFit.cover,
);
},
);
break;
case FileAttachmentType.online:
return FutureBuilder(
future: _initializeVideoPlayerFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: VideoPlayer(_controller),
);
} else {
return Center(child: CircularProgressIndicator());
}
},
);
break;
}
}
switch (widget.attachment.extraData['mime_type']) {
case '7z':
return StreamSvgIcon.filetype_7z();
break;
@@ -175,4 +284,18 @@ class FileAttachment extends StatelessWidget {
break;
}
}
String _getSizeText(int bytes) {
if (bytes == null) {
return 'Size N/A';
}
if (bytes <= 1000) {
return '${bytes} bytes';
} else if (bytes <= 100000) {
return '${(bytes / 1000).toStringAsFixed(2)} KB';
} else {
return '${(bytes / 1000000).toStringAsFixed(2)} MB';
}
}
}
+101 -34
View File
@@ -1,33 +1,47 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
enum LoadingStatus { LOADING, STABLE }
enum _LoadingStatus { LOADING, STABLE }
/// Signature for EndOfPageListeners
typedef EndOfPageListenerCallback = void Function();
/// A widget that wraps a [Widget] and will trigger [onEndOfPage] when it
/// reaches the bottom of the list
/// A widget that wraps a [Widget] and will trigger [onEndOfPage]/[onStartOfPage] when it
/// reaches the bottom/start of the list
class LazyLoadScrollView extends StatefulWidget {
/// The [Widget] that this widget watches for changes on
final Widget child;
/// Called when the [child] reaches the end of the list
final EndOfPageListenerCallback onEndOfPage;
/// Called when the [child] reaches the start of the list
final AsyncCallback onStartOfPage;
/// The offset to take into account when triggering [onEndOfPage] in pixels
final int scrollOffset;
/// Called when the [child] reaches the end of the list
final AsyncCallback onEndOfPage;
/// Called when the list scrolling starts
final VoidCallback onPageScrollStart;
/// Called when the list scrolling ends
final VoidCallback onPageScrollEnd;
/// Called every time the [child] is in-between the list
final VoidCallback onInBetweenOfPage;
/// The offset to take into account when triggering [onEndOfPage]/[onStartOfPage] in pixels
final double scrollOffset;
/// Used to determine if loading of new data has finished. You should use set this if you aren't using a FutureBuilder or StreamBuilder
final bool isLoading;
/// Initiates a LazyLoadScrollView widget
LazyLoadScrollView({
Key key,
@required this.child,
@required this.onEndOfPage,
this.onStartOfPage,
this.onEndOfPage,
this.onPageScrollStart,
this.onPageScrollEnd,
this.onInBetweenOfPage,
this.isLoading = false,
this.scrollOffset = 100,
}) : assert(onEndOfPage != null),
assert(child != null),
}) : assert(child != null),
super(key: key);
@override
@@ -35,15 +49,8 @@ class LazyLoadScrollView extends StatefulWidget {
}
class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
LoadingStatus _loadMoreStatus = LoadingStatus.STABLE;
@override
void didUpdateWidget(LazyLoadScrollView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.isLoading) {
_loadMoreStatus = LoadingStatus.STABLE;
}
}
_LoadingStatus _loadMoreStatus = _LoadingStatus.STABLE;
double _scrollPosition = 0.0;
@override
Widget build(BuildContext context) {
@@ -54,28 +61,88 @@ class _LazyLoadScrollViewState extends State<LazyLoadScrollView> {
}
bool _onNotification(Notification notification) {
if (notification is ScrollStartNotification) {
if (widget.onPageScrollStart != null) {
widget.onPageScrollStart();
return true;
}
}
if (notification is ScrollEndNotification) {
if (widget.onPageScrollEnd != null) {
widget.onPageScrollEnd();
return true;
}
}
if (notification is ScrollUpdateNotification) {
if (notification.metrics.maxScrollExtent > notification.metrics.pixels &&
notification.metrics.maxScrollExtent - notification.metrics.pixels <=
widget.scrollOffset) {
if (_loadMoreStatus != null &&
_loadMoreStatus == LoadingStatus.STABLE) {
_loadMoreStatus = LoadingStatus.LOADING;
widget.onEndOfPage();
final pixels = notification.metrics.pixels;
final maxScrollExtent = notification.metrics.maxScrollExtent;
final minScrollExtent = notification.metrics.minScrollExtent;
final scrollOffset = widget.scrollOffset;
if (pixels > (minScrollExtent + scrollOffset) &&
pixels < (maxScrollExtent - scrollOffset)) {
if (widget.onInBetweenOfPage != null) {
widget.onInBetweenOfPage();
return true;
}
}
final extentBefore = notification.metrics.extentBefore;
final extentAfter = notification.metrics.extentAfter;
final scrollingDown = _scrollPosition < pixels;
if (scrollOffset == null || scrollOffset == 0) {
if (extentAfter == 0) {
_onEndOfPage();
}
if (extentBefore == 0) {
_onStartOfPage();
}
} else {
if (scrollingDown) {
if (extentAfter <= scrollOffset) {
_onEndOfPage();
}
} else {
if (extentBefore <= scrollOffset) {
_onStartOfPage();
}
}
}
_scrollPosition = pixels;
return true;
}
if (notification is OverscrollNotification) {
if (notification.overscroll > 0) {
if (_loadMoreStatus != null &&
_loadMoreStatus == LoadingStatus.STABLE) {
_loadMoreStatus = LoadingStatus.LOADING;
widget.onEndOfPage();
}
_onEndOfPage();
}
if (notification.overscroll < 0) {
_onStartOfPage();
}
return true;
}
return false;
}
void _onEndOfPage() {
if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) {
_loadMoreStatus = _LoadingStatus.LOADING;
if (widget.onEndOfPage != null) {
widget.onEndOfPage().whenComplete(() {
_loadMoreStatus = _LoadingStatus.STABLE;
});
}
}
}
void _onStartOfPage() {
if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) {
_loadMoreStatus = _LoadingStatus.LOADING;
if (widget.onStartOfPage != null) {
widget.onStartOfPage().whenComplete(() {
_loadMoreStatus = _LoadingStatus.STABLE;
});
}
}
}
}
+1 -1
View File
@@ -180,7 +180,7 @@ class MediaThumbnailProvider extends ImageProvider<MediaThumbnailProvider> {
MediaThumbnailProvider key, DecoderCallback decode) async {
assert(key == this);
final bytes = await media.thumbData;
if (bytes.isEmpty) return null;
if (bytes?.isNotEmpty != true) return null;
return await decode(bytes);
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:http_parser/http_parser.dart' as httpParser;
import 'package:mime/mime.dart';
class MediaUtils {
static httpParser.MediaType getMimeType(String filename) {
httpParser.MediaType mimeType;
if (filename != null) {
if (filename.toLowerCase().endsWith('heic')) {
mimeType = httpParser.MediaType.parse('image/heic');
} else {
mimeType = httpParser.MediaType.parse(lookupMimeType(filename));
}
}
return mimeType;
}
}
+2 -1
View File
@@ -113,11 +113,12 @@ class MessageActionsModal extends StatelessWidget {
messageTheme: messageTheme,
showReactions: false,
showUsername: false,
showReplyIndicator: false,
showThreadReplyIndicator: false,
showUserAvatar: showUserAvatar,
showTimestamp: false,
translateUserAvatar: false,
showReactionPickerIndicator: true,
showInChannelIndicator: false,
showSendingIndicator: DisplayWidget.gone,
shape: messageShape,
),
+148 -69
View File
@@ -21,7 +21,6 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:substring_highlight/substring_highlight.dart';
import 'package:video_compress/video_compress.dart';
import 'package:photo_manager/photo_manager.dart';
import '../stream_chat_flutter.dart';
import 'stream_channel.dart';
@@ -302,7 +301,7 @@ class MessageInputState extends State<MessageInput> {
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text('Send also as direct message'),
child: Text('Also send as direct message'),
),
],
),
@@ -326,21 +325,27 @@ class MessageInputState extends State<MessageInput> {
return AnimatedCrossFade(
crossFadeState:
_actionsShrunk ? CrossFadeState.showFirst : CrossFadeState.showSecond,
firstChild: IconButton(
onPressed: () {
firstChild: InkWell(
onTap: () {
setState(() {
_actionsShrunk = false;
});
},
icon: StreamSvgIcon.emptyCircleLeft(
color: StreamChatTheme.of(context).accentColor,
child: Padding(
padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0),
child: StreamSvgIcon.emptyCircleLeft(
color: StreamChatTheme.of(context).accentColor,
),
),
),
secondChild: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (!widget.disableAttachments) _buildAttachmentButton(),
if (widget.editMessage == null) _buildCommandButton(),
if (widget.editMessage == null &&
StreamChannel.of(context).channel?.config?.commands?.isNotEmpty ==
true)
_buildCommandButton(),
],
),
duration: Duration(milliseconds: 300),
@@ -354,11 +359,12 @@ class MessageInputState extends State<MessageInput> {
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20.0),
borderRadius: BorderRadius.circular(24.0),
border: Border.all(
color: Colors.grey,
),
),
padding: _attachments.isEmpty ? null : EdgeInsets.all(6.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -404,26 +410,45 @@ class MessageInputState extends State<MessageInput> {
child: Chip(
backgroundColor:
StreamChatTheme.of(context).accentColor,
label: Text(
_chosenCommand?.name ?? "",
style: TextStyle(color: Colors.white),
),
avatar: StreamSvgIcon.lightning(
color: Colors.white,
padding: EdgeInsets.zero,
labelPadding:
EdgeInsets.symmetric(horizontal: 9.0),
label: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
StreamSvgIcon.lightning(
color: Colors.white,
size: 16.0,
),
Text(
_chosenCommand?.name?.toUpperCase() ?? "",
style: TextStyle(
color: Colors.white, fontSize: 12.0),
),
],
),
),
)
: null,
suffixIcon: _commandEnabled
? IconButton(
icon: Icon(Icons.cancel_outlined),
onPressed: () {
? InkWell(
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: StreamSvgIcon.close_small(),
),
onTap: () {
setState(() {
_commandEnabled = false;
});
},
)
: null,
suffixIconConstraints: BoxConstraints(
maxHeight: 24.0,
maxWidth: 40.0,
),
),
textCapitalization: TextCapitalization.sentences,
),
@@ -436,6 +461,7 @@ class MessageInputState extends State<MessageInput> {
}
Timer _debounce;
void _onChanged(BuildContext context, String s) {
if (_debounce?.isActive == true) _debounce.cancel();
_debounce = Timer(
@@ -520,11 +546,12 @@ class MessageInputState extends State<MessageInput> {
void _checkCommands(String s, BuildContext context) {
if (s.startsWith('/')) {
var matchedCommandsList = StreamChannel.of(context)
.channel
.config
.commands
.where((element) => element.name == s.substring(1))
.toList();
.channel
.config
?.commands
?.where((element) => element.name == s.substring(1))
?.toList() ??
[];
if (matchedCommandsList.length == 1) {
_chosenCommand = matchedCommandsList[0];
@@ -545,11 +572,12 @@ class MessageInputState extends State<MessageInput> {
OverlayEntry _buildCommandsOverlayEntry() {
final text = textEditingController.text.trimLeft();
final commands = StreamChannel.of(context)
.channel
.config
.commands
.where((c) => c.name.contains(text.replaceFirst('/', '')))
.toList();
.channel
.config
?.commands
?.where((c) => c.name.contains(text.replaceFirst('/', '')))
?.toList() ??
[];
RenderBox renderBox = context.findRenderObject();
final size = renderBox.size;
@@ -664,14 +692,18 @@ class MessageInputState extends State<MessageInput> {
Color _getIconColor(int index) {
switch (index) {
case 0:
return _attachmentContainsFile && _attachments.isNotEmpty
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.5);
return _attachments.isEmpty
? StreamChatTheme.of(context).accentColor
: (!_attachmentContainsFile
? StreamChatTheme.of(context).accentColor
: Colors.black.withOpacity(0.2));
break;
case 1:
return !_attachmentContainsFile && _attachments.isNotEmpty
? Colors.black.withOpacity(0.2)
: Colors.black.withOpacity(0.5);
return _attachmentContainsFile
? StreamChatTheme.of(context).accentColor
: (_attachments.isEmpty
? Colors.black.withOpacity(0.5)
: Colors.black.withOpacity(0.2));
break;
case 2:
return _attachmentContainsFile && _attachments.isNotEmpty
@@ -799,7 +831,7 @@ class MessageInputState extends State<MessageInput> {
Widget _buildPickerSection() {
var _attachmentContainsFile =
_attachments.any((element) => element.attachment.type == 'file');
_attachments.any((element) => element.attachment?.type == 'file');
switch (_filePickerIndex) {
case 0:
@@ -813,22 +845,38 @@ class MessageInputState extends State<MessageInput> {
}
if (snapshot.data) {
return IgnorePointer(
ignoring: _attachmentContainsFile,
child: MediaListView(
selectedIds: _attachments.map((e) => e.id).toList(),
onSelect: (media) async {
if (!_attachments
.any((element) => element.id == media.id)) {
_addAttachment(media);
} else {
setState(() {
_attachments
.removeWhere((element) => element.id == media.id);
});
}
if (_attachmentContainsFile) {
return GestureDetector(
onTap: () {
pickFile(DefaultAttachmentTypes.file);
},
),
child: Container(
constraints: BoxConstraints.expand(),
color: Color(0xfff2f2f2),
child: Text(
'Add more files',
style: TextStyle(
color: StreamChatTheme.of(context).accentColor,
fontWeight: FontWeight.bold,
),
),
alignment: Alignment.center,
),
);
}
return MediaListView(
selectedIds: _attachments.map((e) => e.id).toList(),
onSelect: (media) async {
if (!_attachments
.any((element) => element.id == media.id)) {
_addAttachment(media);
} else {
setState(() {
_attachments
.removeWhere((element) => element.id == media.id);
});
}
},
);
}
@@ -1239,10 +1287,11 @@ class MessageInputState extends State<MessageInput> {
children: [
if (_attachments.any((e) => e.attachment?.type == 'file'))
LimitedBox(
maxHeight: 73.0,
maxHeight: 136.0,
child: ListView(
scrollDirection: Axis.horizontal,
children: _attachments
reverse: true,
shrinkWrap: true,
children: _attachments.reversed
.where((e) => e.attachment?.type == 'file')
.map(
(e) => Padding(
@@ -1253,9 +1302,11 @@ class MessageInputState extends State<MessageInput> {
clipBehavior: Clip.antiAlias,
child: FileAttachment(
attachment: e.attachment,
attachmentType: FileAttachmentType.local,
file: e.file,
size: Size(
MediaQuery.of(context).size.width * 0.55,
MediaQuery.of(context).size.height * 0.3,
MediaQuery.of(context).size.width * 0.65,
56.0,
),
trailing: Padding(
padding: const EdgeInsets.all(8.0),
@@ -1442,16 +1493,31 @@ class MessageInputState extends State<MessageInput> {
padding:
const EdgeInsets.only(left: 4.0, right: 8.0, top: 8.0, bottom: 8.0),
child: StreamSvgIcon.lightning(
color: Color(0xFF000000).withAlpha(128),
color: _commandsOverlay != null
? StreamChatTheme.of(context).accentColor
: Color(0xFF000000).withAlpha(128),
),
),
onTap: () {
onTap: () async {
if (_openFilePickerSection) {
setState(() {
_animateContainer = false;
_openFilePickerSection = false;
_filePickerSize = _kMinMediaPickerSize;
});
await Future.delayed(Duration(milliseconds: 300));
}
if (_commandsOverlay == null) {
_commandsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_commandsOverlay);
setState(() {
_commandsOverlay = _buildCommandsOverlayEntry();
Overlay.of(context).insert(_commandsOverlay);
});
} else {
_commandsOverlay?.remove();
_commandsOverlay = null;
setState(() {
_commandsOverlay?.remove();
_commandsOverlay = null;
});
}
},
);
@@ -1646,12 +1712,16 @@ class MessageInputState extends State<MessageInput> {
final mimeType = _getMimeType(file.path.split('/').last);
if (mimeType.type == 'video' || mimeType.type == 'image') {
attachmentType = mimeType.type;
}
Map<String, dynamic> extraDataMap = {};
if (camera) {
if (mimeType.type == 'video' || mimeType.type == 'image') {
attachmentType = mimeType.type;
}
} else {
attachmentType = 'file';
}
if (mimeType?.subtype != null) {
extraDataMap['mime_type'] = mimeType.subtype.toLowerCase();
}
@@ -1667,7 +1737,7 @@ class MessageInputState extends State<MessageInput> {
localUri: file.path != null ? Uri.parse(file.path) : null,
type: attachmentType,
extraData: extraDataMap.isNotEmpty ? extraDataMap : null,
title: file.name ?? 'File',
title: file.name,
),
);
@@ -1784,7 +1854,7 @@ class MessageInputState extends State<MessageInput> {
Widget _buildIdleSendButton(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0),
child: Center(
child: InkWell(
onTap: () {
@@ -1793,6 +1863,8 @@ class MessageInputState extends State<MessageInput> {
child: StreamSvgIcon(
assetName: _getIdleSendIcon(),
color: Colors.grey,
height: 24.0,
width: 24.0,
),
)),
);
@@ -1801,7 +1873,7 @@ class MessageInputState extends State<MessageInput> {
Widget _buildSendButton(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0),
child: InkWell(
onTap: () {
sendMessage();
@@ -1809,6 +1881,8 @@ class MessageInputState extends State<MessageInput> {
child: StreamSvgIcon(
assetName: _getSendIcon(),
color: StreamChatTheme.of(context).accentColor,
height: 24.0,
width: 24.0,
),
),
),
@@ -1859,8 +1933,6 @@ class MessageInputState extends State<MessageInput> {
_mentionsOverlay?.remove();
_mentionsOverlay = null;
final channel = StreamChannel.of(context).channel;
Future sendingFuture;
Message message;
if (widget.editMessage != null) {
@@ -1885,6 +1957,12 @@ class MessageInputState extends State<MessageInput> {
message = await widget.preMessageSending(message);
}
final streamChannel = StreamChannel.of(context);
final channel = streamChannel.channel;
if (!channel.state.isUpToDate) {
await streamChannel.reloadChannel();
}
if (widget.editMessage == null ||
widget.editMessage.status == MessageSendingStatus.FAILED) {
sendingFuture = channel.sendMessage(message);
@@ -1970,6 +2048,7 @@ class MessageInputState extends State<MessageInput> {
}
bool _initialized = false;
@override
void didChangeDependencies() {
if (widget.editMessage != null && !_initialized) {
+455 -284
View File
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:rxdart/rxdart.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter/src/lazy_load_scroll_view.dart';
import 'package:stream_chat_flutter/src/message_widget.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/system_message.dart';
@@ -108,10 +111,11 @@ class MessageListView extends StatefulWidget {
this.onThreadTap,
this.dateDividerBuilder,
this.scrollPhysics = const AlwaysScrollableScrollPhysics(),
this.initialScrollIndex = 0,
this.initialAlignment = 0,
this.initialScrollIndex,
this.initialAlignment,
this.scrollController,
this.itemPositionListener,
this.highlightInitialMessage = false,
}) : super(key: key);
/// Function used to build a custom message widget
@@ -153,6 +157,11 @@ class MessageListView extends StatefulWidget {
/// The ScrollPhysics used by the ListView
final ScrollPhysics scrollPhysics;
/// If true the list will highlight the initialMessage if there is any.
///
/// Also See [StreamChannel]
final bool highlightInitialMessage;
@override
_MessageListViewState createState() => _MessageListViewState();
}
@@ -160,10 +169,50 @@ class MessageListView extends StatefulWidget {
class _MessageListViewState extends State<MessageListView> {
ItemScrollController _scrollController;
bool _bottomWasVisible = false;
bool _topWasVisible = false;
Function _onThreadTap;
bool _showScrollToBottom = false;
ItemPositionsListener _itemPositionListener;
int _messageListLength;
int get _initialIndex {
if (widget.initialScrollIndex != null) return widget.initialScrollIndex;
final streamChannel = StreamChannel.of(context);
if (streamChannel.initialMessageId != null) {
final messages = streamChannel.channel.state.messages;
final totalMessages = messages.length;
final messageIndex = messages.indexWhere((e) {
return e.id == streamChannel.initialMessageId;
});
final index = totalMessages - messageIndex;
if (index != 0) return index - 1;
return index;
}
return 0;
}
double get _initialAlignment {
if (widget.initialAlignment != null) return widget.initialAlignment;
return 0;
}
bool _isInitialMessage(String id) {
final streamChannel = StreamChannel.of(context);
return streamChannel.initialMessageId == id;
}
bool get _upToDate => StreamChannel.of(context).channel.state.isUpToDate;
bool _topPaginationActive = false;
bool _bottomPaginationActive = false;
int initialIndex;
double initialAlignment;
List<Message> messages = <Message>[];
bool initialMessageHighlightComplete = false;
bool _inBetweenList = false;
@override
Widget build(BuildContext context) {
@@ -173,179 +222,265 @@ class _MessageListViewState extends State<MessageListView> {
? streamChannel.channel.state.threadsStream
.where((threads) => threads.containsKey(widget.parentMessage.id))
.map((threads) => threads[widget.parentMessage.id])
: streamChannel.channel.state.messagesStream;
: streamChannel.channel.state?.messagesStream;
return StreamBuilder<List<Message>>(
stream: messagesStream.map((messages) => messages
.where((e) =>
!e.isDeleted ||
(e.isDeleted &&
e.user.id == streamChannel.channel.client.state.user.id))
.toList()),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Center(
child: CircularProgressIndicator(),
);
}
return WillPopScope(
onWillPop: () async {
if (!_upToDate) {
await streamChannel.reloadChannel();
}
return true;
},
child: StreamBuilder<List<Message>>(
stream: messagesStream?.map((messages) => messages
?.where((e) =>
!e.isDeleted ||
(e.isDeleted &&
e.user.id == streamChannel.channel.client.state.user.id))
?.toList()),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Center(
child: const CircularProgressIndicator(),
);
}
final messages = snapshot.data?.reversed?.toList() ?? [];
final messageList = snapshot.data?.reversed?.toList() ?? [];
if (messageList.isEmpty) {
if (_upToDate) {
return Center(
child: Text(
'No chats here yet...',
style: StreamChatTheme.of(context)
.textTheme
.footnote
.copyWith(color: Colors.black.withOpacity(.5)),
),
);
}
} else {
messages = messageList;
}
if (messages.isEmpty) {
return Center(
child: Text(
'No chats here yet...',
style: StreamChatTheme.of(context)
.textTheme
.footnote
.copyWith(color: Colors.black.withOpacity(.5)),
),
);
}
final newMessagesListLength = messages.length;
return Stack(
alignment: Alignment.center,
children: [
ScrollablePositionedList.builder(
itemPositionsListener: _itemPositionListener,
addAutomaticKeepAlives: true,
key: Key('messageListView'),
initialScrollIndex: widget.initialScrollIndex,
initialAlignment: widget.initialAlignment,
physics: widget.scrollPhysics,
itemScrollController: _scrollController,
reverse: true,
itemCount: messages.length +
1 +
(widget.parentMessage != null ? 1 : 0),
itemBuilder: (context, i) {
if (i == messages.length + 1) {
if (widget.parentMessageBuilder != null) {
return widget.parentMessageBuilder(
context,
widget.parentMessage,
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
buildParentMessage(widget.parentMessage),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Container(
padding: const EdgeInsets.all(8),
child: Text(
'Start of thread',
textAlign: TextAlign.center,
),
color:
Theme.of(context).accentColor.withAlpha(50),
),
),
],
if (_messageListLength != null) {
if (_bottomPaginationActive || (_inBetweenList && _upToDate)) {
if (_itemPositionListener.itemPositions.value?.isNotEmpty ==
true) {
final first = _itemPositionListener.itemPositions.value.first;
final diff = newMessagesListLength - _messageListLength;
if (diff > 0) {
initialIndex = first.index + diff;
initialAlignment = first.itemLeadingEdge;
}
}
} else if (!_topPaginationActive && _upToDate) {
// Reset the index in-case we send any new message
initialIndex = 0;
initialAlignment = 0;
}
}
_messageListLength = newMessagesListLength;
return Stack(
alignment: Alignment.center,
children: [
LazyLoadScrollView(
onStartOfPage: () async {
_inBetweenList = false;
if (!_upToDate) {
_topPaginationActive = false;
_bottomPaginationActive = true;
return _paginateData(
streamChannel,
QueryDirection.bottom,
);
}
}
if (i == messages.length) {
return _buildLoadingIndicator(streamChannel);
}
final message = messages[i];
final nextMessage = i > 0 ? messages[i - 1] : null;
Widget messageWidget;
if (i == 0) {
messageWidget = _buildBottomMessage(
context,
message,
messages,
streamChannel,
);
} else if (i == messages.length - 1) {
messageWidget = _buildTopMessage(
context,
message,
messages,
streamChannel,
);
} else {
if (widget.messageBuilder != null) {
messageWidget = Builder(
key: ValueKey<String>('MESSAGE-${message.id}'),
builder: (_) => widget.messageBuilder(
context,
MessageDetails(
context,
message,
messages,
i,
),
messages),
);
} else {
messageWidget = buildMessage(message, messages, i);
}
}
if (nextMessage != null &&
!Jiffy(message.createdAt.toLocal())
.isSame(nextMessage.createdAt.toLocal(), Units.DAY)) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
messageWidget,
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
nextMessage.createdAt.toLocal())
: DateDivider(
dateTime: nextMessage.createdAt.toLocal(),
),
),
],
);
}
return messageWidget;
},
),
if (widget.showScrollToBottom && _showScrollToBottom)
_buildScrollToBottom(),
Positioned(
top: 20.0,
child: ValueListenableBuilder<Iterable<ItemPosition>>(
valueListenable: _itemPositionListener.itemPositions,
builder: (context, values, _) {
final items = _itemPositionListener.itemPositions?.value;
if (items.isEmpty || messages.isEmpty) {
return SizedBox();
}
var index = _getTopElement(values).index;
if (index > messages.length) {
return SizedBox();
}
if (index == messages.length) {
index = max(index - 1, 0);
}
return widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
messages[index].createdAt.toLocal(),
)
: DateDivider(
dateTime: messages[index].createdAt.toLocal(),
);
},
onEndOfPage: () async {
_inBetweenList = false;
_topPaginationActive = true;
_bottomPaginationActive = false;
return _paginateData(
streamChannel,
QueryDirection.top,
);
},
onInBetweenOfPage: () {
_inBetweenList = true;
},
child: ScrollablePositionedList.builder(
key: ValueKey(initialIndex + initialAlignment),
itemPositionsListener: _itemPositionListener,
addAutomaticKeepAlives: true,
initialScrollIndex: initialIndex ?? 0,
initialAlignment: initialAlignment ?? 0,
physics: widget.scrollPhysics,
itemScrollController: _scrollController,
reverse: true,
itemCount: messages.length +
2 +
(widget.parentMessage != null ? 1 : 0),
itemBuilder: (context, i) {
if (i == messages.length + 2) {
if (widget.parentMessageBuilder != null) {
return widget.parentMessageBuilder(
context,
widget.parentMessage,
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
buildParentMessage(widget.parentMessage),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0XFFF7F7F7),
Color(0XFFFCFCFC),
],
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${widget.parentMessage.replyCount} ${widget.parentMessage.replyCount == 1 ? 'Reply' : 'Replies'}',
textAlign: TextAlign.center,
style: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.lastMessageAt,
),
),
),
],
);
}
}
if (i == messages.length + 1) {
return _buildLoadingIndicator(
streamChannel,
QueryDirection.top,
);
}
if (i == 0) {
return _buildLoadingIndicator(
streamChannel,
QueryDirection.bottom,
);
}
final message = messages[i - 1];
final nextMessage = (i - 1) > 0 ? messages[i - 2] : null;
Widget messageWidget;
if (i == 1) {
messageWidget = _buildBottomMessage(
context,
message,
messages,
streamChannel,
);
} else if (i == messages.length - 1) {
messageWidget = _buildTopMessage(
context,
message,
messages,
streamChannel,
);
} else {
if (widget.messageBuilder != null) {
messageWidget = Builder(
key: ValueKey<String>('MESSAGE-${message.id}'),
builder: (_) => widget.messageBuilder(
context,
MessageDetails(
context,
message,
messages,
i,
),
messages),
);
} else {
messageWidget = buildMessage(message, messages, i);
}
}
if (nextMessage != null &&
!Jiffy(message.createdAt.toLocal()).isSame(
nextMessage.createdAt.toLocal(), Units.DAY)) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
messageWidget,
Padding(
padding:
const EdgeInsets.symmetric(vertical: 12.0),
child: widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
nextMessage.createdAt.toLocal())
: DateDivider(
dateTime: nextMessage.createdAt.toLocal(),
),
),
],
);
}
return messageWidget;
},
),
),
),
],
);
});
if (widget.showScrollToBottom) _buildScrollToBottom(),
Positioned(
top: 20.0,
child: ValueListenableBuilder<Iterable<ItemPosition>>(
valueListenable: _itemPositionListener.itemPositions,
builder: (context, values, _) {
final items = _itemPositionListener.itemPositions?.value;
if (items.isEmpty || messages.isEmpty) {
return SizedBox();
}
var index = _getTopElement(values).index;
if (index > messages.length) {
return SizedBox();
}
if (index == messages.length) {
index = max(index - 1, 0);
}
return widget.dateDividerBuilder != null
? widget.dateDividerBuilder(
messages[index].createdAt.toLocal(),
)
: DateDivider(
dateTime: messages[index].createdAt.toLocal(),
);
},
),
),
],
);
}),
);
}
Future<void> _paginateData(
StreamChannelState channel, QueryDirection direction) {
if (widget.parentMessage == null) {
return channel.queryMessages(direction: direction);
} else {
return channel.getReplies(widget.parentMessage.id);
}
}
ItemPosition _getTopElement(Iterable<ItemPosition> values) {
@@ -357,91 +492,120 @@ class _MessageListViewState extends State<MessageListView> {
Widget _buildScrollToBottom() {
final streamChannel = StreamChannel.of(context);
return Positioned(
bottom: 8,
right: 8,
width: 40,
height: 40,
child: Stack(
clipBehavior: Clip.none,
children: [
FloatingActionButton(
backgroundColor: Colors.white,
child: StreamSvgIcon.down(
color: Colors.black,
),
onPressed: () {
setState(() {
_showScrollToBottom = false;
});
_scrollController.scrollTo(
index: 0,
duration: Duration(seconds: 1),
curve: Curves.easeInOut,
);
},
),
if (streamChannel.channel.state.members.any((Member e) =>
e.userId == streamChannel.channel.client.state.user.id))
StreamBuilder<int>(
stream: streamChannel.channel.state.unreadCountStream,
initialData: streamChannel.channel.state.unreadCount,
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data <= 0) {
return Offstage();
return StreamBuilder<Tuple2<bool, int>>(
stream: Rx.combineLatest2(
streamChannel.channel.state.isUpToDateStream,
streamChannel.channel.state.unreadCountStream,
(bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount),
),
builder: (_, snapshot) {
if (snapshot.hasError) {
return Offstage();
} else if (!snapshot.hasData) {
return Offstage();
}
final isUpToDate = snapshot.data.item1;
final showScrollToBottom = !isUpToDate || _showScrollToBottom;
if (!showScrollToBottom) {
return Offstage();
}
final unreadCount = snapshot.data.item2;
final showUnreadCount = unreadCount > 0 &&
streamChannel.channel.state.members.any(
(e) => e.userId == streamChannel.channel.client.state.user.id);
return Positioned(
bottom: 8,
right: 8,
width: 40,
height: 40,
child: Stack(
clipBehavior: Clip.none,
children: [
FloatingActionButton(
backgroundColor: Colors.white,
child: StreamSvgIcon.down(
color: Colors.black,
),
onPressed: () {
if (unreadCount > 0) {
streamChannel.channel.markRead();
}
return Positioned(
width: 20,
height: 20,
left: 10,
top: -10,
child: CircleAvatar(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Text(
snapshot.data.toString(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
if (!_upToDate) {
_bottomPaginationActive = false;
_topPaginationActive = false;
streamChannel.reloadChannel();
} else {
setState(() => _showScrollToBottom = false);
_scrollController.scrollTo(
index: 0,
duration: Duration(seconds: 1),
curve: Curves.easeInOut,
);
}
},
),
if (showUnreadCount)
Positioned(
width: 20,
height: 20,
left: 10,
top: -10,
child: CircleAvatar(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: Text(
'$unreadCount',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
);
}),
],
),
),
),
],
),
);
},
);
}
Container _buildLoadingIndicator(StreamChannelState streamChannel) {
return Container(
key: Key('LOADING-INDICATOR'),
height: 50,
width: double.infinity,
child: StreamBuilder<bool>(
stream: streamChannel.queryMessage,
initialData: false,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Container(
color: Color(0xffd0021B).withAlpha(26),
child: Center(
child: Text('Error loading messages'),
),
);
}
if (!snapshot.data) {
return SizedBox();
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: CircularProgressIndicator(),
Widget _buildLoadingIndicator(
StreamChannelState streamChannel,
QueryDirection direction,
) {
final stream = direction == QueryDirection.top
? streamChannel.queryTopMessages
: streamChannel.queryBottomMessages;
return StreamBuilder<bool>(
key: Key('LOADING-INDICATOR'),
stream: stream,
initialData: false,
builder: (context, snapshot) {
if (snapshot.hasError) {
return Container(
color: Color(0xffd0021B).withAlpha(26),
child: Center(
child: Text('Error loading messages'),
),
);
}),
);
}
if (!snapshot.data) {
if (direction == QueryDirection.top) {
return Container(
height: 52,
width: double.infinity,
);
}
return Offstage();
}
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: const CircularProgressIndicator(),
),
);
});
}
Widget _buildTopMessage(
@@ -468,22 +632,7 @@ class _MessageListViewState extends State<MessageListView> {
} else {
messageWidget = buildMessage(message, messages, messages.length - 1);
}
return VisibilityDetector(
key: ValueKey<String>('TOP-MESSAGE'),
child: messageWidget,
onVisibilityChanged: (visibility) {
final topIsVisible = visibility.visibleBounds != Rect.zero;
if (topIsVisible && !_topWasVisible) {
if (widget.parentMessage == null) {
streamChannel.queryMessages();
} else {
streamChannel.getReplies(widget.parentMessage.id);
}
}
_topWasVisible = topIsVisible;
},
);
return messageWidget;
}
Widget _buildBottomMessage(
@@ -515,18 +664,17 @@ class _MessageListViewState extends State<MessageListView> {
key: ValueKey<String>('BOTTOM-MESSAGE'),
onVisibilityChanged: (visibility) {
final isVisible = visibility.visibleBounds != Rect.zero;
if (isVisible &&
!_bottomWasVisible &&
streamChannel.channel.config?.readEvents == true) {
if (streamChannel.channel.state.unreadCount > 0) {
if (isVisible && !_bottomWasVisible) {
final channel = streamChannel.channel;
if (_upToDate &&
channel.config?.readEvents == true &&
channel.state.unreadCount > 0) {
streamChannel.channel.markRead();
}
_bottomWasVisible = !isVisible;
}
_bottomWasVisible = isVisible;
if (mounted) {
setState(() {
_showScrollToBottom = !isVisible;
});
setState(() => _showScrollToBottom = !isVisible);
}
},
child: messageWidget,
@@ -539,7 +687,8 @@ class _MessageListViewState extends State<MessageListView> {
final isMyMessage = message.user.id == StreamChat.of(context).user.id;
return MessageWidget(
showReplyIndicator: false,
showThreadReplyIndicator: false,
showInChannelIndicator: false,
message: message,
reverse: isMyMessage,
showUsername: !isMyMessage,
@@ -582,7 +731,7 @@ class _MessageListViewState extends State<MessageListView> {
final userId = StreamChat.of(context).user.id;
final isMyMessage = message.user.id == userId;
final isNextUser =
index - 1 >= 0 && message.user.id == messages[index - 1]?.user?.id;
index - 2 >= 0 && message.user.id == messages[index - 2]?.user?.id;
final channel = StreamChannel.of(context).channel;
final readList = channel.state?.read
@@ -597,7 +746,7 @@ class _MessageListViewState extends State<MessageListView> {
final allRead = readList.length >= (channel.memberCount ?? 0) - 1;
return MessageWidget(
Widget child = MessageWidget(
key: ValueKey<String>('MESSAGE-${message.id}'),
message: message,
reverse: isMyMessage,
@@ -605,8 +754,11 @@ class _MessageListViewState extends State<MessageListView> {
padding: EdgeInsets.only(
left: 8.0,
right: 8.0,
bottom: index == 0 ? 30 : (isNextUser ? 5 : 10),
bottom: index == 0 ? 30 : (isNextUser ? 2 : 7),
top: 3,
),
showInChannelIndicator: widget.parentMessage == null,
showThreadReplyIndicator: widget.parentMessage == null,
showUsername: !isMyMessage && !isNextUser,
showSendingIndicator: isMyMessage &&
(index == 0 || message.status != MessageSendingStatus.SENT)
@@ -639,6 +791,30 @@ class _MessageListViewState extends State<MessageListView> {
readList: readList,
allRead: allRead,
);
if (!initialMessageHighlightComplete &&
widget.highlightInitialMessage &&
_isInitialMessage(message.id)) {
final accentColor = Theme.of(context).accentColor;
child = TweenAnimationBuilder<Color>(
tween: ColorTween(
begin: accentColor.withOpacity(0.7),
end: Colors.transparent,
),
duration: const Duration(seconds: 2),
child: child,
onEnd: () {
initialMessageHighlightComplete = true;
},
builder: (_, color, child) {
return Container(
color: color,
child: child,
);
},
);
}
return child;
}
StreamSubscription _messageNewListener;
@@ -651,26 +827,21 @@ class _MessageListViewState extends State<MessageListView> {
final streamChannel = StreamChannel.of(context);
initialIndex = _initialIndex;
initialAlignment = _initialAlignment;
_messageNewListener =
streamChannel.channel.on(EventType.messageNew).listen((event) {
final firstElementInViewport =
_itemPositionListener.itemPositions.value.first;
if (_upToDate) {
_bottomPaginationActive = false;
_topPaginationActive = false;
}
if (event.message.user.id == streamChannel.channel.client.state.user.id) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollController.jumpTo(
index: 0,
);
});
} else {
if (firstElementInViewport.index != 0) {
_scrollController.jumpTo(
index: firstElementInViewport.index + 1,
alignment: firstElementInViewport.itemLeadingEdge,
);
}
}
if (firstElementInViewport.index == 0) {
streamChannel.channel.markRead();
}
});
+1 -1
View File
@@ -105,7 +105,7 @@ class MessageReactionsModal extends StatelessWidget {
showReactions: false,
showUsername: false,
showUserAvatar: showUserAvatar,
showReplyIndicator: false,
showThreadReplyIndicator: false,
showTimestamp: false,
translateUserAvatar: false,
showSendingIndicator: DisplayWidget.gone,
+6 -6
View File
@@ -77,16 +77,16 @@ class MessageSearchItem extends StatelessWidget {
}
Widget _buildDate(BuildContext context, Message message) {
final lastUpdatedAt = message.updatedAt;
final createdAt = message.createdAt;
String stringDate;
final now = DateTime.now();
if (now.year != lastUpdatedAt.year ||
now.month != lastUpdatedAt.month ||
now.day != lastUpdatedAt.day) {
stringDate = Jiffy(lastUpdatedAt.toLocal()).format('dd/MM/yyyy');
if (now.year != createdAt.year ||
now.month != createdAt.month ||
now.day != createdAt.day) {
stringDate = Jiffy(createdAt.toLocal()).format('dd/MM/yyyy');
} else {
stringDate = Jiffy(lastUpdatedAt.toLocal()).format('HH:mm');
stringDate = Jiffy(createdAt.toLocal()).format('HH:mm');
}
return Text(
+9 -6
View File
@@ -14,6 +14,10 @@ typedef MessageSearchItemTapCallback = void Function(GetMessageResponse);
typedef MessageSearchItemBuilder = Widget Function(
BuildContext, GetMessageResponse);
/// Builder used when [MessageSearchListView] is empty
typedef EmptyMessageSearchBuilder = Widget Function(
BuildContext context, String searchQuery);
///
/// It shows the list of searched messages.
///
@@ -85,7 +89,7 @@ class MessageSearchListView extends StatefulWidget {
final MessageSearchItemTapCallback onItemTap;
/// The builder used when the channel list is empty.
final WidgetBuilder emptyBuilder;
final EmptyMessageSearchBuilder emptyBuilder;
/// The builder that will be used in case of error
final Widget Function(Error error) errorBuilder;
@@ -250,11 +254,10 @@ class _MessageSearchListViewState extends State<MessageSearchListView> {
final items = snapshot.data;
if (items.isEmpty && widget.emptyBuilder != null) {
return widget.emptyBuilder(context);
}
if (items.isEmpty && widget.emptyBuilder == null) {
if (items.isEmpty) {
if (widget.emptyBuilder != null) {
return widget.emptyBuilder(context, widget.messageQuery);
}
return LayoutBuilder(
builder: (context, viewportConstraints) {
return SingleChildScrollView(
+304 -238
View File
@@ -99,8 +99,11 @@ class MessageWidget extends StatefulWidget {
final bool allRead;
/// If true the widget will show the reply indicator
final bool showReplyIndicator;
/// If true the widget will show the thread reply indicator
final bool showThreadReplyIndicator;
/// If true the widget will show the show in channel indicator
final bool showInChannelIndicator;
/// The function called when tapping on UserAvatar
final void Function(User) onUserAvatarTap;
@@ -123,6 +126,7 @@ class MessageWidget extends StatefulWidget {
/// Center user avatar with bottom of the message
final bool translateUserAvatar;
///
MessageWidget({
Key key,
@required this.message,
@@ -139,7 +143,8 @@ class MessageWidget extends StatefulWidget {
this.showReactionPickerIndicator = false,
this.showUserAvatar = DisplayWidget.show,
this.showSendingIndicator = DisplayWidget.show,
this.showReplyIndicator = true,
this.showThreadReplyIndicator = true,
this.showInChannelIndicator = true,
this.onThreadTap,
this.showUsername = true,
this.showTimestamp = true,
@@ -211,10 +216,23 @@ class MessageWidget extends StatefulWidget {
}
class _MessageWidgetState extends State<MessageWidget> {
bool get showThreadReplyIndicator =>
widget.showThreadReplyIndicator && widget.message.replyCount > 0;
bool get showUsername => widget.showUsername;
bool get showTimeStamp =>
widget.message.createdAt != null && widget.showTimestamp;
bool get showReadList => widget.readList?.isNotEmpty == true;
bool get showInChannel =>
widget.showInChannelIndicator && widget.message?.showInChannel == true;
@override
Widget build(BuildContext context) {
var leftPadding = widget.showUserAvatar != DisplayWidget.gone
? widget.messageTheme.avatarTheme.constraints.maxWidth + 16.0
? widget.messageTheme.avatarTheme.constraints.maxWidth + 14.5
: 6.0;
final hasFiles =
@@ -234,140 +252,151 @@ class _MessageWidgetState extends State<MessageWidget> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
Stack(
alignment: AlignmentDirectional.bottomStart,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar == DisplayWidget.show)
_buildUserAvatar(),
SizedBox(
width: 6,
),
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(
width: widget.messageTheme.avatarTheme.constraints
.maxWidth +
8,
),
Flexible(
child: PortalEntry(
portal: Container(
transform: Matrix4.translationValues(-16, 2, 0),
child: _buildReactionIndicator(context),
constraints: BoxConstraints(maxWidth: 22 * 6.0),
),
portalAnchor: Alignment(-1.0, -1.0),
childAnchor: Alignment(1, -1.0),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget.message.reactionCounts
?.isNotEmpty ==
true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
widget.message.status !=
MessageSendingStatus
.FAILED_DELETE)
? Transform(
alignment: Alignment.center,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (widget.showUserAvatar == DisplayWidget.show)
_buildUserAvatar(),
SizedBox(width: 6),
if (widget.showUserAvatar == DisplayWidget.hide)
SizedBox(
width: widget.messageTheme.avatarTheme
.constraints.maxWidth +
8,
),
Flexible(
child: PortalEntry(
portal: Container(
transform:
Matrix4.translationValues(-16, 2, 0),
child: _buildReactionIndicator(context),
constraints:
BoxConstraints(maxWidth: 22 * 6.0),
),
portalAnchor: Alignment(-1.0, -1.0),
childAnchor: Alignment(1, -1.0),
child: Stack(
clipBehavior: Clip.none,
children: [
Padding(
padding: widget.showReactions
? EdgeInsets.only(
top: widget.message.reactionCounts
?.isNotEmpty ==
true
? 18
: 0,
)
: EdgeInsets.zero,
child: (widget.message.isDeleted &&
widget.message.status !=
MessageSendingStatus
.FAILED_DELETE)
? Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(
widget.reverse ? pi : 0),
child: DeletedMessage(
reverse: widget.reverse,
borderRadiusGeometry:
widget.borderRadiusGeometry,
borderSide: widget.borderSide,
shape: widget.shape,
messageTheme:
widget.messageTheme,
),
)
: Material(
clipBehavior: Clip.antiAlias,
shape: widget.shape ??
RoundedRectangleBorder(
side: isOnlyEmoji
? BorderSide.none
: widget.borderSide ??
BorderSide(
color: Theme.of(context)
.brightness ==
Brightness
.dark
? Colors.white
.withAlpha(
24)
: Colors.black
.withAlpha(
24),
),
borderRadius: widget
.borderRadiusGeometry ??
BorderRadius.zero,
),
color: _getBackgroundColor(),
child: Padding(
padding: EdgeInsets.all(
hasFiles ? 2.0 : 0.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize:
MainAxisSize.min,
children: <Widget>[
..._parseAttachments(
context),
if (widget.message.text
.trim()
.isNotEmpty &&
!isGiphy)
_buildTextBubble(context),
],
),
),
),
),
if (widget.showReactionPickerIndicator)
Positioned(
right: 0,
top: -6,
child: Transform(
transform: Matrix4.rotationY(
widget.reverse ? pi : 0),
child: DeletedMessage(
reverse: widget.reverse,
borderRadiusGeometry:
widget.borderRadiusGeometry,
borderSide: widget.borderSide,
shape: widget.shape,
messageTheme: widget.messageTheme,
),
)
: Material(
clipBehavior: Clip.antiAlias,
shape: widget.shape ??
RoundedRectangleBorder(
side: isOnlyEmoji
? BorderSide.none
: widget.borderSide ??
BorderSide(
color: Theme.of(context)
.brightness ==
Brightness
.dark
? Colors.white
.withAlpha(24)
: Colors.black
.withAlpha(
24),
),
borderRadius: widget
.borderRadiusGeometry ??
BorderRadius.zero,
),
color: _getBackgroundColor(),
child: Padding(
padding: EdgeInsets.all(
hasFiles ? 2.0 : 0.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
..._parseAttachments(context),
if (widget.message.text
.trim()
.isNotEmpty &&
!isGiphy)
_buildTextBubble(context),
],
child: CustomPaint(
painter: ReactionBubblePainter(
widget.messageTheme
.reactionsBackgroundColor,
widget.messageTheme
.reactionsBorderColor,
),
),
),
),
if (widget.showReactionPickerIndicator)
Positioned(
right: 0,
top: -6,
child: Transform(
transform: Matrix4.rotationY(
widget.reverse ? pi : 0),
child: CustomPaint(
painter: ReactionBubblePainter(
widget.messageTheme
.reactionsBackgroundColor,
widget.messageTheme
.reactionsBorderColor,
),
),
),
),
],
],
),
),
),
),
],
),
if (showThreadReplyIndicator ||
showUsername ||
showTimeStamp ||
showInChannel)
SizedBox(height: 20.0),
],
),
if (widget.showReplyIndicator &&
widget.message.replyCount > 0)
_buildReplyIndicator(leftPadding),
if (showThreadReplyIndicator ||
showUsername ||
showTimeStamp ||
showInChannel)
_buildBottomRow(leftPadding)
],
),
if ((widget.message.createdAt != null &&
widget.showTimestamp) ||
widget.showUsername ||
widget.readList?.isNotEmpty == true)
_buildBottomRow(leftPadding),
],
),
),
@@ -376,6 +405,119 @@ class _MessageWidgetState extends State<MessageWidget> {
);
}
Widget _buildBottomRow(double leftPadding) {
final deleted = widget.message.isDeleted;
var children = <Widget>[];
if (deleted) {
children.add(
Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamSvgIcon.eye(
color: Colors.black.withOpacity(0.5),
size: 16.0,
),
SizedBox(width: 8.0),
Text(
'Only visible to you',
style: TextStyle(
color: Colors.black.withOpacity(0.5),
fontSize: 12.0,
),
),
],
),
);
} else if (showInChannel) {
final onThreadTap = () async {
try {
final channel = StreamChannel.of(context);
final message = await channel.getMessage(widget.message.parentId);
return widget.onThreadTap(message);
} catch (e, stk) {
print(e);
print(stk);
return null;
}
};
children.add(
InkWell(
onTap: widget.onThreadTap != null ? onThreadTap : null,
child: Text('Thread Reply', style: widget.messageTheme?.replies),
),
);
} else {
final showSendingIndicator =
widget.showSendingIndicator == DisplayWidget.show;
final replyCount = widget.message.replyCount;
final msg = replyCount != 0
? '$replyCount ${replyCount > 1 ? 'Thread Replies' : 'Thread Reply'}'
: 'Thread Reply';
final onThreadTap = () async {
var message = widget.message;
return widget.onThreadTap(message);
};
children.addAll([
if (showSendingIndicator) _buildSendingIndicator(),
if (showReadList)
SizedBox.fromSize(
size: Size((widget.readList.length * 10.0) + 10, 17),
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: _buildReadIndicator(),
),
),
if (showThreadReplyIndicator)
InkWell(
onTap: widget.onThreadTap != null ? onThreadTap : null,
child: Text(msg, style: widget.messageTheme?.replies),
),
if (showUsername)
Text(
widget.message.user.name,
style: widget.messageTheme.replies.copyWith(
color: widget.messageTheme.createdAt.color,
),
),
if (showTimeStamp)
Text(
Jiffy(widget.message.createdAt.toLocal()).jm,
style: widget.messageTheme.createdAt,
),
]);
}
if (widget.reverse) children = children.reversed.toList();
return Padding(
padding: EdgeInsets.only(left: leftPadding),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (!deleted && (showThreadReplyIndicator || showInChannel))
Container(
margin: EdgeInsets.only(
bottom: widget.messageTheme.replies.fontSize / 2),
child: CustomPaint(
size: const Size(16, 32),
painter: _ThreadReplyPainter(
color: widget.messageTheme.replyThreadColor,
),
),
),
...children.map(
(child) => Transform(
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
alignment: Alignment.center,
child: child,
),
),
].insertBetween(const SizedBox(width: 8.0)),
),
);
}
Widget _buildUrlAttachment() {
var urlAttachment = widget.message.attachments
.firstWhere((element) => element.ogScrapeUrl != null);
@@ -394,87 +536,6 @@ class _MessageWidgetState extends State<MessageWidget> {
);
}
Padding _buildBottomRow(double leftPadding) {
return Padding(
padding: EdgeInsets.only(
left: leftPadding,
top: 2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
child: RichText(
text: TextSpan(
style: widget.messageTheme.createdAt,
children: <TextSpan>[
if (widget.showUsername)
TextSpan(
text: widget.message.user.name,
style: TextStyle(
fontWeight: FontWeight.bold,
color: widget.messageTheme.createdAt.color
.withOpacity(1)),
),
if (widget.message.createdAt != null && widget.showTimestamp)
TextSpan(
text: Jiffy(widget.message.createdAt.toLocal())
.format(' HH:mm'),
),
],
),
),
),
if (widget.showSendingIndicator == DisplayWidget.show)
_buildSendingIndicator(),
if (widget.readList?.isNotEmpty == true)
SizedBox.fromSize(
size: Size((widget.readList.length * 10.0) + 10, 17),
child: Transform(
alignment: Alignment.center,
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: _buildReadIndicator(),
),
),
),
if (widget.message.isDeleted)
Transform(
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
alignment: Alignment.center,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
StreamSvgIcon.eye(
color: Colors.black.withOpacity(0.5),
size: 16.0,
),
SizedBox(
width: 8.0,
),
Text(
'Only visible to you',
style: TextStyle(
color: Colors.black.withOpacity(0.5),
fontSize: 12.0,
),
),
],
),
),
),
],
),
);
}
bool get isGiphy =>
widget.message.attachments?.any((element) => element.type == 'giphy') ==
true;
@@ -558,13 +619,14 @@ class _MessageWidgetState extends State<MessageWidget> {
message: widget.message,
editMessageInputBuilder: widget.editMessageInputBuilder,
onThreadTap: widget.onThreadTap,
showCopyMessage: widget.message.text?.trim()?.isNotEmpty == true,
showEditMessage: widget.showEditMessage &&
widget.message.attachments
?.any((element) => element.type == 'giphy') !=
true,
showReactions: widget.showReactions,
showReply:
widget.showReplyIndicator && widget.onThreadTap != null,
widget.showThreadReplyIndicator && widget.onThreadTap != null,
),
);
});
@@ -714,38 +776,13 @@ class _MessageWidgetState extends State<MessageWidget> {
return;
}
Widget _buildReplyIndicator(double leftPadding) {
return Padding(
padding: EdgeInsets.only(
left: leftPadding,
),
child: Transform(
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
alignment: Alignment.center,
child: ReplyIndicator(
message: widget.message,
reversed: widget.reverse,
messageTheme: widget.messageTheme,
onTap: widget.onThreadTap != null
? () {
widget.onThreadTap(widget.message);
}
: null,
),
),
);
}
Widget _buildSendingIndicator() {
return Padding(
padding: const EdgeInsets.only(right: 4.0),
child: Transform(
transform: Matrix4.rotationY(widget.reverse ? pi : 0),
alignment: Alignment.center,
child: SendingIndicator(
message: widget.message,
allRead: widget.allRead,
),
return Container(
height: widget.messageTheme.createdAt.fontSize + 2,
width: widget.messageTheme.createdAt.fontSize + 2,
child: SendingIndicator(
message: widget.message,
allRead: widget.allRead,
),
);
}
@@ -957,3 +994,32 @@ class _MessageWidgetState extends State<MessageWidget> {
}
}
}
class _ThreadReplyPainter extends CustomPainter {
final Color color;
const _ThreadReplyPainter({@required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color ?? Color(0XFFDBDBDB)
..style = PaintingStyle.stroke
..strokeWidth = 1
..strokeCap = StrokeCap.round;
final path = Path()
..moveTo(0, 0)
..quadraticBezierTo(0, size.height * 0.38, 0, size.height * 0.50)
..quadraticBezierTo(
0,
size.height,
size.width,
size.height,
);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
+3 -1
View File
@@ -136,7 +136,9 @@ class _ReactionPickerState extends State<ReactionPicker>
void sendReaction(BuildContext context, String reactionType) {
StreamChannel.of(context)
.channel
.sendReaction(widget.message, reactionType);
.sendReaction(widget.message, reactionType, extraData: {
'enforce_unique': true,
});
pop();
}
-56
View File
@@ -1,56 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A reply button indicator
class ReplyIndicator extends StatelessWidget {
final Message message;
final VoidCallback onTap;
final bool reversed;
final MessageTheme messageTheme;
const ReplyIndicator({
Key key,
this.message,
this.onTap,
this.reversed = false,
this.messageTheme,
}) : super(key: key);
@override
Widget build(BuildContext context) {
var row = [
Text(
'Replies: ${message.replyCount}',
style: messageTheme?.replies,
),
Transform(
transform: Matrix4.rotationY(reversed ? 0 : pi),
alignment: Alignment.center,
child: Icon(
Icons.subdirectory_arrow_left,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white12
: Colors.black12,
),
),
];
if (!reversed) {
row = row.reversed.toList();
}
return GestureDetector(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: row,
),
),
);
}
}
+262 -86
View File
@@ -4,23 +4,29 @@ import 'package:flutter/material.dart';
import 'package:rxdart/rxdart.dart';
import 'package:stream_chat/stream_chat.dart';
enum QueryDirection { top, bottom }
/// Widget used to provide information about the channel to the widget tree
///
/// Use [StreamChannel.of] to get the current [StreamChannelState] instance.
class StreamChannel extends StatefulWidget {
StreamChannel({
const StreamChannel({
Key key,
@required this.child,
@required this.channel,
this.showLoading = true,
}) : super(
key: key,
);
this.initialMessageId,
}) : assert(child != null),
assert(channel != null),
super(key: key);
final Widget child;
final Channel channel;
final bool showLoading;
/// If passed the channel will load from this particular message.
final String initialMessageId;
/// Use this method to get the current [StreamChannelState] instance
static StreamChannelState of(BuildContext context) {
StreamChannelState streamChannelState;
@@ -43,87 +49,124 @@ class StreamChannelState extends State<StreamChannel> {
/// Current channel
Channel get channel => widget.channel;
/// InitialMessageId
String get initialMessageId => widget.initialMessageId;
/// Current channel state stream
Stream<ChannelState> get channelStateStream =>
widget.channel.state.channelStateStream;
final BehaviorSubject<bool> _queryMessageController = BehaviorSubject();
final _queryTopMessagesController = BehaviorSubject.seeded(false);
final _queryBottomMessagesController = BehaviorSubject.seeded(false);
/// The stream notifying the state of queryMessage call
Stream<bool> get queryMessage => _queryMessageController.stream;
/// The stream notifying the state of [_queryTopMessages] call
Stream<bool> get queryTopMessages => _queryTopMessagesController.stream;
bool _paginationEnded = false;
/// The stream notifying the state of [_queryBottomMessages] call
Stream<bool> get queryBottomMessages => _queryBottomMessagesController.stream;
/// Calls [channel.query] updating [queryMessage] stream
void queryMessages() {
if (_queryMessageController.value == true || _paginationEnded) {
bool _topPaginationEnded = false;
bool _bottomPaginationEnded = false;
Future<void> _queryTopMessages({
int limit = 20,
bool preferOffline = false,
}) async {
if (_topPaginationEnded || _queryTopMessagesController?.value == true) {
return;
}
_queryTopMessagesController.add(true);
_queryMessageController.add(true);
String firstId;
if (channel.state.messages.isNotEmpty) {
firstId = channel.state.messages.first.id;
if (channel.state.messages.isEmpty) {
return _queryTopMessagesController.add(false);
}
final messageLimit = 50;
final oldestMessage = channel.state.messages.first;
widget.channel
.query(
messagesPagination: PaginationParams(
lessThan: firstId,
limit: messageLimit,
),
preferOffline: true,
)
.then((res) {
if (res.messages.isEmpty || res.messages.length < messageLimit) {
_paginationEnded = true;
try {
final state = await queryBeforeMessage(
oldestMessage.id,
limit: limit,
preferOffline: preferOffline,
);
if (state.messages.isEmpty || state.messages.length < limit) {
_topPaginationEnded = true;
}
_queryMessageController.add(false);
}).catchError((e, stack) {
if (!_queryMessageController.isClosed) {
_queryMessageController.addError(e, stack);
_queryTopMessagesController.add(false);
} catch (e, stk) {
_queryTopMessagesController.addError(e, stk);
}
}
Future<void> _queryBottomMessages({
int limit = 20,
bool preferOffline = false,
}) async {
if (_bottomPaginationEnded ||
_queryBottomMessagesController?.value == true ||
channel?.state?.isUpToDate == true) return;
_queryBottomMessagesController.add(true);
if (channel.state.messages.isEmpty) {
return _queryBottomMessagesController.add(false);
}
final recentMessage = channel.state.messages.last;
try {
final state = await queryAfterMessage(
recentMessage.id,
limit: limit,
preferOffline: preferOffline,
);
if (state.messages.isEmpty || state.messages.length < limit) {
_bottomPaginationEnded = true;
}
});
_queryBottomMessagesController.add(false);
} catch (e, stk) {
_queryBottomMessagesController.addError(e, stk);
}
}
/// Calls [channel.query] updating [queryMessage] stream
Future<void> queryMessages({QueryDirection direction = QueryDirection.top}) {
if (direction == QueryDirection.top) return _queryTopMessages();
return _queryBottomMessages();
}
/// Calls [channel.getReplies] updating [queryMessage] stream
Future<void> getReplies(String parentId) async {
if (_queryMessageController.value == true || _paginationEnded) {
return;
}
Future<void> getReplies(
String parentId, {
int limit = 50,
bool preferOffline = false,
}) async {
if (_topPaginationEnded || _queryTopMessagesController.value) return;
_queryTopMessagesController.add(true);
_queryMessageController.add(true);
String firstId;
if (widget.channel.state.threads.containsKey(parentId)) {
final thread = widget.channel.state.threads[parentId];
if (thread != null && thread.isNotEmpty) {
firstId = thread?.first?.id;
Message message;
if (channel.state.threads.containsKey(parentId)) {
final thread = channel.state.threads[parentId];
if (thread.isNotEmpty) {
message = thread.first;
}
}
final messageLimit = 50;
return widget.channel
.getReplies(
parentId,
PaginationParams(
lessThan: firstId,
limit: messageLimit,
),
preferOffline: true,
)
.then((res) {
if (res.messages.isEmpty || res.messages.length < messageLimit) {
_paginationEnded = true;
try {
final response = await channel.getReplies(
parentId,
PaginationParams(
lessThan: message?.id,
limit: limit,
),
preferOffline: preferOffline,
);
if (response.messages.isEmpty || response.messages.length < limit) {
_topPaginationEnded = true;
}
_queryMessageController.add(false);
}).catchError((e, stack) {
_queryMessageController.addError(e, stack);
});
_queryTopMessagesController.add(false);
} catch (e, stk) {
_queryTopMessagesController.addError(e, stk);
}
}
/// Query the channel members and watchers
@@ -140,41 +183,174 @@ class StreamChannelState extends State<StreamChannel> {
);
}
/// Loads channel at specific message
Future<void> loadChannelAtMessage(
String messageId, {
int before = 20,
int after = 20,
bool preferOffline = false,
}) {
return queryAtMessage(
messageId: messageId,
before: before,
after: after,
preferOffline: preferOffline,
);
}
///
Future<void> queryAtMessage({
String messageId,
int before = 20,
int after = 20,
bool preferOffline = false,
}) async {
if (channel.state == null) return;
channel.state.isUpToDate = false;
channel.state.truncate();
if (messageId == null) {
await channel.query(
messagesPagination: PaginationParams(
limit: before,
),
preferOffline: preferOffline,
);
channel.state.isUpToDate = true;
return;
}
return Future.wait([
queryBeforeMessage(
messageId,
limit: before,
preferOffline: preferOffline,
),
queryAfterMessage(
messageId,
limit: after,
preferOffline: preferOffline,
),
]);
}
///
Future<ChannelState> queryBeforeMessage(
String messageId, {
int limit = 20,
bool preferOffline = false,
}) {
return channel.query(
messagesPagination: PaginationParams(
lessThan: messageId,
limit: limit,
),
preferOffline: preferOffline,
);
}
///
Future<ChannelState> queryAfterMessage(
String messageId, {
int limit = 20,
bool preferOffline = false,
}) async {
final state = await channel.query(
messagesPagination: PaginationParams(
greaterThanOrEqual: messageId,
limit: limit,
),
preferOffline: preferOffline,
);
if (state.messages.isEmpty || state.messages.length < limit) {
channel.state.isUpToDate = true;
}
return state;
}
///
Future<Message> getMessage(String messageId) async {
var message = channel.state.messages.firstWhere(
(it) => it.id == messageId,
orElse: () => null,
);
if (message == null) {
final response = await channel.getMessagesById([messageId]);
message = response.messages.first;
}
return message;
}
/// Reloads the channel with latest message
Future<void> reloadChannel() => queryAtMessage(before: 30);
List<Future<bool>> _futures;
Future<bool> get _loadChannelAtMessage async {
try {
await loadChannelAtMessage(initialMessageId);
return true;
} catch (e, stk) {
print('Error: $e\nStack: $stk');
rethrow;
}
}
@override
void initState() {
super.initState();
_futures = [widget.channel.initialized];
if (initialMessageId != null) {
_futures.add(_loadChannelAtMessage);
}
}
@override
void dispose() {
_queryMessageController.close();
_queryTopMessagesController.close();
_queryBottomMessagesController.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.channel == null) {
return Center(
child: CircularProgressIndicator(),
);
}
return FutureBuilder<bool>(
future: widget.channel.initialized,
initialData: widget.channel.state != null,
Widget child = FutureBuilder<List<bool>>(
future: Future.wait(_futures),
initialData: [
channel.state != null,
if (initialMessageId != null) false,
],
builder: (context, snapshot) {
if (widget.showLoading && (!snapshot.hasData || !snapshot.data)) {
return Container(
height: 30,
child: Center(
child: CircularProgressIndicator(),
),
if (snapshot.hasError) {
if (snapshot.error is Error) {
print((snapshot.error as Error).stackTrace);
}
var message = snapshot.error.toString();
if (snapshot.error is DioError) {
final dioError = snapshot.error as DioError;
if (dioError.type == DioErrorType.RESPONSE) {
message = dioError.message;
} else {
message = 'Check your connection and retry';
}
}
return Center(
child: Text(message),
);
} else if (snapshot.hasError) {
return Container(
height: 30,
child: Center(
child: Text(snapshot.error),
),
);
} else {
return widget.child;
}
final initialized = snapshot.data[0];
final dataLoaded = initialMessageId == null ? true : snapshot.data[1];
if (widget.showLoading && (!initialized || !dataLoaded)) {
return Center(
child: CircularProgressIndicator(),
);
}
return widget.child;
},
);
if (initialMessageId != null) {
child = Material(child: child);
}
return child;
}
}
+15 -4
View File
@@ -201,6 +201,8 @@ class StreamChatThemeData {
this.ownMessageTheme.messageBackgroundColor,
avatarTheme: ownMessageTheme?.avatarTheme ??
this.ownMessageTheme.avatarTheme,
replyThreadColor: ownMessageTheme?.replyThreadColor ??
this.ownMessageTheme.replyThreadColor,
) ??
this.ownMessageTheme,
otherMessageTheme: otherMessageTheme?.copyWith(
@@ -219,6 +221,8 @@ class StreamChatThemeData {
this.otherMessageTheme.messageBackgroundColor,
avatarTheme: otherMessageTheme?.avatarTheme ??
this.otherMessageTheme.avatarTheme,
replyThreadColor: ownMessageTheme?.replyThreadColor ??
this.ownMessageTheme.replyThreadColor,
) ??
this.otherMessageTheme,
reactionIcons: reactionIcons ?? this.reactionIcons,
@@ -302,16 +306,17 @@ class StreamChatThemeData {
color: isDark
? Colors.white.withOpacity(.5)
: Colors.black.withOpacity(.5),
fontSize: 11,
fontSize: 12,
),
replies: TextStyle(
color: accentColor,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 12,
),
messageBackgroundColor: isDark ? Color(0xff191919) : Color(0xffEAEAEA),
reactionsBackgroundColor: isDark ? Colors.black : Colors.white,
reactionsBorderColor: isDark ? Color(0xff191919) : Color(0xffEAEAEA),
replyThreadColor: isDark ? Color(0xff191919) : Color(0xffEAEAEA),
avatarTheme: AvatarTheme(
borderRadius: BorderRadius.circular(20),
constraints: BoxConstraints.tightFor(
@@ -335,17 +340,19 @@ class StreamChatThemeData {
color: isDark
? Colors.white.withOpacity(.5)
: Colors.black.withOpacity(.5),
fontSize: 11,
fontSize: 12,
),
replies: TextStyle(
color: accentColor,
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 12,
),
messageLinks: TextStyle(
color: accentColor,
),
messageBackgroundColor: isDark ? Colors.black : Colors.white,
replyThreadColor:
isDark ? Colors.white.withAlpha(24) : Colors.black.withAlpha(24),
avatarTheme: AvatarTheme(
borderRadius: BorderRadius.circular(20),
constraints: BoxConstraints.tightFor(
@@ -527,6 +534,7 @@ class MessageTheme {
final Color messageBackgroundColor;
final Color reactionsBackgroundColor;
final Color reactionsBorderColor;
final Color replyThreadColor;
final AvatarTheme avatarTheme;
const MessageTheme({
@@ -537,6 +545,7 @@ class MessageTheme {
this.messageBackgroundColor,
this.reactionsBackgroundColor,
this.reactionsBorderColor,
this.replyThreadColor,
this.avatarTheme,
this.createdAt,
});
@@ -551,6 +560,7 @@ class MessageTheme {
AvatarTheme avatarTheme,
Color reactionsBackgroundColor,
Color reactionsBorderColor,
Color replyThreadColor,
}) =>
MessageTheme(
messageText: messageText ?? this.messageText,
@@ -564,6 +574,7 @@ class MessageTheme {
reactionsBackgroundColor:
reactionsBackgroundColor ?? this.reactionsBackgroundColor,
reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor,
replyThreadColor: replyThreadColor ?? this.replyThreadColor,
);
}
+36
View File
@@ -721,4 +721,40 @@ class StreamSvgIcon extends StatelessWidget {
height: size,
);
}
factory StreamSvgIcon.Icon_group({
double size,
Color color,
}) {
return StreamSvgIcon(
assetName: 'Icon_group.svg',
color: color,
width: size,
height: size,
);
}
factory StreamSvgIcon.Icon_notification({
double size,
Color color,
}) {
return StreamSvgIcon(
assetName: 'Icon_notification.svg',
color: color,
width: size,
height: size,
);
}
factory StreamSvgIcon.Icon_user_delete({
double size,
Color color,
}) {
return StreamSvgIcon(
assetName: 'Icon_user_delete.svg',
color: color,
width: size,
height: size,
);
}
}
+45 -36
View File
@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'back_button.dart';
import 'channel_name.dart';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/thread_header.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/thread_header_paint.png)
@@ -77,43 +79,50 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
return AppBar(
automaticallyImplyLeading: false,
elevation: 1,
leading: showBackButton
? StreamBackButton(
onPressed: onBackPressed,
showUnreads: true,
)
: SizedBox(),
backgroundColor:
StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color,
actions: <Widget>[
Container(
child: showBackButton
? AspectRatio(
aspectRatio: 1,
child: IconButton(
onPressed: onBackPressed ?? () => Navigator.pop(context),
icon: StreamSvgIcon.close(
size: 24,
color: Theme.of(context).brightness == Brightness.dark
? Colors.white
: Colors.black,
),
),
)
: SizedBox(),
),
],
centerTitle: false,
title: Text.rich(
TextSpan(
text: 'Thread',
children: [
TextSpan(
text:
' ${parent.replyCount} ${parent.replyCount == 1 ? 'reply' : 'replies'}',
style: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.lastMessageAt,
),
],
),
style:
StreamChatTheme.of(context).channelTheme.channelHeaderTheme.title,
centerTitle: true,
title: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Thread Reply',
style: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.title,
),
SizedBox(height: 2),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'with ',
style: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.lastMessageAt,
),
Flexible(
child: ChannelName(
textStyle: StreamChatTheme.of(context)
.channelTheme
.channelHeaderTheme
.lastMessageAt,
),
),
],
),
],
),
);
}
+2 -1
View File
@@ -78,8 +78,9 @@ class UrlAttachment extends StatelessWidget {
children: [
if (urlAttachment.title != null)
Text(
urlAttachment.title,
urlAttachment.title.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 12.0,
+3 -1
View File
@@ -91,7 +91,9 @@ class UserItem extends StatelessWidget {
Widget _buildLastActive(context) {
return Text(
'Last online ${Jiffy(user.lastActive).fromNow()}',
user.online == true
? 'Online'
: 'Last online ${Jiffy(user.lastActive).fromNow()}',
style: StreamChatTheme.of(context)
.textTheme
.footnote
+4 -2
View File
@@ -190,7 +190,7 @@ class _UserListViewState extends State<UserListView>
}
final groupedUsers = <String, List<User>>{};
for (var e in temp) {
final alphabet = e.name[0];
final alphabet = e.name[0]?.toUpperCase();
groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e];
}
final items = <ListItem>[];
@@ -339,7 +339,9 @@ class _UserListViewState extends State<UserListView>
);
return LazyLoadScrollView(
onEndOfPage: () => _listenUserPagination(usersBlocState),
onEndOfPage: () async {
return _listenUserPagination(usersBlocState);
},
child: child,
);
},
+78 -24
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:url_launcher/url_launcher.dart';
import '../stream_chat_flutter.dart';
Future<void> launchURL(BuildContext context, String url) async {
if (await canLaunch(url)) {
await launch(url);
@@ -15,35 +17,87 @@ Future<void> launchURL(BuildContext context, String url) async {
}
Future<bool> showConfirmationDialog(
BuildContext context,
BuildContext context, {
String title,
Widget icon,
String question,
) {
return showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: Text(question),
actions: <Widget>[
FlatButton(
child: Text('Ok'),
onPressed: () => Navigator.pop(
context,
true,
String okText,
String cancelText,
}) {
return showModalBottomSheet(
backgroundColor: Colors.white,
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
)),
builder: (context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 26.0,
),
),
FlatButton(
child: Text('Cancel'),
onPressed: () => Navigator.pop(
context,
false,
if (icon != null) icon,
SizedBox(
height: 26.0,
),
),
],
);
},
);
Text(
title,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16.0),
),
SizedBox(
height: 7.0,
),
Text(question),
SizedBox(
height: 36.0,
),
Container(
color: Color(0xffe6e6e6),
height: 1.0,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FlatButton(
child: Text(
cancelText,
style: TextStyle(
color: Colors.black.withOpacity(0.5),
fontWeight: FontWeight.w400),
),
onPressed: () {
Navigator.of(context).pop();
},
),
FlatButton(
child: Text(
okText,
style: TextStyle(
color: Colors.red, fontWeight: FontWeight.w400),
),
onPressed: () {
Navigator.pop(context, true);
},
),
],
),
],
);
});
}
/// Get random png with initials
String getRandomPicUrl(User user) =>
'https://getstream.io/random_png/?id=${user.id}&name=${user.name}';
/// List extension
extension ListX<T> on List<T> {
/// Insert any item<T> inBetween the list items
List<T> insertBetween(T item) => expand((e) sync* {
yield item;
yield e;
}).skip(1).toList(growable: false);
}