Merge branch 'develop' into feat/positioned-list-experiment

This commit is contained in:
Gordon Hayes
2021-10-06 15:11:11 +02:00
175 changed files with 3780 additions and 2112 deletions
@@ -19,42 +19,36 @@ class AttachmentTitle extends StatelessWidget {
final Attachment attachment;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: () {
if (attachment.titleLink != null) {
launchURL(context, attachment.titleLink);
}
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (attachment.title != null)
Text(
attachment.title!,
overflow: TextOverflow.ellipsis,
style: messageTheme.messageTextStyle?.copyWith(
color: StreamChatTheme.of(context).colorTheme.accentPrimary,
fontWeight: FontWeight.bold,
),
Widget build(BuildContext context) {
final normalizedTitleLink = attachment.titleLink?.replaceFirst(
RegExp(r'https?://(www\.)?'),
'',
);
return GestureDetector(
onTap: () {
final titleLink = attachment.titleLink;
if (titleLink != null) launchURL(context, titleLink);
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (attachment.title != null)
Text(
attachment.title!,
overflow: TextOverflow.ellipsis,
style: messageTheme.messageTextStyle?.copyWith(
color: StreamChatTheme.of(context).colorTheme.accentPrimary,
fontWeight: FontWeight.bold,
),
if (attachment.titleLink != null ||
attachment.ogScrapeUrl != null)
Text(
Uri.parse(attachment.titleLink ?? attachment.ogScrapeUrl!)
.authority
.split('.')
.reversed
.take(2)
.toList()
.reversed
.join('.'),
style: messageTheme.messageTextStyle,
),
],
),
),
if (normalizedTitleLink != null)
Text(normalizedTitleLink, style: messageTheme.messageTextStyle),
],
),
);
),
);
}
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/upload_progress_indicator.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Widget to build in progress
typedef InProgressBuilder = Widget Function(BuildContext, int, int);
@@ -1,15 +1,14 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
import 'package:stream_chat_flutter/src/extension.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/upload_progress_indicator.dart';
import 'package:stream_chat_flutter/src/utils.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
/// Widget for displaying file attachments
class FileAttachment extends AttachmentWidget {
@@ -258,7 +257,8 @@ class FileAttachment extends AttachmentWidget {
visualDensity: VisualDensity.compact,
splashRadius: 16,
onPressed: () {
launchURL(context, attachment.assetUrl);
final assetUrl = attachment.assetUrl;
if (assetUrl != null) launchURL(context, assetUrl);
},
);
}
@@ -2,10 +2,10 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/visible_footnote.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Widget for showing a GIF attachment
class GiphyAttachment extends AttachmentWidget {
@@ -98,7 +98,13 @@ class GiphyAttachment extends AttachmentWidget {
Padding(
padding: const EdgeInsets.all(2),
child: GestureDetector(
onTap: () => onAttachmentTap ?? _onImageTap(context),
onTap: () {
if (onAttachmentTap != null) {
onAttachmentTap?.call();
} else {
_onImageTap(context);
}
},
child: CachedNetworkImage(
height: size?.height,
width: size?.width,
@@ -253,21 +259,12 @@ class GiphyAttachment extends AttachmentWidget {
Widget _buildSentAttachment(BuildContext context, String imageUrl) =>
SizedBox(
child: GestureDetector(
onTap: () async {
final res =
await Navigator.push(context, MaterialPageRoute(builder: (_) {
final channel = StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: FullScreenMedia(
mediaAttachments: [attachment],
userName: message.user?.name,
message: message,
onShowMessage: onShowMessage,
),
);
}));
if (res != null) onReturnAction!(res);
onTap: () {
if (onAttachmentTap != null) {
onAttachmentTap?.call();
} else {
_onImageTap(context);
}
},
child: Stack(
children: [
@@ -10,6 +10,7 @@ class UrlAttachment extends StatelessWidget {
Key? key,
required this.urlAttachment,
required this.hostDisplayName,
required this.messageTheme,
this.textPadding = const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
@@ -25,15 +26,16 @@ class UrlAttachment extends StatelessWidget {
/// Padding for text
final EdgeInsets textPadding;
/// [MessageThemeData] for showing image title
final MessageThemeData messageTheme;
@override
Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return GestureDetector(
onTap: () {
launchURL(
context,
urlAttachment.ogScrapeUrl,
);
final titleLink = urlAttachment.titleLink;
if (titleLink != null) launchURL(context, titleLink);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -60,7 +62,7 @@ class UrlAttachment extends StatelessWidget {
borderRadius: const BorderRadius.only(
topRight: Radius.circular(16),
),
color: chatThemeData.colorTheme.linkBg,
color: messageTheme.linkBackgroundColor,
),
child: Padding(
padding: const EdgeInsets.only(
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/channel_info.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Bottom Sheet with options
class ChannelBottomSheet extends StatefulWidget {
@@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stream_chat_flutter/src/back_button.dart';
import 'package:stream_chat_flutter/src/channel_info.dart';
import 'package:stream_chat_flutter/src/channel_name.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/info_tile.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header_paint.png)
@@ -137,12 +138,17 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget {
break;
}
final theme = Theme.of(context);
return InfoTile(
showMessage: showConnectionStateTile && showStatus,
message: statusString,
child: AppBar(
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness,
toolbarTextStyle: theme.textTheme.bodyText2,
titleTextStyle: theme.textTheme.headline6,
systemOverlayStyle: theme.brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
elevation: 1,
leading: leadingWidget,
backgroundColor: backgroundColor ?? channelHeaderTheme.color,
@@ -1,8 +1,8 @@
import 'package:collection/collection.dart' show IterableExtension;
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Widget which shows channel info
class ChannelInfo extends StatelessWidget {
@@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_neumorphic_button.dart';
@@ -121,12 +122,16 @@ class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget {
final chatThemeData = StreamChatTheme.of(context);
final channelListHeaderThemeData = ChannelListHeaderTheme.of(context);
final theme = Theme.of(context);
return InfoTile(
showMessage: showConnectionStateTile && showStatus,
message: statusString,
child: AppBar(
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness,
toolbarTextStyle: theme.textTheme.bodyText2,
titleTextStyle: theme.textTheme.headline6,
systemOverlayStyle: theme.brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
elevation: 1,
backgroundColor:
backgroundColor ?? channelListHeaderThemeData.color,
@@ -4,11 +4,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/channel_bottom_sheet.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Callback called when tapping on a channel
typedef ChannelTapCallback = void Function(Channel, Widget?);
@@ -59,7 +59,7 @@ typedef ViewInfoCallback = void Function(Channel);
/// Modify it to change the widget appearance.
class ChannelListView extends StatefulWidget {
/// Instantiate a new ChannelListView
const ChannelListView({
ChannelListView({
Key? key,
this.filter,
this.sort,
@@ -68,9 +68,12 @@ class ChannelListView extends StatefulWidget {
this.presence = false,
this.memberLimit,
this.messageLimit,
this.pagination = const PaginationParams(
limit: 25,
),
@Deprecated(
"'pagination' is deprecated and shouldn't be used. "
"This property is no longer used, Please use 'limit' instead",
)
this.pagination,
int? limit,
this.onChannelTap,
this.onChannelLongPress,
this.channelWidget,
@@ -92,7 +95,8 @@ class ChannelListView extends StatefulWidget {
this.onDeletePressed,
this.swipeActions,
this.channelListController,
}) : super(key: key);
}) : limit = limit ?? pagination?.limit ?? 25,
super(key: key);
/// If true a default swipe to action behaviour will be added to this widget
final bool swipeToAction;
@@ -129,7 +133,14 @@ class ChannelListView extends StatefulWidget {
/// limit: the number of channels to return (max is 30)
/// offset: the offset (max is 1000)
/// message_limit: how many messages should be included to each channel
final PaginationParams pagination;
@Deprecated(
"'pagination' is deprecated and shouldn't be used. "
"This property is no longer used, Please use 'limit' instead",
)
final PaginationParams? pagination;
/// The amount of channels requested per API call.
final int limit;
/// Function called when tapping on a channel
/// By default it calls [Navigator.push] building a [MaterialPageRoute]
@@ -218,7 +229,7 @@ class _ChannelListViewState extends State<ChannelListView> {
presence: widget.presence,
memberLimit: widget.memberLimit,
messageLimit: widget.messageLimit,
pagination: widget.pagination,
limit: widget.limit,
channelListController: _channelListController,
listBuilder: widget.listBuilder ?? _buildListView,
emptyBuilder: widget.emptyBuilder ?? _buildEmptyWidget,
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// It shows the current [Channel] name using a [Text] widget.
///
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Overlay for displaying commands that can be used
class CommandsOverlay extends StatelessWidget {
/// Constructor for creating a [CommandsOverlay]
const CommandsOverlay({
required this.text,
required this.onCommandResult,
required this.size,
required this.channel,
Key? key,
}) : super(key: key);
/// The size of the overlay
final Size size;
/// Query for searching commands
final String text;
/// The channel to search for users
final Channel channel;
/// Callback called when a command is selected
final ValueChanged<Command> onCommandResult;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
final commands = channel.config?.commands
.where((c) => c.name.contains(text.replaceFirst('/', '')))
.toList() ??
[];
if (commands.isEmpty) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.all(4),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
color: _streamChatTheme.colorTheme.barsBg,
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(size),
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: BorderRadius.circular(8)),
child: ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
if (commands.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: StreamSvgIcon.lightning(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Text(
context.translations.instantCommandsLabel,
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
)
],
),
),
const SizedBox(
height: 10,
),
...commands
.map(
(c) => InkWell(
onTap: () {
onCommandResult(c);
},
child: SizedBox(
height: 40,
child: Row(
children: [
const SizedBox(
width: 16,
),
_buildCommandIcon(_streamChatTheme, c.name),
const SizedBox(
width: 8,
),
Text.rich(
TextSpan(
text: c.name.capitalize(),
style: const TextStyle(
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style: _streamChatTheme.textTheme.body
.copyWith(
// ignore: lines_longer_than_80_chars
color: _streamChatTheme
// ignore: lines_longer_than_80_chars
.colorTheme
.textLowEmphasis,
),
),
],
),
),
],
),
),
),
)
.toList(),
],
),
),
),
);
}
Widget _buildCommandIcon(
StreamChatThemeData _streamChatTheme, String iconType) {
switch (iconType) {
case 'giphy':
return CircleAvatar(
radius: 12,
child: StreamSvgIcon.giphyIcon(
size: 24,
),
);
case 'ban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.iconUserDelete(
size: 16,
color: Colors.white,
),
);
case 'flag':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.flag(
size: 14,
color: Colors.white,
),
);
case 'imgur':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: ClipOval(
child: StreamSvgIcon.imgur(
size: 24,
),
),
);
case 'mute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.mute(
size: 16,
color: Colors.white,
),
);
case 'unban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.userAdd(
size: 16,
color: Colors.white,
),
);
case 'unmute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.volumeUp(
size: 16,
color: Colors.white,
),
);
default:
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.lightning(
size: 16,
color: Colors.white,
),
);
}
}
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
/// It shows a date divider depending on the date difference
class DateDivider extends StatelessWidget {
@@ -26,7 +26,8 @@
// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
import 'package:collection/collection.dart' show IterableExtension;
import 'package:collection/collection.dart'
show IterableExtension, ListEquality;
/// All Groups
enum EmojiGroup {
@@ -114240,7 +114241,6 @@ final emojiRegex = RegExp(
class Emoji {
static const variationSelector16 = 65039;
static const ZWJ = 8205;
final String? name;
final String? char;
final String? shortName;
@@ -114252,14 +114252,39 @@ class Emoji {
/// Emoji class.
/// [name] of emoji. [char] and character of emoji. [shortName] and a digest name of emoji, [emojiGroup] is emoji's group and [emojiSubgroup] is emoji's subgroup. [keywords] list of keywords for emoji. [modifiable] `true` if emoji has skin.
Emoji(
{this.name,
this.char,
this.shortName,
this.emojiGroup,
this.emojiSubgroup,
this.keywords = const [],
this.modifiable = false});
Emoji({
this.name,
this.char,
this.shortName,
this.emojiGroup,
this.emojiSubgroup,
this.keywords = const [],
this.modifiable = false,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Emoji &&
runtimeType == other.runtimeType &&
name == other.name &&
char == other.char &&
shortName == other.shortName &&
emojiGroup == other.emojiGroup &&
emojiSubgroup == other.emojiSubgroup &&
const ListEquality().equals(keywords, other.keywords) &&
modifiable == other.modifiable;
@override
int get hashCode => Object.hash(
name.hashCode,
char.hashCode,
shortName.hashCode,
emojiGroup.hashCode,
emojiSubgroup.hashCode,
keywords.hashCode,
modifiable.hashCode,
);
/// Runes of Emoji Character
List<int> get charRunes {
@@ -114339,9 +114364,11 @@ class Emoji {
return _emojis.firstWhereOrNull((Emoji emoji) => emoji.name == name);
}
/// Returns Emoji by [name] as short name.
static Emoji? byShortName(String name) {
return _emojis.firstWhereOrNull((Emoji emoji) => emoji.char == name);
/// Returns Emoji by [shortName] as short name.
static Emoji? byShortName(String shortName) {
return _emojis.firstWhereOrNull(
(Emoji emoji) => emoji.shortName == shortName,
);
}
/// Returns list of Emojis in a same [group]
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:substring_highlight/substring_highlight.dart';
/// Overlay for displaying emoji that can be used
class EmojiOverlay extends StatelessWidget {
/// Constructor for creating a [EmojiOverlay]
const EmojiOverlay({
required this.query,
required this.onEmojiResult,
required this.size,
Key? key,
}) : super(key: key);
/// The size of the overlay
final Size size;
/// Query for searching emoji
final String query;
/// Callback called when an emoji is selected
final ValueChanged<Emoji> onEmojiResult;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
final _emojiNames =
Emoji.all().where((it) => it.name != null).map((e) => e.name!);
final emojis = _emojiNames
.where((e) => e.contains(query))
.map(Emoji.byName)
.where((e) => e != null);
if (emojis.isEmpty) {
return const SizedBox();
}
return Card(
margin: const EdgeInsets.all(8),
elevation: 2,
color: _streamChatTheme.colorTheme.barsBg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(size),
decoration: BoxDecoration(
boxShadow: const [
BoxShadow(
spreadRadius: -8,
blurRadius: 5,
offset: Offset(0, -4),
),
],
color: _streamChatTheme.colorTheme.barsBg,
),
child: ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: emojis.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(left: 8, top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: StreamSvgIcon.smile(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Flexible(
child: Text(
context.translations.emojiMatchingQueryText(
query,
),
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
),
)
],
),
);
}
final emoji = emojis.elementAt(i - 1)!;
final themeData = Theme.of(context);
return ListTile(
title: SubstringHighlight(
text:
// ignore: lines_longer_than_80_chars
"${emoji.char} ${emoji.name!.replaceAll('_', ' ')}",
term: query,
textStyleHighlight: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.bold,
),
textStyle: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
),
),
onTap: () {
onEmojiResult(emoji);
},
);
},
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:characters/characters.dart';
import 'package:diacritic/diacritic.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
@@ -11,7 +12,7 @@ final _emojiChars = Emoji.chars();
extension StringExtension on String {
/// Returns the capitalized string
String capitalize() =>
'${this[0].toUpperCase()}${substring(1).toLowerCase()}';
isNotEmpty ? '${this[0].toUpperCase()}${substring(1).toLowerCase()}' : '';
/// Returns whether the string contains only emoji's or not.
///
@@ -24,6 +25,12 @@ extension StringExtension on String {
final characters = trim().characters;
return characters.every(_emojiChars.contains);
}
/// Removes accents and diacritics from the given String.
String get diacriticsInsensitive => removeDiacritics(this);
/// Levenshtein distance between this and [t].
int levenshteinDistance(String t) => levenshtein(this, t);
}
/// List extension
@@ -170,3 +177,49 @@ extension IconButtonX on IconButton {
icon: icon ?? this.icon,
);
}
/// Extensions on List<User>
extension UserListX on List<User> {
/// It does an search on a list of [User] and returns users with
/// `id` or `name` containing the [query].
///
/// Results are returned sorted by their edit distance from the
/// searched string, distance is calculated using the [levenshtein] algorithm.
List<User> search(String query) {
String normalize(String input) => input.toLowerCase().diacriticsInsensitive;
final normalizedQuery = normalize(query);
final matchingUsers = <User, int>{}; // User:lDistance
for (final user in this) {
final normalizedId = normalize(user.id);
final normalizedUserName = normalize(user.name);
final lDistance = normalizedUserName.levenshteinDistance(normalizedQuery);
final containsId = normalizedId.contains(normalizedQuery);
final containsName = normalizedUserName.contains(normalizedQuery);
if (lDistance < 3 || containsId || containsName) {
matchingUsers[user] = lDistance;
}
}
final entries = matchingUsers.entries.toList(growable: false)
..sort((prev, curr) {
bool containsQuery(User user) =>
normalize(user.id).contains(normalizedQuery) ||
normalize(user.name).contains(normalizedQuery);
final containsInPrev = containsQuery(prev.key);
final containsInCurr = containsQuery(curr.key);
if (containsInPrev && !containsInCurr) {
return -1;
} else if (!containsInPrev && containsInCurr) {
return 1;
}
return prev.value.compareTo(curr.value);
});
return entries.map((e) => e.key).toList(growable: false);
}
}
@@ -5,11 +5,11 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/gallery_footer.dart';
import 'package:stream_chat_flutter/src/gallery_header.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:video_player/video_player.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Return action for coming back from pages
enum ReturnActionType {
@@ -112,6 +112,8 @@ class _FullScreenMediaState extends State<FullScreenMedia>
attachment.assetUrl ??
attachment.thumbUrl;
return PhotoView(
loadingBuilder: (context, image) =>
const Offstage(),
imageProvider: (imageUrl == null &&
attachment.localUri != null &&
attachment.file?.bytes != null)
@@ -7,12 +7,12 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Footer widget for media display
class GalleryFooter extends StatefulWidget implements PreferredSizeWidget {
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stream_chat_flutter/src/attachment_actions_modal.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
@@ -57,9 +58,13 @@ class GalleryHeader extends StatelessWidget implements PreferredSizeWidget {
@override
Widget build(BuildContext context) {
final galleryHeaderThemeData = GalleryHeaderTheme.of(context);
final theme = Theme.of(context);
return AppBar(
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness,
toolbarTextStyle: theme.textTheme.bodyText2,
titleTextStyle: theme.textTheme.headline6,
systemOverlayStyle: theme.brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
elevation: 1,
leading: showBackButton
? IconButton(
@@ -15,6 +15,7 @@ class ImageGroup extends StatelessWidget {
required this.size,
this.onReturnAction,
this.onShowMessage,
this.onAttachmentTap,
}) : super(key: key);
/// List of attachments to show
@@ -23,6 +24,9 @@ class ImageGroup extends StatelessWidget {
/// Callback when attachment is returned to from other screens
final ValueChanged<ReturnActionType>? onReturnAction;
/// Callback when attachment is tapped
final void Function(Message message, Attachment attachment)? onAttachmentTap;
/// Message which images are attached to
final Message message;
@@ -117,6 +121,10 @@ class ImageGroup extends StatelessWidget {
BuildContext context,
int index,
) async {
if (onAttachmentTap != null) {
return onAttachmentTap!(message, images[index]);
}
final channel = StreamChannel.of(context).channel;
final res = await Navigator.push(
@@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// This widget is used for showing user tiles for mentions
/// Use [title], [subtitle], [leading], [trailing] for
/// substituting widgets in respective positions
@Deprecated('Use `UserMentionTile` instead. Will be removed in future release')
class MentionTile extends StatelessWidget {
/// Constructor for creating a [MentionTile] widget
const MentionTile(
File diff suppressed because it is too large Load Diff
@@ -169,6 +169,7 @@ class MessageListView extends StatefulWidget {
this.messageListController,
this.reverse = true,
this.paginationLimit = 20,
this.paginationLoadingIndicatorBuilder,
}) : super(key: key);
/// Function used to build a custom message widget
@@ -284,6 +285,9 @@ class MessageListView extends StatefulWidget {
/// Use [ChannelListController.paginateData] pagination.
final MessageListController? messageListController;
/// Builder used to build the loading indicator shown while paginating.
final WidgetBuilder? paginationLoadingIndicatorBuilder;
@override
_MessageListViewState createState() => _MessageListViewState();
}
@@ -293,7 +297,6 @@ class _MessageListViewState extends State<MessageListView> {
void Function(Message)? _onThreadTap;
bool _showScrollToBottom = false;
late final ItemPositionsListener _itemPositionListener;
late final Stream<Iterable<ItemPosition>> _itemPositionStream;
int? _messageListLength;
StreamChannelState? streamChannel;
late StreamChatThemeData _streamTheme;
@@ -499,14 +502,18 @@ class _MessageListViewState extends State<MessageListView> {
return _buildThreadSeparator();
}
if (i == itemCount - 3) {
if (widget.headerBuilder == null) {
if (widget.reverse
? widget.headerBuilder == null
: widget.footerBuilder == null) {
if (_isThreadConversation) return const Offstage();
return const SizedBox(height: 52);
}
return const SizedBox(height: 8);
}
if (i == 0) {
if (widget.footerBuilder == null) {
if (widget.reverse
? widget.footerBuilder == null
: widget.headerBuilder == null) {
return const SizedBox(height: 30);
}
return const SizedBox(height: 8);
@@ -530,13 +537,13 @@ class _MessageListViewState extends State<MessageListView> {
? widget.dateDividerBuilder!(
nextMessage.createdAt.toLocal(),
)
: DateDivider(
dateTime: nextMessage.createdAt.toLocal(),
: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: DateDivider(
dateTime: nextMessage.createdAt.toLocal(),
),
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: divider,
);
return divider;
}
final timeDiff =
Jiffy(nextMessage.createdAt.toLocal()).diff(
@@ -565,27 +572,42 @@ class _MessageListViewState extends State<MessageListView> {
}
if (i == itemCount - 2) {
return widget.headerBuilder?.call(context) ??
const Offstage();
if (widget.reverse) {
return widget.headerBuilder?.call(context) ??
const Offstage();
} else {
return widget.footerBuilder?.call(context) ??
const Offstage();
}
}
final indicatorBuilder =
widget.paginationLoadingIndicatorBuilder;
if (i == itemCount - 3) {
return _buildLoadingIndicator(
return _loadingIndicator(
streamChannel!,
QueryDirection.top,
indicatorBuilder: indicatorBuilder,
);
}
if (i == 1) {
return _buildLoadingIndicator(
return _loadingIndicator(
streamChannel!,
QueryDirection.bottom,
indicatorBuilder: indicatorBuilder,
);
}
if (i == 0) {
return widget.footerBuilder?.call(context) ??
const Offstage();
if (widget.reverse) {
return widget.footerBuilder?.call(context) ??
const Offstage();
} else {
return widget.headerBuilder?.call(context) ??
const Offstage();
}
}
const bottomMessageIndex = 2; // 1 -> loader // 0 -> footer
@@ -657,7 +679,8 @@ class _MessageListViewState extends State<MessageListView> {
right: 0,
child: BetterStreamBuilder<Iterable<ItemPosition>>(
initialData: _itemPositionListener.itemPositions.value,
stream: _itemPositionStream,
stream: _valueListenableToStreamAdapter(
_itemPositionListener.itemPositions),
comparator: (a, b) {
if (a == null || b == null) {
return false;
@@ -808,15 +831,17 @@ class _MessageListViewState extends State<MessageListView> {
},
);
Widget _buildLoadingIndicator(
Widget _loadingIndicator(
StreamChannelState streamChannel,
QueryDirection direction,
) =>
QueryDirection direction, {
WidgetBuilder? indicatorBuilder,
}) =>
_LoadingIndicator(
direction: direction,
streamTheme: _streamTheme,
streamChannel: streamChannel,
isThreadConversation: _isThreadConversation,
indicatorBuilder: indicatorBuilder,
);
Widget _buildBottomMessage(
@@ -996,7 +1021,7 @@ class _MessageListViewState extends State<MessageListView> {
final isOnlyEmoji = message.text?.isOnlyEmoji ?? false;
final hasUrlAttachment =
message.attachments.any((it) => it.ogScrapeUrl != null) == true;
message.attachments.any((it) => it.titleLink != null) == true;
final borderSide =
isOnlyEmoji || hasUrlAttachment || (isMyMessage && !hasFileAttachment)
@@ -1185,8 +1210,6 @@ class _MessageListViewState extends State<MessageListView> {
_scrollController = widget.scrollController ?? ItemScrollController();
_itemPositionListener =
widget.itemPositionListener ?? ItemPositionsListener.create();
_itemPositionStream =
_valueListenableToStreamAdapter(_itemPositionListener.itemPositions);
_getOnThreadTap();
super.initState();
@@ -1204,10 +1227,12 @@ class _MessageListViewState extends State<MessageListView> {
initialAlignment = _initialAlignment;
WidgetsBinding.instance!.addPostFrameCallback((timeStamp) {
_scrollController?.jumpTo(
index: initialIndex,
alignment: initialAlignment,
);
if (_scrollController?.isAttached == true) {
_scrollController?.jumpTo(
index: initialIndex,
alignment: initialAlignment,
);
}
});
_messageNewListener =
@@ -1216,8 +1241,9 @@ class _MessageListViewState extends State<MessageListView> {
_bottomPaginationActive = false;
_topPaginationActive = false;
}
if (event.message!.user!.id ==
streamChannel!.channel.client.state.currentUser!.id) {
if (event.message?.parentId == widget.parentMessage?.id &&
event.message!.user!.id ==
streamChannel!.channel.client.state.currentUser!.id) {
WidgetsBinding.instance!.addPostFrameCallback((_) {
_scrollController?.jumpTo(
index: 0,
@@ -1280,12 +1306,14 @@ class _LoadingIndicator extends StatelessWidget {
required this.isThreadConversation,
required this.direction,
required this.streamChannel,
this.indicatorBuilder,
}) : super(key: key);
final StreamChatThemeData streamTheme;
final bool isThreadConversation;
final QueryDirection direction;
final StreamChannelState streamChannel;
final WidgetBuilder? indicatorBuilder;
@override
Widget build(BuildContext context) {
@@ -1304,12 +1332,13 @@ class _LoadingIndicator extends StatelessWidget {
),
builder: (context, data) {
if (!data) return const Offstage();
return const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
),
);
return indicatorBuilder?.call(context) ??
const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(),
),
);
},
);
}
@@ -1,6 +1,7 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/reaction_bubble.dart';
import 'package:stream_chat_flutter/src/reaction_picker.dart';
import 'package:stream_chat_flutter/src/stream_chat.dart';
@@ -8,7 +9,6 @@ import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Modal widget for displaying message reactions
class MessageReactionsModal extends StatelessWidget {
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// It shows the current [Message] preview.
///
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/info_tile.dart';
import 'package:stream_chat_flutter/src/message_search_item.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Callback called when tapping on a user
typedef MessageSearchItemTapCallback = void Function(GetMessageResponse);
@@ -30,13 +30,13 @@ typedef EmptyMessageSearchBuilder = Widget Function(
/// Widget build(BuildContext context) {
/// return Scaffold(
/// body: MessageSearchListView(
/// messageQuery: _channelQuery,
/// filters: {
/// 'members': {
/// r'$in': [user.id]
/// }
/// },
/// paginationParams: PaginationParams(limit: 20),
/// messageQuery: _channelQuery,
/// filters: {
/// 'members': {
/// r'$in': [user.id]
/// }
/// },
/// limit: 20,
/// ),
/// );
/// }
@@ -58,7 +58,7 @@ class MessageSearchListView extends StatefulWidget {
required this.filters,
this.messageQuery,
this.sortOptions,
this.paginationParams,
this.limit = 30,
this.messageFilters,
this.separatorBuilder,
this.itemBuilder,
@@ -89,11 +89,8 @@ class MessageSearchListView extends StatefulWidget {
/// Direction can be ascending or descending.
final List<SortOption>? sortOptions;
/// Pagination parameters
/// limit: the number of users to return (max is 30)
/// offset: the offset (max is 1000)
/// message_limit: how many messages should be included to each channel
final PaginationParams? paginationParams;
/// The amount of messages requested per API call.
final int limit;
/// The message query filters to use.
/// You can query on any of the custom fields you've defined on the [Channel].
@@ -152,7 +149,7 @@ class _MessageSearchListViewState extends State<MessageSearchListView> {
filters: widget.filters,
sortOptions: widget.sortOptions,
messageQuery: widget.messageQuery,
paginationParams: widget.paginationParams,
limit: widget.limit,
messageFilters: widget.messageFilters,
messageSearchListController: _messageSearchListController,
emptyBuilder: widget.emptyBuilder ??
@@ -7,6 +7,7 @@ import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_portal/flutter_portal.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/attachment/url_attachment.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/image_group.dart';
import 'package:stream_chat_flutter/src/message_action.dart';
@@ -15,7 +16,6 @@ import 'package:stream_chat_flutter/src/message_reactions_modal.dart';
import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
import 'package:stream_chat_flutter/src/reaction_bubble.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/src/url_attachment.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Widget builder for building attachments
@@ -96,7 +96,7 @@ class MessageWidget extends StatefulWidget {
this.bottomRowBuilder,
this.deletedBottomRowBuilder,
this.onReturnAction,
Map<String, AttachmentBuilder>? customAttachmentBuilders,
this.customAttachmentBuilders,
this.readList,
this.padding,
this.textPadding = const EdgeInsets.symmetric(
@@ -133,6 +133,7 @@ class MessageWidget extends StatefulWidget {
messageTheme: messageTheme,
onShowMessage: onShowMessage,
onReturnAction: onReturnAction,
onAttachmentTap: onAttachmentTap,
),
),
border,
@@ -214,6 +215,11 @@ class MessageWidget extends StatefulWidget {
),
onShowMessage: onShowMessage,
onReturnAction: onReturnAction,
onAttachmentTap: onAttachmentTap != null
? () {
onAttachmentTap(message, attachment);
}
: null,
);
}).toList(),
),
@@ -243,6 +249,11 @@ class MessageWidget extends StatefulWidget {
mediaQueryData.size.width * 0.8,
mediaQueryData.size.height * 0.3,
),
onAttachmentTap: onAttachmentTap != null
? () {
onAttachmentTap(message, attachment);
}
: null,
),
border,
reverse,
@@ -395,6 +406,9 @@ class MessageWidget extends StatefulWidget {
/// Builder for respective attachment types
final Map<String, AttachmentBuilder> attachmentBuilders;
/// Builder for respective attachment types (user facing builder)
final Map<String, AttachmentBuilder>? customAttachmentBuilders;
/// Center user avatar with bottom of the message
final bool translateUserAvatar;
@@ -519,7 +533,7 @@ class MessageWidget extends StatefulWidget {
showPinButton: showPinButton ?? this.showPinButton,
showPinHighlight: showPinHighlight ?? this.showPinHighlight,
customAttachmentBuilders:
customAttachmentBuilders ?? attachmentBuilders,
customAttachmentBuilders ?? this.customAttachmentBuilders,
translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar,
onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap,
onMessageTap: onMessageTap ?? this.onMessageTap,
@@ -567,11 +581,11 @@ class _MessageWidgetState extends State<MessageWidget>
bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true;
bool get hasNonUrlAttachments => widget.message.attachments
.where((it) => it.ogScrapeUrl == null)
.where((it) => it.titleLink == null || it.type == 'giphy')
.isNotEmpty;
bool get hasUrlAttachments =>
widget.message.attachments.any((it) => it.ogScrapeUrl != null) == true;
bool get hasUrlAttachments => widget.message.attachments
.any((it) => it.titleLink != null && it.type != 'giphy');
bool get showBottomRow =>
showThreadReplyIndicator ||
@@ -984,9 +998,9 @@ class _MessageWidgetState extends State<MessageWidget>
Widget _buildUrlAttachment() {
final urlAttachment = widget.message.attachments
.firstWhere((element) => element.ogScrapeUrl != null);
.firstWhere((element) => element.titleLink != null);
final host = Uri.parse(urlAttachment.ogScrapeUrl!).host;
final host = Uri.parse(urlAttachment.titleLink!).host;
final splitList = host.split('.');
final hostName = splitList.length == 3 ? splitList[1] : splitList[0];
final hostDisplayName = urlAttachment.authorName?.capitalize() ??
@@ -997,6 +1011,7 @@ class _MessageWidgetState extends State<MessageWidget>
urlAttachment: urlAttachment,
hostDisplayName: hostDisplayName,
textPadding: widget.textPadding,
messageTheme: widget.messageTheme,
);
}
@@ -1155,7 +1170,9 @@ class _MessageWidgetState extends State<MessageWidget>
final attachmentGroups = <String, List<Attachment>>{};
widget.message.attachments
.where((element) => element.ogScrapeUrl == null && element.type != null)
.where((element) =>
(element.titleLink == null && element.type != null) ||
element.type == 'giphy')
.forEach((e) {
if (attachmentGroups[e.type] == null) {
attachmentGroups[e.type!] = [];
@@ -1335,7 +1352,7 @@ class _MessageWidgetState extends State<MessageWidget>
}
if (hasUrlAttachments) {
return _streamChatTheme.colorTheme.linkBg;
return widget.messageTheme.linkBackgroundColor;
}
if (isOnlyEmoji) {
@@ -0,0 +1,63 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_portal/flutter_portal.dart';
/// Class that contains the parameters for building an overlay entry
class OverlayOptions {
/// Constructs a new overlay options object
/// [visible] - the visibility of the overlay
/// [widget] - the widget to be displayed
OverlayOptions({
required this.visible,
required this.widget,
});
/// the visibility of the overlay
final bool visible;
/// the widget to be displayed
final Widget widget;
}
/// Widget that renders a single overlay widget from a list of [overlayOptions]
/// It shows the first one that is visible
class MultiOverlay extends StatelessWidget {
/// Constructs a new MultiOverlay widget
/// [overlayOptions] - the list of overlay options
/// [overlayAnchor] - the anchor relative to the overlay
/// [childAnchor] - the anchor relative to the child
/// [child] - the child widget
const MultiOverlay({
Key? key,
required this.overlayOptions,
required this.child,
required this.overlayAnchor,
required this.childAnchor,
}) : super(key: key);
/// The list of overlay options
final List<OverlayOptions> overlayOptions;
/// The child widget
final Widget child;
/// The anchor relative to the overlay
final Alignment? overlayAnchor;
/// The anchor relative to the child
final Alignment? childAnchor;
@override
Widget build(BuildContext context) {
final visibleOverlay =
overlayOptions.firstWhereOrNull((element) => element.visible);
return PortalEntry(
childAnchor: childAnchor,
portalAnchor: overlayAnchor,
visible: visibleOverlay != null,
portal: visibleOverlay?.widget,
child: child,
);
}
}
@@ -97,8 +97,8 @@ class QuotedMessageWidget extends StatelessWidget {
bool get _hasAttachments => message.attachments.isNotEmpty == true;
bool get _containsScrapeUrl =>
message.attachments.any((element) => element.ogScrapeUrl != null) == true;
bool get _containsLinkAttachment =>
message.attachments.any((element) => element.titleLink != null) == true;
bool get _containsText => message.text?.isNotEmpty == true;
@@ -198,9 +198,9 @@ class QuotedMessageWidget extends StatelessWidget {
Widget _parseAttachments(BuildContext context) {
Widget child;
Attachment attachment;
if (_containsScrapeUrl) {
if (_containsLinkAttachment) {
attachment = message.attachments.firstWhere(
(element) => element.ogScrapeUrl != null,
(element) => element.titleLink != null,
);
child = _buildUrlAttachment(attachment);
} else {
@@ -280,8 +280,8 @@ class QuotedMessageWidget extends StatelessWidget {
};
Color? _getBackgroundColor(BuildContext context) {
if (_containsScrapeUrl) {
return StreamChatTheme.of(context).colorTheme.linkBg;
if (_containsLinkAttachment) {
return messageTheme.linkBackgroundColor;
}
return messageTheme.messageBackgroundColor;
}
@@ -100,7 +100,9 @@ class StreamChatState extends State<StreamChat> {
return Theme(
data: materialTheme.copyWith(
primaryIconTheme: streamTheme.primaryIconTheme,
accentColor: streamTheme.colorTheme.accentPrimary,
colorScheme: materialTheme.colorScheme.copyWith(
secondary: streamTheme.colorTheme.accentPrimary,
),
),
child: StreamChatCore(
client: client,
@@ -131,7 +131,7 @@ class StreamChatThemeData {
final defaultTheme = StreamChatThemeData(brightness: theme.brightness);
final customizedTheme = StreamChatThemeData.fromColorAndTextTheme(
defaultTheme.colorTheme.copyWith(
accentPrimary: theme.accentColor,
accentPrimary: theme.colorScheme.secondary,
),
defaultTheme.textTheme,
);
@@ -223,6 +223,7 @@ class StreamChatThemeData {
messageLinksStyle: TextStyle(
color: accentColor,
),
linkBackgroundColor: colorTheme.linkBg,
),
otherMessageTheme: MessageThemeData(
reactionsBackgroundColor: colorTheme.disabled,
@@ -246,6 +247,7 @@ class StreamChatThemeData {
width: 32,
),
),
linkBackgroundColor: colorTheme.linkBg,
),
messageInputTheme: MessageInputThemeData(
borderRadius: BorderRadius.circular(20),
@@ -17,6 +17,7 @@ class MessageThemeData with Diagnosticable {
this.reactionsMaskColor,
this.avatarTheme,
this.createdAtStyle,
this.linkBackgroundColor,
});
/// Text style for message text
@@ -52,6 +53,9 @@ class MessageThemeData with Diagnosticable {
/// Theme of the avatar
final AvatarThemeData? avatarTheme;
/// Background color for messages with url attachments.
final Color? linkBackgroundColor;
/// Copy with a theme
MessageThemeData copyWith({
TextStyle? messageTextStyle,
@@ -65,6 +69,7 @@ class MessageThemeData with Diagnosticable {
Color? reactionsBackgroundColor,
Color? reactionsBorderColor,
Color? reactionsMaskColor,
Color? linkBackgroundColor,
}) =>
MessageThemeData(
messageTextStyle: messageTextStyle ?? this.messageTextStyle,
@@ -80,6 +85,7 @@ class MessageThemeData with Diagnosticable {
reactionsBackgroundColor ?? this.reactionsBackgroundColor,
reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor,
reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor,
linkBackgroundColor: linkBackgroundColor ?? this.linkBackgroundColor,
);
/// Linearly interpolate from one [MessageThemeData] to another.
@@ -105,6 +111,8 @@ class MessageThemeData with Diagnosticable {
reactionsMaskColor:
Color.lerp(a.reactionsMaskColor, b.reactionsMaskColor, t),
repliesStyle: TextStyle.lerp(a.repliesStyle, b.repliesStyle, t),
linkBackgroundColor:
Color.lerp(a.linkBackgroundColor, b.linkBackgroundColor, t),
);
/// Merge with a theme
@@ -127,6 +135,7 @@ class MessageThemeData with Diagnosticable {
reactionsBackgroundColor: other.reactionsBackgroundColor,
reactionsBorderColor: other.reactionsBorderColor,
reactionsMaskColor: other.reactionsMaskColor,
linkBackgroundColor: other.linkBackgroundColor,
);
}
@@ -145,7 +154,8 @@ class MessageThemeData with Diagnosticable {
reactionsBackgroundColor == other.reactionsBackgroundColor &&
reactionsBorderColor == other.reactionsBorderColor &&
reactionsMaskColor == other.reactionsMaskColor &&
avatarTheme == other.avatarTheme;
avatarTheme == other.avatarTheme &&
linkBackgroundColor == other.linkBackgroundColor;
@override
int get hashCode =>
@@ -159,7 +169,8 @@ class MessageThemeData with Diagnosticable {
reactionsBackgroundColor.hashCode ^
reactionsBorderColor.hashCode ^
reactionsMaskColor.hashCode ^
avatarTheme.hashCode;
avatarTheme.hashCode ^
linkBackgroundColor.hashCode;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@@ -175,6 +186,7 @@ class MessageThemeData with Diagnosticable {
..add(DiagnosticsProperty('avatarTheme', avatarTheme))
..add(ColorProperty('reactionsBackgroundColor', reactionsBackgroundColor))
..add(ColorProperty('reactionsBorderColor', reactionsBorderColor))
..add(ColorProperty('reactionsMaskColor', reactionsMaskColor));
..add(ColorProperty('reactionsMaskColor', reactionsMaskColor))
..add(ColorProperty('linkBackgroundColor', linkBackgroundColor));
}
}
@@ -1,8 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/thread_header.png)
/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/thread_header_paint.png)
@@ -127,10 +128,14 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget {
],
);
final theme = Theme.of(context);
return AppBar(
automaticallyImplyLeading: false,
textTheme: Theme.of(context).textTheme,
brightness: Theme.of(context).brightness,
toolbarTextStyle: theme.textTheme.bodyText2,
titleTextStyle: theme.textTheme.headline6,
systemOverlayStyle: theme.brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
elevation: 1,
leading: leading ??
(showBackButton
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// Widget to show the current list of typing users
class TypingIndicator extends StatelessWidget {
@@ -66,6 +66,12 @@ class UserAvatar extends StatelessWidget {
final placeholder =
this.placeholder ?? streamChatTheme.placeholderUserImage;
final backupGradientAvatar = ClipRRect(
borderRadius: borderRadius ??
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
child: streamChatTheme.defaultUserImage(context, user),
);
Widget avatar = FittedBox(
fit: BoxFit.cover,
child: Container(
@@ -76,8 +82,7 @@ class UserAvatar extends StatelessWidget {
fit: BoxFit.cover,
filterQuality: FilterQuality.high,
imageUrl: user.image!,
errorWidget: (context, __, ___) =>
streamChatTheme.defaultUserImage(context, user),
errorWidget: (context, __, ___) => backupGradientAvatar,
placeholder: placeholder != null
? (context, __) => placeholder(context, user)
: null,
@@ -91,11 +96,7 @@ class UserAvatar extends StatelessWidget {
),
),
)
: ClipRRect(
borderRadius: borderRadius ??
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
child: streamChatTheme.defaultUserImage(context, user),
),
: backupGradientAvatar,
),
);
@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_list_view.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
///
/// It shows the current [User] preview.
@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Callback called when tapping on a user
typedef UserTapCallback = void Function(User, Widget?);
@@ -46,12 +46,17 @@ typedef UserItemBuilder = Widget Function(BuildContext, User, bool);
/// Modify it to change the widget appearance.
class UserListView extends StatefulWidget {
/// Instantiate a new UserListView
const UserListView({
UserListView({
Key? key,
this.filter,
this.filter = const Filter.empty(),
this.sort,
this.presence,
this.pagination,
@Deprecated(
"'pagination' is deprecated and shouldn't be used. "
"This property is no longer used, Please use 'limit' instead",
)
this.pagination,
int? limit,
this.onUserTap,
this.onUserLongPress,
this.userWidget,
@@ -71,12 +76,13 @@ class UserListView extends StatefulWidget {
crossAxisCount == 1 || groupAlphabetically == false,
'Cannot group alphabetically when crossAxisCount > 1',
),
limit = limit ?? pagination?.limit ?? 30,
super(key: key);
/// The query filters to use.
/// You can query on any of the custom fields you've defined on the [Channel].
/// You can also filter other built-in channel fields.
final Filter? filter;
final Filter filter;
/// The sorting used for the channels matching the filters.
/// Sorting is based on field and direction, multiple sorting options can
@@ -93,8 +99,15 @@ class UserListView extends StatefulWidget {
/// limit: the number of users to return (max is 30)
/// offset: the offset (max is 1000)
/// message_limit: how many messages should be included to each channel
@Deprecated(
"'pagination' is deprecated and shouldn't be used. "
"This property is no longer used, Please use 'limit' instead",
)
final PaginationParams? pagination;
/// The amount of users requested per API call.
final int limit;
/// Function called when tapping on a channel
/// By default it calls [Navigator.push] building a [MaterialPageRoute]
/// with the widget [userWidget] as child.
@@ -184,7 +197,7 @@ class _UserListViewState extends State<UserListView>
),
listBuilder:
widget.listBuilder ?? (context, list) => _buildListView(list),
pagination: widget.pagination,
limit: widget.limit,
sort: widget.sort,
filter: widget.filter,
presence: widget.presence,
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// This widget is used for showing user tiles for mentions
/// Use [title], [subtitle], [leading], [trailing] for
/// substituting widgets in respective positions
class UserMentionTile extends StatelessWidget {
/// Constructor for creating a [UserMentionTile] widget
const UserMentionTile(
this.user, {
Key? key,
this.title,
this.subtitle,
this.leading,
this.trailing,
}) : super(key: key);
/// User to display in the tile
final User user;
/// Widget to display as title
final Widget? title;
/// Widget to display below [title]
final Widget? subtitle;
/// Widget at the start of the tile
final Widget? leading;
/// Widget at the end of tile
final Widget? trailing;
@override
Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(
width: 16,
),
leading ??
UserAvatar(
user: user,
constraints: BoxConstraints.tight(const Size(40, 40)),
),
const SizedBox(width: 8),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title ??
Text(
user.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: chatThemeData.textTheme.bodyBold,
),
const SizedBox(height: 2),
subtitle ??
Text(
'@${user.id}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: chatThemeData.textTheme.footnoteBold.copyWith(
color: chatThemeData.colorTheme.textLowEmphasis,
),
),
],
),
),
),
trailing ??
Padding(
padding: const EdgeInsets.only(
right: 18,
left: 8,
),
child: StreamSvgIcon.mentions(
color: chatThemeData.colorTheme.accentPrimary,
),
),
],
),
);
}
}
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/user_mention_tile.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// Builder function for building a mention tile.
///
/// Use [UserMentionTile] for the default implementation.
typedef MentionTileBuilder = Widget Function(
BuildContext context,
User user,
);
/// Overlay for displaying users that can be mentioned.
class UserMentionsOverlay extends StatefulWidget {
/// Constructor for creating a [UserMentionsOverlay].
UserMentionsOverlay({
Key? key,
required this.query,
required this.channel,
required this.size,
this.client,
this.limit = 10,
this.mentionAllAppUsers = false,
this.mentionsTileBuilder,
this.onMentionUserTap,
}) : assert(
channel.state != null,
'Channel ${channel.cid} is not yet initialized',
),
assert(
!mentionAllAppUsers || (mentionAllAppUsers && client != null),
'StreamChatClient is required in order to use mentionAllAppUsers',
),
super(key: key);
/// Query for searching users.
final String query;
/// Limit applied on user search results.
final int limit;
/// The size of the overlay.
final Size size;
/// The channel to search for users.
final Channel channel;
/// The client to search for users in case [mentionAllAppUsers] is True.
final StreamChatClient? client;
/// When enabled mentions search users across the entire app.
///
/// Defaults to false.
final bool mentionAllAppUsers;
/// Customize the tile for the mentions overlay.
final MentionTileBuilder? mentionsTileBuilder;
/// Callback called when a user is selected.
final void Function(User user)? onMentionUserTap;
@override
_UserMentionsOverlayState createState() => _UserMentionsOverlayState();
}
class _UserMentionsOverlayState extends State<UserMentionsOverlay> {
late Future<List<User>> userMentionsFuture;
@override
void initState() {
super.initState();
userMentionsFuture = queryMentions(widget.query);
}
@override
void didUpdateWidget(covariant UserMentionsOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.channel != oldWidget.channel ||
widget.query != oldWidget.query ||
widget.mentionAllAppUsers != oldWidget.mentionAllAppUsers ||
widget.limit != oldWidget.limit) {
userMentionsFuture = queryMentions(widget.query);
}
}
@override
Widget build(BuildContext context) {
final theme = StreamChatTheme.of(context);
return Card(
margin: const EdgeInsets.all(8),
elevation: 2,
color: theme.colorTheme.barsBg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(widget.size),
decoration: BoxDecoration(color: theme.colorTheme.barsBg),
child: FutureBuilder<List<User>>(
future: userMentionsFuture,
builder: (context, snapshot) {
if (snapshot.hasError) return const Offstage();
if (!snapshot.hasData) return const Offstage();
final users = snapshot.data!;
return ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return Material(
color: theme.colorTheme.barsBg,
child: InkWell(
onTap: () => widget.onMentionUserTap?.call(user),
child: widget.mentionsTileBuilder?.call(context, user) ??
UserMentionTile(user),
),
);
},
);
},
),
),
);
}
List<User> get membersAndWatchers {
final state = widget.channel.state!;
return {
...state.watchers,
...state.members.map((it) => it.user),
}.whereType<User>().toList(growable: false);
}
Future<List<User>> queryMentions(String query) async {
if (widget.mentionAllAppUsers) {
return _queryUsers(query);
}
var channelState = widget.channel.state;
channelState = channelState!;
final members = channelState.members;
// By default, we return maximum 100 members via queryChannels api call.
// Thus it is safe to assume, that if number of members in channel.state
// is < 100, then all the members are already available on client side
// and we don't need to make any api call to queryMembers endpoint.
if (members.length < 100) {
final matchingUsers = membersAndWatchers.search(query);
return matchingUsers.toList(growable: false);
}
final result = await _queryMembers(query);
return result
.map((it) => it.user)
.whereType<User>()
.toList(growable: false);
}
Future<List<Member>> _queryMembers(String query) async {
final response = await widget.channel.queryMembers(
pagination: PaginationParams(limit: widget.limit),
filter: query.isEmpty
? const Filter.empty()
: Filter.autoComplete('name', query),
);
return response.members;
}
Future<List<User>> _queryUsers(String query) async {
assert(
widget.client != null,
'StreamChatClient is required in order to query all app users',
);
final response = await widget.client!.queryUsers(
pagination: PaginationParams(limit: widget.limit),
filter: query.isEmpty
? const Filter.empty()
: Filter.or([
Filter.autoComplete('id', query),
Filter.autoComplete('name', query),
]),
sort: [const SortOption('id', direction: SortOption.ASC)],
);
return response.users;
}
}
@@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Launch URL
Future<void> launchURL(BuildContext context, String? url) async {
if (url != null && await canLaunch(url)) {
Future<void> launchURL(BuildContext context, String url) async {
if (await canLaunch(url)) {
await launch(url);
} else {
ScaffoldMessenger.of(context).showSnackBar(
@@ -388,3 +389,39 @@ class Tuple2<T1, T2> {
@override
int get hashCode => item1.hashCode ^ item2.hashCode;
}
/// Levenshtein algorithm implementation based on:
/// http://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
int levenshtein(String s, String t, {bool caseSensitive = true}) {
if (!caseSensitive) {
// ignore: parameter_assignments
s = s.toLowerCase();
// ignore: parameter_assignments
t = t.toLowerCase();
}
if (s == t) return 0;
if (s.isEmpty) return t.length;
if (t.isEmpty) return s.length;
final v0 = List<int>.filled(t.length + 1, 0);
final v1 = List<int>.filled(t.length + 1, 0);
for (var i = 0; i < t.length + 1; i < i++) {
v0[i] = i;
}
for (var i = 0; i < s.length; i++) {
v1[0] = i + 1;
for (var j = 0; j < t.length; j++) {
final cost = (s[i] == t[j]) ? 0 : 1;
v1[j + 1] = math.min(v1[j] + 1, math.min(v0[j + 1] + 1, v0[j] + cost));
}
for (var j = 0; j < t.length + 1; j++) {
v0[j] = v1[j];
}
}
return v1[t.length];
}
@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter/src/extension.dart';
/// Widget for displaying a footnote
class VisibleFootnote extends StatelessWidget {