Merge branch 'develop' into fix-cascade-deletion

This commit is contained in:
Salvatore Giordano
2021-09-03 10:30:52 +02:00
93 changed files with 3622 additions and 976 deletions
-43
View File
@@ -1,43 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**What package are you using? What version?**
**What platform is it about?**
- [ ] Android
- [ ] iOS
- [ ] Web
- [ ] Windows
- [ ] MacOS
- [ ] Linux
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Logs **
Run `flutter analyze` and attach any output of that command below.
If there are any analysis errors, try resolving them before filing this issue.
Paste the output of running `flutter doctor -v` here.
**Additional context**
Add any other context about the problem here.
+98
View File
@@ -0,0 +1,98 @@
name: Bug Report
description: Create a report to help us improve
labels: [bug]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: dropdown
id: packages
attributes:
label: Which packages are you using?
description: You may select more than one.
multiple: true
options:
- stream_chat
- stream_chat_flutter
- stream_chat_flutter_core
- stream_chat_persistance
- stream_chat_localizations
validations:
required: true
- type: dropdown
id: platforms
attributes:
label: On what platforms did you experience the issue?
description: You may select more than one.
multiple: true
options:
- iOS
- Android
- Web
- Windows
- MacOS
- Linux
validations:
required: true
- type: textarea
id: version
attributes:
label: What version are you using?
description: Please specify the package names and versions
placeholder: package - version
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: Also, what did you expect to happen?
placeholder: Description of the bug and what was expected.
validations:
required: true
- type: textarea
id: repro-steps
attributes:
label: Steps to reproduce
description: How do you trigger this bug? Please walk us through it step by step.
value: |
1. Go to '...'
2. Click on '...'
3. Scroll down to '...'
...
render: bash
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Supporting info to reproduce
description: Please add any relevant code, screenshots and info needed to reproduce this issue.
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
render: shell
- type: textarea
id: flutter-analyze
attributes:
label: Flutter analyze output
description: Paste the output of `flutter analyze` here.
placeholder: If there are any analysis errors, try resolving them before filing this issue.
render: shell
- type: textarea
id: flutter-doctor
attributes:
label: Flutter doctor output
description: Paste the output of `flutter doctor -v` here.
render: shell
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/GetStream/stream-chat-flutter/blob/develop/CODE_OF_CONDUCT.md)
options:
- label: "I agree to follow this project's Code of Conduct"
required: true
-20
View File
@@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
@@ -0,0 +1,66 @@
name: Feature Request
description: Suggest an idea for this project
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to help us improve!
- type: dropdown
id: packages
attributes:
label: Please select which package this feature is related to
description: You may select more than one.
multiple: true
options:
- stream_chat
- stream_chat_flutter
- stream_chat_flutter_core
- stream_chat_persistance
- stream_chat_localizations
validations:
required: true
- type: dropdown
id: platforms
attributes:
label: Which platforms would this feature impact?
description: You may select more than one.
multiple: true
options:
- iOS
- Android
- Web
- Windows
- MacOS
- Linux
- type: textarea
id: problem
attributes:
label: Is your feature request related to a problem?
description: A clear description of what the problem is.
placeholder: "Example: I'm always frustrated when [...]"
- type: textarea
id: solution
attributes:
label: "Describe the solution that you'd like"
description: A clear description of what you want to happen.
placeholder: "Example: When clicking this I want that."
- type: textarea
id: alternatives
attributes:
label: "Describe alternatives that you have considered"
description: "A clear description of any alternative solutions or features you've considered."
placeholder: "Example: Instead of this it should do that."
- type: textarea
id: additional
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here."
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/GetStream/stream-chat-flutter/blob/develop/CODE_OF_CONDUCT.md)
options:
- label: "I agree to follow this project's Code of Conduct"
required: true
+19 -2
View File
@@ -1,4 +1,4 @@
name: 'PR Title is Conventional'
name: 'PR is Conventional and Semantic'
on:
pull_request_target:
types:
@@ -9,7 +9,7 @@ on:
- develop
jobs:
main:
conventional_pr_title:
runs-on: ubuntu-latest
steps:
- uses: amannn/[email protected]
@@ -25,3 +25,20 @@ jobs:
requireScope: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
semantic_changelog_update:
needs: conventional_pr_title # Trigger after the [conventional_pr_title] completes
runs-on: ubuntu-latest
steps:
- uses: GetStream/verify-semantic-changelog-update@main
with:
scopes: |
{
"llc": "packages/stream_chat",
"ui": "packages/stream_chat_flutter",
"core": "packages/stream_chat_flutter_core",
"localization": "packages/stream_chat_localizations",
"persistence": "packages/stream_chat_persistence"
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -52,6 +52,8 @@ Stream's Flutter code is kept in a single mono-repository consisting of multiple
`stream_chat_persistence` - This package provides a persistence client for fetching and saving chat data locally. Stream Chat Persistence uses Moor as a disk cache.
`stream_chat_localizations` - This package provides a set of localizations for the SDK.
### Local Setup
Congratulations! 🎉. You've successfully cloned our repo, and you are ready to make your first contribution. Before you can start making code changes, there are a few things to configure.
+1 -1
View File
@@ -17,7 +17,7 @@ This repository contains code for our [Dart](https://dart.dev/) and [Flutter](ht
Stream allows developers to rapidly deploy scalable feeds and chat messaging with an industry leading 99.999% uptime SLA guarantee.
## Sample apps and demos
Our team maintains a dedicated repository for fully-fledged sample applications and demos. Consider checking out [GetStream/flutter-samples](https://github.com/GetStream/flutter-samples) to learn more or get started by looking at our latest [Stream Chat demo](https://github.com/GetStream/flutter-samples/tree/main/stream_chat_v1).
Our team maintains a dedicated repository for fully-fledged sample applications and demos. Consider checking out [GetStream/flutter-samples](https://github.com/GetStream/flutter-samples) to learn more or get started by looking at our latest [Stream Chat demo](https://github.com/GetStream/flutter-samples/tree/main/packages/stream_chat_v1).
## Free for Makers
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

+15 -14
View File
@@ -8,13 +8,14 @@ Exploring The Basics Of Stream Chat
![](../assets/sdk_title.png)
Stream Chat is a service that helps you easily build a full chat experience in your Flutter (and more) apps.
Stream Chat is a service that helps you easily build a full chat experience in your Flutter apps.
We also support a variety of other SDKs.
This section of the documentation focuses on our Flutter SDK which helps you easily
ship high quality messaging experiences in apps and programs built with the [Flutter toolkit made
by Google](https://flutter.dev).
ship high quality messaging experiences in apps and programs built with the [Flutter toolkit
made by Google](https://flutter.dev).
The Stream Chat Flutter SDK comprises of four different packages to choose from ranging from ones
The Stream Chat Flutter SDK comprises five different packages to choose from, ranging from ones
giving you complete control to ones that give you a rich out-of-the-box chat experience.
The packages that make up the Stream Chat SDK are:
@@ -30,28 +31,28 @@ reusable and customisable UI components.
saving chat data locally.
5. <b>Localizations (stream_chat_localizations)</b>: provides a set of localizations for the SDK.
We recommend building prototypes using the full UI package since it contains UI widgets already
integrated with Stream's API. [stream_chat_flutter](https://pub.dev/packages/stream_chat_flutter)
is the fastest way to get up and running using Stream chat in your app.
We recommend building prototypes using the full UI package, [stream_chat_flutter](https://pub.dev/packages/stream_chat_flutter),
since it contains UI widgets already integrated with Stream's API. It is the fastest way to get up
and running using Stream chat in your app.
The Flutter SDK enables you to build any type of chat or messaging experience for Android, iOS, Web
and Desktop.
If you're building a very custom UI and would prefer a more lean package,
our [core package](https://pub.dev/packages/stream_chat_flutter) will be suited to this use case. Core allows you to build custom,
expressive UIs while retaining the benefits of our full Flutter SDK.
APIs for accessing and controlling users, sending messages, etc are seamlessly integrated into
this package and accessible via providers and builders.
[stream_chat_flutter_core](https://pub.dev/packages/stream_chat_flutter_core) will be suited to this
use case. Core allows you to build custom, expressive UIs while retaining the benefits of our full
Flutter SDK. APIs for accessing and controlling users, sending messages, and so forth are seamlessly integrated
into this package and accessible via providers and builders.
Before going into the docs, let's take a small detour to look at how the elements of Stream Chat are structured.
### Basic Structure
There are two core elements in chat, <b>Users and Channels.</b>
There are two core elements in chat, <b>Users</b> and <b>Channels</b>.
Channels are groups of one or more users that can message each other.
In an app, you need to have a user connected to query channels.
There is no specific distinction between a chat between two people and a group chat,
There is no specific distinction between a chat with only two people and a group chat,
but there is a way to create a unique chat between a certain number of people by creating a <b>distinct</b> channel.
![](../assets/chat_basics.png)
@@ -68,7 +69,7 @@ While this is a simplistic overview of the service, the Flutter SDK handles the
Before reading the docs, consider trying our [online API tour](https://getstream.io/chat/get_started/),
it is a nice way to learn how the API works.
It's in-browser so Javascript-based but the ideas are pretty much the same as Dart.
It's in-browser so you'll need to use Javascript but the core conceps are pretty much the same as Dart.
You may also like to look at the [Flutter tutorial](https://getstream.io/chat/flutter/tutorial/)
which focuses on using the UI package to get Stream Chat integrated into a Flutter app.
@@ -146,21 +146,25 @@ Next, we build the Static Maps URL (Add your API key before using the code snipp
}
```
And then modify the MessageListView and tell it how to build a location attachment:
And then modify the `MessageListView` and tell it how to build a location attachment, using the `messageBuilder` property and copying the default message implementation overriding the `customAttachmentBuilders` property:
```dart
MessageListView(
customAttachmentBuilders: {
'location': (context, message, attachments) {
var attachmentWidget = Image.network(
_buildMapAttachment(
attachments[0].extraData['latitude'],
attachments[0].extraData['longitude'],
),
);
messageBuilder: (context, details, messages, defaultMessage) {
return defaultMessage.copyWith(
customAttachmentBuilders: {
'location': (context, message, attachments) {
final attachmentWidget = Image.network(
_buildMapAttachment(
attachments[0].extraData['latitude'],
attachments[0].extraData['longitude'],
),
);
return wrapAttachmentWidget(context, attachmentWidget, null, true, BorderRadius.circular(8.0));
}
return wrapAttachmentWidget(context, attachmentWidget, null, true, BorderRadius.circular(8.0));
}
},
);
},
),
```
@@ -0,0 +1,76 @@
---
id: adding_local_data_persistence
sidebar_position: 9
title: Adding Local Data Persistence
---
Adding Local Data Persistence
### Introduction
Most messaging apps need to work regardless of whether the app is currently connected to the internet.
Local data persistence stores the fetched data from the backend on a local SQLite database using the
moor package in Flutter. All packages in the SDK can use local data persistence to store messages
across multiple platforms.
### Implementation
To add data persistence you can extend the class ChatPersistenceClient and pass an instance to the StreamChatClient.
```dart
class CustomChatPersistentClient extends ChatPersistenceClient {
...
}
final client = StreamChatClient(
apiKey ?? kDefaultStreamApiKey,
logLevel: Level.INFO,
)..chatPersistenceClient = CustomChatPersistentClient();
```
We provide an official persistent client in the [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence)
package that works using the library [moor](https://moor.simonbinder.eu), an SQLite ORM.
Add this to your package's `pubspec.yaml` file, using the latest version.
```yaml
dependencies:
stream_chat_persistence: ^latest_version
```
You should then run `flutter packages get`
The usage is pretty simple.
1. Create a new instance of `StreamChatPersistenceClient` providing `logLevel` and `connectionMode`
```dart
final chatPersistentClient = StreamChatPersistenceClient(
logLevel: Level.INFO,
connectionMode: ConnectionMode.background,
);
```
2. Pass the instance to the official `StreamChatClient`
```dart
final client = StreamChatClient(
apiKey ?? kDefaultStreamApiKey,
logLevel: Level.INFO,
)..chatPersistenceClient = chatPersistentClient;
```
And you are ready to go...
Note that passing `ConnectionMode.background` the database uses a background isolate to unblock the main thread.
The `StreamChatClient` uses the `chatPersistentClient` to synchronize the database with the newest
information every time it receives new data about channels/messages/users.
### Multi-user
The DB file is named after the `userId`, so if you instantiate a client using a different `userId` you will use a different database.
Calling `client.disconnectUser(flushChatPersistence: true)` flushes all current database data.
### Updating/deleting/sending a message while offline
The information about the action is saved in offline storage. When the client returns online, everything is retried.
@@ -1,7 +1,7 @@
---
id: adding_localization
sidebar_position: 2
title: Adding Localization
title: Adding Localization (l10n) / Internationalization (i18n)
---
Adding Localization To UI Widgets
@@ -14,10 +14,14 @@ We have a dedicated package for adding localization to our UI widgets. It's call
## What is Localization?
If you deploy your app to users who speak another language, you'll need to internationalize (localize) it. That means you need to write the app in a way that makes it possible to localize values like text and layouts for each language or locale that the app supports. For more information, see the [Flutter documentation](https://flutter.dev/docs/development/accessibility-and-localization/**internationalization**).
If you deploy your app to users who speak another language, you'll need to internationalize (localize) it. That means you need to write the app in a way that makes it possible to localize values like text and layouts for each language or locale that the app supports. For more information, see the [Flutter documentation](https://flutter.dev/docs/development/accessibility-and-localization/internationalization).
What this package allows you to do is to provide localized strings for the Stream chat widgets. For example, depending on the application locale, the Stream Chat widgets will display the appropriate language. The locale will be set automatically, based on system preferences, or you could set it programmatically in your app. The package supports several different languages, with more to be added. The package allows you to override any supported language or add a new language that isn't supported.
:::note
If you want to translate messages, or enable automatic translation, please see the [Translation documentation](https://getstream.io/chat/docs/flutter-dart/translation/?language=dart).
:::
### Supported languages
At the moment we support the following languages:
@@ -26,7 +30,8 @@ At the moment we support the following languages:
- [Italian](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart)
- [French](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart)
- [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart)
- [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart)
- [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart)
More languages will be added in the future. Feel free to [contribute](https://github.com/GetStream/stream-chat-flutter/blob/master/CONTRIBUTING.md) to add more languages.
### Add dependency
@@ -64,6 +69,8 @@ class MyApp extends StatelessWidget {
Locale('fr'),
Locale('it'),
Locale('es'),
Locale('ja'),
Locale('ko'),
],
// Add GlobalStreamChatLocalizations.delegates
localizationsDelegates: GlobalStreamChatLocalizations.delegates,
@@ -133,6 +140,8 @@ Here is an example of how that would look like:
Locale('fr'),
Locale('it'),
Locale('es'),
Locale('ja'),
Locale('ko'),
],
// locales are the locales of the device
// supportedLocales are the app supported locales
@@ -175,6 +184,8 @@ Example:
<string>nb</string>
<string>fr</string>
<string>it</string>
<string>es</string>
<string>es</string>
<string>ja</string>
<string>ko</string>
</array>
```
@@ -187,8 +187,33 @@ StreamChat(
As you can see we generate a local notification whenever a message.new or notification.message_new event is received.
### Foreground notifications
Sometimes you may want to show a notification when the app is in the foreground.
For example, when you're in a channel and you receive a new message from someone in another channel.
For this scenario, you can also use the `flutter_local_notifications` package to show a notification.
You need to listen for new events using `StreamChatClient.on` and handle them accordingly.
Here we're checking if the event is a `message.new` or `notification.message_new` event, and if the message is from a different user than the current user. In that case we'll show a notification.
```dart
client.on(
EventType.messageNew,
EventType.notificationMessageNew,
).listen((event) {
if (event.message?.user?.id == client.state.currentUser?.id) {
return;
}
showLocalNotification(event, client.state.currentUser!.id, context);
});
```
:::note
Using `flutter_local_notifications` is a great way to implement notifications while the is in foreground too! You can generate a local notification listening to events using the method `streamChatClient.on()` and react to the events you want.
You should also check that the channel of the message is different than the channel in the foreground.
How you do this depends on your app infrastructure and how you handle navigation.
Take a look at the [Stream Chat v1 sample app](https://github.com/GetStream/flutter-samples/blob/main/packages/stream_chat_v1/lib/home_page.dart#L11) to see how we're doing it over there.
:::
### Saving notification messages to the offline storage
@@ -0,0 +1,182 @@
---
id: customize_message_widget
sidebar_position: 11
title: Customizing The MessageWidget
---
Customizing Text Messages
### Introduction
Every application provides a unique look and feel to their own messaging interface including and not
limited to fonts, colors, and shapes.
This guide details how to customize the `MessageWidget` in the Stream Chat Flutter UI SDK.
### Building Custom Messages
This guide goes into detail about the ability to customize the `MessageWidget`. However, if you want
to customize the default `MessageWidget` in the `MessageListView` provided, you can use the `.copyWith()` method
provided inside the `messageBuilder` parameter of the `MessageListView` like this:
```dart
MessageListView(
messageBuilder: (context, details, messageList, defaultImpl) {
// Your implementation of the message here
// E.g: return Text(details.message.text ?? '');
},
),
```
### Theming
You can customize the `MessageWidget` using the `StreamChatTheme` class, so that you can change the
message theme at the top instead of creating your own `MessageWidget` at the lower implementation level.
There are several things you can change in the theme including text styles and colors of various elements.
You can also set a different theme for the user's own messages and messages received by them.
:::note
Theming allows you to change minor factors like style while using the widget directly allows you much
more customization such as replacing a certain widget with another. Some things can only be customized
through the widget and not the theme.
:::
Here is an example:
```dart
StreamChatThemeData(
/// Sets theme for user's messages
ownMessageTheme: MessageThemeData(
messageBackgroundColor: colorTheme.textHighEmphasis,
),
/// Sets theme for received messages
otherMessageTheme: MessageThemeData(
avatarTheme: AvatarThemeData(
borderRadius: BorderRadius.circular(8),
),
),
)
```
![](../assets/message_theming.png)
#### Change message text style
The `MessageWidget` has multiple `Text` widgets that you can manipulate the styles of. The three main
are the actual message text, user name, message links, and the message timestamp.
```dart
MessageThemeData(
messageTextStyle: TextStyle(...),
createdAtStyle: TextStyle(...),
messageAuthorStyle: TextStyle(...),
messageLinksStyle: TextStyle(...),
)
```
![](../assets/message_styles.png)
#### Change avatar theme
You can change the attributes of the avatar (if displayed) using the `avatarTheme` property.
```dart
MessageThemeData(
avatarTheme: AvatarThemeData(
borderRadius: BorderRadius.circular(8),
),
)
```
![](../assets/message_rounded_avatar.png)
#### Changing Reaction theme
You also customize the reactions attached to every message using the theme.
```dart
MessageThemeData(
reactionsBackgroundColor: Colors.red,
reactionsBorderColor: Colors.redAccent,
reactionsMaskColor: Colors.pink,
),
```
![](../assets/message_reaction_theming.png)
### Changing Message Actions
When a message is long pressed, the `MessageActionsModal` is shown.
The `MessageWidget` allows showing or hiding some options if you so choose.
```dart
MessageWidget(
...
showUsername = true,
showTimestamp = true,
showReactions = true,
showDeleteMessage = true,
showEditMessage = true,
showReplyMessage = true,
showThreadReplyMessage = true,
showResendMessage = true,
showCopyMessage = true,
showFlagButton = true,
showPinButton = true,
showPinHighlight = true,
),
```
![](../assets/message_widget_actions.png)
### Building attachments
The `customAttachmentBuilder` property allows you to build any kind of attachment (inbuilt or custom)
in your own way. While a separate guide is written for this, it is included here because of relevance.
```dart
MessageListView(
messageBuilder: (context, details, messages, defaultMessage) {
return defaultMessage.copyWith(
customAttachmentBuilders: {
'location': (context, message, attachments) {
final attachmentWidget = Image.network(
_buildMapAttachment(
attachments[0].extraData['latitude'],
attachments[0].extraData['longitude'],
),
);
return wrapAttachmentWidget(context, attachmentWidget, null, true, BorderRadius.circular(8.0));
}
},
);
},
),
```
### Widget Builders
Some parameters allow you to construct your own widget in place of some elements in the `MessageWidget`.
These are:
* `userAvatarBuilder` : Allows user to substitute their own widget in place of the user avatar.
* `editMessageInputBuilder` : Allows user to substitute their own widget in place of the input in edit mode.
* `textBuilder` : Allows user to substitute their own widget in place of the text.
* `bottomRowBuilder` : Allows user to substitute their own widget in the bottom of the message when not deleted.
* `deletedBottomRowBuilder` : Allows user to substitute their own widget in the bottom of the message when deleted.
```dart
MessageWidget(
...
textBuilder: (context, message) {
// Add your own text implementation here.
},
),
```
@@ -0,0 +1,143 @@
---
id: understanding_filters
sidebar_position: 10
title: Understanding Filters
---
Understanding Filters
### Introduction
Filters are used to get a specific subset of objects (channels, users, messages, members, etc) which
fit the conditions specified. Earlier versions of the SDK contained String-based filters which are now replaced by type-safe
filters. This guide aims to explain the different types of filters and how to use them.
### Types Of Filters
#### Filter.equal
The 'equal' filter gets the objects where the given key has the specified value.
```dart
Filter.equal('type', 'messaging'),
```
#### Filter.notEqual
The 'notEqual' filter gets the objects where the given key does not have the specified value.
```dart
Filter.notEqual('type', 'messaging'),
```
#### Filter.greater
The 'greater' filter gets the objects where the given key has a higher value than the specified value.
```dart
Filter.greater('count', 5),
```
#### Filter.greaterOrEqual
The 'greaterOrEqual' filter gets the objects where the given key has an equal or higher value than the specified value.
```dart
Filter.greaterOrEqual('count', 5),
```
#### Filter.less
The 'less' filter gets the objects where the given key has a lesser value than the specified value.
```dart
Filter.less('count', 5),
```
#### Filter.lessOrEqual
The 'lessOrEqual' filter gets the objects where the given key has a lesser or equal value than the specified value.
```dart
Filter.lessOrEqual('count', 5),
```
#### Filter.in_
The 'in_' filter allows getting objects where the key matches any in a specified array.
```dart
Filter.in_('members', [user.id])
```
:::note
Since 'in' is a keyword in Dart, the filter has an underscore added. This does not apply to the 'notIn'
keyword.
:::
#### Filter.notIn
The 'notIn' filter allows getting objects where the key matches none in a specified array.
```dart
Filter.notIn('members', [user.id])
```
#### Filter.query
The 'query' filter matches values by performing text search with the specified value.
```dart
Filter.query('name', 'demo')
```
#### Filter.autoComplete
The 'autoComplete' filter matches values with the specified prefix.
```dart
Filter.autoComplete('name', 'demo')
```
#### Filter.exists
The 'exists' filter matches values that exist, or don't exist, based on the specified boolean value.
```dart
Filter.exists('name', true)
```
### Group Queries
#### Filter.and
The 'and' operator combines multiple queries.
```dart
final filter = Filter.and([
Filter.equal('type', 'messaging'),
Filter.in_('members', [user.id])
])
```
#### Filter.or
Combines the provided filters and matches the values matched by at least one of the filters.
```dart
final filter = Filter.or([
Filter.in_('bannedUsers', [user.id]),
Filter.in_('shadowBannedUsers', [user.id])
])
```
#### Filter.nor
Combines the provided filters and matches the values not matched by all the filters.
```dart
final filter = Filter.nor([
Filter.in_('bannedUsers', [user.id]),
Filter.in_('shadowBannedUsers', [user.id])
])
```
@@ -0,0 +1,448 @@
---
id: token_generation_with_firebase
sidebar_position: 5
title: User Token Generation With Firebase Auth and Cloud Functions
---
Securely generate Stream Chat user tokens using Firebase Authentication and Cloud Functions.
:::note
This guide assumes that you are familiar with Firebase Authentication and Cloud Functions for Flutter and using the Flutter Stream Chat SDK.
:::
### Introduction
In this guide, you'll explore how you can use Firebase Auth as an authentication provider and create Firebase Cloud functions to securely
generate Stream Chat user tokens.
You will use Stream's [NodeJS client](https://getstream.io/chat/docs/node/?language=javascript) for Stream account creation and
token generation, and [Flutter Cloud Functions for Firebase](https://firebase.flutter.dev/docs/functions/overview) to invoke the cloud functions
from your Flutter app.
Stream supports several different [backend clients](https://getstream.io/chat/sdk/#backend-clients) to integrate with your server. This guide only shows an easy way to integrate Stream Chat authentication using Firebase and Flutter.
### Flutter Firebase
See the [Flutter Firebase getting started](https://firebase.flutter.dev/docs/overview) docs for setup and installation instructions.
You will also need to add the [Flutter Firebase Authentication](https://firebase.flutter.dev/docs/auth/overview), and [Flutter Firebase Cloud Functions](https://firebase.flutter.dev/docs/functions/overview) packages to your app. Depending on the platform that you target, there may be specific configurations that you need to do.
#### Starting Code
The following code shows a basic application with **FirebaseAuth** and **FirebaseFunctions**.
You will extend this later to execute cloud functions.
```dart
import 'package:cloud_functions/cloud_functions.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'dart:async';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Auth(),
),
);
}
}
class Auth extends StatefulWidget {
Auth({Key? key}) : super(key: key);
@override
_AuthState createState() => _AuthState();
}
class _AuthState extends State<Auth> {
late FirebaseAuth auth;
late FirebaseFunctions functions;
@override
void initState() {
super.initState();
auth = FirebaseAuth.instance;
functions = FirebaseFunctions.instance;
}
final email = '[email protected]';
final password = 'password';
Future<void> createAccount() async {
// Create Firebase account
await auth.createUserWithEmailAndPassword(email: email, password: password);
print('Firebase account created');
}
Future<void> signIn() async {
// Sign in with Firebase
await auth.signInWithEmailAndPassword(email: email, password: password);
print('Firebase signed in');
}
Future<void> signOut() async {
// Revoke Stream chat token.
final callable = functions.httpsCallable('revokeStreamUserToken');
await callable();
print('Stream user token revoked');
}
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AuthenticationState(
streamUser: auth.authStateChanges(),
),
ElevatedButton(
onPressed: createAccount,
child: Text('Create account'),
),
ElevatedButton(
onPressed: signIn,
child: Text('Sign in'),
),
ElevatedButton(
onPressed: signOut,
child: Text('Sign out'),
),
],
),
);
}
}
class AuthenticationState extends StatelessWidget {
const AuthenticationState({
Key? key,
required this.streamUser,
}) : super(key: key);
final Stream<User?> streamUser;
@override
Widget build(BuildContext context) {
return StreamBuilder<User?>(
stream: streamUser,
builder: (context, snapshot) {
if (snapshot.hasData) {
return (snapshot.data != null)
? Text('Authenticated')
: Text('Not Authenticated');
}
return Text('Not Authenticated');
},
);
}
}
```
Running the above will give this:
![](../assets/authentication_demo_app.jpg)
The `Auth` widget handles all of the authentication logic. It initializes a `FirebaseAuth.instance` and uses that
in the `createAccount`, `signIn` and `signOut` methods. There is a button to envoke each of these methods.
The `FirebaseFunctions.instance` will be used later in this guide.
The `AuthenticationState` widget listens to `auth.authStateChanges()` to display a message
indicating if a user is authenticated.
### Firebase Cloud Functions
Firebase Cloud Functions allows you to extend Firebase with custom operations that an event can trigger:
- **Internal event**: For example, when creating a new Firebase account this is automatically triggered.
- **External event**: For example, directly calling a cloud function from your Flutter application.
To set up your local environment to deploy cloud functions, please see the
[Cloud Functions getting started](https://firebase.flutter.dev/docs/overview) docs.
After initializing your project with cloud functions, you should have a **functions** folder in your project, including a `package.json` file.
There should be two dependencies already added, **firebase-admin** and **firebase-functions**. You will also need to add the **stream-chat** dependency.
Navigate to the **functions** folder and run `npm install stream-chat --save-prod`.
This will install the node module and add it as a dependency to `package.json`.
Now open `index.js` and add the following (this is the complete example):
```js
const StreamChat = require('stream-chat').StreamChat;
const functions = require("firebase-functions");
const admin = require("firebase-admin");
admin.initializeApp();
const serverClient = StreamChat.getInstance(functions.config().stream.key, functions.config().stream.secret);
// When a user is deleted from Firebase their associated Stream account is also deleted.
exports.deleteStreamUser = functions.auth.user().onDelete((user, context) => {
return serverClient.deleteUser(user.uid);
});
// Create a Stream user and return auth token.
exports.createStreamUserAndGetToken = functions.https.onCall(async (data, context) => {
// Checking that the user is authenticated.
if (!context.auth) {
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('failed-precondition', 'The function must be called ' +
'while authenticated.');
} else {
try {
// Create user using the serverClient.
await serverClient.upsertUser({
id: context.auth.uid,
name: context.auth.token.name,
email: context.auth.token.email,
image: context.auth.token.image,
});
/// Create and return user auth token.
return serverClient.createToken(context.auth.uid);
} catch (err) {
console.error(`Unable to create user with ID ${context.auth.uid} on Stream. Error ${err}`);
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('aborted', "Could not create Stream user");
}
}
});
// Get Stream user token.
exports.getStreamUserToken = functions.https.onCall((data, context) => {
// Checking that the user is authenticated.
if (!context.auth) {
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('failed-precondition', 'The function must be called ' +
'while authenticated.');
} else {
try {
return serverClient.createToken(context.auth.uid);
} catch (err) {
console.error(`Unable to get user token with ID ${context.auth.uid} on Stream. Error ${err}`);
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('aborted', "Could not get Stream user");
}
}
});
// Revoke the authenticated user's Stream chat token.
exports.revokeStreamUserToken = functions.https.onCall((data, context) => {
// Checking that the user is authenticated.
if (!context.auth) {
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('failed-precondition', 'The function must be called ' +
'while authenticated.');
} else {
try {
return serverClient.revokeUserToken(context.auth.uid);
} catch (err) {
console.error(`Unable to revoke user token with ID ${context.auth.uid} on Stream. Error ${err}`);
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('aborted', "Could not get Stream user");
}
}
});
```
First, you import the necessary packages and call `admin.initializeApp();` to set up Firebase cloud functions.
Next, you initialize the **StreamChat** server client by calling `StreamChat.getInstance`. This function requires your Stream app's
**token** and **secret**. You can get this from the Stream Dashboard for your app.
Set these values as environment data on Firebase Functions.
```bash
firebase functions:config:set stream.key="app-key" stream.secret="app-secret"
```
*Replace **app-key** and **app-secret** with the values for your Stream app.*
This creates an object of **stream** with properties **key** and **secret**. To access this environment
data use `functions.config().stream.key` and `functions.config().stream.secret`.
See the [Firebase environment configuration](https://firebase.google.com/docs/functions/config-env)
documentation for additional information.
To deploy these functions to Firebase, run:
```bash
firebase deploy --only functions
```
### Create a Stream User and Get the User's Token
In the `createStreamUserAndGetToken` cloud function you create an `onCall` HTTPS handler, which exposes
a cloud function that can be envoked from your Flutter app.
```js
// Create a Stream user and return auth token.
exports.createStreamUserAndGetToken = functions.https.onCall(async (data, context) => {
// Checking that the user is authenticated.
if (!context.auth) {
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('failed-precondition', 'The function must be called ' +
'while authenticated.');
} else {
try {
// Create user using the serverClient.
await serverClient.upsertUser({
id: context.auth.uid,
name: context.auth.token.name,
email: context.auth.token.email,
image: context.auth.token.image,
});
/// Create and return user auth token.
return serverClient.createToken(context.auth.uid);
} catch (err) {
console.error(`Unable to create user with ID ${context.auth.uid} on Stream. Error ${err}`);
// Throwing an HttpsError so that the client gets the error details.
throw new functions.https.HttpsError('aborted', "Could not create Stream user");
}
}
});
```
This function first does a check to see that the client that calls it is authenticated,
by ensuring that `context.auth` is not null. If it is null, then it throws an `HttpsError` with a descriptive
message. This error can be caught in your Flutter application.
If the caller is authenticated the function proceeds to use the `serverClient` to create a new Stream Chat
user by calling the `upsertUser` method and passing in some user data. It uses the authenticated caller's **uid** as an **id**.
After the user is created it generates a token for that user. This token is then returned to the caller.
To call this from Flutter, you will need to use the `cloud_functions` package.
Update the **createAccount** method in your Flutter code to the following:
```dart
Future<void> createAccount() async {
// Create Firebase account
await auth.createUserWithEmailAndPassword(email: email, password: password);
print('Firebase account created');
// Create Stream user and get token
final callable = functions.httpsCallable('createStreamUserAndGetToken');
final results = await callable();
print('Stream account created, token: ${results.data}');
}
```
Calling this method will do the following:
1. Create a new Firebase User and authenticate that user.
2. Call the `createStreamUserAndGetToken` cloud function and get the Stream user token for the authenticated user.
As you can see, calling a cloud function is easy and will also send all the necessary user authentication information (such as the UID)
in the request.
Once you have the Stream user token, you can authenticate your Stream Chat user as you normally would.
Please see our [initialization documention](https://getstream.io/chat/docs/flutter-dart/init_and_users/?language=dart) for more information.
As you can see below, the User ID matches on both Firebase's and Stream's user database.
##### Firebase Authentication Database
![Firebase Auth Database with new user created](../assets/firebase_authentication_dashboard.jpg)
##### Stream Chat User Database
![Stream chat user database new account created](../assets/stream_chat_user_database.jpg)
### Get the Stream User Token
The `getStreamUserToken` cloud function is very similar to the `createStreamUserAndGetToken` function. The only difference is
that it only creates a user token and does not create a new user account on Stream.
Update the **signIn** method in your Flutter code to the following:
```dart
Future<void> signIn() async {
// Sign in with Firebase
await auth.signInWithEmailAndPassword(email: email, password: password);
print('Firebase signed in');
// Get Stream user token
final callable = functions.httpsCallable('getStreamUserToken');
final results = await callable();
print('Stream user token retrieved: ${results.data}');
}
```
Calling this method will do the following:
1. Sign in using Firebase Auth.
2. Call the `getStreamUserToken` cloud function to get a Stream user token.
:::note
The user needs to be authenticated to call this cloud function. Otherwise, the function will throw
the **failed-precondition** error that you specified.
:::
### Revoke Stream User Token
You may also want to revoke the Stream user token if you sign out from Firebase.
Update the `signOut` method in your Flutter code to the following:
```dart
Future<void> signOut() async {
// Revoke Stream user token.
final callable = functions.httpsCallable('revokeStreamUserToken');
await callable();
print('Stream user token revoked');
// Sign out Firebase.
await auth.signOut();
print('Firebase signed out');
}
```
:::note
Call the cloud function before signing out from Firebase.
:::
### Delete Stream User
When deleting a Firebase user account, it would make sense also to delete the
associated Stream user account.
The cloud function looks like this:
```js
// When a user is deleted from Firebase their associated Stream account is also deleted.
exports.deleteStreamUser = functions.auth.user().onDelete((user, context) => {
return serverClient.deleteUser(user.uid);
});
```
In this function, you are listening to delete events on Firebase auth. When an account is deleted, this function will be triggered, and you can get the
user's **uid** and call the `deleteUser` method on the `serverClient`.
This is not an external cloud function; it can only be triggered when an
account is deleted.
### Conclusion
In this guide, you have seen how to securely create Stream Chat tokens using
Firebase Authentication and Cloud Functions.
The principles shown in this guide can be applied to your preferred authentication
provider and cloud architecture of choice.
+6
View File
@@ -69,6 +69,12 @@ scripts:
select-package:
dir-exists: coverage
docs:
run: |
npm install -g https://github.com/GetStream/stream-chat-docusaurus-cli &&
npx stream-chat-docusaurus -i -s
description: Runs the docusaurus documentation locally.
environment:
sdk: '>=2.12.0 <3.0.0'
flutter: '>=1.22.4 <2.0.0'
+11 -2
View File
@@ -1,4 +1,11 @@
## Upcoming
## 2.2.1
🐞 Fixed
- Fixed unread indicator not updating correctly
- Fix `channel.show` not working because of null body
## 2.2.0
🐞 Fixed
@@ -8,7 +15,9 @@
- `User` and `OwnUser` classes now have an `image` property. Setting an image will also set the 'image' key on `extraData`, so `user.image` and `user.extraData['image']` is the same.
- `User` and `OwnUser` classes now have a `name` property. Setting a name will also set the 'name' key on `extraData`, so `user.name` and `user.extraData['name']` is the same.
- `Channel` class now has extra `image` getter and setter. As well as an `updateImage` to do a partial update after a channel has been initialized.
- `Channel` class now has extra `name` getter and setter. As well as an `updateName` to do a partial update after a channel has been initialized.
- Added slow mode which allows a cooldown period after a user sends a message.
## 2.1.1
🐞 Fixed
File diff suppressed because it is too large Load Diff
@@ -767,7 +767,9 @@ class StreamChatClient {
cancelToken: cancelToken,
);
/// Replaces the [channelId] of type [ChannelType] data with [data]
/// Replaces the [channelId] of type [ChannelType] data with [data].
///
/// Use [updateChannelPartial] for a partial update.
Future<UpdateChannelResponse> updateChannel(
String channelId,
String channelType,
@@ -781,7 +783,10 @@ class StreamChatClient {
message: message,
);
/// Updates the [channelId] of type [ChannelType] data with [data]
/// Partial update for the [channelId] of type [ChannelType]. Sets the
/// data provided in [set], and removes the attributes given in [unset].
///
/// Use [updateChannel] for a full update.
Future<PartialUpdateChannelResponse> updateChannelPartial(
String channelId,
String channelType, {
@@ -1247,6 +1252,28 @@ class StreamChatClient {
language,
);
/// Enables slow mode
Future<PartialUpdateChannelResponse> enableSlowdown(
String channelId,
String channelType,
int cooldown,
) async =>
_chatApi.channel.enableSlowdown(
channelId,
channelType,
cooldown,
);
/// Disables slow mode
Future<PartialUpdateChannelResponse> disableSlowdown(
String channelId,
String channelType,
) async =>
_chatApi.channel.disableSlowdown(
channelId,
channelType,
);
/// Pins provided message
/// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds
/// to be added to [DateTime.now]
@@ -123,6 +123,35 @@ class ChannelApi {
return PartialUpdateChannelResponse.fromJson(response.data);
}
/// Enable slowdown
Future<PartialUpdateChannelResponse> enableSlowdown(
String channelId,
String channelType,
int cooldown,
) async {
final response = await updateChannelPartial(
channelId,
channelType,
set: {
'cooldown': cooldown,
},
);
return response;
}
/// Disable slowdown
Future<PartialUpdateChannelResponse> disableSlowdown(
String channelId,
String channelType,
) async {
final response = await updateChannelPartial(
channelId,
channelType,
unset: ['cooldown'],
);
return response;
}
/// Accept invitation to the channel
Future<AcceptInviteResponse> acceptChannelInvite(
String channelId,
@@ -263,6 +292,7 @@ class ChannelApi {
) async {
final response = await _client.post(
'${_getChannelUrl(channelId, channelType)}/show',
data: {},
);
return EmptyResponse.fromJson(response.data);
}
@@ -23,6 +23,7 @@ class ChannelModel {
this.memberCount = 0,
this.extraData = const {},
this.team,
this.cooldown = 0,
}) : assert(
(cid != null && cid.contains(':')) || (id != null && type != null),
'provide either a cid or an id and type',
@@ -81,6 +82,10 @@ class ChannelModel {
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly, defaultValue: 0)
final int memberCount;
/// The number of seconds in a cooldown
@JsonKey(includeIfNull: false, defaultValue: 0)
final int cooldown;
/// Map of custom channel extraData
@JsonKey(
includeIfNull: false,
@@ -107,6 +112,7 @@ class ChannelModel {
'deleted_at',
'member_count',
'team',
'cooldown',
];
/// Shortcut for channel name
@@ -133,6 +139,7 @@ class ChannelModel {
int? memberCount,
Map<String, Object?>? extraData,
String? team,
int? cooldown,
}) =>
ChannelModel(
id: id ?? this.id,
@@ -148,6 +155,7 @@ class ChannelModel {
memberCount: memberCount ?? this.memberCount,
extraData: extraData ?? this.extraData,
team: team ?? this.team,
cooldown: cooldown ?? this.cooldown,
);
/// Returns a new [ChannelModel] that is a combination of this channelModel
@@ -168,6 +176,7 @@ class ChannelModel {
memberCount: other.memberCount,
extraData: other.extraData,
team: other.team,
cooldown: other.cooldown,
);
}
}
@@ -33,6 +33,7 @@ ChannelModel _$ChannelModelFromJson(Map<String, dynamic> json) {
memberCount: json['member_count'] as int? ?? 0,
extraData: json['extra_data'] as Map<String, dynamic>? ?? {},
team: json['team'] as String?,
cooldown: json['cooldown'] as int? ?? 0,
);
}
@@ -57,6 +58,7 @@ Map<String, dynamic> _$ChannelModelToJson(ChannelModel instance) {
writeNotNull('updated_at', readonly(instance.updatedAt));
writeNotNull('deleted_at', readonly(instance.deletedAt));
writeNotNull('member_count', readonly(instance.memberCount));
val['cooldown'] = instance.cooldown;
val['extra_data'] = instance.extraData;
writeNotNull('team', readonly(instance.team));
return val;
@@ -184,6 +184,8 @@ class EventChannel extends ChannelModel {
DateTime? deletedAt,
required int memberCount,
Map<String, Object?>? extraData,
required int cooldown,
String? team,
}) : super(
id: id,
type: type,
@@ -197,6 +199,8 @@ class EventChannel extends ChannelModel {
deletedAt: deletedAt,
memberCount: memberCount,
extraData: extraData ?? {},
cooldown: cooldown,
team: team,
);
/// Create a new instance from a json
@@ -87,5 +87,7 @@ EventChannel _$EventChannelFromJson(Map<String, dynamic> json) {
: DateTime.parse(json['deleted_at'] as String),
memberCount: json['member_count'] as int? ?? 0,
extraData: json['extra_data'] as Map<String, dynamic>? ?? {},
cooldown: json['cooldown'] as int? ?? 0,
team: json['team'] as String?,
);
}
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names
const PACKAGE_VERSION = '2.1.1';
const PACKAGE_VERSION = '2.2.1';
+1 -1
View File
@@ -1,7 +1,7 @@
name: stream_chat
homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications.
version: 2.1.1
version: 2.2.1
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -4,6 +4,7 @@
"id": "dev",
"type": "team",
"frozen": true,
"cooldown": 0,
"name": "#dev",
"image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png",
"example": 1
@@ -72,6 +72,40 @@ void main() {
expect(channel.extraData.containsKey('name'), isTrue);
expect(channel.extraData['name'], 'test-channel-name');
});
test('should be able to get and set `image`', () {
expect(channel.extraData.isEmpty, isTrue);
const imageUrl = 'https://getstream.io/some-image';
channel.image = imageUrl;
expect(channel.image, imageUrl);
expect(channel.extraData['image'], imageUrl);
const newImage = 'https://getstream.io/new-image';
final newChannelInstance =
Channel(client, channelType, channelId, image: newImage);
expect(newChannelInstance.image, newImage);
expect(newChannelInstance.extraData['image'], newImage);
});
test('should be able to get and set `name`', () {
expect(channel.extraData.isEmpty, isTrue);
const name = 'Channel name';
channel.name = name;
expect(channel.name, name);
expect(channel.extraData['name'], name);
const newName = 'New channel name';
final newChannelInstance =
Channel(client, channelType, channelId, name: newName);
expect(newChannelInstance.name, newName);
expect(newChannelInstance.extraData['name'], newName);
});
});
// TODO : test all persistence related logic in this group
@@ -192,6 +226,22 @@ void main() {
}
});
test('should throw if trying to set `image`', () {
try {
channel.image = 'https://stream.io/some-image';
} catch (e) {
expect(e, isA<StateError>());
}
});
test('should throw if trying to set `name`', () {
try {
channel.name = 'New name';
} catch (e) {
expect(e, isA<StateError>());
}
});
group('`.sendMessage`', () {
test('should work fine', () async {
final message = Message(id: 'test-message-id');
@@ -1192,6 +1242,62 @@ void main() {
message: any(named: 'message'))).called(1);
});
test('`.updateImage`', () async {
const image = 'https://getstream.io/new-image';
final channelModel = ChannelModel(
cid: channelCid,
extraData: {'image': image},
);
when(() => client.updateChannelPartial(
channelId,
channelType,
set: {'image': image},
)).thenAnswer(
(_) async => PartialUpdateChannelResponse()..channel = channelModel,
);
final res = await channel.updateImage(image);
expect(res, isNotNull);
expect(res.channel.extraData['image'], image);
verify(() => client.updateChannelPartial(
channelId,
channelType,
set: {'image': image},
)).called(1);
});
test('`.updateName`', () async {
const name = 'Name';
final channelModel = ChannelModel(
cid: channelCid,
extraData: {'name': name},
);
when(() => client.updateChannelPartial(
channelId,
channelType,
set: {'name': name},
)).thenAnswer(
(_) async => PartialUpdateChannelResponse()..channel = channelModel,
);
final res = await channel.updateName(name);
expect(res, isNotNull);
expect(res.channel.extraData['name'], name);
verify(() => client.updateChannelPartial(
channelId,
channelType,
set: {'name': name},
)).called(1);
});
test('`.updatePartial`', () async {
const set = {
'name': 'Stream Team',
@@ -1753,6 +1859,50 @@ void main() {
).called(1);
});
test('`.enableSlowMode`', () async {
const cooldown = 10;
final channelModel = ChannelModel(
cid: channelCid,
cooldown: cooldown,
);
when(() => client.enableSlowdown(
channelId,
channelType,
cooldown,
)).thenAnswer((_) async => PartialUpdateChannelResponse()
..channel = channelModel);
final res = await channel.enableSlowMode(cooldownInterval: 10);
expect(res, isNotNull);
verify(() => client.enableSlowdown(
channelId,
channelType,
cooldown,
)).called(1);
});
test('`.disableSlowMode`', () async {
final channelModel = ChannelModel(
cid: channelCid,
);
when(() => client.disableSlowdown(
channelId,
channelType,
)).thenAnswer((_) async => PartialUpdateChannelResponse()
..channel = channelModel);
final res = await channel.disableSlowMode();
expect(res, isNotNull);
verify(() => client.disableSlowdown(channelId, channelType)).called(1);
});
test('`.banUser`', () async {
const userId = 'test-user-id';
const options = {'key': 'value'};
@@ -550,14 +550,21 @@ void main() {
final path = '${_getChannelUrl(channelId, channelType)}/show';
when(() => client.post(path)).thenAnswer(
(_) async => successResponse(path, data: <String, dynamic>{}));
when(() => client.post(
path,
data: {},
))
.thenAnswer(
(_) async => successResponse(path, data: <String, dynamic>{}));
final res = await channelApi.showChannel(channelId, channelType);
expect(res, isNotNull);
verify(() => client.post(path)).called(1);
verify(() => client.post(
path,
data: {},
)).called(1);
verifyNoMoreInteractions(client);
});
@@ -605,4 +612,65 @@ void main() {
verify(() => client.post(path, data: {})).called(1);
verifyNoMoreInteractions(client);
});
test('enableSlowdown', () async {
const channelId = 'test-channel-id';
const channelType = 'test-channel-type';
const cooldown = 10;
const set = {
'cooldown': 10,
};
final path = _getChannelUrl(channelId, channelType);
final channelModel = ChannelModel(
id: channelId,
type: channelType,
extraData: set,
);
when(() => client.patch(path, data: {
'set': set,
})).thenAnswer((_) async => successResponse(path, data: {
'channel': channelModel.toJson(),
}));
final res =
await channelApi.enableSlowdown(channelId, channelType, cooldown);
expect(res, isNotNull);
verify(() => client.patch(path, data: {
'set': set,
})).called(1);
verifyNoMoreInteractions(client);
});
test('disableSlowdown', () async {
const channelId = 'test-channel-id';
const channelType = 'test-channel-type';
const unset = ['cooldown'];
final path = _getChannelUrl(channelId, channelType);
final channelModel = ChannelModel(
id: channelId,
type: channelType,
);
when(() => client.patch(path, data: {
'unset': unset,
})).thenAnswer((_) async => successResponse(path, data: {
'channel': channelModel.toJson(),
}));
final res = await channelApi.disableSlowdown(channelId, channelType);
expect(res, isNotNull);
verify(() => client.patch(path, data: {
'unset': unset,
})).called(1);
verifyNoMoreInteractions(client);
});
}
@@ -12,6 +12,7 @@ void main() {
expect(channel.cid, equals('livestream:test'));
expect(channel.extraData['cats'], equals(true));
expect(channel.extraData['fruit'], equals(['bananas', 'apples']));
expect(channel.cooldown, equals(0));
});
test('should serialize to json correctly', () {
@@ -24,7 +25,13 @@ void main() {
expect(
channel.toJson(),
{'id': 'id', 'type': 'type', 'frozen': false, 'name': 'cool'},
{
'id': 'id',
'type': 'type',
'frozen': false,
'cooldown': 0,
'name': 'cool',
},
);
});
@@ -38,7 +45,13 @@ void main() {
expect(
channel.toJson(),
{'id': 'id', 'type': 'type', 'name': 'cool', 'frozen': false},
{
'id': 'id',
'type': 'type',
'frozen': false,
'cooldown': 0,
'name': 'cool',
},
);
});
});
@@ -13,10 +13,11 @@ void main() {
expect(reaction.type, 'wow');
expect(
reaction.user?.toJson(),
User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const {
'image': 'https://randomuser.me/api/portraits/women/45.jpg',
'name': 'Daisy Morgan'
}).toJson(),
User(
id: '2de0297c-f3f2-489d-b930-ef77342edccf',
image: 'https://randomuser.me/api/portraits/women/45.jpg',
name: 'Daisy Morgan',
).toJson(),
);
expect(reaction.score, 1);
expect(reaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf');
@@ -28,11 +29,11 @@ void main() {
messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04',
createdAt: DateTime.parse('2020-01-28T22:17:31.108742Z'),
type: 'wow',
user:
User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const {
'image': 'https://randomuser.me/api/portraits/women/45.jpg',
'name': 'Daisy Morgan'
}),
user: User(
id: '2de0297c-f3f2-489d-b930-ef77342edccf',
image: 'https://randomuser.me/api/portraits/women/45.jpg',
name: 'Daisy Morgan',
),
userId: '2de0297c-f3f2-489d-b930-ef77342edccf',
extraData: {'bananas': 'yes'},
score: 1,
@@ -58,10 +59,11 @@ void main() {
expect(newReaction.type, 'wow');
expect(
newReaction.user?.toJson(),
User(id: '2de0297c-f3f2-489d-b930-ef77342edccf', extraData: const {
'image': 'https://randomuser.me/api/portraits/women/45.jpg',
'name': 'Daisy Morgan',
}).toJson(),
User(
id: '2de0297c-f3f2-489d-b930-ef77342edccf',
image: 'https://randomuser.me/api/portraits/women/45.jpg',
name: 'Daisy Morgan',
).toJson(),
);
expect(newReaction.score, 1);
expect(newReaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf');
+98 -55
View File
@@ -1,22 +1,43 @@
## Upcoming
## 2.2.1
- Updated `stream_chat_flutter_core` dependency to 2.2.1
## 2.2.0
✅ Added
- [#516](https://github.com/GetStream/stream-chat-flutter/issues/516): Added `StreamChatThemeData.placeholderUserImage` for
building a widget when the `UserAvatar` image is loading
- [#516](https://github.com/GetStream/stream-chat-flutter/issues/516):
Added `StreamChatThemeData.placeholderUserImage` for building a widget when the `UserAvatar` image
is loading
- Added a `backgroundColor` property to the following widgets:
- `ChannelHeader`
- `ChannelListHeader`
- `GalleryHeader`
- `GalleryFooter`
- `ThreadHeader`
- Added `MessageInput.attachmentLimit` in order to limit the no. of attachments that can be sent with a single message.
- Added `MessageInput.onAttachmentLimitExceed` callback which will be called when the `attachmentLimit` is exceeded.
This will override the default error alert behaviour.
- Added `MessageInput.attachmentButtonBuilder` and `MessageInput.commandButtonBuilder` for more customizations.
```dart
typedef ActionButtonBuilder = Widget Function(
BuildContext context,
IconButton defaultActionButton,
);
```
> **_NOTE:_** The last parameter is the default `ActionButton`
You can call `.copyWith` to customize just a subset of properties.
- Added slow mode which allows a cooldown period after a user sends a message.
🔄 Changed
Theming has been upgraded! Most theme classes now have `InheritedTheme` classes
associated with them, and have been upgraded with some goodies like `lerp` functions.
Here's the full naming breakdown:
Theming has been upgraded! Most theme classes now have `InheritedTheme` classes associated with
them, and have been upgraded with some goodies like `lerp` functions. Here's the full naming
breakdown:
* `AvatarTheme` is now `AvatarThemeData`
* `ChannelHeaderTheme` is now `ChannelHeaderThemeData`
* `ChannelListHeaderTheme` is now `ChannelListHeaderThemeData`
@@ -28,12 +49,23 @@ Here's the full naming breakdown:
* `MessageTheme` is now `MessageThemeData`
* `UserListViewTheme` is now `UserListViewThemeData`
- Updated core dependency.
🐞 Fixed
- Fixed `MessageInput` textField case where `input` is not enabled if the file picked from the
camera is null.
- Fixed date dividers position/alignment in non reversed `MessageListView`.
- Fixed `MessageListView` not opening to the right initialMessage if `StreamChannel.initialMessageId` is set.
- Fixed null check errors when accessing `message.text` in `MessageWidget` and `MessageListView`; this occurred when sending a message with no text.
## 2.1.2
🐞 Fixed
- [#590](https://github.com/GetStream/stream-chat-flutter/issues/590): livestream use case, no members when sending message
- [#590](https://github.com/GetStream/stream-chat-flutter/issues/590): livestream use case, no
members when sending message
## 2.1.1
- Updated core dependency
@@ -50,7 +82,8 @@ Here's the full naming breakdown:
🔄 Changed
- `StreamChat.of(context).user` is now deprecated in favor of `StreamChat.of(context).currentUser`.
- `StreamChat.of(context).userStream` is now deprecated in favor of `StreamChat.of(context).currentUserStream`.
- `StreamChat.of(context).userStream` is now deprecated in favor
of `StreamChat.of(context).currentUserStream`.
🐞 Fixed
@@ -64,17 +97,17 @@ Here's the full naming breakdown:
- Renamed `ChannelImage` to `ChannelAvatar`
- Updated `StreamChatThemeData.reactionIcons` to accept custom builder
- Renamed `ColorTheme` properties to reflect the purpose of the colors
- `ColorTheme.black` -> `ColorTheme.textHighEmphasis`
- `ColorTheme.grey` -> `ColorTheme.textLowEmphasis`
- `ColorTheme.greyGainsboro` -> `ColorTheme.disabled`
- `ColorTheme.greyWhisper` -> `ColorTheme.borders`
- `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg`
- `ColorTheme.whiteSnow` -> `ColorTheme.appBg`
- `ColorTheme.white` -> `ColorTheme.barsBg`
- `ColorTheme.blueAlice` -> `ColorTheme.linkBg`
- `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary`
- `ColorTheme.accentRed` -> `ColorTheme.accentError`
- `ColorTheme.accentGreen` -> `ColorTheme.accentInfo`
- `ColorTheme.black` -> `ColorTheme.textHighEmphasis`
- `ColorTheme.grey` -> `ColorTheme.textLowEmphasis`
- `ColorTheme.greyGainsboro` -> `ColorTheme.disabled`
- `ColorTheme.greyWhisper` -> `ColorTheme.borders`
- `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg`
- `ColorTheme.whiteSnow` -> `ColorTheme.appBg`
- `ColorTheme.white` -> `ColorTheme.barsBg`
- `ColorTheme.blueAlice` -> `ColorTheme.linkBg`
- `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary`
- `ColorTheme.accentRed` -> `ColorTheme.accentError`
- `ColorTheme.accentGreen` -> `ColorTheme.accentInfo`
- `ChannelListCore` options property is removed in favor of individual properties
- `options.state` -> bool state
@@ -95,7 +128,7 @@ typedef MessageBuilder = Widget Function(
);
```
the last parameter is the default `MessageWidget`
> **_NOTE:_** the last parameter is the default `MessageWidget`
You can call `.copyWith` to customize just a subset of properties
@@ -103,7 +136,8 @@ You can call `.copyWith` to customize just a subset of properties
- Added video compress options (frame and quality) to `MessageInput`
- TypingIndicator now has a property called `parentId` to show typing indicator specific to threads
- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView header/footer
- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView
header/footer
- `MessageWidget` accepts a `userAvatarBuilder`
- Added pinMessage ui support
- Added `MessageListView.threadSeparatorBuilder` property
@@ -112,10 +146,12 @@ You can call `.copyWith` to customize just a subset of properties
🐞 Fixed
- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text box when editing
message
- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator use case
- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without a reload
- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text
box when editing message
- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator
use case
- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without
a reload
- `MessageListView` not rendering if the user is not a member of the channel
- Fix `MessageInput` overflow when there are no actions
- Minor fixes and improvements
@@ -125,17 +161,17 @@ You can call `.copyWith` to customize just a subset of properties
🛑️ Breaking Changes from `2.0.0-nullsafety.8`
- Renamed `ColorTheme` properties to reflect the purpose of the colors
- `ColorTheme.black` -> `ColorTheme.textHighEmphasis`
- `ColorTheme.grey` -> `ColorTheme.textLowEmphasis`
- `ColorTheme.greyGainsboro` -> `ColorTheme.disabled`
- `ColorTheme.greyWhisper` -> `ColorTheme.borders`
- `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg`
- `ColorTheme.whiteSnow` -> `ColorTheme.appBg`
- `ColorTheme.white` -> `ColorTheme.barsBg`
- `ColorTheme.blueAlice` -> `ColorTheme.linkBg`
- `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary`
- `ColorTheme.accentRed` -> `ColorTheme.accentError`
- `ColorTheme.accentGreen` -> `ColorTheme.accentInfo`
- `ColorTheme.black` -> `ColorTheme.textHighEmphasis`
- `ColorTheme.grey` -> `ColorTheme.textLowEmphasis`
- `ColorTheme.greyGainsboro` -> `ColorTheme.disabled`
- `ColorTheme.greyWhisper` -> `ColorTheme.borders`
- `ColorTheme.whiteSmoke` -> `ColorTheme.inputBg`
- `ColorTheme.whiteSnow` -> `ColorTheme.appBg`
- `ColorTheme.white` -> `ColorTheme.barsBg`
- `ColorTheme.blueAlice` -> `ColorTheme.linkBg`
- `ColorTheme.accentBlue` -> `ColorTheme.accentPrimary`
- `ColorTheme.accentRed` -> `ColorTheme.accentError`
- `ColorTheme.accentGreen` -> `ColorTheme.accentInfo`
✅ Added
@@ -162,21 +198,24 @@ typedef MessageBuilder = Widget Function(
);
```
the last parameter is the default `MessageWidget`
You can call `.copyWith` to customize just a subset of properties
> **_NOTE:_** The last parameter is the default `MessageWidget`
You can call `.copyWith` to customize just a subset of properties.
✅ Added
- TypingIndicator now has a property called `parentId` to show typing indicator specific to threads
- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView header/footer
- [#493](https://github.com/GetStream/stream-chat-flutter/pull/493): add support for messageListView
header/footer
- `MessageWidget` accepts a `userAvatarBuilder`
🐞 Fixed
- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text box when editing
message
- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator use case
- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without a reload
- [#483](https://github.com/GetStream/stream-chat-flutter/issues/483): Keyboard covers input text
box when editing message
- Modals are shown using the nearest `Navigator` to make using the SDK easier in a nested navigator
use case
- [#484](https://github.com/GetStream/stream-chat-flutter/issues/484): messages don't update without
a reload
- `MessageListView` not rendering if the user is not a member of the channel
## 2.0.0-nullsafety.7
@@ -246,7 +285,8 @@ You can call `.copyWith` to customize just a subset of properties
- Show error messages as system and keep them in the message input
- Remove notification badge logic
- Use shimmer while loading images
- Polished `StreamChatTheme` adding more options and a new `MessageInputTheme` dedicated to `MessageInput`
- Polished `StreamChatTheme` adding more options and a new `MessageInputTheme` dedicated
to `MessageInput`
- Add possibility to specify custom message actions using `MessageWidget.customActions`
- Added `MessageListView.onAttachmentTap` callback
- Fixed message newline issue
@@ -303,7 +343,8 @@ You can call `.copyWith` to customize just a subset of properties
- Improved api documentation
- Updated `stream_chat` dependency to `^1.0.0-beta`
- Extracted sample app into dedicated [repo](https://github.com/GetStream/flutter-samples)
- Reimplemented existing widgets using [stream_chat_flutter_core](https://pub.dev/packages/stream_chat_flutter_core)
- Reimplemented existing widgets
using [stream_chat_flutter_core](https://pub.dev/packages/stream_chat_flutter_core)
## 0.2.21
@@ -320,8 +361,8 @@ You can call `.copyWith` to customize just a subset of properties
## 0.2.20+2
- Added `shouldAddChannel` to ChannelsBloc in order to check if a channel has to be added to the list when a new message
arrives
- Added `shouldAddChannel` to ChannelsBloc in order to check if a channel has to be added to the
list when a new message arrives
## 0.2.20+1
@@ -355,7 +396,8 @@ You can call `.copyWith` to customize just a subset of properties
## 0.2.16
- Do not wrap channel preview builder. Users will have to implement they're custom onTap/onLongPress implementation
- Do not wrap channel preview builder. Users will have to implement they're custom onTap/onLongPress
implementation
- Make public autofocus field of the TextField of message_input
## 0.2.15
@@ -540,10 +582,11 @@ You can call `.copyWith` to customize just a subset of properties
## 0.2.1-alpha+1
- Removed the additional `Navigator` in `StreamChat` widget. It was added to make the app have the `StreamChat` widget
as ancestor in every route. Now the recommended way to add `StreamChat` to your app is using the `builder` property of
your `MaterialApp` widget. Otherwise you can use it in the usual way, but you need to add a `StreamChat` widget to
every route of your app. Read [this issue](https://github.com/GetStream/stream-chat-flutter/issues/47) for more
- Removed the additional `Navigator` in `StreamChat` widget. It was added to make the app have
the `StreamChat` widget as ancestor in every route. Now the recommended way to add `StreamChat` to
your app is using the `builder` property of your `MaterialApp` widget. Otherwise you can use it in
the usual way, but you need to add a `StreamChat` widget to every route of your app.
Read [this issue](https://github.com/GetStream/stream-chat-flutter/issues/47) for more
information.
```dart
@@ -645,8 +688,8 @@ Widget build(BuildContext context) {
- Add gesture (vertical drag down) to close the keyboard
- Add keyboard type parameters (set it to TextInputType.text to show the submit button that will even close the
keyboard)
- Add keyboard type parameters (set it to TextInputType.text to show the submit button that will
even close the keyboard)
The property showVideoFullScreen was added mainly because of this issue brianegan/chewie#261
@@ -106,7 +106,7 @@ class ChannelPage extends StatelessWidget {
Expanded(
child: MessageListView(),
),
MessageInput(),
MessageInput(attachmentLimit: 3),
],
),
);
@@ -90,57 +90,54 @@ class ChannelAvatar extends StatelessWidget {
final colorTheme = chatThemeData.colorTheme;
final previewTheme = chatThemeData.channelPreviewTheme.avatarTheme;
return BetterStreamBuilder<Map<String, dynamic>>(
stream: channel.extraDataStream,
initialData: channel.extraData,
builder: (context, extraData) {
final channelImage = extraData['image'];
if (channelImage != null) {
Widget child = ClipRRect(
borderRadius: borderRadius ?? previewTheme?.borderRadius,
child: Container(
constraints: constraints ?? previewTheme?.constraints,
decoration: BoxDecoration(color: colorTheme.accentPrimary),
child: InkWell(
onTap: onTap,
child: CachedNetworkImage(
imageUrl: channelImage,
errorWidget: (_, __, ___) => Center(
child: Text(
extraData['name']?[0] ?? '',
style: TextStyle(
color: colorTheme.barsBg,
fontWeight: FontWeight.bold,
),
return BetterStreamBuilder<String>(
stream: channel.imageStream,
initialData: channel.image,
builder: (context, channelImage) {
Widget child = ClipRRect(
borderRadius: borderRadius ?? previewTheme?.borderRadius,
child: Container(
constraints: constraints ?? previewTheme?.constraints,
decoration: BoxDecoration(color: colorTheme.accentPrimary),
child: InkWell(
onTap: onTap,
child: CachedNetworkImage(
imageUrl: channelImage,
errorWidget: (_, __, ___) => Center(
child: Text(
channel.name?[0] ?? '',
style: TextStyle(
color: colorTheme.barsBg,
fontWeight: FontWeight.bold,
),
),
fit: BoxFit.cover,
),
fit: BoxFit.cover,
),
),
),
);
if (selected) {
child = ClipRRect(
key: const Key('selectedImage'),
borderRadius: BorderRadius.circular(selectionThickness) +
(borderRadius ??
previewTheme?.borderRadius ??
BorderRadius.zero),
child: Container(
constraints: constraints ?? previewTheme?.constraints,
color: selectionColor ?? colorTheme.accentPrimary,
child: Padding(
padding: EdgeInsets.all(selectionThickness),
child: child,
),
),
);
if (selected) {
child = ClipRRect(
key: const Key('selectedImage'),
borderRadius: BorderRadius.circular(selectionThickness) +
(borderRadius ??
previewTheme?.borderRadius ??
BorderRadius.zero),
child: Container(
constraints: constraints ?? previewTheme?.constraints,
color: selectionColor ?? colorTheme.accentPrimary,
child: Padding(
padding: EdgeInsets.all(selectionThickness),
child: child,
),
),
);
}
return child;
}
return child;
},
noDataBuilder: (context) {
final currentUser = streamChat.currentUser!;
final otherMembers = channel.state!.members
.where((it) => it.userId != currentUser.id)
@@ -584,11 +584,16 @@ class _ChannelListViewState extends State<ChannelListView> {
),
],
child: widget.channelPreviewBuilder?.call(context, channel) ??
ChannelPreview(
onLongPress: widget.onChannelLongPress,
channel: channel,
onImageTap: () => widget.onImageTap?.call(channel),
onTap: (channel) => onTap(channel, widget.channelWidget),
DecoratedBox(
decoration: BoxDecoration(
color: chatThemeData.channelListViewTheme.backgroundColor,
),
child: ChannelPreview(
onLongPress: widget.onChannelLongPress,
channel: channel,
onImageTap: () => widget.onImageTap?.call(channel),
onTap: (channel) => onTap(channel, widget.channelWidget),
),
),
),
);
@@ -27,40 +27,46 @@ class ChannelName extends StatelessWidget {
final client = StreamChat.of(context);
final channel = StreamChannel.of(context).channel;
return BetterStreamBuilder<Map<String, Object?>>(
stream: channel.extraDataStream,
initialData: channel.extraData,
builder: (context, data) => _buildName(
data,
channel.state?.members,
client,
assert(channel.state != null, 'Channel ${channel.id} is not initialized');
return BetterStreamBuilder<String>(
stream: channel.nameStream,
initialData: channel.name,
builder: (context, channelName) => Text(
channelName,
style: textStyle,
overflow: textOverflow,
),
noDataBuilder: (context) => _generateName(
client.currentUser!,
channel.state!.members,
),
);
}
Widget _buildName(
Map<String, dynamic> extraData,
List<Member>? members,
StreamChatState client,
Widget _generateName(
User currentUser,
List<Member> members,
) =>
LayoutBuilder(
builder: (context, constraints) {
var title = context.translations.noTitleText;
if (extraData['name'] != null) {
title = extraData['name'];
} else {
final otherMembers = members
?.where((member) => member.userId != client.currentUser!.id);
if (otherMembers?.length == 1) {
if (otherMembers!.first.user != null) {
title = otherMembers.first.user!.name;
var channelName = context.translations.noTitleText;
final otherMembers = members.where(
(member) => member.userId != currentUser.id,
);
if (otherMembers.isNotEmpty) {
if (otherMembers.length == 1) {
final user = otherMembers.first.user;
if (user != null) {
channelName = user.name;
}
} else if (otherMembers?.isNotEmpty == true) {
} else {
final maxWidth = constraints.maxWidth;
final maxChars = maxWidth / (textStyle?.fontSize ?? 1);
var currentChars = 0;
final currentMembers = <Member>[];
otherMembers!.forEach((element) {
otherMembers.forEach((element) {
final newLength =
currentChars + (element.user?.name.length ?? 0);
if (newLength < maxChars) {
@@ -71,13 +77,14 @@ class ChannelName extends StatelessWidget {
final exceedingMembers =
otherMembers.length - currentMembers.length;
title = '${currentMembers.map((e) => e.user?.name).join(', ')} '
channelName =
'${currentMembers.map((e) => e.user?.name).join(', ')} '
'${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}';
}
}
return Text(
title,
channelName,
style: textStyle,
overflow: textOverflow,
);
@@ -94,13 +94,13 @@ class ChannelPreview extends StatelessWidget {
textStyle: channelPreviewTheme.titleStyle,
),
),
BetterStreamBuilder<List<Member>?>(
BetterStreamBuilder<List<Member>>(
stream: channel.state?.membersStream,
initialData: channel.state?.members,
comparator: const ListEquality().equals,
builder: (context, members) {
if (members?.isEmpty == true ||
members?.any((Member e) =>
if (members.isEmpty ||
members.any((Member e) =>
e.user!.id ==
channel.client.state.currentUser?.id) !=
true) {
@@ -132,7 +132,7 @@ class ChannelPreview extends StatelessWidget {
message: lastMessage!,
size: channelPreviewTheme.indicatorIconSize,
isMessageRead: channel.state!.read
?.where((element) =>
.where((element) =>
element.user.id !=
channel
.client.state.currentUser!.id)
@@ -153,13 +153,10 @@ class ChannelPreview extends StatelessWidget {
));
}
Widget _buildDate(BuildContext context) => BetterStreamBuilder<DateTime?>(
Widget _buildDate(BuildContext context) => BetterStreamBuilder<DateTime>(
stream: channel.lastMessageAtStream,
initialData: channel.lastMessageAt,
builder: (context, data) {
if (data == null) {
return const Offstage();
}
final lastMessageAt = data.toLocal();
String stringDate;
@@ -213,12 +210,12 @@ class ChannelPreview extends StatelessWidget {
Widget _buildLastMessage(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: BetterStreamBuilder<List<Message>?>(
child: BetterStreamBuilder<List<Message>>(
stream: channel.state!.messagesStream,
initialData: channel.state!.messages,
builder: (context, data) {
final lastMessage = data
?.lastWhereOrNull((m) => m.shadowed != true && !m.isDeleted);
final lastMessage =
data.lastWhereOrNull((m) => !m.shadowed && !m.isDeleted);
if (lastMessage == null) {
return const SizedBox();
}
@@ -38,7 +38,7 @@ class ConnectionStatusBuilder extends StatelessWidget {
return BetterStreamBuilder<ConnectionStatus>(
initialData: client.wsConnectionStatus,
stream: stream,
loadingBuilder: loadingBuilder,
noDataBuilder: loadingBuilder,
errorBuilder: (context, error) {
if (errorBuilder != null) {
return errorBuilder!(context, error);
@@ -46,9 +46,9 @@ extension PlatformFileX on PlatformFile {
);
}
///
/// Extension on [InputDecoration]
extension InputDecorationX on InputDecoration {
///
/// Merges this [InputDecoration] with the [other]
InputDecoration merge(InputDecoration? other) {
if (other == null) return this;
return copyWith(
@@ -123,3 +123,50 @@ extension FlipBorder on BorderRadius {
bottomRight: bottomLeft)
: this;
}
/// Extension on [IconButton]
extension IconButtonX on IconButton {
/// Creates a copy of [IconButton] with specified attributes overridden.
IconButton copyWith({
double? iconSize,
VisualDensity? visualDensity,
EdgeInsetsGeometry? padding,
AlignmentGeometry? alignment,
double? splashRadius,
Color? color,
Color? focusColor,
Color? hoverColor,
Color? highlightColor,
Color? splashColor,
Color? disabledColor,
void Function()? onPressed,
MouseCursor? mouseCursor,
FocusNode? focusNode,
bool? autofocus,
String? tooltip,
bool? enableFeedback,
BoxConstraints? constraints,
Widget? icon,
}) =>
IconButton(
iconSize: iconSize ?? this.iconSize,
visualDensity: visualDensity ?? this.visualDensity,
padding: padding ?? this.padding,
alignment: alignment ?? this.alignment,
splashRadius: splashRadius ?? this.splashRadius,
color: color ?? this.color,
focusColor: focusColor ?? this.focusColor,
hoverColor: hoverColor ?? this.hoverColor,
highlightColor: highlightColor ?? this.highlightColor,
splashColor: splashColor ?? this.splashColor,
disabledColor: disabledColor ?? this.disabledColor,
onPressed: onPressed ?? this.onPressed,
mouseCursor: mouseCursor ?? this.mouseCursor,
focusNode: focusNode ?? this.focusNode,
autofocus: autofocus ?? this.autofocus,
tooltip: tooltip ?? this.tooltip,
enableFeedback: enableFeedback ?? this.enableFeedback,
constraints: constraints ?? this.constraints,
icon: icon ?? this.icon,
);
}
@@ -142,9 +142,9 @@ class _GalleryFooterState extends State<GalleryFooter> {
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Text(
'${widget.currentPage + 1} '
'${context.translations.ofText} '
'${widget.totalPages}',
context.translations.galleryPaginationText(
currentPage: widget.currentPage,
totalPages: widget.totalPages),
style: galleryFooterThemeData.titleTextStyle,
),
],
@@ -100,6 +100,9 @@ abstract class Translations {
/// The label for write a message in [MessageInput]
String get writeAMessageLabel;
/// The label for slow mode enabled in [MessageInput]
String get slowModeOnLabel;
/// The label for instant commands in [MessageInput]
String get instantCommandsLabel;
@@ -296,14 +299,19 @@ abstract class Translations {
/// The text shown for "You"
String get youText;
/// The text shown for "Of"
String get ofText;
/// Gallery footer pagination text
String galleryPaginationText(
{required int currentPage, required int totalPages});
/// The text shown for "File"
String get fileText;
/// The label for "Reply to message"
String get replyToMessageLabel;
/// Label for "Attachment limit exceeded:
/// it's not possible to add more than $limit attachments"
String attachmentLimitExceedError(int limit);
}
/// Default implementation of Translation strings for the stream chat widgets
@@ -657,11 +665,20 @@ class DefaultTranslations implements Translations {
String get youText => 'You';
@override
String get ofText => 'of';
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'${currentPage + 1} of $totalPages';
@override
String get fileText => 'File';
@override
String get replyToMessageLabel => 'Reply to Message';
@override
String get slowModeOnLabel => 'Slow mode ON';
@override
String attachmentLimitExceedError(int limit) => """
Attachment limit exceeded: it's not possible to add more than $limit attachments""";
}
@@ -37,6 +37,16 @@ typedef ErrorListener = void Function(
StackTrace? stackTrace,
);
/// A callback that can be passed to [MessageInput.onAttachmentLimitExceed].
///
/// This callback should not throw.
///
/// It exists merely for showing custom error, and should not be used otherwise.
typedef AttachmentLimitExceedListener = void Function(
int limit,
String error,
);
/// Builder for attachment thumbnails
typedef AttachmentThumbnailBuilder = Widget Function(
BuildContext,
@@ -50,6 +60,15 @@ typedef MentionTileBuilder = Widget Function(
Member member,
);
/// Widget builder for action button.
///
/// [defaultActionButton] is the default [IconButton] configuration,
/// use [defaultActionButton.copyWith] to easily customize it.
typedef ActionButtonBuilder = Widget Function(
BuildContext context,
IconButton defaultActionButton,
);
/// Location for actions on the [MessageInput]
enum ActionsLocation {
/// Align to left
@@ -164,7 +183,15 @@ class MessageInput extends StatefulWidget {
this.compressedVideoQuality = VideoQuality.DefaultQuality,
this.compressedVideoFrameRate = 30,
this.onError,
}) : super(key: key);
this.attachmentLimit = 10,
this.onAttachmentLimitExceed,
this.attachmentButtonBuilder,
this.commandButtonBuilder,
}) : assert(
initialMessage == null || editMessage == null,
"Can't provide both `initialMessage` and `editMessage`",
),
super(key: key);
/// Message to edit
final Message? editMessage;
@@ -247,6 +274,26 @@ class MessageInput extends StatefulWidget {
/// A callback for error reporting
final ErrorListener? onError;
/// A limit for the no. of attachments that can be sent with a single message.
final int attachmentLimit;
/// A callback for when the [attachmentLimit] is exceeded.
///
/// This will override the default error alert behaviour.
final AttachmentLimitExceedListener? onAttachmentLimitExceed;
/// Builder for customizing the attachment button.
///
/// The builder contains the default [IconButton] that can be customized by
/// calling `.copyWith`.
final ActionButtonBuilder? attachmentButtonBuilder;
/// Builder for customizing the command button.
///
/// The builder contains the default [IconButton] that can be customized by
/// calling `.copyWith`.
final ActionButtonBuilder? commandButtonBuilder;
@override
MessageInputState createState() => MessageInputState();
@@ -270,7 +317,6 @@ class MessageInputState extends State<MessageInput> {
final _imagePicker = ImagePicker();
late final FocusNode _focusNode;
bool _inputEnabled = true;
bool _messageIsPresent = false;
bool _commandEnabled = false;
OverlayEntry? _commandsOverlay, _mentionsOverlay, _emojiOverlay;
late Iterable<String> _emojiNames;
@@ -280,9 +326,8 @@ class MessageInputState extends State<MessageInput> {
bool _sendAsDm = false;
bool _openFilePickerSection = false;
int _filePickerIndex = 0;
double _filePickerSize = _kMinMediaPickerSize;
final KeyboardVisibilityController _keyboardVisibilityController =
KeyboardVisibilityController();
final _keyboardVisibilityController = KeyboardVisibilityController();
/// The editing controller passed to the input TextField
late final TextEditingController textEditingController;
@@ -292,6 +337,8 @@ class MessageInputState extends State<MessageInput> {
bool get _hasQuotedMessage => widget.quotedMessage != null;
bool get _messageIsPresent => textEditingController.text.trim().isNotEmpty;
@override
void initState() {
super.initState();
@@ -325,6 +372,33 @@ class MessageInputState extends State<MessageInput> {
});
}
int _timeOut = 0;
Timer? _slowModeTimer;
void _startSlowMode() {
final channel = StreamChannel.of(context).channel;
final cooldownStartedAt = channel.cooldownStartedAt;
if (cooldownStartedAt != null) {
final diff = DateTime.now().difference(cooldownStartedAt).inSeconds;
if (diff < channel.cooldown) {
_timeOut = channel.cooldown - diff;
if (_timeOut > 0) {
_slowModeTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_timeOut == 0) {
timer.cancel();
} else {
if (mounted) {
setState(() => _timeOut -= 1);
}
}
});
}
}
}
}
void _stopSlowMode() => _slowModeTimer?.cancel();
@override
Widget build(BuildContext context) {
Widget child = DecoratedBox(
@@ -403,11 +477,11 @@ class MessageInputState extends State<MessageInput> {
children: <Widget>[
if (!_commandEnabled &&
widget.actionsLocation == ActionsLocation.left)
_buildExpandActionsButton(),
_buildExpandActionsButton(context),
_buildTextInput(context),
if (!_commandEnabled &&
widget.actionsLocation == ActionsLocation.right)
_buildExpandActionsButton(),
_buildExpandActionsButton(context),
if (widget.sendButtonLocation == SendButtonLocation.outside)
_animateSendButton(context),
],
@@ -473,24 +547,27 @@ class MessageInputState extends State<MessageInput> {
);
Widget _animateSendButton(BuildContext context) {
final sendButton = widget.activeSendButton != null
? InkWell(
onTap: sendMessage,
child: widget.activeSendButton,
)
: _buildSendButton(context);
return AnimatedCrossFade(
crossFadeState: (_messageIsPresent || _attachments.isNotEmpty)
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild: sendButton,
secondChild: widget.idleSendButton ?? _buildIdleSendButton(context),
duration: _messageInputTheme.sendAnimationDuration!,
alignment: Alignment.center,
late Widget sendButton;
if (_timeOut > 0) {
sendButton = _CountdownButton(count: _timeOut);
} else if (!_messageIsPresent && _attachments.isEmpty) {
sendButton = widget.idleSendButton ?? _buildIdleSendButton(context);
} else {
sendButton = widget.activeSendButton != null
? InkWell(
onTap: sendMessage,
child: widget.activeSendButton,
)
: _buildSendButton(context);
}
return AnimatedSwitcher(
duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!,
child: sendButton,
);
}
Widget _buildExpandActionsButton() {
Widget _buildExpandActionsButton(BuildContext context) {
final channel = StreamChannel.of(context).channel;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -528,12 +605,13 @@ class MessageInputState extends State<MessageInput> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
if (!widget.disableAttachments) _buildAttachmentButton(),
if (!widget.disableAttachments)
_buildAttachmentButton(context),
if (widget.showCommandsButton &&
widget.editMessage == null &&
channel.state != null &&
channel.config?.commands.isNotEmpty == true)
_buildCommandButton(),
_buildCommandButton(context),
...widget.actions ?? [],
].insertBetween(const SizedBox(width: 8)),
),
@@ -548,7 +626,7 @@ class MessageInputState extends State<MessageInput> {
final margin = (widget.sendButtonLocation == SendButtonLocation.inside
? const EdgeInsets.only(right: 8)
: EdgeInsets.zero) +
(widget.actionsLocation != ActionsLocation.left
(widget.actionsLocation != ActionsLocation.left || _commandEnabled
? const EdgeInsets.only(left: 8)
: EdgeInsets.zero);
return Expanded(
@@ -669,9 +747,7 @@ class MessageInputState extends State<MessageInput> {
: (widget.actionsLocation == ActionsLocation.leftInside
? Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildExpandActionsButton(),
],
children: [_buildExpandActionsButton(context)],
)
: null),
suffixIconConstraints: const BoxConstraints.tightFor(height: 40),
@@ -697,7 +773,7 @@ class MessageInputState extends State<MessageInput> {
),
if (!_commandEnabled &&
widget.actionsLocation == ActionsLocation.rightInside)
_buildExpandActionsButton(),
_buildExpandActionsButton(context),
if (widget.sendButtonLocation == SendButtonLocation.inside)
_animateSendButton(context),
],
@@ -728,7 +804,6 @@ class MessageInputState extends State<MessageInput> {
.catchError((e) {});
setState(() {
_messageIsPresent = s.trim().isNotEmpty;
_actionsShrunk = s.trim().isNotEmpty &&
((widget.actions?.length ?? 0) +
(widget.showCommandsButton ? 1 : 0) +
@@ -759,6 +834,10 @@ class MessageInputState extends State<MessageInput> {
if (_attachments.isNotEmpty) {
return context.translations.addACommentOrSendLabel;
}
if (_timeOut != 0) {
return context.translations.slowModeOnLabel;
}
return context.translations.writeAMessageLabel;
}
@@ -817,7 +896,6 @@ class MessageInputState extends State<MessageInput> {
if (matchedCommandsList.length == 1) {
_chosenCommand = matchedCommandsList[0];
textEditingController.clear();
_messageIsPresent = false;
setState(() {
_commandEnabled = true;
});
@@ -964,6 +1042,9 @@ class MessageInputState extends State<MessageInput> {
final _attachmentContainsFile =
_attachments.values.any((it) => it.type == 'file');
final attachmentLimitCrossed =
_attachments.length >= widget.attachmentLimit;
Color _getIconColor(int index) {
final streamChatThemeData = _streamChatTheme;
switch (index) {
@@ -983,15 +1064,21 @@ class MessageInputState extends State<MessageInput> {
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.2));
case 2:
return _attachmentContainsFile && _attachments.isNotEmpty
return attachmentLimitCrossed
? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2)
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.5);
: _attachmentContainsFile && _attachments.isNotEmpty
? streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.2)
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.5);
case 3:
return _attachmentContainsFile && _attachments.isNotEmpty
return attachmentLimitCrossed
? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2)
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.5);
: _attachmentContainsFile && _attachments.isNotEmpty
? streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.2)
: streamChatThemeData.colorTheme.textHighEmphasis
.withOpacity(0.5);
default:
return Colors.black;
}
@@ -999,7 +1086,7 @@ class MessageInputState extends State<MessageInput> {
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: _openFilePickerSection ? _filePickerSize : 0,
height: _openFilePickerSection ? _kMinMediaPickerSize : 0,
child: Material(
color: _streamChatTheme.colorTheme.inputBg,
child: Column(
@@ -1034,10 +1121,11 @@ class MessageInputState extends State<MessageInput> {
icon: StreamSvgIcon.camera(
color: _getIconColor(2),
),
onPressed: _attachmentContainsFile && _attachments.isNotEmpty
onPressed: attachmentLimitCrossed ||
(_attachmentContainsFile && _attachments.isNotEmpty)
? null
: () {
pickFile(DefaultAttachmentTypes.image, true);
pickFile(DefaultAttachmentTypes.image, camera: true);
},
),
IconButton(
@@ -1045,46 +1133,32 @@ class MessageInputState extends State<MessageInput> {
icon: StreamSvgIcon.record(
color: _getIconColor(3),
),
onPressed: _attachmentContainsFile && _attachments.isNotEmpty
onPressed: attachmentLimitCrossed ||
(_attachmentContainsFile && _attachments.isNotEmpty)
? null
: () {
pickFile(DefaultAttachmentTypes.video, true);
pickFile(DefaultAttachmentTypes.video, camera: true);
},
),
],
),
GestureDetector(
onVerticalDragUpdate: (update) {
setState(() {
_filePickerSize = (_filePickerSize - update.delta.dy).clamp(
_kMinMediaPickerSize,
MediaQuery.of(context).size.height / 1.7,
);
});
},
child: DecoratedBox(
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
DecoratedBox(
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
child: SizedBox(
width: double.infinity,
child: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: SizedBox(
width: 40,
height: 4,
child: DecoratedBox(
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.inputBg,
borderRadius: BorderRadius.circular(4),
),
),
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.inputBg,
borderRadius: BorderRadius.circular(4),
),
),
),
@@ -1107,7 +1181,7 @@ class MessageInputState extends State<MessageInput> {
if (_attachments.containsKey(media.id)) {
setState(() => _attachments.remove(media.id));
} else {
_addAttachment(media);
_addAssetAttachment(media);
}
},
),
@@ -1119,15 +1193,13 @@ class MessageInputState extends State<MessageInput> {
);
}
void _addAttachment(AssetEntity medium) async {
void _addAssetAttachment(AssetEntity medium) async {
final mediaFile = await medium.originFile.timeout(
const Duration(seconds: 5),
onTimeout: () => medium.originFile,
);
if (mediaFile == null) {
return;
}
if (mediaFile == null) return;
var file = AttachmentFile(
path: mediaFile.path,
@@ -1166,11 +1238,12 @@ class MessageInputState extends State<MessageInput> {
}
setState(() {
_attachments[medium.id] = Attachment(
final attachment = Attachment(
id: medium.id,
file: file,
type: medium.type == AssetType.image ? 'image' : 'video',
);
_addAttachments([attachment]);
});
}
@@ -1491,7 +1564,6 @@ class MessageInputState extends State<MessageInput> {
setState(() {
_chosenCommand = c;
_commandEnabled = true;
_messageIsPresent = false;
});
_commandsOverlay?.remove();
_commandsOverlay = null;
@@ -1681,10 +1753,9 @@ class MessageInputState extends State<MessageInput> {
}
}
Widget _buildCommandButton() {
Widget _buildCommandButton(BuildContext context) {
final s = textEditingController.text.trim();
return IconButton(
final defaultButton = IconButton(
icon: StreamSvgIcon.lightning(
color: s.isNotEmpty
? _streamChatTheme.colorTheme.disabled
@@ -1700,10 +1771,7 @@ class MessageInputState extends State<MessageInput> {
splashRadius: 24,
onPressed: () async {
if (_openFilePickerSection) {
setState(() {
_openFilePickerSection = false;
_filePickerSize = _kMinMediaPickerSize;
});
setState(() => _openFilePickerSection = false);
await Future.delayed(const Duration(milliseconds: 300));
}
@@ -1722,38 +1790,43 @@ class MessageInputState extends State<MessageInput> {
}
},
);
return widget.commandButtonBuilder?.call(context, defaultButton) ??
defaultButton;
}
Widget _buildAttachmentButton() => IconButton(
icon: StreamSvgIcon.attach(
color: _openFilePickerSection
? _messageInputTheme.actionButtonColor
: _messageInputTheme.actionButtonIdleColor,
),
padding: const EdgeInsets.all(0),
constraints: const BoxConstraints.tightFor(
height: 24,
width: 24,
),
splashRadius: 24,
onPressed: () async {
_emojiOverlay?.remove();
_emojiOverlay = null;
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
Widget _buildAttachmentButton(BuildContext context) {
final defaultButton = IconButton(
icon: StreamSvgIcon.attach(
color: _openFilePickerSection
? _messageInputTheme.actionButtonColor
: _messageInputTheme.actionButtonIdleColor,
),
padding: const EdgeInsets.all(0),
constraints: const BoxConstraints.tightFor(
height: 24,
width: 24,
),
splashRadius: 24,
onPressed: () async {
_emojiOverlay?.remove();
_emojiOverlay = null;
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
if (_openFilePickerSection) {
setState(() {
_openFilePickerSection = false;
_filePickerSize = _kMinMediaPickerSize;
});
} else {
showAttachmentModal();
}
},
);
if (_openFilePickerSection) {
setState(() => _openFilePickerSection = false);
} else {
showAttachmentModal();
}
},
);
return widget.attachmentButtonBuilder?.call(context, defaultButton) ??
defaultButton;
}
/// Show the attachment modal, making the user choose where to
/// pick a media from
@@ -1768,87 +1841,91 @@ class MessageInputState extends State<MessageInput> {
});
} else {
showModalBottomSheet(
clipBehavior: Clip.hardEdge,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
clipBehavior: Clip.hardEdge,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(32),
topRight: Radius.circular(32),
),
context: context,
isScrollControlled: true,
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
title: Text(
context.translations.addAFileLabel,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.image),
title: Text(context.translations.uploadAPhotoLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.image);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.video_library),
title: Text(context.translations.uploadAVideoLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.video);
Navigator.pop(context);
},
),
if (!kIsWeb)
ListTile(
leading: const Icon(Icons.camera_alt),
title: Text(context.translations.photoFromCameraLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.image, true);
Navigator.pop(context);
},
),
if (!kIsWeb)
ListTile(
leading: const Icon(Icons.videocam),
title: Text(context.translations.videoFromCameraLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.video, true);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.insert_drive_file),
title: Text(context.translations.uploadAFileLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.file);
Navigator.pop(context);
},
),
],
));
),
context: context,
isScrollControlled: true,
builder: (_) => Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
title: Text(
context.translations.addAFileLabel,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
ListTile(
leading: const Icon(Icons.image),
title: Text(context.translations.uploadAPhotoLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.image);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.video_library),
title: Text(context.translations.uploadAVideoLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.video);
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.insert_drive_file),
title: Text(context.translations.uploadAFileLabel),
onTap: () {
pickFile(DefaultAttachmentTypes.file);
Navigator.pop(context);
},
),
],
),
);
}
}
/// Add an attachment to the sending message
/// Use this to add custom type attachments
///
/// Note: Only meant to be used from outside the state.
void addAttachment(Attachment attachment) {
setState(() {
_attachments[attachment.id] = attachment.copyWith(
uploadState: attachment.uploadState,
setState(() => _addAttachments([attachment]));
}
/// Adds an attachment to the [_attachments] map
void _addAttachments(Iterable<Attachment> attachments) {
final limit = widget.attachmentLimit;
final length = _attachments.length + attachments.length;
if (length > limit) {
final onAttachmentLimitExceed = widget.onAttachmentLimitExceed;
if (onAttachmentLimitExceed != null) {
return onAttachmentLimitExceed(
widget.attachmentLimit,
context.translations.attachmentLimitExceedError(limit),
);
}
return _showErrorAlert(
context.translations.attachmentLimitExceedError(limit),
);
});
}
for (final attachment in attachments) {
_attachments[attachment.id] = attachment;
}
}
/// Pick a file from the device
/// If [camera] is true then the camera will open
// ignore: avoid_positional_boolean_parameters
void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async {
void pickFile(
DefaultAttachmentTypes fileType, {
bool camera = false,
}) async {
setState(() => _inputEnabled = false);
AttachmentFile? file;
@@ -1869,15 +1946,14 @@ class MessageInputState extends State<MessageInput> {
} else if (fileType == DefaultAttachmentTypes.video) {
pickedFile = await _imagePicker.pickVideo(source: ImageSource.camera);
}
if (pickedFile == null) {
return;
if (pickedFile != null) {
final bytes = await pickedFile.readAsBytes();
file = AttachmentFile(
size: bytes.length,
path: pickedFile.path,
bytes: bytes,
);
}
final bytes = await pickedFile.readAsBytes();
file = AttachmentFile(
size: bytes.length,
path: pickedFile.path,
bytes: bytes,
);
} else {
late FileType type;
if (fileType == DefaultAttachmentTypes.image) {
@@ -1947,16 +2023,14 @@ class MessageInputState extends State<MessageInput> {
}
}
_attachments[attachment.id] = attachment;
setState(() {
_attachments.update(
attachment.id,
(it) => it.copyWith(
file: file,
extraData: {...it.extraData}
..update('file_size', ((_) => file!.size!)),
));
_addAttachments([
attachment.copyWith(
file: file,
extraData: {...attachment.extraData}
..update('file_size', ((_) => file!.size!)),
),
]);
});
}
@@ -2023,7 +2097,6 @@ class MessageInputState extends State<MessageInput> {
widget.onQuotedMessageCleared?.call();
setState(() {
_messageIsPresent = false;
_commandEnabled = false;
});
@@ -2087,6 +2160,7 @@ class MessageInputState extends State<MessageInput> {
if (resp.message?.type == 'error') {
_parseExistingMessage(message);
}
_startSlowMode();
widget.onMessageSent?.call(resp.message);
} catch (e, stk) {
if (widget.onError != null) {
@@ -2153,7 +2227,8 @@ class MessageInputState extends State<MessageInput> {
child: Text(
context.translations.okLabel,
style: _streamChatTheme.textTheme.bodyBold.copyWith(
color: _streamChatTheme.colorTheme.accentPrimary),
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
),
],
@@ -2164,13 +2239,9 @@ class MessageInputState extends State<MessageInput> {
}
void _parseExistingMessage(Message message) {
textEditingController.text = message.text!;
_messageIsPresent = true;
for (final attachment in message.attachments) {
_attachments[attachment.id] = attachment.copyWith(
uploadState: attachment.uploadState,
);
}
final messageText = message.text;
if (messageText != null) textEditingController.text = messageText;
_addAttachments(message.attachments);
}
@override
@@ -2179,6 +2250,8 @@ class MessageInputState extends State<MessageInput> {
_emojiOverlay?.remove();
_mentionsOverlay?.remove();
_keyboardListener?.cancel();
textEditingController.dispose();
_stopSlowMode();
super.dispose();
}
@@ -2188,7 +2261,10 @@ class MessageInputState extends State<MessageInput> {
void didChangeDependencies() {
_streamChatTheme = StreamChatTheme.of(context);
_messageInputTheme = MessageInputTheme.of(context);
if (widget.editMessage != null && !_initialized) {
if (widget.editMessage == null) _startSlowMode();
if ((widget.editMessage != null || widget.initialMessage != null) &&
!_initialized) {
FocusScope.of(context).requestFocus(_focusNode);
_initialized = true;
}
@@ -2196,54 +2272,6 @@ class MessageInputState extends State<MessageInput> {
}
}
/// Represents a 2-tuple, or pair.
class Tuple2<T1, T2> {
/// Creates a new tuple value with the specified items.
const Tuple2(this.item1, this.item2);
/// Create a new tuple value with the specified list [items].
factory Tuple2.fromList(List items) {
if (items.length != 2) {
throw ArgumentError('items must have length 2');
}
return Tuple2<T1, T2>(items[0] as T1, items[1] as T2);
}
/// Returns the first item of the tuple
final T1 item1;
/// Returns the second item of the tuple
final T2 item2;
/// Returns a tuple with the first item set to the specified value.
Tuple2<T1, T2> withItem1(T1 v) => Tuple2<T1, T2>(v, item2);
/// Returns a tuple with the second item set to the specified value.
Tuple2<T1, T2> withItem2(T2 v) => Tuple2<T1, T2>(item1, v);
/// Creates a [List] containing the items of this [Tuple2].
///
/// The elements are in item order. The list is variable-length
/// if [growable] is true.
List toList({bool growable = false}) =>
List.from([item1, item2], growable: growable);
@override
String toString() => '[$item1, $item2]';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Tuple2 &&
runtimeType == other.runtimeType &&
item1 == other.item1 &&
item2 == other.item2;
@override
int get hashCode => item1.hashCode ^ item2.hashCode;
}
class _PickerWidget extends StatefulWidget {
const _PickerWidget({
Key? key,
@@ -2281,74 +2309,101 @@ class _PickerWidgetState extends State<_PickerWidget> {
return const Offstage();
}
return FutureBuilder<bool>(
future: requestPermission,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
future: requestPermission,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.data!) {
if (widget.containsFile) {
return GestureDetector(
onTap: () {
widget.onAddMoreFilesClick(DefaultAttachmentTypes.file);
},
child: Container(
constraints: const BoxConstraints.expand(),
color: widget.streamChatTheme.colorTheme.inputBg,
alignment: Alignment.center,
if (snapshot.data!) {
if (widget.containsFile) {
return GestureDetector(
onTap: () {
widget.onAddMoreFilesClick(DefaultAttachmentTypes.file);
},
child: Container(
constraints: const BoxConstraints.expand(),
color: widget.streamChatTheme.colorTheme.inputBg,
alignment: Alignment.center,
child: Text(
context.translations.addMoreFilesLabel,
style: TextStyle(
color: widget.streamChatTheme.colorTheme.accentPrimary,
fontWeight: FontWeight.bold,
),
),
),
);
}
return MediaListView(
selectedIds: widget.selectedMedias,
onSelect: widget.onMediaSelected,
);
}
return InkWell(
onTap: () async {
PhotoManager.openSetting();
},
child: Container(
color: widget.streamChatTheme.colorTheme.inputBg,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SvgPicture.asset(
'svgs/icon_picture_empty_state.svg',
package: 'stream_chat_flutter',
height: 140,
color: widget.streamChatTheme.colorTheme.disabled,
),
Text(
context.translations.enablePhotoAndVideoAccessMessage,
style: widget.streamChatTheme.textTheme.body.copyWith(
color: widget.streamChatTheme.colorTheme.textLowEmphasis),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Center(
child: Text(
context.translations.addMoreFilesLabel,
style: TextStyle(
context.translations.allowGalleryAccessMessage,
style: widget.streamChatTheme.textTheme.bodyBold.copyWith(
color: widget.streamChatTheme.colorTheme.accentPrimary,
fontWeight: FontWeight.bold,
),
),
),
);
}
return MediaListView(
selectedIds: widget.selectedMedias,
onSelect: widget.onMediaSelected,
);
}
return InkWell(
onTap: () async {
PhotoManager.openSetting();
},
child: Container(
color: widget.streamChatTheme.colorTheme.inputBg,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SvgPicture.asset(
'svgs/icon_picture_empty_state.svg',
package: 'stream_chat_flutter',
height: 140,
color: widget.streamChatTheme.colorTheme.disabled,
),
Text(
context.translations.enablePhotoAndVideoAccessMessage,
style: widget.streamChatTheme.textTheme.body.copyWith(
color:
widget.streamChatTheme.colorTheme.textLowEmphasis),
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Center(
child: Text(
context.translations.allowGalleryAccessMessage,
style: widget.streamChatTheme.textTheme.bodyBold.copyWith(
color: widget.streamChatTheme.colorTheme.accentPrimary,
),
),
),
],
),
],
),
);
});
),
);
},
);
}
}
class _CountdownButton extends StatelessWidget {
const _CountdownButton({
Key? key,
required this.count,
}) : super(key: key);
final int count;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(8),
child: DecoratedBox(
decoration: BoxDecoration(
color: StreamChatTheme.of(context).colorTheme.disabled,
shape: BoxShape.circle,
),
child: SizedBox(
height: 24,
width: 24,
child: Center(
child: Text('$count'),
),
),
),
);
}
@@ -298,8 +298,9 @@ class _MessageListViewState extends State<MessageListView> {
StreamChannelState? streamChannel;
late StreamChatThemeData _streamTheme;
int? get _initialIndex {
if (widget.initialScrollIndex != null) return widget.initialScrollIndex;
int get _initialIndex {
final initialScrollIndex = widget.initialScrollIndex;
if (initialScrollIndex != null) return initialScrollIndex;
if (streamChannel!.initialMessageId != null) {
final messages = streamChannel!.channel.state!.messages;
final totalMessages = messages.length;
@@ -312,8 +313,9 @@ class _MessageListViewState extends State<MessageListView> {
return 0;
}
double? get _initialAlignment {
if (widget.initialAlignment != null) return widget.initialAlignment;
double get _initialAlignment {
final initialAlignment = widget.initialAlignment;
if (initialAlignment != null) return initialAlignment;
return 0;
}
@@ -326,8 +328,8 @@ class _MessageListViewState extends State<MessageListView> {
bool _topPaginationActive = false;
bool _bottomPaginationActive = false;
int? initialIndex;
double? initialAlignment;
int initialIndex = 0;
double initialAlignment = 0;
List<Message> messages = <Message>[];
@@ -454,10 +456,12 @@ class _MessageListViewState extends State<MessageListView> {
_inBetweenList = true;
},
child: ScrollablePositionedList.separated(
key: ValueKey(initialIndex! + initialAlignment!),
key: _upToDate
? null
: ValueKey(initialIndex + initialAlignment),
itemPositionsListener: _itemPositionListener,
initialScrollIndex: initialIndex ?? 0,
initialAlignment: initialAlignment ?? 0,
initialScrollIndex: initialIndex,
initialAlignment: initialAlignment,
physics: widget.scrollPhysics,
itemScrollController: _scrollController,
reverse: widget.reverse,
@@ -505,8 +509,14 @@ class _MessageListViewState extends State<MessageListView> {
if (i == 1 || i == itemCount - 4) return const Offstage();
final message = messages[i - 1];
final nextMessage = messages[i - 2];
late final Message message, nextMessage;
if (widget.reverse) {
message = messages[i - 1];
nextMessage = messages[i - 2];
} else {
message = messages[i - 2];
nextMessage = messages[i - 1];
}
if (!Jiffy(message.createdAt.toLocal()).isSame(
nextMessage.createdAt.toLocal(),
Units.DAY,
@@ -636,8 +646,7 @@ class _MessageListViewState extends State<MessageListView> {
}
Positioned _buildFloatingDateDivider(int itemCount) => Positioned(
top: widget.reverse ? 20 : null,
bottom: widget.reverse ? null : 20,
top: 20,
left: 0,
right: 0,
child: BetterStreamBuilder<Iterable<ItemPosition>>(
@@ -647,19 +656,36 @@ class _MessageListViewState extends State<MessageListView> {
if (a == null || b == null) {
return false;
}
final aTop = _getTopElementIndex(a);
final bTop = _getTopElementIndex(b);
return aTop == bTop;
if (widget.reverse) {
final aTop = _getTopElementIndex(a);
final bTop = _getTopElementIndex(b);
return aTop == bTop;
} else {
final aBottom = _getBottomElementIndex(a);
final bBottom = _getBottomElementIndex(b);
return aBottom == bBottom;
}
},
builder: (context, values) {
if (values.isEmpty || messages.isEmpty) {
return const Offstage();
}
final index = _getTopElementIndex(values);
int? index;
if (widget.reverse) {
index = _getTopElementIndex(values);
} else {
index = _getBottomElementIndex(values);
}
if (index == null || index <= 2 || index >= itemCount - 3) {
return const Offstage();
if (index == null) return const Offstage();
if (index <= 2 || index >= itemCount - 3) {
if (widget.reverse) {
index = itemCount - 4;
} else {
index = 2;
}
}
final message = messages[index - 2];
@@ -685,6 +711,15 @@ class _MessageListViewState extends State<MessageListView> {
.index;
}
int? _getBottomElementIndex(Iterable<ItemPosition> values) {
final inView = values.where((position) => position.itemLeadingEdge < 1);
if (inView.isEmpty) return null;
return inView
.reduce((min, position) =>
position.itemLeadingEdge < min.itemLeadingEdge ? position : min)
.index;
}
Widget _buildScrollToBottom() => StreamBuilder<Tuple2<bool, int>>(
stream: Rx.combineLatest2(
streamChannel!.channel.state!.isUpToDateStream.distinct(),
@@ -814,7 +849,7 @@ class _MessageListViewState extends State<MessageListView> {
) {
final isMyMessage =
message.user!.id == StreamChat.of(context).currentUser!.id;
final isOnlyEmoji = message.text!.isOnlyEmoji;
final isOnlyEmoji = message.text?.isOnlyEmoji ?? false;
final currentUser = StreamChat.of(context).currentUser;
final members = StreamChannel.of(context).channel.state?.members ?? [];
final currentUserMember =
@@ -915,7 +950,7 @@ class _MessageListViewState extends State<MessageListView> {
}
final channel = streamChannel!.channel;
final readList = channel.state?.read?.where((read) {
final readList = channel.state?.read.where((read) {
if (read.user.id == userId) return false;
return read.lastRead.isAfter(message.createdAt) ||
read.lastRead.isAtSameMomentAs(message.createdAt);
@@ -953,7 +988,7 @@ class _MessageListViewState extends State<MessageListView> {
final showInChannelIndicator = !_isThreadConversation && isThreadMessage;
final showThreadReplyIndicator = !_isThreadConversation && hasReplies;
final isOnlyEmoji = message.text!.isOnlyEmoji;
final isOnlyEmoji = message.text?.isOnlyEmoji ?? false;
final hasUrlAttachment =
message.attachments.any((it) => it.ogScrapeUrl != null) == true;
@@ -1163,6 +1198,13 @@ class _MessageListViewState extends State<MessageListView> {
initialIndex = _initialIndex;
initialAlignment = _initialAlignment;
WidgetsBinding.instance!.addPostFrameCallback((timeStamp) {
_scrollController?.jumpTo(
index: initialIndex,
alignment: initialAlignment,
);
});
_messageNewListener =
streamChannel!.channel.on(EventType.messageNew).listen((event) {
if (_upToDate) {
@@ -1203,8 +1245,7 @@ class _MessageListViewState extends State<MessageListView> {
MaterialPageRoute(
builder: (_) => BetterStreamBuilder<Message>(
stream: streamChannel!.channel.state!.messagesStream.map(
(messages) =>
messages!.firstWhere((m) => m.id == message.id)),
(messages) => messages.firstWhere((m) => m.id == message.id)),
initialData: message,
builder: (_, data) => StreamChannel(
channel: streamChannel!.channel,
@@ -1048,7 +1048,7 @@ class _MessageWidgetState extends State<MessageWidget>
messageWidget: widget.copyWith(
key: const Key('MessageWidget'),
message: widget.message.copyWith(
text: widget.message.text!.length > 200
text: (widget.message.text?.length ?? 0) > 200
? '${widget.message.text!.substring(0, 200)}...'
: widget.message.text,
),
@@ -1111,7 +1111,7 @@ class _MessageWidgetState extends State<MessageWidget>
messageWidget: widget.copyWith(
key: const Key('MessageWidget'),
message: widget.message.copyWith(
text: widget.message.text!.length > 200
text: (widget.message.text?.length ?? 0) > 200
? '${widget.message.text!.substring(0, 200)}...'
: widget.message.text,
),
@@ -1258,7 +1258,7 @@ class _MessageWidgetState extends State<MessageWidget>
);
Widget _buildTextBubble() {
if (widget.message.text!.trim().isEmpty) return const Offstage();
if (widget.message.text?.trim().isEmpty ?? false) return const Offstage();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -17,7 +17,7 @@ class UnreadIndicator extends StatelessWidget {
Widget build(BuildContext context) {
final client = StreamChat.of(context).client;
return IgnorePointer(
child: BetterStreamBuilder<int?>(
child: BetterStreamBuilder<int>(
stream: cid != null
? client.state.channels[cid]?.state?.unreadCountStream
: client.state.totalUnreadCountStream,
@@ -25,7 +25,7 @@ class UnreadIndicator extends StatelessWidget {
? client.state.channels[cid]?.state?.unreadCount
: client.state.totalUnreadCount,
builder: (context, data) {
if (data == null || data == 0) {
if (data == 0) {
return const Offstage();
}
return Material(
@@ -340,3 +340,51 @@ Widget wrapAttachmentWidget(
type: MaterialType.transparency,
child: attachmentWidget,
);
/// Represents a 2-tuple, or pair.
class Tuple2<T1, T2> {
/// Creates a new tuple value with the specified items.
const Tuple2(this.item1, this.item2);
/// Create a new tuple value with the specified list [items].
factory Tuple2.fromList(List items) {
if (items.length != 2) {
throw ArgumentError('items must have length 2');
}
return Tuple2<T1, T2>(items[0] as T1, items[1] as T2);
}
/// Returns the first item of the tuple
final T1 item1;
/// Returns the second item of the tuple
final T2 item2;
/// Returns a tuple with the first item set to the specified value.
Tuple2<T1, T2> withItem1(T1 v) => Tuple2<T1, T2>(v, item2);
/// Returns a tuple with the second item set to the specified value.
Tuple2<T1, T2> withItem2(T2 v) => Tuple2<T1, T2>(item1, v);
/// Creates a [List] containing the items of this [Tuple2].
///
/// The elements are in item order. The list is variable-length
/// if [growable] is true.
List toList({bool growable = false}) =>
List.from([item1, item2], growable: growable);
@override
String toString() => '[$item1, $item2]';
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Tuple2 &&
runtimeType == other.runtimeType &&
item1 == other.item1 &&
item2 == other.item2;
@override
int get hashCode => item1.hashCode ^ item2.hashCode;
}
@@ -12,6 +12,7 @@ export 'src/channel_preview.dart';
export 'src/connection_status_builder.dart';
export 'src/date_divider.dart';
export 'src/deleted_message.dart';
export 'src/extension.dart' show IconButtonX;
export 'src/full_screen_media.dart';
export 'src/gallery_footer.dart';
export 'src/gallery_header.dart';
+2 -2
View File
@@ -1,7 +1,7 @@
name: stream_chat_flutter
homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
version: 2.1.2
version: 2.2.1
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -37,7 +37,7 @@ dependencies:
scrollable_positioned_list: ^0.2.0-nullsafety.0
share_plus: ^2.0.3
shimmer: ^2.0.0
stream_chat_flutter_core: ^2.1.1
stream_chat_flutter_core: ^2.2.1
substring_highlight: ^1.0.26
synchronized: ^3.0.0
url_launcher: ^6.0.3
@@ -26,12 +26,11 @@ void main() {
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
when(() => channelState.unreadCount).thenReturn(1);
when(() => client.wsConnectionStatusStream)
.thenAnswer((_) => Stream.value(ConnectionStatus.connected));
@@ -91,12 +90,11 @@ void main() {
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
when(() => channelState.unreadCount).thenReturn(1);
when(() => channelState.unreadCountStream)
.thenAnswer((i) => Stream.value(1));
@@ -159,12 +157,11 @@ void main() {
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
when(() => channelState.unreadCount).thenReturn(1);
when(() => channelState.unreadCountStream)
.thenAnswer((i) => Stream.value(1));
@@ -305,12 +302,11 @@ void main() {
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
when(() => channelState.unreadCount).thenReturn(1);
when(() => channelState.unreadCountStream)
.thenAnswer((i) => Stream.value(1));
@@ -373,12 +369,11 @@ void main() {
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
when(() => channelState.unreadCount).thenReturn(1);
when(() => channelState.unreadCountStream)
.thenAnswer((i) => Stream.value(1));
@@ -20,14 +20,11 @@ void main() {
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
when(() => channel.state).thenReturn(channelState);
when(() => channel.client).thenReturn(client);
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
'image': 'imagetest',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
'image': 'imagetest',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
await tester.pumpWidget(MaterialApp(
home: StreamChat(
@@ -43,12 +40,12 @@ void main() {
final image =
tester.widget<CachedNetworkImage>(find.byType(CachedNetworkImage));
expect(image.imageUrl, 'imagetest');
expect(image.imageUrl, 'https://bit.ly/321RmWb');
},
);
testWidgets(
'it should show the the other member image',
'it should show the other member image',
(tester) async {
final client = MockClient();
final clientState = MockClientState();
@@ -59,12 +56,10 @@ void main() {
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
when(() => channel.state).thenReturn(channelState);
when(() => channel.client).thenReturn(client);
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream).thenAnswer((i) => Stream.value(null));
when(() => channel.image).thenReturn(null);
when(() => channelState.membersStream).thenAnswer((i) => Stream.value([
Member(
userId: 'user-id',
@@ -74,9 +69,7 @@ void main() {
userId: 'user-id2',
user: User(
id: 'user-id2',
extraData: const {
'image': 'testimage',
},
image: 'testimage',
),
)
]));
@@ -85,9 +78,7 @@ void main() {
userId: 'user-id2',
user: User(
id: 'user-id2',
extraData: const {
'image': 'testimage',
},
image: 'testimage',
),
),
Member(
@@ -98,9 +89,7 @@ void main() {
when(() => clientState.usersStream).thenAnswer((i) => Stream.value({
'user-id2': User(
id: 'user-id2',
extraData: const {
'image': 'testimage',
},
image: 'testimage',
),
}));
when(() => channel.extraData).thenReturn({
@@ -138,38 +127,29 @@ void main() {
when(() => clientState.currentUser).thenReturn(currentUser);
when(() => channel.state).thenReturn(channelState);
when(() => channel.client).thenReturn(client);
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream).thenAnswer((i) => Stream.value(null));
final members = [
Member(
userId: 'user-id',
user: User(
id: 'user-id',
extraData: const {
'image': 'testimage1',
},
image: 'testimage1',
),
),
Member(
userId: 'user-id2',
user: User(
id: 'user-id2',
extraData: const {
'image': 'testimage2',
},
image: 'testimage2',
),
),
Member(
userId: 'user-id3',
user: User(
id: 'user-id3',
extraData: const {
'image': 'testimage3',
},
image: 'testimage3',
),
),
];
@@ -210,14 +190,11 @@ void main() {
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
when(() => channel.state).thenReturn(channelState);
when(() => channel.client).thenReturn(client);
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
'image': 'imagetest',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
'image': 'imagetest',
});
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
await tester.pumpWidget(MaterialApp(
home: StreamChat(
@@ -21,17 +21,13 @@ void main() {
when(() => channel.state).thenReturn(channelState);
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false));
when(() => channel.nameStream).thenAnswer((_) => Stream.value('test'));
when(() => channel.name).thenReturn('test');
when(() => channelState.unreadCount).thenReturn(1);
when(() => channelState.unreadCountStream)
.thenAnswer((i) => Stream.value(1));
when(() => channelState.membersStream).thenAnswer((i) => Stream.value([
when(() => channelState.membersStream).thenAnswer((_) => Stream.value([
Member(
userId: 'user-id',
user: User(id: 'user-id'),
@@ -21,17 +21,19 @@ void main() {
when(() => clientState.currentUser).thenReturn(user);
when(() => clientState.currentUserStream)
.thenAnswer((_) => Stream.value(user));
when(() => channel.lastMessageAtStream)
.thenAnswer((_) => Stream.value(lastMessageAt));
when(() => channel.lastMessageAt).thenReturn(lastMessageAt);
when(() => channel.state).thenReturn(channelState);
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test name',
}));
when(() => channel.extraData).thenReturn({
'name': 'test name',
});
when(() => channel.nameStream)
.thenAnswer((i) => Stream.value('test name'));
when(() => channel.name).thenReturn('test name');
when(() => channel.imageStream)
.thenAnswer((i) => Stream.value('https://bit.ly/321RmWb'));
when(() => channel.image).thenReturn('https://bit.ly/321RmWb');
when(() => clientState.channels).thenReturn({
channel.cid!: channel,
});
@@ -167,7 +167,7 @@ void main() {
expect(translations.withText, isNotNull);
expect(translations.inText, isNotNull);
expect(translations.youText, isNotNull);
expect(translations.ofText, isNotNull);
expect(translations.galleryPaginationText, isNotNull);
expect(translations.fileText, isNotNull);
expect(translations.replyToMessageLabel, isNotNull);
});
@@ -69,4 +69,69 @@ void main() {
expect(find.byKey(const Key('messageInputText')), findsOneWidget);
},
);
testWidgets(
'checks message input slow mode',
(WidgetTester tester) async {
final client = MockClient();
final clientState = MockClientState();
final channel = MockChannel();
final channelState = MockChannelState();
final lastMessageAt = DateTime.parse('2020-06-22 12:00:00');
when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
when(() => channel.lastMessageAt).thenReturn(lastMessageAt);
when(() => channel.state).thenReturn(channelState);
when(() => channel.cooldown).thenReturn(10);
when(() => channel.cooldownStartedAt).thenReturn(DateTime.now());
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channelState.membersStream).thenAnswer((i) => Stream.value([
Member(
userId: 'user-id',
user: User(id: 'user-id'),
)
]));
when(() => channelState.members).thenReturn([
Member(
userId: 'user-id',
user: User(id: 'user-id'),
),
]);
when(() => channelState.messages).thenReturn([
Message(
text: 'hello',
user: User(id: 'other-user'),
)
]);
when(() => channelState.messagesStream).thenAnswer((i) => Stream.value([
Message(
text: 'hello',
user: User(id: 'other-user'),
)
]));
await tester.pumpWidget(MaterialApp(
home: StreamChat(
client: client,
child: StreamChannel(
channel: channel,
child: const Scaffold(
body: MessageInput(),
),
),
),
));
expect(find.text('Slow mode ON'), findsOneWidget);
},
);
}
@@ -22,12 +22,8 @@ void main() {
when(() => channel.client).thenReturn(client);
when(() => channel.isMuted).thenReturn(false);
when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false));
when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({
'name': 'test',
}));
when(() => channel.extraData).thenReturn({
'name': 'test',
});
when(() => channel.name).thenReturn('test');
when(() => channel.nameStream).thenAnswer((i) => Stream.value('test'));
when(() => channelState.unreadCount).thenReturn(1);
when(() => channelState.unreadCountStream)
.thenAnswer((i) => Stream.value(1));
@@ -1,3 +1,18 @@
## 2.2.1
- Updated `stream_chat` dependency to 2.2.1
## 2.2.0
🛑️ Breaking Changes from `2.1.1`
- Renamed `BetterStreamBuilder.loadingBuilder` to `.noDataBuilder`
🔄 Changed
- `BetterStreamBuilder.initialData` is now nullable/not-required.
🐞 Fixed
- [#612](https://github.com/GetStream/stream-chat-flutter/issues/612) `ChannelListView` pagination doesn't work after refresh
## 2.1.1
- Updated llc dependency
+1 -1
View File
@@ -26,7 +26,7 @@ It teaches you how to use this SDK and also shows how to make frequently require
## Example App
This repo includes a fully functional example app with setup instructions.
The example is available under the [example](https://github.com/GetStream/stream-chat-flutter-core/tree/master/example) folder.
The example is available under the [example](https://github.com/GetStream/stream-chat-flutter/tree/main/packages/stream_chat_flutter_core/example) folder.
## Add dependency
Add this to your package's pubspec.yaml file, use the latest version [![Pub](https://img.shields.io/pub/v/stream_chat_flutter_core.svg)](https://pub.dartlang.org/packages/stream_chat_flutter_core)
@@ -329,21 +329,9 @@ class _MessageScreenState extends State<MessageScreen> {
}
}
/// Extensions can be used to add functionality to the SDK. In the examples
/// below, we add two simple extensions to the [StreamChatClient] and [Channel].
/// Extensions can be used to add functionality to the SDK. In the example
/// below, we add a simple extensions to the [StreamChatClient].
extension on StreamChatClient {
/// Fetches the current user id.
String get uid => state.currentUser!.id;
}
extension on Channel {
/// Fetches the name of the channel by accessing [extraData] or [cid].
String? get name {
final _channelName = extraData['name'];
if (_channelName != null) {
return _channelName as String;
} else {
return cid;
}
}
}
@@ -6,23 +6,23 @@ import 'package:flutter/widgets.dart';
/// It requires [initialData] and will rebuild
/// only when the new data is different than the current data
/// The [comparator] is used to check if the new data is different
class BetterStreamBuilder<T> extends StatefulWidget {
class BetterStreamBuilder<T extends Object> extends StatefulWidget {
/// Creates a new BetterStreamBuilder
const BetterStreamBuilder({
required this.stream,
required this.initialData,
required this.builder,
this.loadingBuilder,
this.initialData,
this.noDataBuilder,
this.errorBuilder,
this.comparator,
Key? key,
}) : super(key: key);
/// The stream to listen to
final Stream<T>? stream;
final Stream<T?>? stream;
/// The initial data available
final T initialData;
final T? initialData;
/// Comparator used to check if the new data is different than the last one
final bool Function(T?, T?)? comparator;
@@ -31,7 +31,7 @@ class BetterStreamBuilder<T> extends StatefulWidget {
final Widget Function(BuildContext context, T data) builder;
/// Builder that builds when the data is null
final Widget Function(BuildContext context)? loadingBuilder;
final Widget Function(BuildContext context)? noDataBuilder;
/// Builder used when there is an error
final Widget Function(BuildContext context, Object error)? errorBuilder;
@@ -40,21 +40,26 @@ class BetterStreamBuilder<T> extends StatefulWidget {
_BetterStreamBuilderState createState() => _BetterStreamBuilderState<T>();
}
class _BetterStreamBuilderState<T> extends State<BetterStreamBuilder<T>> {
class _BetterStreamBuilderState<T extends Object>
extends State<BetterStreamBuilder<T>> {
T? _lastEvent;
StreamSubscription? _subscription;
StreamSubscription<T?>? _subscription;
Object? _lastError;
@override
Widget build(BuildContext context) {
if (_lastError != null) {
return widget.errorBuilder!(context, _lastError!);
final error = _lastError;
if (error != null) {
final errorBuilder = widget.errorBuilder;
if (errorBuilder != null) {
return errorBuilder(context, error);
}
}
if (_lastEvent == null) {
return widget.loadingBuilder?.call(context) ?? const Offstage();
final event = _lastEvent;
if (event == null) {
return widget.noDataBuilder?.call(context) ?? const Offstage();
}
return widget.builder(context, _lastEvent ?? widget.initialData);
return widget.builder(context, event);
}
@override
@@ -87,22 +92,22 @@ class _BetterStreamBuilderState<T> extends State<BetterStreamBuilder<T>> {
void _onError(error) {
if (widget.errorBuilder != null && error != _lastError) {
_lastError = error;
if (mounted) {
setState(() {});
}
_lastError = error;
}
}
void _onEvent(T event) {
void _onEvent(T? event) {
_lastError = null;
final isEqual =
widget.comparator?.call(_lastEvent, event) ?? event == _lastEvent;
if (!isEqual) {
_lastEvent = event;
if (mounted) {
setState(() {});
}
_lastEvent = event;
}
}
}
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/better_stream_builder.dart';
import 'package:stream_chat_flutter_core/src/channels_bloc.dart';
import 'package:stream_chat_flutter_core/src/stream_chat_core.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart';
@@ -137,19 +138,14 @@ class ChannelListCoreState extends State<ChannelListCore> {
@override
Widget build(BuildContext context) => _buildListView(_channelsBloc);
StreamBuilder<List<Channel>> _buildListView(
BetterStreamBuilder<List<Channel>> _buildListView(
ChannelsBlocState channelsBlocState,
) =>
StreamBuilder<List<Channel>>(
BetterStreamBuilder<List<Channel>>(
stream: channelsBlocState.channelsStream,
builder: (context, snapshot) {
if (snapshot.hasError) {
return widget.errorBuilder(context, snapshot.error!);
}
if (!snapshot.hasData) {
return widget.loadingBuilder(context);
}
final channels = snapshot.data!;
errorBuilder: widget.errorBuilder,
noDataBuilder: widget.loadingBuilder,
builder: (context, channels) {
if (channels.isEmpty) {
return widget.emptyBuilder(context);
}
@@ -106,6 +106,9 @@ class ChannelsBlocState extends State<ChannelsBloc>
final client = _streamChatCoreState!.client;
final clear = paginationParams.offset == 0;
if (clear && _paginationEnded) {
_paginationEnded = false;
}
if ((!clear && _paginationEnded) ||
_queryChannelsLoadingController.value == true) {
@@ -138,7 +138,7 @@ class MessageListCoreState extends State<MessageListCore> {
return true;
}
return BetterStreamBuilder<List<Message>?>(
return BetterStreamBuilder<List<Message>>(
initialData: initialData,
comparator: const ListEquality().equals,
stream: messagesStream!.map(
@@ -148,9 +148,9 @@ class MessageListCoreState extends State<MessageListCore> {
),
),
errorBuilder: widget.errorBuilder,
loadingBuilder: widget.loadingBuilder,
noDataBuilder: widget.loadingBuilder,
builder: (context, data) {
final messageList = data?.reversed.toList(growable: false) ?? [];
final messageList = data.reversed.toList(growable: false);
if (messageList.isEmpty && !_isThreadConversation) {
if (_upToDate) {
return widget.emptyBuilder(context);
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/better_stream_builder.dart';
import 'package:stream_chat_flutter_core/src/message_search_bloc.dart';
import 'package:stream_chat_flutter_core/src/typedef.dart';
@@ -132,16 +133,11 @@ class MessageSearchListCoreState extends State<MessageSearchListCore> {
Widget build(BuildContext context) => _buildListView(_messageSearchBloc!);
Widget _buildListView(MessageSearchBlocState messageSearchBloc) =>
StreamBuilder<List<GetMessageResponse>>(
BetterStreamBuilder<List<GetMessageResponse>>(
stream: messageSearchBloc.messagesStream,
builder: (context, snapshot) {
if (snapshot.hasError) {
return widget.errorBuilder(context, snapshot.error!);
}
if (!snapshot.hasData) {
return widget.loadingBuilder(context);
}
final items = snapshot.data!;
errorBuilder: widget.errorBuilder,
noDataBuilder: widget.loadingBuilder,
builder: (context, items) {
if (items.isEmpty) {
return widget.emptyBuilder(context);
}
@@ -176,16 +176,11 @@ class UserListCoreState extends State<UserListCore>
},
);
StreamBuilder<List<ListItem>> _buildListView() => StreamBuilder(
BetterStreamBuilder<List<ListItem>> _buildListView() => BetterStreamBuilder(
stream: _buildUserStream(),
builder: (context, snapshot) {
if (snapshot.hasError) {
return widget.errorBuilder(context, snapshot.error!);
}
if (!snapshot.hasData) {
return widget.loadingBuilder(context);
}
final items = snapshot.data!;
errorBuilder: widget.errorBuilder,
noDataBuilder: widget.loadingBuilder,
builder: (context, items) {
if (items.isEmpty) {
return widget.emptyBuilder(context);
}
@@ -1,7 +1,7 @@
name: stream_chat_flutter_core
homepage: https://github.com/GetStream/stream-chat-flutter
description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter.
version: 2.1.1
version: 2.2.1
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -16,7 +16,7 @@ dependencies:
sdk: flutter
meta: ^1.3.0
rxdart: ^0.27.0
stream_chat: ^2.1.1
stream_chat: ^2.2.1
dev_dependencies:
fake_async: ^1.2.0
@@ -558,6 +558,7 @@ void main() {
config: ChannelConfig(),
createdAt: DateTime.now(),
memberCount: 1,
cooldown: 0,
),
);
@@ -1,7 +1,27 @@
## Upcoming
* Added support for [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) locale.
🐞 Fixed
* Fixed typos in `Italian` translations.
## 1.1.0
✅ Added
* Added support for [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart) locale.
* Added support for [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart) locale.
* Added support for [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart) locale.
* Added translations for cooldown mode.
* Added translations for attachmentLimitExceed.
🔄 Changed
* Some of the `Hindi` translations have been updated/changed for better understanding.
- 'रिप्लाई' -> 'जवाब दें'
- 'तस्वीरें' -> 'फ़ोटोज'
- 'बिता हुआ कल' -> 'कल'
- 'चैनल मौन है' -> 'चैनल म्यूट है'
## 1.0.2
* Updated `stream_chat_flutter` dependency
@@ -35,6 +35,8 @@ At the moment we support the following languages:
- [Italian](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart)
- [French](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart)
- [Spanish](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart)
- [Japanese](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart)
- [Korean](https://github.com/GetStream/stream-chat-flutter/blob/master/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart)
More languages will be added in the future. Feel free to [contribute](https://github.com/GetStream/stream-chat-flutter/blob/master/CONTRIBUTING.md) to add more languages.
@@ -70,6 +72,8 @@ class MyApp extends StatelessWidget {
Locale('fr'),
Locale('it'),
Locale('es'),
Locale('ja'),
Locale('ko'),
],
// Add GlobalStreamChatLocalizations.delegates
localizationsDelegates: GlobalStreamChatLocalizations.delegates,
@@ -113,6 +117,8 @@ Example:
<string>fr</string>
<string>it</string>
<string>es</string>
<string>ja</string>
<string>ko</string>
</array>
```
@@ -374,13 +374,22 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations {
String get youText => 'You';
@override
String get ofText => 'of';
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'$currentPage of $totalPages';
@override
String get fileText => 'File';
@override
String get replyToMessageLabel => 'Reply to Message';
@override
String attachmentLimitExceedError(int limit) =>
'Attachment limit exceeded, limit: $limit';
@override
String get slowModeOnLabel => 'Slow mode ON';
}
void main() async {
@@ -453,6 +462,9 @@ class MyApp extends StatelessWidget {
Locale('hi'),
Locale('fr'),
Locale('it'),
Locale('es'),
Locale('ja'),
Locale('ko'),
// Add support for additional 'nn' locale
Locale('nn'),
],
@@ -72,6 +72,9 @@ class MyApp extends StatelessWidget {
Locale('hi'),
Locale('fr'),
Locale('it'),
Locale('es'),
Locale('ja'),
Locale('ko'),
],
// Add GlobalStreamChatLocalizations.delegates
localizationsDelegates: GlobalStreamChatLocalizations.delegates,
@@ -97,6 +97,9 @@ class MyApp extends StatelessWidget {
Locale('hi'),
Locale('fr'),
Locale('it'),
Locale('es'),
Locale('ja'),
Locale('ko'),
],
// Add overridden "CustomStreamChatLocalizationsEn.delegate" along with
// "GlobalStreamChatLocalizations.delegates"
@@ -11,6 +11,10 @@ part 'stream_chat_localizations_fr.dart';
part 'stream_chat_localizations_it.dart';
part 'stream_chat_localizations_ja.dart';
part 'stream_chat_localizations_ko.dart';
part 'stream_chat_localizations_hi.dart';
/// The set of supported languages, as language code strings.
@@ -27,6 +31,8 @@ const kStreamChatSupportedLanguages = {
'fr',
'it',
'es',
'ja',
'ko'
};
/// Creates a [GlobalStreamChatLocalizations] instance for the given `locale`.
@@ -59,6 +65,10 @@ GlobalStreamChatLocalizations? getStreamChatTranslation(Locale locale) {
return const StreamChatLocalizationsIt();
case 'es':
return const StreamChatLocalizationsEs();
case 'ja':
return const StreamChatLocalizationsJa();
case 'ko':
return const StreamChatLocalizationsKo();
}
}
@@ -350,11 +350,20 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations {
String get youText => 'You';
@override
String get ofText => 'of';
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'${currentPage + 1} of $totalPages';
@override
String get fileText => 'File';
@override
String get replyToMessageLabel => 'Reply to Message';
@override
String attachmentLimitExceedError(int limit) =>
'Attachment limit exceeded, limit: $limit';
@override
String get slowModeOnLabel => 'Slow mode ON';
}
@@ -355,11 +355,21 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations {
String get youText => 'Usted';
@override
String get ofText => 'de';
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'${currentPage + 1} de $totalPages';
@override
String get fileText => 'Archivo';
@override
String get replyToMessageLabel => 'Responder al Mensaje';
@override
String attachmentLimitExceedError(int limit) => '''
No es posible añadir más de $limit archivos adjuntos
''';
@override
String get slowModeOnLabel => 'Modo lento activado';
}
@@ -207,7 +207,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations {
@override
String togglePinUnpinText({required bool pinned}) {
if (pinned) return 'Décrocher de la conversation';
return 'Épingler à la discussion';
return 'Épingler à la conversation';
}
@override
@@ -354,11 +354,21 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations {
String get youText => 'Vous';
@override
String get ofText => 'de';
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'${currentPage + 1} de $totalPages';
@override
String get fileText => 'Fichier';
@override
String get replyToMessageLabel => 'Répondre au Message';
@override
String attachmentLimitExceedError(int limit) => '''
Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $limit pièces jointes
''';
@override
String get slowModeOnLabel => 'Mode lent activé';
}
@@ -197,7 +197,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations {
'कार्रवाई पूरी नहीं की जा सकी.';
@override
String get replyLabel => 'रिप्लाई';
String get replyLabel => 'जवाब दें';
@override
String togglePinUnpinText({required bool pinned}) {
@@ -224,7 +224,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations {
}
@override
String get photosLabel => 'तस्वीरें';
String get photosLabel => 'फ़ोटोज';
String _getDay(DateTime dateTime) {
final now = DateTime.now();
@@ -250,10 +250,10 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations {
String get todayLabel => 'आज';
@override
String get yesterdayLabel => 'बिता हुआ कल';
String get yesterdayLabel => 'कल';
@override
String get channelIsMutedText => 'चैनल मौन है';
String get channelIsMutedText => 'चैनल म्यूट है';
@override
String get noTitleText => 'कोई शीर्षक नहीं';
@@ -340,20 +340,30 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations {
String get sendLabel => 'भेजें';
@override
String get withText => 'विद';
String get withText => 'विद'; //TODO: break?
@override
String get inText => 'इन';
String get inText => 'इन'; //TODO: break?
@override
String get youText => 'आप';
@override
String get ofText => 'ऑफ़';
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'${currentPage + 1} ऑफ़ $totalPages';
@override
String get fileText => 'फ़ाइल';
@override
String get replyToMessageLabel => 'संदेश का जवाब';
@override
String attachmentLimitExceedError(int limit) => '''
िि: $limit ि
''';
@override
String get slowModeOnLabel => 'स्लो मोड चालू';
}
@@ -255,7 +255,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.''';
String get yesterdayLabel => 'Ieri';
@override
String get channelIsMutedText => 'Il canale è mutato';
String get channelIsMutedText => 'Il canale è silenziato';
@override
String get noTitleText => 'Nessun titolo';
@@ -274,7 +274,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.''';
String get loadingChannelsError => 'Errore durante il caricamento dei canali';
@override
String get deleteConversationLabel => 'Elemina conversazione';
String get deleteConversationLabel => 'Elimina conversazione';
@override
String get deleteConversationQuestion =>
@@ -351,11 +351,21 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.''';
String get youText => 'te';
@override
String get ofText => 'di';
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'${currentPage + 1} di $totalPages';
@override
String get fileText => 'file';
@override
String get replyToMessageLabel => 'Rispondi al messaggio';
@override
String attachmentLimitExceedError(int limit) => '''
Attenzione: il limite massimo di $limit file è stato superato.
''';
@override
String get slowModeOnLabel => 'Slowmode attiva';
}
@@ -0,0 +1,357 @@
part of 'stream_chat_localizations.dart';
/// The translations for Japanese (`ja`).
class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations {
/// Create an instance of the translation bundle for Japanese.
const StreamChatLocalizationsJa({String localeName = 'ja'})
: super(localeName: localeName);
@override
String get launchUrlError => 'URLの起動ができません';
@override
String get loadingUsersError => 'ユーザーの読み込みができません';
@override
String get noUsersLabel => '現在、ユーザーはいません。';
@override
String get retryLabel => '再試行';
@override
String get userLastOnlineText => '前回のオンライン';
@override
String get userOnlineText => 'オンライン';
@override
String userTypingText(Iterable<User> users) {
if (users.isEmpty) return '';
final first = users.first;
if (users.length == 1) {
return '${first.name}が入力しています';
}
return '${first.name}${users.length - 1}人が入力しています';
}
@override
String get threadReplyLabel => 'スレッド返信';
@override
String get onlyVisibleToYouText => '自分しか見れません';
@override
String threadReplyCountText(int count) => '$countつのスレッド返信';
@override
String attachmentsUploadProgressText({
required int remaining,
required int total,
}) =>
'$remaining/${total}mbのアップロード中 。。。';
@override
String pinnedByUserText({
required User pinnedBy,
required User currentUser,
}) {
final pinnedByCurrentUser = currentUser.id == pinnedBy.id;
if (pinnedByCurrentUser) return 'あなたのピン';
return '${pinnedBy.name}のピン';
}
@override
String get emptyMessagesText => '現在、メッセージはありません。';
@override
String get genericErrorText => 'エラーが発生しました';
@override
String get loadingMessagesError => 'メッセージの読み込みエラー';
@override
String resultCountText(int count) => '$count件の結果';
@override
String get messageDeletedText => 'このメッセージは削除されました。';
@override
String get messageDeletedLabel => 'メッセージ削除';
@override
String get messageReactionsLabel => 'メッセージのリアクション';
@override
String get emptyChatMessagesText => 'チャットがありませんが。。。';
@override
String threadSeparatorText(int replyCount) => '$replyCount件の返信';
@override
String get connectedLabel => '接続しています';
@override
String get disconnectedLabel => '接続切れ';
@override
String get reconnectingLabel => '再接続中。。。';
@override
String get alsoSendAsDirectMessageLabel => 'ダイレクトメッセージでも送信';
@override
String get addACommentOrSendLabel => 'コメントの追加や送信';
@override
String get searchGifLabel => 'GIFの検索';
@override
String get writeAMessageLabel => 'メッセージを書く';
@override
String get instantCommandsLabel => 'インスタントコマンド';
@override
String fileTooLargeAfterCompressionError(double limitInMB) =>
'ファイルのサイズが大きすぎてアップロードできません。'
'ファイルサイズの制限は${limitInMB}MBです。'
'圧縮してみましたが、十分ではありませんでした。';
@override
String fileTooLargeError(double limitInMB) =>
'ファイルが大きすぎてアップロードできません。ファイルサイズの制限は${limitInMB}MBです。';
@override
String emojiMatchingQueryText(String query) => '「"$query"」とお揃いの絵文字';
@override
String get addAFileLabel => 'ファイルの追加';
@override
String get photoFromCameraLabel => 'カメラからの写真';
@override
String get uploadAFileLabel => 'ファイルのアップロード';
@override
String get uploadAPhotoLabel => '写真のアップロード';
@override
String get uploadAVideoLabel => '動画のアップロード';
@override
String get videoFromCameraLabel => 'カメラからの動画';
@override
String get okLabel => 'よし';
@override
String get somethingWentWrongError => 'エラーが発生しました';
@override
String get addMoreFilesLabel => 'ファイルの追加';
@override
String get enablePhotoAndVideoAccessMessage => 'お友達と共有できるように、写真'
'\nやビデオへのアクセスを有効にしてください。';
@override
String get allowGalleryAccessMessage => 'ギャラリーへのアクセスを許可する';
@override
String get flagMessageLabel => 'メッセージをフラグする';
@override
String get flagMessageQuestion => 'このメッセージのコピーを'
'\nモデレーターに送って、さらに調査してもらいますか?';
@override
String get flagLabel => 'フラグする';
@override
String get cancelLabel => 'キャンセル';
@override
String get flagMessageSuccessfulLabel => 'メッセージにフラグが付けられました';
@override
String get flagMessageSuccessfulText => 'このメッセージはモデレーターに報告されました。';
@override
String get deleteLabel => '削除';
@override
String get deleteMessageLabel => 'メッセージを削除する ';
@override
String get deleteMessageQuestion => 'このメッセージ'
'\nを完全に削除してもよろしいですか?';
@override
String get operationCouldNotBeCompletedText => '操作を完了できませんでした。';
@override
String get replyLabel => '返信';
@override
String togglePinUnpinText({required bool pinned}) {
if (pinned) return '会話のピンを外す';
return '会話にピンする';
}
@override
String toggleDeleteRetryDeleteMessageText({required bool isDeleteFailed}) {
if (isDeleteFailed) return 'メッセージの削除を再試行する';
return 'メッセージを削除する';
}
@override
String get copyMessageLabel => 'メッセージをコピーする';
@override
String get editMessageLabel => 'メッセージを編集する';
@override
String toggleResendOrResendEditedMessage({required bool isUpdateFailed}) {
if (isUpdateFailed) return '編集したメッセージを再送する';
return '再送';
}
@override
String get photosLabel => '写真';
String _getDay(DateTime dateTime) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = DateTime(now.year, now.month, now.day - 1);
final date = DateTime(dateTime.year, dateTime.month, dateTime.day);
if (date == today) {
return '今日';
} else if (date == yesterday) {
return '昨日';
} else {
return '${Jiffy(date).MMMd}';
}
}
@override
String sentAtText({required DateTime date, required DateTime time}) =>
'${_getDay(date)}${Jiffy(time.toLocal()).format('HH:mm')}に送信しました ';
@override
String get todayLabel => '今日';
@override
String get yesterdayLabel => '昨日';
@override
String get channelIsMutedText => 'チャンネルが無音されています';
@override
String get noTitleText => 'タイトル無し';
@override
String get letsStartChattingLabel => 'チャットを始めよう!';
@override
String get sendingFirstMessageLabel => '友人に最初のメッセージを送りましょうか?';
@override
String get startAChatLabel => 'チャットを開始する';
@override
String get loadingChannelsError => 'チャネルのロード中にエラーが発生しました';
@override
String get deleteConversationLabel => '会話を削除する';
@override
String get deleteConversationQuestion => '本当に会話を削除しますか?';
@override
String get streamChatLabel => 'ストリームチャット';
@override
String get searchingForNetworkText => 'ネットワークを検索中';
@override
String get offlineLabel => 'オフライン。。。';
@override
String get tryAgainLabel => '再試行する';
@override
String membersCountText(int count) => '$count人のメンバー';
@override
String watchersCountText(int count) => '$count人がオンライン';
@override
String get viewInfoLabel => '情報を見る';
@override
String get leaveGroupLabel => 'グループを離れる';
@override
String get leaveLabel => '離れる';
@override
String get leaveConversationLabel => '会話を離れる';
@override
String get leaveConversationQuestion => '本当に会話を離れますか?';
@override
String get showInChatLabel => 'チャットで表示';
@override
String get saveImageLabel => '画像を保存';
@override
String get saveVideoLabel => 'ビデオを保存';
@override
String get uploadErrorLabel => 'アップロードエラー';
@override
String get giphyLabel => 'ギフィー';
@override
String get shuffleLabel => 'ミックス';
@override
String get sendLabel => '送信';
@override
String get withText => '';
@override
String get inText => '';
// This is the word for 'customer' or 'user' because saying 'you' directly
//is too informal and rude
@override
String get youText => 'あなた';
@override
String galleryPaginationText({
required int currentPage,
required int totalPages,
}) =>
'${currentPage + 1} / $totalPages';
@override
String get fileText => 'ファイル';
@override
String get replyToMessageLabel => 'メッセージに返信';
@override
String get slowModeOnLabel => 'スローモードオン';
@override
String attachmentLimitExceedError(int limit) => '''
$limit個のファイル以上を添付することはできません
''';
}
@@ -0,0 +1,354 @@
part of 'stream_chat_localizations.dart';
/// The translations for Korean (`ko`).
class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations {
/// Create an instance of the translation bundle for Korean.
const StreamChatLocalizationsKo({String localeName = 'ko'})
: super(localeName: localeName);
@override
String get launchUrlError => 'URL을 시작할 수 없습니다';
@override
String get loadingUsersError => '사용자를 로드하는 중 오류 발생';
@override
String get noUsersLabel => '현재 사용자가 없습니다';
@override
String get retryLabel => '다시 시도하십시오';
@override
String get userLastOnlineText => '마지막 온라인입니다';
@override
String get userOnlineText => '온라인';
@override
String userTypingText(Iterable<User> users) {
if (users.isEmpty) return '';
final first = users.first;
if (users.length == 1) {
return '${first.name} 타이핑중';
}
return '${first.name}하고 ${users.length - 1}명 타이핑중';
}
@override
String get threadReplyLabel => '스레드 응답입니다';
@override
String get onlyVisibleToYouText => '당신만 볼 수 있습니다';
@override
String threadReplyCountText(int count) => '$count스레드 답장';
@override
String attachmentsUploadProgressText({
required int remaining,
required int total,
}) =>
'$remaining/${total}mb를 업로드중...';
@override
String pinnedByUserText({
required User pinnedBy,
required User currentUser,
}) {
final pinnedByCurrentUser = currentUser.id == pinnedBy.id;
if (pinnedByCurrentUser) return '당신의 핀';
return '${pinnedBy.name}의 핀';
}
@override
String get emptyMessagesText => '현재 메시지가 없습니다';
@override
String get genericErrorText => '뭔가 잘못됐습니다';
@override
String get loadingMessagesError => '메시지를 로드하는 동안 오류가 발생했습니다';
@override
String resultCountText(int count) => '$count개의 결과';
@override
String get messageDeletedText => '이 메시지는 삭제되었습니다.';
@override
String get messageDeletedLabel => '메시지가 삭제되었습니다';
@override
String get messageReactionsLabel => '메시지에 대한 응답';
@override
String get emptyChatMessagesText => '아직 채팅이 없습니다...';
@override
String threadSeparatorText(int replyCount) => '$replyCount개의 답장';
@override
String get connectedLabel => '연결중';
@override
String get disconnectedLabel => '연결이 끊겼습니다';
@override
String get reconnectingLabel => '다시 연결하는 중...';
@override
String get alsoSendAsDirectMessageLabel => '다이렉트 메시지로도 보냅니다';
@override
String get addACommentOrSendLabel => '주석을 추가하거나 보냅니다';
@override
String get searchGifLabel => 'GIF 검색';
@override
String get writeAMessageLabel => '메시지 쓰기';
@override
String get instantCommandsLabel => '인스턴트 커맨즈';
@override
String fileTooLargeAfterCompressionError(double limitInMB) =>
'파일이 너무 커서 업로드할 수 없습니다. '
'파일 크기 제한은 ${limitInMB}MB입니다. '
'우리는 압축해 보았지만 충분하지 않았습니다.';
@override
String fileTooLargeError(double limitInMB) =>
'파일이 너무 커서 업로드할 수 없습니다. 파일 크기 제한은 ${limitInMB}MB입니다.';
@override
String emojiMatchingQueryText(String query) => '"$query"과 일치하는 이모티콘입니다';
@override
String get addAFileLabel => '파일을 추가함';
@override
String get photoFromCameraLabel => '카메라에서 찍은 사진';
@override
String get uploadAFileLabel => '파일을 업로드함';
@override
String get uploadAPhotoLabel => '사진을 업로드함';
@override
String get uploadAVideoLabel => '비디오를 업로드함';
@override
String get videoFromCameraLabel => '카메라의 비디오.';
@override
String get okLabel => '확인';
@override
String get somethingWentWrongError => '뭔가 잘못됐습느다';
@override
String get addMoreFilesLabel => '파일을 추가함';
@override
String get enablePhotoAndVideoAccessMessage => '친구와 공유할 수 있도록 사진과'
'\n동영상에 액세스할 수 있도록 설정하십시오.';
@override
String get allowGalleryAccessMessage => '갤러리에 대한 액세스를 허용합니다';
@override
String get flagMessageLabel => ' 메시지를 플래그함';
@override
String get flagMessageQuestion => '추가 조사를 위해 진행자에게 이 메시지의 복사본을 전송하시겠습니까?';
@override
String get flagLabel => '플래그함';
@override
String get cancelLabel => '취소';
@override
String get flagMessageSuccessfulLabel => '메시지에 플래그가 지정되었습니다';
@override
String get flagMessageSuccessfulText => '메시지가 진행자에게 보고되었습니다.';
@override
String get deleteLabel => '삭제';
@override
String get deleteMessageLabel => '메시지를 삭제합니다.';
@override
String get deleteMessageQuestion => '이 메시지를 완전히 삭제하시겠습니까?';
@override
String get operationCouldNotBeCompletedText => '작업을 완료할 수 없습니다.';
@override
String get replyLabel => '답글';
@override
String togglePinUnpinText({required bool pinned}) {
if (pinned) return '대화의 핀을 분리합니다';
return '대화에 고정합니다';
}
@override
String toggleDeleteRetryDeleteMessageText({required bool isDeleteFailed}) {
if (isDeleteFailed) return '메시지 삭제를 다시 시도합니다';
return '메시지를 삭제합니다';
}
@override
String get copyMessageLabel => '메시지를 복사합니다.';
@override
String get editMessageLabel => '메시지를 편집합니다.';
@override
String toggleResendOrResendEditedMessage({required bool isUpdateFailed}) {
if (isUpdateFailed) return '편집된 메시지를 다시 보냅니다.';
return '다시 보냅니다.';
}
@override
String get photosLabel => '사진';
String _getDay(DateTime dateTime) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = DateTime(now.year, now.month, now.day - 1);
final date = DateTime(dateTime.year, dateTime.month, dateTime.day);
if (date == today) {
return '오늘';
} else if (date == yesterday) {
return '어제';
} else {
return '${Jiffy(date).MMMd}';
}
}
@override
String sentAtText({required DateTime date, required DateTime time}) =>
'${_getDay(date)} ${Jiffy(time.toLocal()).format('HH:mm')}에 보냈습니다';
@override
String get todayLabel => '오늘';
@override
String get yesterdayLabel => '어제';
@override
String get channelIsMutedText => '채널이 음소거됩니다.';
@override
String get noTitleText => '제목이 없습니다.';
@override
String get letsStartChattingLabel => '채팅 시작해요!';
@override
String get sendingFirstMessageLabel => '친구에게 첫 메시지를 보내 볼까요?';
@override
String get startAChatLabel => '대화를 시작합니다.';
@override
String get loadingChannelsError => '채널을 로드하는 동안 오류가 발생했습니다.';
@override
String get deleteConversationLabel => '대화를 삭제합니다.';
@override
String get deleteConversationQuestion => '대화를 삭제하시겠습니까?';
@override
String get streamChatLabel => '스트림 채팅';
@override
String get searchingForNetworkText => '네트워크를 검색하는 중입니다.';
@override
String get offlineLabel => '오프라인...';
@override
String get tryAgainLabel => '다시 시도합니다';
@override
String membersCountText(int count) => '$count명';
@override
String watchersCountText(int count) => '$count명이 온라인';
@override
String get viewInfoLabel => '정보를 보기';
@override
String get leaveGroupLabel => '그룹을 떠납니다.';
@override
String get leaveLabel => '떠나다';
@override
String get leaveConversationLabel => '대화에서 떠납니다.';
@override
String get leaveConversationQuestion => '정말 이 대화에서 나가시겠습니까?';
@override
String get showInChatLabel => '채팅에 표시합니다.';
@override
String get saveImageLabel => '이미지를 저장합니다.';
@override
String get saveVideoLabel => '비디오를 저장합니다.';
@override
String get uploadErrorLabel => '업로드 오류';
@override
String get giphyLabel => '지피';
@override
String get shuffleLabel => '섞기';
@override
String get sendLabel => '보내기';
@override
String get withText => '함께';
@override
String get inText => '';
// This is the word for 'customer' or 'user' because saying 'you' directly
// is too informal and rude
@override
String get youText => '당신';
@override
String galleryPaginationText(
{required int currentPage, required int totalPages}) =>
'${currentPage + 1} / $totalPages';
//3 / 11
@override
String get fileText => '파일';
@override
String get replyToMessageLabel => '메시지에 회신합니다.';
@override
String get slowModeOnLabel => '슬로모드 켜짐';
@override
String attachmentLimitExceedError(int limit) =>
'첨부 파일 제한 초과: $limit 이상의 첨부 파일을 추가할 수 없습니다';
}
@@ -1,6 +1,6 @@
name: stream_chat_localizations
description: The Official localizations for Stream Chat Flutter, a service for building chat applications
version: 1.0.2
version: 1.1.0
homepage: https://github.com/GetStream/stream-chat-flutter
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -14,7 +14,7 @@ dependencies:
sdk: flutter
flutter_localizations:
sdk: flutter
stream_chat_flutter: ^2.1.1
stream_chat_flutter: ^2.2.0
dev_dependencies:
flutter_test:
@@ -174,9 +174,10 @@ void main() {
expect(localizations.withText, isNotNull);
expect(localizations.inText, isNotNull);
expect(localizations.youText, isNotNull);
expect(localizations.ofText, isNotNull);
expect(localizations.galleryPaginationText, isNotNull);
expect(localizations.fileText, isNotNull);
expect(localizations.replyToMessageLabel, isNotNull);
expect(localizations.attachmentLimitExceedError(3), isNotNull);
});
}
@@ -1,3 +1,9 @@
## 2.2.0
- Updated llc dependency
- Added support for message.i18n
- Added support for user.language
## 2.1.1
- Updated llc dependency
@@ -1,7 +1,7 @@
name: stream_chat_persistence
homepage: https://github.com/GetStream/stream-chat-flutter
description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter.
version: 2.1.1
version: 2.2.0
repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
@@ -18,7 +18,7 @@ dependencies:
path: ^1.8.0
path_provider: ^2.0.1
sqlite3_flutter_libs: ^0.5.0
stream_chat: ^2.1.1
stream_chat: ^2.2.0
dev_dependencies:
build_runner: ^2.0.1