diff --git a/.github/workflows/flutter-analyze.yml b/.github/workflows/flutter-analyze.yml deleted file mode 100644 index 312a65ba..00000000 --- a/.github/workflows/flutter-analyze.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: Flutter Analyze -on: - push: - branches: - - master - - pull_request: - branches: - - '*' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: subosito/flutter-action@v1 - with: - channel: 'beta' - - run: flutter pub get - - - name: 'Flutter Format Check' - run: flutter format --set-exit-if-changed --dry-run . - - - run: flutter analyze diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index bb72c2f2..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: CI - -on: - push: - branches: - - master - pull_request: - release: - types: - - created - -jobs: - test: - runs-on: [ubuntu-latest] - steps: - - uses: actions/checkout@v2 - - name: Flutter action - uses: subosito/flutter-action@v1.4.0 - with: - channel: 'stable' - - name: Get dependencies - run: flutter pub get - - name: Coverage fix - run: | - file=test/coverage_helper_test.dart - echo "// Helper file to make coverage work for all dart files\n" > $file - echo "// ignore_for_file: unused_import" >> $file - find lib -name '*.dart' | grep -e '[^g]\.dart' | grep -v '_html.dart' | cut -c4- | awk -v package=stream_chat_flutter '{printf "import '\''package:%s%s'\'';\n", package, $1}' >> $file - echo "" >> $file - echo "void main(){}" >> $file - cat $file - - name: Run tests - run: flutter test --coverage - - name: Codecov - run: bash <(curl -s https://codecov.io/bash) -c -t ${{ secrets.CODECOV_TOKEN }} -f coverage/lcov.info -F flutter_tool diff --git a/.github/workflows/scripts/install-flutter.sh b/.github/workflows/scripts/install-flutter.sh new file mode 100755 index 00000000..247d2797 --- /dev/null +++ b/.github/workflows/scripts/install-flutter.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +BRANCH=$1 + +if [ "$BRANCH" == "dev" ] +then + # TODO Flutter dev branch is currently broken so we're unable to test MacOS. + echo "TODO: Skipping macOS testing due to Flutter dev branch issue. Switching branch to stable." + BRANCH=stable +fi + +git clone https://github.com/flutter/flutter.git --depth 1 -b $BRANCH _flutter +echo "::add-path::$GITHUB_WORKSPACE/_flutter/bin" \ No newline at end of file diff --git a/.github/workflows/scripts/install-tools.sh b/.github/workflows/scripts/install-tools.sh new file mode 100755 index 00000000..1f77f519 --- /dev/null +++ b/.github/workflows/scripts/install-tools.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +flutter pub global activate melos 0.4.0+1 +echo "::add-path::$HOME/.pub-cache/bin" +echo "::add-path::$GITHUB_WORKSPACE/_flutter/.pub-cache/bin" +echo "::add-path::$GITHUB_WORKSPACE/_flutter/bin/cache/dart-sdk/bin" \ No newline at end of file diff --git a/.github/workflows/scripts/validate-formatting.sh b/.github/workflows/scripts/validate-formatting.sh new file mode 100755 index 00000000..27e33215 --- /dev/null +++ b/.github/workflows/scripts/validate-formatting.sh @@ -0,0 +1,30 @@ +#!/bin/bash +if [[ $(git ls-files --modified) ]]; then + echo "" + echo "" + echo "These files are not formatted correctly:" + for f in $(git ls-files --modified); do + echo "" + echo "" + echo "-----------------------------------------------------------------" + echo "$f" + echo "-----------------------------------------------------------------" + echo "" + git --no-pager diff --unified=0 --minimal $f + echo "" + echo "-----------------------------------------------------------------" + echo "" + echo "" + done + if [[ $GITHUB_WORKFLOW ]]; then + git checkout . > /dev/null 2>&1 + fi + echo "" + echo "❌ Some files are incorrectly formatted, see above output." + echo "" + echo "To fix these locally, run: 'melos run format'." + exit 1 +else + echo "" + echo "✅ All files are formatted correctly." +fi \ No newline at end of file diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml new file mode 100644 index 00000000..4c8882d7 --- /dev/null +++ b/.github/workflows/stream_flutter_workflow.yml @@ -0,0 +1,103 @@ +name: stream_flutter_workflow + +env: + ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' + +on: + pull_request: + push: + branches: + - master + paths-ignore: + - 'docs/**' + +jobs: + analyze: + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 0 + - name: 'Install Flutter' + run: ./.github/workflows/scripts/install-flutter.sh stable + - name: 'Install Tools' + run: | + ./.github/workflows/scripts/install-tools.sh + flutter pub global activate tuneup + - name: 'Bootstrap Workspace' + run: melos bootstrap + - name: 'Dart Analyze' + run: | + melos exec -c 3 -- \ + tuneup check + - name: 'Pub Check' + run: | + melos exec -c 1 --no-private --ignore="*example*" -- \ + pub publish --dry-run + format: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 0 + - name: 'Install Flutter' + run: ./.github/workflows/scripts/install-flutter.sh stable + - name: 'Install Tools' + run: | + ./.github/workflows/scripts/install-tools.sh + curl -sL https://github.com/google/google-java-format/releases/download/google-java-format-1.3/google-java-format-1.3-all-deps.jar -o $HOME/google-java-format.jar + - name: 'Bootstrap Workspace' + run: melos bootstrap + - name: 'Dart' + run: | + melos exec -c 1 -- \ + flutter format . + ./.github/workflows/scripts/validate-formatting.sh + - name: 'Objective-C' + if: ${{ success() || failure() }} + run: | + melos exec -c 4 --ignore="*platform_interface*" --ignore="*web*" -- \ + find . -maxdepth 3 -name "*.h" -o -name "*.m" -print0 \| xargs -0 clang-format -i --style=Google --verbose + ./.github/workflows/scripts/validate-formatting.sh + - name: 'Java' + if: ${{ success() || failure() }} + run: | + melos exec -c 4 --ignore="*platform_interface*" --ignore="*web*" -- \ + find . -maxdepth 12 -name "*.java" -print0 \| xargs -0 java -jar $HOME/google-java-format.jar --replace + ./.github/workflows/scripts/validate-formatting.sh + + test_dart: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 0 + - name: 'Install Flutter' + run: ./.github/workflows/scripts/install-flutter.sh stable + - name: 'Install Tools' + run: ./.github/workflows/scripts/install-tools.sh + - name: 'Bootstrap Workspace' + run: melos bootstrap + - name: 'Flutter Test' + run: cd packages/stream_chat && flutter pub run test + + test_flutter: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v1 + with: + fetch-depth: 0 + - name: 'Install Flutter' + run: ./.github/workflows/scripts/install-flutter.sh stable + - name: 'Install Tools' + run: ./.github/workflows/scripts/install-tools.sh + - name: 'Bootstrap Workspace' + run: melos bootstrap + - name: 'Flutter Test' + run: | + melos exec -c 3 --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ + flutter test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3a158cd4..f7a23bbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,64 +1,53 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp .DS_Store .atom/ -.buildlog/ -.history -.svn/ - -# IntelliJ related -*.iml -*.ipr -*.iws .idea/ +.vscode/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies .packages -.pub-cache/ .pub/ -/build/ -coverage/ -coverage_helper_test.dart - -# Web related -lib/generated_plugin_registrant.dart - -# Exceptions to above rules. -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages -# See https://www.dartlang.org/guides/libraries/private-files - -# Files and directories created by pub .dart_tool/ -.packages -build/ -# If you're building an application, you may want to check-in your pubspec.lock pubspec.lock +flutter_export_environment.sh -# Directory created by dartdoc -# If you don't generate documentation locally you can remove this line. -doc/api/ +examples/all_plugins/pubspec.yaml -# Avoid committing generated Javascript files: -*.dart.js -*.info.json # Produced by the --dump-info flag. -*.js # When generated by dart2js. Don't specify *.js if your - # project includes source files written in JavaScript. -*.js_ -*.js.deps -*.js.map +Podfile +Podfile.lock +Pods/ +.symlinks/ +**/Flutter/App.framework/ +**/Flutter/ephemeral/ +**/Flutter/Flutter.framework/ +**/Flutter/Generated.xcconfig +**/Flutter/flutter_assets/ -fvm -google-services.json -example/ios/dist \ No newline at end of file +ServiceDefinitions.json +xcuserdata/ +**/DerivedData/ + +local.properties +keystore.properties +.gradle/ +gradlew +gradlew.bat +gradle-wrapper.jar +.flutter-plugins-dependencies +*.iml + +generated_plugin_registrant.dart +GeneratedPluginRegistrant.h +GeneratedPluginRegistrant.m +GeneratedPluginRegistrant.java +GeneratedPluginRegistrant.swift +build/ +.flutter-plugins + +.project +.classpath +.settings +/.fvm + +.melos_tool/ +/packages/flutter_widgets/example/ios/Flutter/.last_build_id +/packages/dart_client/example/ios/Flutter/.last_build_id +/packages/dart_client/example/ios/Runner.xcodeproj/project.pbxproj diff --git a/README.md b/README.md index c5e4c9e7..816e96a1 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,59 @@ -# Official Flutter SDK for [Stream Chat](https://getstream.io/chat/) +# Stream Chat Dart -

- Flutter Chat -

+![](https://camo.githubusercontent.com/f5f074f3e1cde523ae0d425347149e20f861024d1d8e19b22053294ad85c43c8/68747470733a2f2f692e696d6775722e636f6d2f4c344d6a3853322e706e67) -> The official Flutter components for Stream Chat, a service for -> building chat applications. +This repository contains code for our [Dart](https://dart.dev/) and [Flutter](https://flutter.dev/) chat clients. -[![Pub](https://img.shields.io/pub/v/stream_chat_flutter.svg)](https://pub.dartlang.org/packages/stream_chat_flutter) -![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) -[![Gitter](https://badges.gitter.im/GetStream/stream-chat-flutter.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) -![CI](https://github.com/GetStream/stream-chat-flutter/workflows/CI/badge.svg?branch=master) - +Stream allows developers to rapidly deploy scalable feeds and chat messaging with an industry leading 99.999% uptime SLA guarantee. -**Quick Links** +## Structure +Stream Chat Dart is a monorepo built using [Melos](https://docs.page/invertase/melos). Individual packages can be found in the `packages` directory while configuration and top level commands can be found in `melos.yaml`. -- [Register](https://getstream.io/chat/trial/) to get an API key for Stream Chat -- [Flutter Chat Tutorial](https://getstream.io/chat/flutter/tutorial/) -- [Chat UI Kit](https://getstream.io/chat/ui-kit/) +To get started, run `bootstrap` after cloning the project. -## Flutter Chat Tutorial - -The best place to start is the [Flutter Chat Tutorial](https://getstream.io/chat/flutter/tutorial/). -It teaches you how to use this SDK and also shows how to make frequently required changes. - -## 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/tree/master/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.svg)](https://pub.dartlang.org/packages/stream_chat_flutter) -```yaml -dependencies: - stream_chat_flutter: ^latest_version +```shell +melos bootstrap ``` -You should then run `flutter packages get` +## Available Commands +### Analyze +> Requires `tuneup` to be activated globally. Please see https://pub.dev/packages/tuneup +```shell +melos run analyze +``` -### Android +### Pub Lint +Runs pub publish with ``--dry-run`` +```shell +melos run lint:pub +``` -All set ✅ +### Build iOS +Builds iOS examples without codesign +```shell +melos run build:examples:ios +``` -### iOS +### Build APK +Builds an Android APK for examples +```shell +melos run build:examples:android +``` -The library uses [flutter file picker plugin](https://github.com/miguelpruivo/flutter_file_picker) to pick -files from the os. -Follow [this wiki](https://github.com/miguelpruivo/flutter_file_picker/wiki/Setup#ios) to fulfill iOS requirements. +### Build MACOS +Builds MacOs for all examples +```shell +melos run build:examples:macos +``` -We also use [video_player](https://pub.dev/packages/video_player) to reproduce videos. Follow [this guide](https://pub.dev/packages/video_player#installation) to fulfill the requirements. +### Test +Runs `flutter test` on all packages +```shell +melos run test +``` -To pick images from the camera, we use the [image_picker](https://pub.dev/packages/image_picker) plugin. -Follow [these instructions](https://pub.dev/packages/image_picker#ios) to check the requirements. - -### Troubleshooting - -It may happen that you have some problems building the app. -If it seems related to the [flutter file picker plugin](https://github.com/miguelpruivo/flutter_file_picker) make sure to check [this page](https://github.com/miguelpruivo/flutter_file_picker/wiki/Troubleshooting) - -## Docs - -### Business logic components - -We provide 3 Widgets dedicated to business logic and state management: - -- [StreamChat](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChat-class.html) -- [StreamChannel](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChannel-class.html) -- [ChannelsBloc](https://pub.dev/documentation/stream_chat_flutter/0.2.0-alpha+2/stream_chat_flutter/ChannelsBloc-class.html) - -### UI Components - -These are the available Widgets that you can use to build your application UI. -Every widget uses the `StreamChat` or `StreamChannel` widgets to manage the state and communicate with Stream services. - -- [ChannelHeader](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelHeader-class.html) -- [ChannelImage](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelImage-class.html) -- [ChannelListView](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelListView-class.html) -- [ChannelName](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelName-class.html) -- [ChannelPreview](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelPreview-class.html) -- [MessageInput](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageInput-class.html) -- [MessageListView](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageListView-class.html) -- [MessageWidget](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageWidget-class.html) -- [StreamChatTheme](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChatTheme-class.html) -- [ThreadHeader](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ThreadHeader-class.html) -- ... - -### Customizing styles - -The Flutter SDK comes with a fully designed set of widgets that you can customize to fit with your application style and typography. -Changing the theme of Chat widgets works in a very similar way that `MaterialApp` and `Theme` do. - -Out of the box, all chat widgets use their default styling, and there are two ways to change the styling: - - 1. Initialize the `StreamChatTheme` from your existing `MaterialApp` style - ```dart - class MyApp extends StatelessWidget { - final Client client; - - MyApp(this.client); - - @override - Widget build(BuildContext context) { - final theme = ThemeData( - primarySwatch: Colors.green, - ); - - return MaterialApp( - theme: theme, - builder: (context, child) => StreamChat( - child: child, - client: client, - streamChatThemeData: StreamChatThemeData.fromTheme(theme), - ), - home: ChannelListPage(), - ); - } - } - ``` - - 2. Construct a custom theme and provide all the customizations needed - ```dart - class MyApp extends StatelessWidget { - final Client client; - - MyApp(this.client); - - @override - Widget build(BuildContext context) { - final theme = ThemeData( - primarySwatch: Colors.green, - ); - - return MaterialApp( - theme: theme, - builder: (context, child) => StreamChat( - child: child, - client: client, - streamChatThemeData: StreamChatThemeData.fromTheme(theme).copyWith( - ownMessageTheme: MessageTheme( - messageBackgroundColor: Colors.black, - messageText: TextStyle( - color: Colors.white, - ), - avatarTheme: AvatarTheme( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - home: ChannelListPage(), - ); - } - } - ``` - -### Offline storage - -By default the library saves information about channels and messages in a SQLite DB. - -Set the property `persistenceEnabled` to false if you don't want to use the offline storage. - -## Contributing - -We welcome code changes that improve this library or fix a problem, -please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. -We are pleased to merge your code into the official repository. -Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. -See our license file for more details. +### Test Web +Runs `flutter test --platform=chrome` on all packages +```shell +melos run test:web +``` diff --git a/analysis_options.yaml b/analysis_options.yaml index 3723c0af..3ba5d599 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,61 +1,13 @@ -include: package:pedantic/analysis_options.yaml +include: package:pedantic/analysis_options.1.9.0.yaml analyzer: exclude: - - lib/**/*.g.dart - - example/** + # Ignore generated files + - '**/*.g.dart' + - 'lib/src/generated/*.dart' linter: rules: - # these rules are documented on and in the same order as - # the Dart Lint rules page to make maintenance easier - # https://github.com/dart-lang/linter/blob/master/example/all.yaml - # - always_declare_return_types - # - always_specify_types - # - annotate_overrides - # - avoid_as - - avoid_empty_else - - avoid_init_to_null - - avoid_return_types_on_setters - - avoid_web_libraries_in_flutter - - await_only_futures - - camel_case_types - - cancel_subscriptions - - close_sinks - # - comment_references # we do not presume as to what people want to reference in their dartdocs - # - constant_identifier_names # https://github.com/dart-lang/linter/issues/204 - - control_flow_in_finally - - empty_constructor_bodies - - empty_statements - - hash_and_equals - - implementation_imports - # - invariant_booleans - # - iterable_contains_unrelated_type - - library_names - # - library_prefixes - # - list_remove_unrelated_type - # - literal_only_boolean_expressions - - non_constant_identifier_names - # - one_member_abstracts - # - only_throw_errors - # - overridden_fields -# - package_api_docs - - package_names - - package_prefixed_library_names - - prefer_is_not_empty - # - prefer_mixin # https://github.com/dart-lang/language/issues/32 - - public_member_api_docs - - slash_for_doc_comments - # - sort_constructors_first - # - sort_unnamed_constructors_first - # - super_goes_last # no longer needed w/ Dart 2 - - test_types_in_equals - - throw_in_finally - # - type_annotate_public_apis # subset of always_specify_types - - type_init_formals - # - unawaited_futures - - unnecessary_brace_in_string_interps - - unnecessary_getters_setters - - unnecessary_statements - - unrelated_type_equality_checks - - valid_regexps + public_member_api_docs: true + prefer_final_in_for_each: true + prefer_final_locals: true \ No newline at end of file diff --git a/example/android/.gitignore b/example/android/.gitignore deleted file mode 100644 index bc2100d8..00000000 --- a/example/android/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java diff --git a/example/android/app/src/main/kotlin/com/example/example/Application.kt b/example/android/app/src/main/kotlin/com/example/example/Application.kt deleted file mode 100644 index dd47a03c..00000000 --- a/example/android/app/src/main/kotlin/com/example/example/Application.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.example.example - -import com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin -import io.flutter.app.FlutterApplication -import io.flutter.plugin.common.PluginRegistry -import io.flutter.plugin.common.PluginRegistry.PluginRegistrantCallback -import io.flutter.plugins.firebasemessaging.FirebaseMessagingPlugin -import io.flutter.plugins.firebasemessaging.FlutterFirebaseMessagingService -import io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin -import io.flutter.plugins.pathprovider.PathProviderPlugin - -class Application : FlutterApplication(), PluginRegistrantCallback { - override fun onCreate() { - super.onCreate() - FlutterFirebaseMessagingService.setPluginRegistrant(this) - } - - override fun registerWith(registry: PluginRegistry?) { - PathProviderPlugin.registerWith(registry?.registrarFor( - "io.flutter.plugins.pathprovider.PathProviderPlugin")) - SharedPreferencesPlugin.registerWith(registry?.registrarFor( - "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")) - FlutterLocalNotificationsPlugin.registerWith(registry?.registrarFor( - "com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin")) - FirebaseMessagingPlugin.registerWith(registry?.registrarFor("io.flutter.plugins.firebasemessaging.FirebaseMessagingPlugin")) - } -} \ No newline at end of file diff --git a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt deleted file mode 100644 index 1656503f..00000000 --- a/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.example.example - -import androidx.annotation.NonNull; -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugins.GeneratedPluginRegistrant - -class MainActivity: FlutterActivity() { - override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { - GeneratedPluginRegistrant.registerWith(flutterEngine); - } -} diff --git a/example/android/app/src/main/res/values/styles.xml b/example/android/app/src/main/res/values/styles.xml deleted file mode 100644 index 00fa4417..00000000 --- a/example/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - diff --git a/example/android/settings.gradle b/example/android/settings.gradle deleted file mode 100644 index 5a2f14fb..00000000 --- a/example/android/settings.gradle +++ /dev/null @@ -1,15 +0,0 @@ -include ':app' - -def flutterProjectRoot = rootProject.projectDir.parentFile.toPath() - -def plugins = new Properties() -def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins') -if (pluginsFile.exists()) { - pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) } -} - -plugins.each { name, path -> - def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile() - include ":$name" - project(":$name").projectDir = pluginDirectory -} diff --git a/example/ios/Notifications/Info.plist b/example/ios/Notifications/Info.plist deleted file mode 100644 index a225b5ca..00000000 --- a/example/ios/Notifications/Info.plist +++ /dev/null @@ -1,31 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Notifications - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - NSExtension - - NSExtensionPointIdentifier - com.apple.usernotifications.service - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).NotificationService - - - diff --git a/example/ios/Notifications/NotificationService.swift b/example/ios/Notifications/NotificationService.swift deleted file mode 100644 index 60e44390..00000000 --- a/example/ios/Notifications/NotificationService.swift +++ /dev/null @@ -1,182 +0,0 @@ -// -// NotificationService.swift -// Notifications -// -// Created by Salvatore Giordano on 25/03/2020. -// Copyright © 2020 The Chromium Authors. All rights reserved. -// - -import UserNotifications -//import StreamChatClient - -final class NotificationService: UNNotificationServiceExtension { - - var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNMutableNotificationContent? - - override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { - self.contentHandler = contentHandler - bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent) - - guard let sharedDefaults = UserDefaults(suiteName: "group.io.stream.flutter"), - let apiKey = sharedDefaults.string(forKey: "KEY_API_KEY"), - let userId = sharedDefaults.string(forKey: "KEY_USER_ID"), - let token = sharedDefaults.string(forKey: "KEY_TOKEN"), - let messageId = bestAttemptContent?.userInfo["message_id"] as? String else { - return - } - -// Client.config = .init(apiKey: apiKey, logOptions: .error) -// Client.shared.set(user: User(id: userId), token: token) { res in -// guard res.isConnected else { -// return -// } -// -// Client.shared.message(withId: messageId) { [weak self] res in -// if let message = res.value?.message, -// let channel = res.value?.channel { -// let messageWrapper = MessageWrapper(channel: channel, message: message) -// if let encodedData = try? JSONEncoder.stream.encode(messageWrapper), -// let encodedString = String(data: encodedData, encoding: .utf8) { -// let storedMessages = sharedDefaults.stringArray(forKey: "messageQueue") ?? [] -// sharedDefaults.setValue(storedMessages + [encodedString], forKey: "messageQueue") -// -// // Modify the notification content here... -// self?.bestAttemptContent?.title = "[modified] \(self?.bestAttemptContent?.title ?? "")" -// contentHandler(self?.bestAttemptContent ?? request.content) -// } -// Client.shared.disconnect() -// } -// } -// } - } - - override func serviceExtensionTimeWillExpire() { - if let contentHandler = contentHandler, let bestAttemptContent = bestAttemptContent { - contentHandler(bestAttemptContent) - } - } -} - -//public struct MessageWrapper: Encodable { -// private enum CodingKeys: String, CodingKey { -// case id -// case channel -// case type -// case user -// case created = "created_at" -// case updated = "updated_at" -// case text -// case command -// case args -// case attachments -// case parentId = "parent_id" -// case showReplyInChannel = "show_in_channel" -// case mentionedUsers = "mentioned_users" -// } -// -// init(channel: Channel, message: Message) { -// id = message.id -// type = message.type -// user = message.user -// created = message.created -// updated = message.updated -// text = message.text -// command = message.command -// args = message.args -// attachments = message.attachments -// parentId = message.parentId -// showReplyInChannel = message.showReplyInChannel -// mentionedUsers = message.mentionedUsers -// extraData = message.extraData -// self.channel = ChannelWrapper(channel: channel) -// } -// -// /// A message id. -// public let id: String -// /// The channel cid. -// public let channel: ChannelWrapper? -// /// A message type (see `MessageType`). -// public let type: MessageType -// /// A user (see `User`). -// public let user: User -// /// A created date. -// public let created: Date -// /// A updated date. -// public let updated: Date -// /// A text. -// public let text: String -// /// A used command name. -// public let command: String? -// /// A used command args. -// public let args: String? -// /// Attachments (see `Attachment`). -// public let attachments: [Attachment] -// /// A parent message id. -// public let parentId: String? -// /// Check if this reply message needs to show in the channel. -// public let showReplyInChannel: Bool -// /// Mentioned users (see `User`). -// public let mentionedUsers: [User] -// /// An extra data for the message. -// public let extraData: Codable? -//} -// -//public struct ChannelWrapper: Encodable { -// /// Coding keys for the encoding. -// private enum CodingKeys: String, CodingKey { -// case id -// case cid -// case type -// case name -// case imageURL = "image" -// case members -// case lastMessageDate = "last_message_at" -// case createdBy = "created_by" -// case created = "created_at" -// case deleted = "deleted_at" -// case frozen -// } -// -// init(channel: Channel) { -// id = channel.id -// cid = channel.cid -// type = channel.type -// name = channel.name -// imageURL = channel.imageURL -// lastMessageDate = channel.lastMessageDate -// created = channel.created -// deleted = channel.deleted -// createdBy = channel.createdBy -// config = channel.config -// frozen = channel.frozen -// extraData = channel.extraData -// } -// -// /// A channel id. -// public let id: String -// /// A channel type + id. -// public let cid: ChannelId -// /// A channel type. -// public let type: ChannelType -// /// A channel name. -// public let name: String? -// /// An image of the channel. -// public let imageURL: URL? -// /// The last message date. -// public let lastMessageDate: Date? -// /// A channel created date. -// public let created: Date -// /// A channel deleted date. -// public let deleted: Date? -// /// A creator of the channel. -// public let createdBy: User? -// /// A config. -// public let config: Channel.Config -// /// Checks if the channel is frozen. -// public let frozen: Bool -// /// A list of user ids of the channel members. -// public let members = Set() -// /// An extra data for the channel. -// public let extraData: Codable? -//} diff --git a/example/ios/Podfile b/example/ios/Podfile deleted file mode 100644 index 63861e27..00000000 --- a/example/ios/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -# Uncomment this line to define a global platform for your project -platform :ios, '11.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - use_modular_headers! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) -end - -pod 'StreamChatClient' - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/example/ios/Runner/Runner-Bridging-Header.h b/example/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 7335fdf9..00000000 --- a/example/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" \ No newline at end of file diff --git a/example/lib/customize_message_widget.dart b/example/lib/customize_message_widget.dart deleted file mode 100644 index fc614125..00000000 --- a/example/lib/customize_message_widget.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Fifth step of the [tutorial](https://getstream.io/chat/flutter/tutorial/) -/// -/// Customizing how messages are rendered is another very common use-case that the SDK supports easily. -/// -/// Replace the built-in message component with your own is done by passing it as a builder function to the [MessageListView] widget. -/// -/// The message builder function will get the usual [BuildContext] argument as well as the [Message] object and its position inside the list. -/// -/// If you look at the code you can see that we use [StreamChat.of] to retrieve the current user so that we can style messages own messages in a different way. -/// -/// Since custom widgets and builders are always children of [StreamChat] or part of a [Channel], -/// you can use [StreamChat.of], [StreamChannel.of] and [StreamChatTheme.of] to use the API client directly -/// or to retrieve outer scope needed such as messages from the [Channel.state]. -void main() async { - final client = Client( - 's2dxdhpxd94g', - logLevel: Level.INFO, - ); - - await client.setUser( - User(id: 'super-band-9'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', - ); - - runApp(MyApp(client)); -} - -class MyApp extends StatelessWidget { - final Client client; - - MyApp(this.client); - - @override - Widget build(BuildContext context) { - return MaterialApp( - builder: (context, child) => StreamChat( - child: child, - client: client, - ), - home: Container( - child: ChannelListPage(), - ), - ); - } -} - -class ChannelListPage extends StatelessWidget { - @override - Widget build(BuildContext context) { - return Scaffold( - body: ChannelsBloc( - child: ChannelListView( - filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], - } - }, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( - limit: 20, - ), - channelWidget: ChannelPage(), - ), - ), - ); - } -} - -class ChannelPage extends StatelessWidget { - const ChannelPage({ - Key key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: ChannelHeader(), - body: Column( - children: [ - Expanded( - child: MessageListView( - messageBuilder: _messageBuilder, - ), - ), - MessageInput(), - ], - ), - ); - } - - Widget _messageBuilder( - BuildContext context, - MessageDetails details, - List messages, - ) { - final message = details.message; - final color = details.isMyMessage ? Colors.blueGrey : Colors.blue; - if (message.isSystem) { - return SizedBox(); - } - return MessageWidget( - message: message, - messageTheme: details.isMyMessage - ? StreamChatTheme.of(context).ownMessageTheme - : StreamChatTheme.of(context).otherMessageTheme, - borderSide: BorderSide( - color: color, - width: 2, - ), - padding: const EdgeInsets.all(2), - attachmentBorderSide: BorderSide( - color: color, - width: 2, - ), - attachmentPadding: EdgeInsets.all(8), - borderRadiusGeometry: BorderRadius.vertical( - top: !details.isLastUser ? Radius.circular(16) : Radius.zero, - bottom: !details.isNextUser ? Radius.circular(16) : Radius.zero, - ), - showSendingIndicator: DisplayWidget.gone, - reverse: false, - showUserAvatar: - details.isNextUser ? DisplayWidget.hide : DisplayWidget.show, - showTimestamp: !details.isNextUser, - showUsername: !details.isNextUser, - showReactions: false, - showReplyIndicator: false, - ); - } -} diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart deleted file mode 100644 index 747db1da..00000000 --- a/example/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:example/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(MyApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/example/test_driver/single_conversation.dart b/example/test_driver/single_conversation.dart deleted file mode 100644 index 28ec8e56..00000000 --- a/example/test_driver/single_conversation.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'package:flutter_driver/driver_extension.dart'; - -import '../lib/single_conversation.dart' as app; - -void main() async { - enableFlutterDriverExtension(); - - await app.main(); -} diff --git a/example/test_driver/single_conversation_test.dart b/example/test_driver/single_conversation_test.dart deleted file mode 100644 index c4f7b991..00000000 --- a/example/test_driver/single_conversation_test.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:io'; - -import 'package:flutter_driver/flutter_driver.dart'; -import 'package:test/test.dart'; - -void main() { - test('Single conversation', () async { - final inputFinder = find.byValueKey('messageInputText'); - final sendButtonFinder = find.byValueKey('sendButton'); - final messageListViewFinder = find.byValueKey('messageListView'); - - FlutterDriver driver = await FlutterDriver.connect(); - // Connect to the Flutter driver before running any tests. - - sleep(Duration(seconds: 5)); - - await driver.waitFor(inputFinder); - - await driver.tap(inputFinder); - - sleep(Duration(seconds: 1)); - - await driver.enterText('hey'); - - sleep(Duration(seconds: 1)); - - await driver.tap(sendButtonFinder); - - sleep(Duration(seconds: 1)); - - await driver.scroll( - messageListViewFinder, - 0, - 2000, - Duration(seconds: 1), - ); - - sleep(Duration(seconds: 1)); - - // Close the connection to the driver after the tests have completed. - sleep(Duration(seconds: 5)); - if (driver != null) { - await driver.close(); - } - }); -} diff --git a/example/web/favicon.png b/example/web/favicon.png deleted file mode 100644 index 8aaa46ac..00000000 Binary files a/example/web/favicon.png and /dev/null differ diff --git a/example/web/icons/Icon-192.png b/example/web/icons/Icon-192.png deleted file mode 100644 index b749bfef..00000000 Binary files a/example/web/icons/Icon-192.png and /dev/null differ diff --git a/example/web/icons/Icon-512.png b/example/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48d..00000000 Binary files a/example/web/icons/Icon-512.png and /dev/null differ diff --git a/example/web/index.html b/example/web/index.html deleted file mode 100644 index c9fb7275..00000000 --- a/example/web/index.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - - - - - example - - - - - - - - - diff --git a/example/web/manifest.json b/example/web/manifest.json deleted file mode 100644 index 8c012917..00000000 --- a/example/web/manifest.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "example", - "short_name": "example", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - } - ] -} diff --git a/example/web/sql-wasm.js b/example/web/sql-wasm.js deleted file mode 100644 index 9ef1837d..00000000 --- a/example/web/sql-wasm.js +++ /dev/null @@ -1,207 +0,0 @@ -// We are modularizing this manually because the current modularize setting in Emscripten has some issues: -// https://github.com/kripken/emscripten/issues/5820 -// In addition, When you use emcc's modularization, it still expects to export a global object called `Module`, -// which is able to be used/called before the WASM is loaded. -// The modularization below exports a promise that loads and resolves to the actual sql.js module. -// That way, this module can't be used before the WASM is finished loading. - -// We are going to define a function that a user will call to start loading initializing our Sql.js library -// However, that function might be called multiple times, and on subsequent calls, we don't actually want it to instantiate a new instance of the Module -// Instead, we want to return the previously loaded module - -// TODO: Make this not declare a global if used in the browser -var initSqlJsPromise = undefined; - -var initSqlJs = function (moduleConfig) { - -if (initSqlJsPromise){ -return initSqlJsPromise; -} -// If we're here, we've never called this function before -initSqlJsPromise = new Promise((resolveModule, reject) => { - -// We are modularizing this manually because the current modularize setting in Emscripten has some issues: -// https://github.com/kripken/emscripten/issues/5820 - -// The way to affect the loading of emcc compiled modules is to create a variable called `Module` and add -// properties to it, like `preRun`, `postRun`, etc -// We are using that to get notified when the WASM has finished loading. -// Only then will we return our promise - -// If they passed in a moduleConfig object, use that -// Otherwise, initialize Module to the empty object -var Module = typeof moduleConfig !== 'undefined' ? moduleConfig : {}; - -// EMCC only allows for a single onAbort function (not an array of functions) -// So if the user defined their own onAbort function, we remember it and call it -var originalOnAbortFunction = Module['onAbort']; -Module['onAbort'] = function (errorThatCausedAbort) { -reject(new Error(errorThatCausedAbort)); -if (originalOnAbortFunction){ -originalOnAbortFunction(errorThatCausedAbort); -} -}; - -Module['postRun'] = Module['postRun'] || []; -Module['postRun'].push(function () { -// When Emscripted calls postRun, this promise resolves with the built Module -resolveModule(Module); -}); - -// There is a section of code in the emcc-generated code below that looks like this: -// (Note that this is lowercase `module`) -// if (typeof module !== 'undefined') { -// module['exports'] = Module; -// } -// When that runs, it's going to overwrite our own modularization export efforts in shell-post.js! -// The only way to tell emcc not to emit it is to pass the MODULARIZE=1 or MODULARIZE_INSTANCE=1 flags, -// but that carries with it additional unnecessary baggage/bugs we don't want either. -// So, we have three options: -// 1) We undefine `module` -// 2) We remember what `module['exports']` was at the beginning of this function and we restore it later -// 3) We write a script to remove those lines of code as part of the Make process. -// -// Since those are the only lines of code that care about module, we will undefine it. It's the most straightforward -// of the options, and has the side effect of reducing emcc's efforts to modify the module if its output were to change in the future. -// That's a nice side effect since we're handling the modularization efforts ourselves -module = undefined; - -// The emcc-generated code and shell-post.js code goes below, -// meaning that all of it runs inside of this promise. If anything throws an exception, our promise will abort -var aa;var f;f||(f=typeof Module !== 'undefined' ? Module : {}); -var va=function(){var a;var b=h(4);var c={};var d=function(){function a(a,b){this.fb=a;this.db=b;this.nb=1;this.Eb=[]}a.prototype.bind=function(a){if(!this.fb)throw"Statement closed";this.reset();return Array.isArray(a)?this.lc(a):this.mc(a)};a.prototype.step=function(){var a;if(!this.fb)throw"Statement closed";this.nb=1;switch(a=Tb(this.fb)){case c.hc:return!0;case c.DONE:return!1;default:return this.db.handleError(a)}};a.prototype.sc=function(a){null==a&&(a=this.nb++);return Ub(this.fb,a)};a.prototype.tc= -function(a){null==a&&(a=this.nb++);return Vb(this.fb,a)};a.prototype.getBlob=function(a){var b;null==a&&(a=this.nb++);var c=Wb(this.fb,a);var d=Xb(this.fb,a);var e=new Uint8Array(c);for(a=b=0;0<=c?bc;a=0<=c?++b:--b)e[a]=l[d+a];return e};a.prototype.get=function(a){var b,d;null!=a&&this.bind(a)&&this.step();var e=[];a=b=0;for(d=ib(this.fb);0<=d?bd;a=0<=d?++b:--b)switch(Yb(this.fb,a)){case c.fc:case c.FLOAT:e.push(this.sc(a));break;case c.ic:e.push(this.tc(a));break;case c.Zb:e.push(this.getBlob(a)); -break;default:e.push(null)}return e};a.prototype.getColumnNames=function(){var a,b;var c=[];var d=a=0;for(b=ib(this.fb);0<=b?ab;d=0<=b?++a:--a)c.push(Zb(this.fb,d));return c};a.prototype.getAsObject=function(a){var b,c;var d=this.get(a);var e=this.getColumnNames();var g={};a=b=0;for(c=e.length;b>>0);if(null!=a){var c=this.filename,d=c?n("/",c):"/";c=ia(!0,!0);d=ja(d,(void 0!==c?c:438)&4095|32768,0);if(a){if("string"===typeof a){for(var e=Array(a.length),k=0,m=a.length;kc;e=0<=c?++g:--g){var m=q(d+4*e,"i32");var z=jc(m);e=function(){switch(!1){case 1!==z:return kc; -case 2!==z:return lc;case 3!==z:return mc;case 4!==z:return function(a){var b,c;var d=nc(a);var e=oc(a);a=new Uint8Array(d);for(b=c=0;0<=d?cd;b=0<=d?++c:--c)a[b]=l[e+b];return a};default:return function(){return null}}}();e=e(m);k.push(e)}if(c=b.apply(null,k))switch(typeof c){case "number":return pc(a,c);case "string":return qc(a,c,-1,-1)}else return rc(a)});this.handleError(sc(this.db,a,b.length,c.jc,0,d,0,0,0));return this};return a}();var g=f.cwrap("sqlite3_open","number",["string","number"]); -var k=f.cwrap("sqlite3_close_v2","number",["number"]);var m=f.cwrap("sqlite3_exec","number",["number","string","number","number","number"]);f.cwrap("sqlite3_free","",["number"]);var y=f.cwrap("sqlite3_changes","number",["number"]);var z=f.cwrap("sqlite3_prepare_v2","number",["number","string","number","number","number"]);var fa=f.cwrap("sqlite3_prepare_v2","number",["number","number","number","number","number"]);var ca=f.cwrap("sqlite3_bind_text","number",["number","number","number","number","number"]); -var Ia=f.cwrap("sqlite3_bind_blob","number",["number","number","number","number","number"]);var ac=f.cwrap("sqlite3_bind_double","number",["number","number","number"]);var $b=f.cwrap("sqlite3_bind_int","number",["number","number","number"]);var bc=f.cwrap("sqlite3_bind_parameter_index","number",["number","string"]);var Tb=f.cwrap("sqlite3_step","number",["number"]);var hc=f.cwrap("sqlite3_errmsg","string",["number"]);var ib=f.cwrap("sqlite3_data_count","number",["number"]);var Ub=f.cwrap("sqlite3_column_double", -"number",["number","number"]);var Vb=f.cwrap("sqlite3_column_text","string",["number","number"]);var Xb=f.cwrap("sqlite3_column_blob","number",["number","number"]);var Wb=f.cwrap("sqlite3_column_bytes","number",["number","number"]);var Yb=f.cwrap("sqlite3_column_type","number",["number","number"]);var Zb=f.cwrap("sqlite3_column_name","string",["number","number"]);var dc=f.cwrap("sqlite3_reset","number",["number"]);var cc=f.cwrap("sqlite3_clear_bindings","number",["number"]);var ec=f.cwrap("sqlite3_finalize", -"number",["number"]);var sc=f.cwrap("sqlite3_create_function_v2","number","number string number number number number number number number".split(" "));var jc=f.cwrap("sqlite3_value_type","number",["number"]);var nc=f.cwrap("sqlite3_value_bytes","number",["number"]);var mc=f.cwrap("sqlite3_value_text","string",["number"]);var kc=f.cwrap("sqlite3_value_int","number",["number"]);var oc=f.cwrap("sqlite3_value_blob","number",["number"]);var lc=f.cwrap("sqlite3_value_double","number",["number"]);var pc= -f.cwrap("sqlite3_result_double","",["number","number"]);var rc=f.cwrap("sqlite3_result_null","",["number"]);var qc=f.cwrap("sqlite3_result_text","",["number","string","number","number"]);var fc=f.cwrap("RegisterExtensionFunctions","number",["number"]);this.SQL={Database:e};for(a in this.SQL)f[a]=this.SQL[a];var da=0;c.xb=0;c.we=1;c.Pe=2;c.Ze=3;c.Cc=4;c.Ec=5;c.Se=6;c.NOMEM=7;c.bf=8;c.Qe=9;c.Re=10;c.Hc=11;c.NOTFOUND=12;c.Oe=13;c.Fc=14;c.$e=15;c.EMPTY=16;c.cf=17;c.df=18;c.Gc=19;c.Te=20;c.Ue=21;c.Ve= -22;c.Dc=23;c.Ne=24;c.af=25;c.We=26;c.Xe=27;c.ef=28;c.hc=100;c.DONE=101;c.fc=1;c.FLOAT=2;c.ic=3;c.Zb=4;c.Ye=5;c.jc=1}.bind(this);f.preRun=f.preRun||[];f.preRun.push(va);var wa={},u;for(u in f)f.hasOwnProperty(u)&&(wa[u]=f[u]);f.arguments=[];f.thisProgram="./this.program";f.quit=function(a,b){throw b;};f.preRun=[];f.postRun=[];var v=!1,w=!1,x=!1,xa=!1;v="object"===typeof window;w="function"===typeof importScripts;x="object"===typeof process&&"function"===typeof require&&!v&&!w;xa=!v&&!x&&!w;var A=""; -if(x){A=__dirname+"/";var ya,za;f.read=function(a,b){ya||(ya=require("fs"));za||(za=require("path"));a=za.normalize(a);a=ya.readFileSync(a);return b?a:a.toString()};f.readBinary=function(a){a=f.read(a,!0);a.buffer||(a=new Uint8Array(a));assert(a.buffer);return a};1>2];a=b+a+15&-16;if(a<=Da())D[Ca>>2]=a;else if(!Ea(a))return 0;return b} -var Fa={"f64-rem":function(a,b){return a%b},"debugger":function(){debugger}},Ga=1,E=Array(64);function ua(a){for(var b=0;64>b;b++)if(!E[b])return E[b]=a,Ga+b;throw"Finished up all reserved function pointers. Use a higher value for RESERVED_FUNCTION_POINTERS.";}"object"!==typeof WebAssembly&&C("no native wasm support detected"); -function q(a,b){b=b||"i8";"*"===b.charAt(b.length-1)&&(b="i32");switch(b){case "i1":return l[a>>0];case "i8":return l[a>>0];case "i16":return Ha[a>>1];case "i32":return D[a>>2];case "i64":return D[a>>2];case "float":return Ja[a>>2];case "double":return Ka[a>>3];default:B("invalid type for getValue: "+b)}return null}var La,Ma=!1;function assert(a,b){a||B("Assertion failed: "+b)}function Na(a){var b=f["_"+a];assert(b,"Cannot call unknown function "+a+", make sure it is exported");return b} -function Oa(a,b,c,d){var e={string:function(a){var b=0;if(null!==a&&void 0!==a&&0!==a){var c=(a.length<<2)+1;b=h(c);r(a,F,b,c)}return b},array:function(a){var b=h(a.length);l.set(a,b);return b}},g=Na(a),k=[];a=0;if(d)for(var m=0;m>0]=0;break;case "i8":l[a>>0]=0;break;case "i16":Ha[a>>1]=0;break;case "i32":D[a>>2]=0;break;case "i64":aa=[0,1<=+Pa(0)?~~+Qa(0)>>>0:0];D[a>>2]=aa[0];D[a+4>>2]=aa[1];break;case "float":Ja[a>>2]=0;break;case "double":Ka[a>>3]=0;break;default:B("invalid type for setValue: "+b)}}var Ra=0,Sa=3; -function ea(a){var b=Ra;if("number"===typeof a){var c=!0;var d=a}else c=!1,d=a.length;b=b==Sa?e:[Ta,h,Ba][b](Math.max(d,1));if(c){var e=b;assert(0==(b&3));for(a=b+(d&-4);e>2]=0;for(a=b+d;e>0]=0;return b}a.subarray||a.slice?F.set(a,b):F.set(new Uint8Array(a),b);return b}var Ua="undefined"!==typeof TextDecoder?new TextDecoder("utf8"):void 0; -function t(a,b,c){var d=b+c;for(c=b;a[c]&&!(c>=d);)++c;if(16e?d+=String.fromCharCode(e):(e-=65536,d+=String.fromCharCode(55296|e>>10,56320|e&1023))}}else d+=String.fromCharCode(e)}return d}function G(a){return a?t(F,a,void 0):""} -function r(a,b,c,d){if(!(0=k){var m=a.charCodeAt(++g);k=65536+((k&1023)<<10)|m&1023}if(127>=k){if(c>=d)break;b[c++]=k}else{if(2047>=k){if(c+1>=d)break;b[c++]=192|k>>6}else{if(65535>=k){if(c+2>=d)break;b[c++]=224|k>>12}else{if(c+3>=d)break;b[c++]=240|k>>18;b[c++]=128|k>>12&63}b[c++]=128|k>>6&63}b[c++]=128|k&63}}b[c]=0;return c-e} -function oa(a){for(var b=0,c=0;c=d&&(d=65536+((d&1023)<<10)|a.charCodeAt(++c)&1023);127>=d?++b:b=2047>=d?b+2:65535>=d?b+3:b+4}return b}"undefined"!==typeof TextDecoder&&new TextDecoder("utf-16le");function Va(a){return a.replace(/__Z[\w\d_]+/g,function(a){return a===a?a:a+" ["+a+"]"})}function Wa(a){0Ya&&C("TOTAL_MEMORY should be larger than TOTAL_STACK, was "+Ya+"! (TOTAL_STACK=5242880)"); -f.buffer?buffer=f.buffer:"object"===typeof WebAssembly&&"function"===typeof WebAssembly.Memory?(La=new WebAssembly.Memory({initial:Ya/65536}),buffer=La.buffer):buffer=new ArrayBuffer(Ya);Xa();D[Ca>>2]=5303264;function Za(a){for(;0>2];var c=D[b>>2]}else ob.rb=!0,J.USER=J.LOGNAME="web_user",J.PATH="/",J.PWD="/",J.HOME="/home/web_user",J.LANG="C.UTF-8",J._=f.thisProgram,c=db?Ta(1024):Ba(1024),b=db?Ta(256):Ba(256),D[b>>2]=c,D[a>>2]=b;a=[];var d=0,e;for(e in J)if("string"===typeof J[e]){var g=e+"="+J[e];a.push(g);d+=g.length}if(1024>0]=d.charCodeAt(m);l[k>>0]=0;D[b+ -4*e>>2]=c;c+=g.length+1}D[b+4*a.length>>2]=0}function pb(a){f.___errno_location&&(D[f.___errno_location()>>2]=a);return a}function qb(a,b){for(var c=0,d=a.length-1;0<=d;d--){var e=a[d];"."===e?a.splice(d,1):".."===e?(a.splice(d,1),c++):c&&(a.splice(d,1),c--)}if(b)for(;c;c--)a.unshift("..");return a}function rb(a){var b="/"===a.charAt(0),c="/"===a.substr(-1);(a=qb(a.split("/").filter(function(a){return!!a}),!b).join("/"))||b||(a=".");a&&c&&(a+="/");return(b?"/":"")+a} -function sb(a){var b=/^(\/?|)([\s\S]*?)((?:\.{1,2}|[^\/]+?|)(\.[^.\/]*|))(?:[\/]*)$/.exec(a).slice(1);a=b[0];b=b[1];if(!a&&!b)return".";b&&(b=b.substr(0,b.length-1));return a+b}function tb(a){if("/"===a)return"/";var b=a.lastIndexOf("/");return-1===b?a:a.substr(b+1)}function ub(){var a=Array.prototype.slice.call(arguments,0);return rb(a.join("/"))}function n(a,b){return rb(a+"/"+b)} -function vb(){for(var a="",b=!1,c=arguments.length-1;-1<=c&&!b;c--){b=0<=c?arguments[c]:"/";if("string"!==typeof b)throw new TypeError("Arguments to path.resolve must be strings");if(!b)return"";a=b+"/"+a;b="/"===b.charAt(0)}a=qb(a.split("/").filter(function(a){return!!a}),!b).join("/");return(b?"/":"")+a||"."}var wb=[];function xb(a,b){wb[a]={input:[],output:[],ub:b};yb(a,zb)} -var zb={open:function(a){var b=wb[a.node.rdev];if(!b)throw new K(L.Cb);a.tty=b;a.seekable=!1},close:function(a){a.tty.ub.flush(a.tty)},flush:function(a){a.tty.ub.flush(a.tty)},read:function(a,b,c,d){if(!a.tty||!a.tty.ub.Xb)throw new K(L.Ob);for(var e=0,g=0;g=b||(b=Math.max(b,c*(1048576>c?2:1.125)|0),0!=c&&(b=Math.max(b,256)),c=a.bb,a.bb=new Uint8Array(b),0b)a.bb.length=b;else for(;a.bb.length=a.node.gb)return 0;a=Math.min(a.node.gb-e,d);if(8b)throw new K(L.ib);return b},Pb:function(a,b,c){M.Tb(a.node,b+c);a.node.gb=Math.max(a.node.gb,b+c)},zb:function(a,b,c,d,e,g,k){if(32768!== -(a.node.mode&61440))throw new K(L.Cb);c=a.node.bb;if(k&2||c.buffer!==b&&c.buffer!==b.buffer){if(0>2)}catch(c){if(!c.code)throw c; -throw new K(L[c.code]);}return b.mode},kb:function(a){for(var b=[];a.parent!==a;)b.push(a.name),a=a.parent;b.push(a.jb.Hb.root);b.reverse();return ub.apply(null,b)},qc:function(a){a&=-2656257;var b=0,c;for(c in P.Ub)a&c&&(b|=P.Ub[c],a^=c);if(a)throw new K(L.ib);return b},ab:{lb:function(a){a=P.kb(a);try{var b=fs.lstatSync(a)}catch(c){if(!c.code)throw c;throw new K(L[c.code]);}P.yb&&!b.pb&&(b.pb=4096);P.yb&&!b.blocks&&(b.blocks=(b.size+b.pb-1)/b.pb|0);return{dev:b.dev,ino:b.ino,mode:b.mode,nlink:b.nlink, -uid:b.uid,gid:b.gid,rdev:b.rdev,size:b.size,atime:b.atime,mtime:b.mtime,ctime:b.ctime,pb:b.pb,blocks:b.blocks}},hb:function(a,b){var c=P.kb(a);try{void 0!==b.mode&&(fs.chmodSync(c,b.mode),a.mode=b.mode),void 0!==b.size&&fs.truncateSync(c,b.size)}catch(d){if(!d.code)throw d;throw new K(L[d.code]);}},lookup:function(a,b){var c=n(P.kb(a),b);c=P.Wb(c);return P.createNode(a,b,c)},vb:function(a,b,c,d){a=P.createNode(a,b,c,d);b=P.kb(a);try{N(a.mode)?fs.mkdirSync(b,a.mode):fs.writeFileSync(b,"",{mode:a.mode})}catch(e){if(!e.code)throw e; -throw new K(L[e.code]);}return a},rename:function(a,b,c){a=P.kb(a);b=n(P.kb(b),c);try{fs.renameSync(a,b)}catch(d){if(!d.code)throw d;throw new K(L[d.code]);}},unlink:function(a,b){a=n(P.kb(a),b);try{fs.unlinkSync(a)}catch(c){if(!c.code)throw c;throw new K(L[c.code]);}},rmdir:function(a,b){a=n(P.kb(a),b);try{fs.rmdirSync(a)}catch(c){if(!c.code)throw c;throw new K(L[c.code]);}},readdir:function(a){a=P.kb(a);try{return fs.readdirSync(a)}catch(b){if(!b.code)throw b;throw new K(L[b.code]);}},symlink:function(a, -b,c){a=n(P.kb(a),b);try{fs.symlinkSync(c,a)}catch(d){if(!d.code)throw d;throw new K(L[d.code]);}},readlink:function(a){var b=P.kb(a);try{return b=fs.readlinkSync(b),b=Fb.relative(Fb.resolve(a.jb.Hb.root),b)}catch(c){if(!c.code)throw c;throw new K(L[c.code]);}}},cb:{open:function(a){var b=P.kb(a.node);try{32768===(a.node.mode&61440)&&(a.wb=fs.openSync(b,P.qc(a.flags)))}catch(c){if(!c.code)throw c;throw new K(L[c.code]);}},close:function(a){try{32768===(a.node.mode&61440)&&a.wb&&fs.closeSync(a.wb)}catch(b){if(!b.code)throw b; -throw new K(L[b.code]);}},read:function(a,b,c,d,e){if(0===d)return 0;try{return fs.readSync(a.wb,P.Rb(b.buffer),c,d,e)}catch(g){throw new K(L[g.code]);}},write:function(a,b,c,d,e){try{return fs.writeSync(a.wb,P.Rb(b.buffer),c,d,e)}catch(g){throw new K(L[g.code]);}},ob:function(a,b,c){if(1===c)b+=a.position;else if(2===c&&32768===(a.node.mode&61440))try{b+=fs.fstatSync(a.wb).size}catch(d){throw new K(L[d.code]);}if(0>b)throw new K(L.ib);return b}}},Gb=null,Hb={},Q=[],Ib=1,R=null,Jb=!0,S={},K=null, -Eb={};function T(a,b){a=vb("/",a);b=b||{};if(!a)return{path:"",node:null};var c={Vb:!0,Jb:0},d;for(d in c)void 0===b[d]&&(b[d]=c[d]);if(8>>0)%R.length}function Nb(a){var b=Mb(a.parent.id,a.name);a.tb=R[b];R[b]=a}function Ob(a){var b=Mb(a.parent.id,a.name);if(R[b]===a)R[b]=a.tb;else for(b=R[b];b;){if(b.tb===a){b.tb=a.tb;break}b=b.tb}} -function O(a,b){var c;if(c=(c=Pb(a,"x"))?c:a.ab.lookup?0:13)throw new K(c,a);for(c=R[Mb(a.id,b)];c;c=c.tb){var d=c.name;if(c.parent.id===a.id&&d===b)return c}return a.ab.lookup(a,b)} -function Db(a,b,c,d){Qb||(Qb=function(a,b,c,d){a||(a=this);this.parent=a;this.jb=a.jb;this.sb=null;this.id=Ib++;this.name=b;this.mode=c;this.ab={};this.cb={};this.rdev=d},Qb.prototype={},Object.defineProperties(Qb.prototype,{read:{get:function(){return 365===(this.mode&365)},set:function(a){a?this.mode|=365:this.mode&=-366}},write:{get:function(){return 146===(this.mode&146)},set:function(a){a?this.mode|=146:this.mode&=-147}}}));a=new Qb(a,b,c,d);Nb(a);return a} -function N(a){return 16384===(a&61440)}var Rb={r:0,rs:1052672,"r+":2,w:577,wx:705,xw:705,"w+":578,"wx+":706,"xw+":706,a:1089,ax:1217,xa:1217,"a+":1090,"ax+":1218,"xa+":1218};function ic(a){var b=["r","w","rw"][a&3];a&512&&(b+="w");return b}function Pb(a,b){if(Jb)return 0;if(-1===b.indexOf("r")||a.mode&292){if(-1!==b.indexOf("w")&&!(a.mode&146)||-1!==b.indexOf("x")&&!(a.mode&73))return 13}else return 13;return 0}function tc(a,b){try{return O(a,b),17}catch(c){}return Pb(a,"wx")} -function uc(a,b,c){try{var d=O(a,b)}catch(e){return e.eb}if(a=Pb(a,"wx"))return a;if(c){if(!N(d.mode))return 20;if(d===d.parent||"/"===Lb(d))return 16}else if(N(d.mode))return 21;return 0}function vc(a){var b=4096;for(a=a||0;a<=b;a++)if(!Q[a])return a;throw new K(24);} -function wc(a,b){xc||(xc=function(){},xc.prototype={},Object.defineProperties(xc.prototype,{object:{get:function(){return this.node},set:function(a){this.node=a}}}));var c=new xc,d;for(d in a)c[d]=a[d];a=c;b=vc(b);a.fd=b;return Q[b]=a}var Cb={open:function(a){a.cb=Hb[a.node.rdev].cb;a.cb.open&&a.cb.open(a)},ob:function(){throw new K(29);}};function yb(a,b){Hb[a]={cb:b}} -function yc(a,b){var c="/"===b,d=!b;if(c&&Gb)throw new K(16);if(!c&&!d){var e=T(b,{Vb:!1});b=e.path;e=e.node;if(e.sb)throw new K(16);if(!N(e.mode))throw new K(20);}b={type:a,Hb:{},Yb:b,wc:[]};a=a.jb(b);a.jb=b;b.root=a;c?Gb=a:e&&(e.sb=b,e.jb&&e.jb.wc.push(b))}function ja(a,b,c){var d=T(a,{parent:!0}).node;a=tb(a);if(!a||"."===a||".."===a)throw new K(22);var e=tc(d,a);if(e)throw new K(e);if(!d.ab.vb)throw new K(1);return d.ab.vb(d,a,b,c)}function U(a,b){ja(a,(void 0!==b?b:511)&1023|16384,0)} -function zc(a,b,c){"undefined"===typeof c&&(c=b,b=438);ja(a,b|8192,c)}function Ac(a,b){if(!vb(a))throw new K(2);var c=T(b,{parent:!0}).node;if(!c)throw new K(2);b=tb(b);var d=tc(c,b);if(d)throw new K(d);if(!c.ab.symlink)throw new K(1);c.ab.symlink(c,b,a)} -function ta(a){var b=T(a,{parent:!0}).node,c=tb(a),d=O(b,c),e=uc(b,c,!1);if(e)throw new K(e);if(!b.ab.unlink)throw new K(1);if(d.sb)throw new K(16);try{S.willDeletePath&&S.willDeletePath(a)}catch(g){console.log("FS.trackingDelegate['willDeletePath']('"+a+"') threw an exception: "+g.message)}b.ab.unlink(b,c);Ob(d);try{if(S.onDeletePath)S.onDeletePath(a)}catch(g){console.log("FS.trackingDelegate['onDeletePath']('"+a+"') threw an exception: "+g.message)}} -function Kb(a){a=T(a).node;if(!a)throw new K(2);if(!a.ab.readlink)throw new K(22);return vb(Lb(a.parent),a.ab.readlink(a))}function ra(a,b){a=T(a,{qb:!b}).node;if(!a)throw new K(2);if(!a.ab.lb)throw new K(1);return a.ab.lb(a)}function Bc(a){return ra(a,!0)}function ka(a,b){var c;"string"===typeof a?c=T(a,{qb:!0}).node:c=a;if(!c.ab.hb)throw new K(1);c.ab.hb(c,{mode:b&4095|c.mode&-4096,timestamp:Date.now()})} -function Cc(a){var b;"string"===typeof a?b=T(a,{qb:!0}).node:b=a;if(!b.ab.hb)throw new K(1);b.ab.hb(b,{timestamp:Date.now()})}function Dc(a,b){if(0>b)throw new K(22);var c;"string"===typeof a?c=T(a,{qb:!0}).node:c=a;if(!c.ab.hb)throw new K(1);if(N(c.mode))throw new K(21);if(32768!==(c.mode&61440))throw new K(22);if(a=Pb(c,"w"))throw new K(a);c.ab.hb(c,{size:b,timestamp:Date.now()})} -function p(a,b,c,d){if(""===a)throw new K(2);if("string"===typeof b){var e=Rb[b];if("undefined"===typeof e)throw Error("Unknown file open mode: "+b);b=e}c=b&64?("undefined"===typeof c?438:c)&4095|32768:0;if("object"===typeof a)var g=a;else{a=rb(a);try{g=T(a,{qb:!(b&131072)}).node}catch(k){}}e=!1;if(b&64)if(g){if(b&128)throw new K(17);}else g=ja(a,c,0),e=!0;if(!g)throw new K(2);8192===(g.mode&61440)&&(b&=-513);if(b&65536&&!N(g.mode))throw new K(20);if(!e&&(c=g?40960===(g.mode&61440)?40:N(g.mode)&& -("r"!==ic(b)||b&512)?21:Pb(g,ic(b)):2))throw new K(c);b&512&&Dc(g,0);b&=-641;d=wc({node:g,path:Lb(g),flags:b,seekable:!0,position:0,cb:g.cb,Bc:[],error:!1},d);d.cb.open&&d.cb.open(d);!f.logReadFiles||b&1||(Ec||(Ec={}),a in Ec||(Ec[a]=1,console.log("FS.trackingDelegate error on read file: "+a)));try{S.onOpenFile&&(g=0,1!==(b&2097155)&&(g|=1),0!==(b&2097155)&&(g|=2),S.onOpenFile(a,g))}catch(k){console.log("FS.trackingDelegate['onOpenFile']('"+a+"', flags) threw an exception: "+k.message)}return d} -function ma(a){if(null===a.fd)throw new K(9);a.Gb&&(a.Gb=null);try{a.cb.close&&a.cb.close(a)}catch(b){throw b;}finally{Q[a.fd]=null}a.fd=null}function Fc(a,b,c){if(null===a.fd)throw new K(9);if(!a.seekable||!a.cb.ob)throw new K(29);if(0!=c&&1!=c&&2!=c)throw new K(22);a.position=a.cb.ob(a,b,c);a.Bc=[]} -function sa(a,b,c,d,e){if(0>d||0>e)throw new K(22);if(null===a.fd)throw new K(9);if(1===(a.flags&2097155))throw new K(9);if(N(a.node.mode))throw new K(21);if(!a.cb.read)throw new K(22);var g="undefined"!==typeof e;if(!g)e=a.position;else if(!a.seekable)throw new K(29);b=a.cb.read(a,b,c,d,e);g||(a.position+=b);return b} -function la(a,b,c,d,e,g){if(0>d||0>e)throw new K(22);if(null===a.fd)throw new K(9);if(0===(a.flags&2097155))throw new K(9);if(N(a.node.mode))throw new K(21);if(!a.cb.write)throw new K(22);a.flags&1024&&Fc(a,0,2);var k="undefined"!==typeof e;if(!k)e=a.position;else if(!a.seekable)throw new K(29);b=a.cb.write(a,b,c,d,e,g);k||(a.position+=b);try{if(a.path&&S.onWriteToFile)S.onWriteToFile(a.path)}catch(m){console.log("FS.trackingDelegate['onWriteToFile']('"+a.path+"') threw an exception: "+m.message)}return b} -function Gc(){K||(K=function(a,b){this.node=b;this.zc=function(a){this.eb=a};this.zc(a);this.message="FS error";this.stack&&Object.defineProperty(this,"stack",{value:Error().stack,writable:!0})},K.prototype=Error(),K.prototype.constructor=K,[2].forEach(function(a){Eb[a]=new K(a);Eb[a].stack=""}))}var Hc;function ia(a,b){var c=0;a&&(c|=365);b&&(c|=146);return c} -function Ic(a,b,c){a=n("/dev",a);var d=ia(!!b,!!c);Jc||(Jc=64);var e=Jc++<<8|0;yb(e,{open:function(a){a.seekable=!1},close:function(){c&&c.buffer&&c.buffer.length&&c(10)},read:function(a,c,d,e){for(var g=0,k=0;k>2]=d.dev;D[c+4>>2]=0;D[c+8>>2]=d.ino;D[c+12>>2]=d.mode;D[c+16>>2]=d.nlink;D[c+20>>2]=d.uid;D[c+24>>2]=d.gid;D[c+28>>2]=d.rdev;D[c+32>>2]=0;D[c+36>>2]=d.size;D[c+40>>2]=4096;D[c+44>>2]=d.blocks;D[c+48>>2]=d.atime.getTime()/1E3|0;D[c+52>>2]=0;D[c+56>>2]=d.mtime.getTime()/1E3|0;D[c+60>>2]=0;D[c+64>>2]=d.ctime.getTime()/1E3|0;D[c+68>>2]=0;D[c+72>>2]=d.ino;return 0}var W=0; -function X(){W+=4;return D[W-4>>2]}function Y(){return G(X())}function Z(){var a=Q[X()];if(!a)throw new K(L.Kb);return a}function Da(){return l.length}function Ea(a){if(2147418112=b?b=Wa(2*b):b=Math.min(Wa((3*b+2147483648)/4),2147418112);a=Wa(b);var c=buffer.byteLength;try{var d=-1!==La.grow((a-c)/65536)?buffer=La.buffer:null}catch(e){d=null}if(!d||d.byteLength!=b)return!1;Xa();return!0} -function Mc(a){if(0===a)return 0;a=G(a);if(!J.hasOwnProperty(a))return 0;Mc.rb&&ha(Mc.rb);a=J[a];var b=oa(a)+1,c=Ta(b);c&&r(a,l,c,b);Mc.rb=c;return Mc.rb}r("GMT",F,60272,4); -function Nc(){function a(a){return(a=a.toTimeString().match(/\(([A-Za-z ]+)\)$/))?a[1]:"GMT"}if(!Oc){Oc=!0;D[Pc()>>2]=60*(new Date).getTimezoneOffset();var b=new Date(2E3,0,1),c=new Date(2E3,6,1);D[Qc()>>2]=Number(b.getTimezoneOffset()!=c.getTimezoneOffset());var d=a(b),e=a(c);d=ea(ba(d));e=ea(ba(e));c.getTimezoneOffset()>2]=d,D[Rc()+4>>2]=e):(D[Rc()>>2]=e,D[Rc()+4>>2]=d)}}var Oc; -function Sc(a){a/=1E3;if((v||w)&&self.performance&&self.performance.now)for(var b=self.performance.now();self.performance.now()-b>2]=c.position;c.Gb&&0===d&&0===g&&(c.Gb=null);return 0}catch(k){return"undefined"!==typeof V&&k instanceof K||B(k),-k.eb}},ca:function(a,b){W=b;try{var c=Y(),d=X();ka(c,d);return 0}catch(e){return"undefined"!==typeof V&&e instanceof K||B(e),-e.eb}},ba:function(a,b){W=b;try{var c=X(),d=X();if(0===d)return-L.ib;if(dd?-L.ib:p(c.path,c.flags,0,d).fd;case 1:case 2:return 0; -case 3:return c.flags;case 4:return d=X(),c.flags|=d,0;case 12:return d=X(),Ha[d+0>>1]=2,0;case 13:case 14:return 0;case 16:case 8:return-L.ib;case 9:return pb(L.ib),-1;default:return-L.ib}}catch(e){return"undefined"!==typeof V&&e instanceof K||B(e),-e.eb}},U:function(a,b){W=b;try{var c=Z(),d=X(),e=X();return sa(c,l,d,e)}catch(g){return"undefined"!==typeof V&&g instanceof K||B(g),-g.eb}},T:function(a,b){W=b;try{var c=Y();var d=X();if(d&-8)var e=-L.ib;else{var g=T(c,{qb:!0}).node;a="";d&4&&(a+="r"); -d&2&&(a+="w");d&1&&(a+="x");e=a&&Pb(g,a)?-L.$b:0}return e}catch(k){return"undefined"!==typeof V&&k instanceof K||B(k),-k.eb}},S:function(a,b){W=b;try{var c=Y(),d=X();a=c;a=rb(a);"/"===a[a.length-1]&&(a=a.substr(0,a.length-1));U(a,d);return 0}catch(e){return"undefined"!==typeof V&&e instanceof K||B(e),-e.eb}},R:function(a,b){W=b;try{var c=Z(),d=X(),e=X();return la(c,l,d,e)}catch(g){return"undefined"!==typeof V&&g instanceof K||B(g),-g.eb}},Q:function(a,b){W=b;try{var c=Y(),d=T(c,{parent:!0}).node, -e=tb(c),g=O(d,e),k=uc(d,e,!0);if(k)throw new K(k);if(!d.ab.rmdir)throw new K(1);if(g.sb)throw new K(16);try{S.willDeletePath&&S.willDeletePath(c)}catch(m){console.log("FS.trackingDelegate['willDeletePath']('"+c+"') threw an exception: "+m.message)}d.ab.rmdir(d,e);Ob(g);try{if(S.onDeletePath)S.onDeletePath(c)}catch(m){console.log("FS.trackingDelegate['onDeletePath']('"+c+"') threw an exception: "+m.message)}return 0}catch(m){return"undefined"!==typeof V&&m instanceof K||B(m),-m.eb}},P:function(a,b){W= -b;try{var c=Y(),d=X(),e=X();return p(c,d,e).fd}catch(g){return"undefined"!==typeof V&&g instanceof K||B(g),-g.eb}},s:function(a,b){W=b;try{var c=Z();ma(c);return 0}catch(d){return"undefined"!==typeof V&&d instanceof K||B(d),-d.eb}},O:function(a,b){W=b;try{var c=Y(),d=X();var e=X();if(0>=e)var g=-L.ib;else{var k=Kb(c),m=Math.min(e,oa(k)),y=l[d+m];r(k,F,d,e+1);l[d+m]=y;g=m}return g}catch(z){return"undefined"!==typeof V&&z instanceof K||B(z),-z.eb}},N:function(a,b){W=b;try{var c=X(),d=X(),e=Kc[c];if(!e)return 0; -if(d===e.uc){var g=Q[e.fd],k=e.flags,m=new Uint8Array(F.subarray(c,c+d));g&&g.cb.Ab&&g.cb.Ab(g,m,0,d,k);Kc[c]=null;e.Db&&ha(e.vc)}return 0}catch(y){return"undefined"!==typeof V&&y instanceof K||B(y),-y.eb}},M:function(a,b){W=b;try{var c=X(),d=X(),e=Q[c];if(!e)throw new K(9);ka(e.node,d);return 0}catch(g){return"undefined"!==typeof V&&g instanceof K||B(g),-g.eb}},L:Da,K:function(a,b,c){F.set(F.subarray(b,b+c),a)},J:Ea,r:Mc,q:function(a){var b=Date.now();D[a>>2]=b/1E3|0;D[a+4>>2]=b%1E3*1E3|0;return 0}, -I:function(a){return Math.log(a)/Math.LN10},p:function(){B("trap!")},H:function(a){Nc();a=new Date(1E3*D[a>>2]);D[15056]=a.getSeconds();D[15057]=a.getMinutes();D[15058]=a.getHours();D[15059]=a.getDate();D[15060]=a.getMonth();D[15061]=a.getFullYear()-1900;D[15062]=a.getDay();var b=new Date(a.getFullYear(),0,1);D[15063]=(a.getTime()-b.getTime())/864E5|0;D[15065]=-(60*a.getTimezoneOffset());var c=(new Date(2E3,6,1)).getTimezoneOffset();b=b.getTimezoneOffset();a=(c!=b&&a.getTimezoneOffset()==Math.min(b, -c))|0;D[15064]=a;a=D[Rc()+(a?4:0)>>2];D[15066]=a;return 60224},G:function(a,b){var c=D[a>>2];a=D[a+4>>2];0!==b&&(D[b>>2]=0,D[b+4>>2]=0);return Sc(1E6*c+a/1E3)},F:function(a){switch(a){case 30:return 16384;case 85:return 131068;case 132:case 133:case 12:case 137:case 138:case 15:case 235:case 16:case 17:case 18:case 19:case 20:case 149:case 13:case 10:case 236:case 153:case 9:case 21:case 22:case 159:case 154:case 14:case 77:case 78:case 139:case 80:case 81:case 82:case 68:case 67:case 164:case 11:case 29:case 47:case 48:case 95:case 52:case 51:case 46:return 200809; -case 79:return 0;case 27:case 246:case 127:case 128:case 23:case 24:case 160:case 161:case 181:case 182:case 242:case 183:case 184:case 243:case 244:case 245:case 165:case 178:case 179:case 49:case 50:case 168:case 169:case 175:case 170:case 171:case 172:case 97:case 76:case 32:case 173:case 35:return-1;case 176:case 177:case 7:case 155:case 8:case 157:case 125:case 126:case 92:case 93:case 129:case 130:case 131:case 94:case 91:return 1;case 74:case 60:case 69:case 70:case 4:return 1024;case 31:case 42:case 72:return 32; -case 87:case 26:case 33:return 2147483647;case 34:case 1:return 47839;case 38:case 36:return 99;case 43:case 37:return 2048;case 0:return 2097152;case 3:return 65536;case 28:return 32768;case 44:return 32767;case 75:return 16384;case 39:return 1E3;case 89:return 700;case 71:return 256;case 40:return 255;case 2:return 100;case 180:return 64;case 25:return 20;case 5:return 16;case 6:return 6;case 73:return 4;case 84:return"object"===typeof navigator?navigator.hardwareConcurrency||1:1}pb(22);return-1}, -E:function(a){var b=Date.now()/1E3|0;a&&(D[a>>2]=b);return b},D:function(a,b){if(b){var c=1E3*D[b+8>>2];c+=D[b+12>>2]/1E3}else c=Date.now();a=G(a);try{b=c;var d=T(a,{qb:!0}).node;d.ab.hb(d,{timestamp:Math.max(b,c)});return 0}catch(e){a=e;if(!(a instanceof K)){a+=" : ";a:{d=Error();if(!d.stack){try{throw Error(0);}catch(g){d=g}if(!d.stack){d="(no stack trace available)";break a}}d=d.stack.toString()}f.extraStackTrace&&(d+="\n"+f.extraStackTrace());d=Va(d);throw a+d;}pb(a.eb);return-1}},C:function(){B("OOM")}, -a:Ca},buffer);f.asm=Vc;f._RegisterExtensionFunctions=function(){return f.asm.ha.apply(null,arguments)};var nb=f.___emscripten_environ_constructor=function(){return f.asm.ia.apply(null,arguments)};f.___errno_location=function(){return f.asm.ja.apply(null,arguments)}; -var Qc=f.__get_daylight=function(){return f.asm.ka.apply(null,arguments)},Pc=f.__get_timezone=function(){return f.asm.la.apply(null,arguments)},Rc=f.__get_tzname=function(){return f.asm.ma.apply(null,arguments)},ha=f._free=function(){return f.asm.na.apply(null,arguments)},Ta=f._malloc=function(){return f.asm.oa.apply(null,arguments)},Tc=f._memalign=function(){return f.asm.pa.apply(null,arguments)},Uc=f._memset=function(){return f.asm.qa.apply(null,arguments)}; -f._sqlite3_bind_blob=function(){return f.asm.ra.apply(null,arguments)};f._sqlite3_bind_double=function(){return f.asm.sa.apply(null,arguments)};f._sqlite3_bind_int=function(){return f.asm.ta.apply(null,arguments)};f._sqlite3_bind_parameter_index=function(){return f.asm.ua.apply(null,arguments)};f._sqlite3_bind_text=function(){return f.asm.va.apply(null,arguments)};f._sqlite3_changes=function(){return f.asm.wa.apply(null,arguments)};f._sqlite3_clear_bindings=function(){return f.asm.xa.apply(null,arguments)}; -f._sqlite3_close_v2=function(){return f.asm.ya.apply(null,arguments)};f._sqlite3_column_blob=function(){return f.asm.za.apply(null,arguments)};f._sqlite3_column_bytes=function(){return f.asm.Aa.apply(null,arguments)};f._sqlite3_column_double=function(){return f.asm.Ba.apply(null,arguments)};f._sqlite3_column_name=function(){return f.asm.Ca.apply(null,arguments)};f._sqlite3_column_text=function(){return f.asm.Da.apply(null,arguments)};f._sqlite3_column_type=function(){return f.asm.Ea.apply(null,arguments)}; -f._sqlite3_create_function_v2=function(){return f.asm.Fa.apply(null,arguments)};f._sqlite3_data_count=function(){return f.asm.Ga.apply(null,arguments)};f._sqlite3_errmsg=function(){return f.asm.Ha.apply(null,arguments)};f._sqlite3_exec=function(){return f.asm.Ia.apply(null,arguments)};f._sqlite3_finalize=function(){return f.asm.Ja.apply(null,arguments)};f._sqlite3_free=function(){return f.asm.Ka.apply(null,arguments)};f._sqlite3_open=function(){return f.asm.La.apply(null,arguments)}; -f._sqlite3_prepare_v2=function(){return f.asm.Ma.apply(null,arguments)};f._sqlite3_reset=function(){return f.asm.Na.apply(null,arguments)};f._sqlite3_result_double=function(){return f.asm.Oa.apply(null,arguments)};f._sqlite3_result_null=function(){return f.asm.Pa.apply(null,arguments)};f._sqlite3_result_text=function(){return f.asm.Qa.apply(null,arguments)};f._sqlite3_step=function(){return f.asm.Ra.apply(null,arguments)};f._sqlite3_value_blob=function(){return f.asm.Sa.apply(null,arguments)}; -f._sqlite3_value_bytes=function(){return f.asm.Ta.apply(null,arguments)};f._sqlite3_value_double=function(){return f.asm.Ua.apply(null,arguments)};f._sqlite3_value_int=function(){return f.asm.Va.apply(null,arguments)};f._sqlite3_value_text=function(){return f.asm.Wa.apply(null,arguments)};f._sqlite3_value_type=function(){return f.asm.Xa.apply(null,arguments)}; -var h=f.stackAlloc=function(){return f.asm.Za.apply(null,arguments)},qa=f.stackRestore=function(){return f.asm._a.apply(null,arguments)},na=f.stackSave=function(){return f.asm.$a.apply(null,arguments)};f.dynCall_vi=function(){return f.asm.Ya.apply(null,arguments)};f.asm=Vc;f.cwrap=function(a,b,c,d){c=c||[];var e=c.every(function(a){return"number"===a});return"string"!==b&&e&&!d?Na(a):function(){return Oa(a,b,c,arguments)}};f.stackSave=na;f.stackRestore=qa;f.stackAlloc=h; -function Wc(a){this.name="ExitStatus";this.message="Program terminated with exit("+a+")";this.status=a}Wc.prototype=Error();Wc.prototype.constructor=Wc;gb=function Xc(){f.calledRun||Yc();f.calledRun||(gb=Xc)}; -function Yc(){function a(){if(!f.calledRun&&(f.calledRun=!0,!Ma)){db||(db=!0,f.noFSInit||Hc||(Hc=!0,Gc(),f.stdin=f.stdin,f.stdout=f.stdout,f.stderr=f.stderr,f.stdin?Ic("stdin",f.stdin):Ac("/dev/tty","/dev/stdin"),f.stdout?Ic("stdout",null,f.stdout):Ac("/dev/tty","/dev/stdout"),f.stderr?Ic("stderr",null,f.stderr):Ac("/dev/tty1","/dev/stderr"),p("/dev/stdin","r"),p("/dev/stdout","w"),p("/dev/stderr","w")),Za(ab));Jb=!1;Za(bb);if(f.onRuntimeInitialized)f.onRuntimeInitialized();if(f.postRun)for("function"== -typeof f.postRun&&(f.postRun=[f.postRun]);f.postRun.length;){var a=f.postRun.shift();cb.unshift(a)}Za(cb)}}if(!(0[ - Padding( - padding: const EdgeInsets.only(right: 10.0), - child: Center( - child: ChannelImage( - onTap: onImageTap, - ), - ), - ), - ], - centerTitle: true, - title: InkWell( - onTap: onTitleTap, - child: Container( - height: preferredSize.height, - width: preferredSize.width, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ChannelName( - textStyle: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .title, - ), - _buildLastActive(context, channel), - ], - ), - ), - ), - ); - } - - Widget _buildLastActive(BuildContext context, Channel channel) { - return StreamBuilder( - stream: channel.lastMessageAtStream, - initialData: channel.lastMessageAt, - builder: (context, snapshot) { - if (snapshot.data == null) { - return SizedBox(); - } - final jiffyDate = Jiffy(snapshot.data?.toLocal()); - return Text( - 'Active ${jiffyDate.isBefore(Jiffy()) ? jiffyDate.fromNow() : 'now'}', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, - ); - }, - ); - } - - @override - final Size preferredSize; -} diff --git a/lib/src/channel_name.dart b/lib/src/channel_name.dart deleted file mode 100644 index 96a6a14a..00000000 --- a/lib/src/channel_name.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; - -import '../stream_chat_flutter.dart'; -import 'stream_channel.dart'; - -/// It shows the current [Channel] name using a [Text] widget. -/// -/// The widget uses a [StreamBuilder] to render the channel information image as soon as it updates. -class ChannelName extends StatelessWidget { - /// Instantiate a new ChannelName - const ChannelName({ - Key key, - this.channel, - this.textStyle, - }) : super(key: key); - - /// The channel to show the name of - final Channel channel; - - /// The style of the text displayed - final TextStyle textStyle; - - @override - Widget build(BuildContext context) { - final client = StreamChat.of(context); - final channel = this.channel ?? StreamChannel.of(context).channel; - return StreamBuilder>( - stream: channel.extraDataStream, - initialData: channel.extraData, - builder: (context, snapshot) { - String title; - if (snapshot.data['name'] == null && - channel.state.members.length == 2) { - final otherMember = channel.state.members - .firstWhere((member) => member.user.id != client.user.id); - title = otherMember.user.name; - } else { - title = snapshot.data['name'] ?? channel.id; - } - - return Text( - title, - style: textStyle, - overflow: TextOverflow.ellipsis, - ); - }, - ); - } -} diff --git a/lib/src/date_divider.dart b/lib/src/date_divider.dart deleted file mode 100644 index 102c6e14..00000000 --- a/lib/src/date_divider.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; - -/// It shows a date divider depending on the date difference -class DateDivider extends StatelessWidget { - final DateTime dateTime; - - const DateDivider({ - Key key, - @required this.dateTime, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final divider = Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Divider(), - ), - ); - - final createdAt = Jiffy(dateTime); - final now = DateTime.now(); - final hourInfo = createdAt.format('h:mm a'); - - String dayInfo; - if (Jiffy(createdAt).isSame(now, Units.DAY)) { - dayInfo = 'TODAY'; - } else if (Jiffy(createdAt) - .isSame(now.subtract(Duration(days: 1)), Units.DAY)) { - dayInfo = 'YESTERDAY'; - } else if (Jiffy(createdAt).isAfter( - now.subtract(Duration(days: 7)), - Units.DAY, - )) { - dayInfo = createdAt.format('EEEE').toUpperCase(); - } else if (Jiffy(createdAt).isAfter( - Jiffy(now).subtract(years: 1), - Units.DAY, - )) { - dayInfo = createdAt.format('dd/MM').toUpperCase(); - } else { - dayInfo = createdAt.format('dd/MM/yyyy').toUpperCase(); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - divider, - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Text.rich( - TextSpan( - children: [ - TextSpan( - text: dayInfo, - style: TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: ' AT'), - TextSpan(text: ' $hourInfo'), - ], - style: TextStyle( - fontWeight: FontWeight.normal, - ), - ), - style: TextStyle( - fontSize: 10, - color: - Theme.of(context).textTheme.headline6.color.withOpacity(.5), - ), - ), - ), - divider, - ], - ); - } -} diff --git a/lib/src/deleted_message.dart b/lib/src/deleted_message.dart deleted file mode 100644 index 93fd62ff..00000000 --- a/lib/src/deleted_message.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; - -class DeletedMessage extends StatelessWidget { - const DeletedMessage({ - Key key, - @required this.messageTheme, - }) : super(key: key); - - final MessageTheme messageTheme; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Text( - 'This message was deleted...', - style: messageTheme.messageText.copyWith( - fontStyle: FontStyle.italic, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - ), - ), - ); - } -} diff --git a/lib/src/file_attachment.dart b/lib/src/file_attachment.dart deleted file mode 100644 index 05318862..00000000 --- a/lib/src/file_attachment.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/utils.dart'; - -class FileAttachment extends StatelessWidget { - final Attachment attachment; - final Size size; - - const FileAttachment({ - Key key, - @required this.attachment, - this.size, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Material( - child: InkWell( - onTap: () { - launchURL(context, attachment.assetUrl); - }, - child: Container( - width: size?.width ?? 100, - height: size?.height ?? 100, - child: Center( - child: Icon(Icons.attach_file), - ), - ), - ), - ); - } -} diff --git a/lib/src/full_screen_image.dart b/lib/src/full_screen_image.dart deleted file mode 100644 index 76953b1a..00000000 --- a/lib/src/full_screen_image.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:photo_view/photo_view.dart'; - -/// A full screen image widget -class FullScreenImage extends StatelessWidget { - /// The url of the image - final String url; - - /// Instantiate a new FullScreenImage - const FullScreenImage({ - Key key, - @required this.url, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: IconThemeData( - color: Colors.white, - ), - ), - body: PhotoView( - imageProvider: CachedNetworkImageProvider(url), - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: url, - ), - ), - ); - } -} diff --git a/lib/src/full_screen_video.dart b/lib/src/full_screen_video.dart deleted file mode 100644 index 98729243..00000000 --- a/lib/src/full_screen_video.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; - -import 'utils.dart'; - -class FullScreenVideo extends StatefulWidget { - final Attachment attachment; - - FullScreenVideo({ - Key key, - @required this.attachment, - }) : super(key: key); - - @override - _FullScreenVideoState createState() => _FullScreenVideoState(); -} - -class _FullScreenVideoState extends State { - ChewieController _chewieController; - VideoPlayerController _videoPlayerController; - bool initialized = false; - final GlobalKey _scaffoldKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Colors.black, - iconTheme: IconThemeData( - color: Colors.white, - ), - ), - body: Builder( - key: _scaffoldKey, - builder: (context) { - if (!initialized) { - return Center( - child: CircularProgressIndicator(), - ); - } - return Chewie( - controller: _chewieController, - ); - }, - ), - ); - } - - @override - void initState() { - super.initState(); - _videoPlayerController = - VideoPlayerController.network(widget.attachment.assetUrl); - _videoPlayerController.initialize().whenComplete(() { - setState(() { - initialized = true; - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController, - autoInitialize: false, - aspectRatio: _videoPlayerController.value.aspectRatio, - ); - }); - }); - - VoidCallback errorListener; - errorListener = () { - if (_videoPlayerController.value.hasError) { - Navigator.pop(context); - launchURL(_scaffoldKey.currentContext, widget.attachment.titleLink); - } - _videoPlayerController.removeListener(errorListener); - }; - _videoPlayerController.addListener(errorListener); - } - - @override - void dispose() { - _videoPlayerController?.dispose(); - _chewieController?.dispose(); - super.dispose(); - } -} diff --git a/lib/src/giphy_attachment.dart b/lib/src/giphy_attachment.dart deleted file mode 100644 index 62de980b..00000000 --- a/lib/src/giphy_attachment.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment_actions.dart'; - -import '../stream_chat_flutter.dart'; -import 'attachment_error.dart'; -import 'attachment_title.dart'; -import 'full_screen_image.dart'; - -class GiphyAttachment extends StatelessWidget { - final Attachment attachment; - final MessageTheme messageTheme; - final Message message; - final Size size; - - const GiphyAttachment({ - Key key, - this.attachment, - this.messageTheme, - this.message, - this.size, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (attachment.thumbUrl == null && - attachment.imageUrl == null && - attachment.assetUrl == null) { - return AttachmentError( - attachment: attachment, - ); - } - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Stack( - children: [ - GestureDetector( - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) { - return FullScreenImage( - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, - ); - })); - }, - child: CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, - ), - fit: BoxFit.cover, - ), - ), - ], - ), - if (attachment.title != null) - Container( - alignment: Alignment.bottomCenter, - child: Material( - color: messageTheme.messageBackgroundColor, - child: AttachmentTitle( - messageTheme: messageTheme, - attachment: attachment, - ), - ), - ), - if (attachment.actions != null) - AttachmentActions( - attachment: attachment, - message: message, - ), - ], - ); - } -} diff --git a/lib/src/message_actions_bottom_sheet.dart b/lib/src/message_actions_bottom_sheet.dart deleted file mode 100644 index 53ad4d22..00000000 --- a/lib/src/message_actions_bottom_sheet.dart +++ /dev/null @@ -1,218 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/reaction_picker.dart'; -import 'package:stream_chat_flutter/src/stream_channel.dart'; -import 'package:stream_chat_flutter/src/user_reaction_display.dart'; - -import '../stream_chat_flutter.dart'; -import 'message_input.dart'; -import 'stream_chat.dart'; - -class MessageActionsBottomSheet extends StatelessWidget { - final Widget Function(BuildContext, Message) editMessageInputBuilder; - final void Function(Message) onThreadTap; - final Message message; - final bool showReactions; - final bool showDeleteMessage; - final bool showEditMessage; - final bool showReply; - final Map reactionToEmoji = const { - 'love': '❤️️', - 'haha': '😂', - 'like': '👍', - 'sad': '😕', - 'angry': '😡', - 'wow': '😲', - }; - - const MessageActionsBottomSheet({ - Key key, - this.message, - this.showReactions, - this.showDeleteMessage, - this.showEditMessage, - this.onThreadTap, - this.showReply, - this.editMessageInputBuilder, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showReactions && - (message.status == MessageSendingStatus.SENT || - message.status == null) && - message.latestReactions.isNotEmpty) - UserReactionDisplay( - reactionToEmoji: reactionToEmoji, - message: message, - ), - if (showReactions && - (message.status == MessageSendingStatus.SENT || - message.status == null)) - ReactionPicker( - channel: channel, - reactionToEmoji: reactionToEmoji, - message: message, - ), - if (showDeleteMessage) _buildDeleteButton(context), - if (showEditMessage) _buildEditMessage(context), - if (showReply && - (message.status == MessageSendingStatus.SENT || - message.status == null) && - message.parentId == null) - _buildReplyButton(context), - ], - ), - ); - } - - FlatButton _buildDeleteButton(BuildContext context) { - return FlatButton( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Delete message', - style: - Theme.of(context).textTheme.headline5.copyWith(color: Colors.red), - ), - ), - onPressed: () { - Navigator.pop(context); - StreamChat.of(context).client.deleteMessage( - message, - StreamChannel.of(context).channel.cid, - ); - }, - ); - } - - FlatButton _buildEditMessage(BuildContext context) { - return FlatButton( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Edit message', - style: Theme.of(context).textTheme.headline5, - ), - ), - onPressed: () async { - Navigator.pop(context); - _showEditBottomSheet(context); - }, - ); - } - - void _showEditBottomSheet(BuildContext context) { - final channel = StreamChannel.of(context).channel; - showModalBottomSheet( - context: context, - elevation: 2, - clipBehavior: Clip.hardEdge, - isScrollControlled: true, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), - ), - builder: (context) { - return StreamChannel( - channel: channel, - child: Flex( - direction: Axis.vertical, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.only( - top: 16.0, - left: 16.0, - right: 16.0, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Edit message', - style: Theme.of(context).textTheme.headline6, - ), - Container( - height: 30, - padding: const EdgeInsets.all(2.0), - child: AspectRatio( - aspectRatio: 1, - child: RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - disabledElevation: 0, - hoverElevation: 0, - onPressed: () { - Navigator.of(context).pop(); - }, - fillColor: - Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(.1) - : Colors.black.withOpacity(.1), - padding: EdgeInsets.all(4), - child: Icon( - Icons.close, - size: 15, - color: StreamChatTheme.of(context) - .primaryIconTheme - .color, - ), - ), - ), - ), - ], - ), - ), - Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: editMessageInputBuilder != null - ? editMessageInputBuilder(context, message) - : MessageInput( - editMessage: message, - onMessageSent: (_) { - FocusScope.of(context).unfocus(); - Navigator.pop(context); - }, - ), - ), - ], - ), - ); - }, - ); - } - - FlatButton _buildReplyButton(BuildContext context) { - return FlatButton( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Start a thread', - style: Theme.of(context).textTheme.headline5, - ), - ), - onPressed: () { - Navigator.pop(context); - if (onThreadTap != null) { - onThreadTap(message); - } - }, - ); - } -} diff --git a/lib/src/reply_indicator.dart b/lib/src/reply_indicator.dart deleted file mode 100644 index cdb1cd57..00000000 --- a/lib/src/reply_indicator.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// A reply button indicator -class ReplyIndicator extends StatelessWidget { - final Message message; - final VoidCallback onTap; - final bool reversed; - final MessageTheme messageTheme; - - const ReplyIndicator({ - Key key, - this.message, - this.onTap, - this.reversed = false, - this.messageTheme, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - var row = [ - Text( - 'Replies: ${message.replyCount}', - style: messageTheme?.replies, - ), - Transform( - transform: Matrix4.rotationY(reversed ? 0 : pi), - alignment: Alignment.center, - child: Icon( - Icons.subdirectory_arrow_left, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white12 - : Colors.black12, - ), - ), - ]; - - if (!reversed) { - row = row.reversed.toList(); - } - - return GestureDetector( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Row( - mainAxisSize: MainAxisSize.min, - children: row, - ), - ), - ); - } -} diff --git a/lib/src/sending_indicator.dart b/lib/src/sending_indicator.dart deleted file mode 100644 index 81e7dfef..00000000 --- a/lib/src/sending_indicator.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Used to show the sending status of the message -class SendingIndicator extends StatelessWidget { - final Message message; - - const SendingIndicator({ - Key key, - this.message, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (message.status == MessageSendingStatus.SENT || message.status == null) { - return CircleAvatar( - radius: 4, - backgroundColor: StreamChatTheme.of(context).accentColor, - child: Icon( - Icons.done, - color: Colors.white, - size: 4, - ), - ); - } - if (message.status == MessageSendingStatus.SENDING || - message.status == MessageSendingStatus.UPDATING) { - return CircleAvatar( - radius: 4, - backgroundColor: Colors.grey, - child: Icon( - Icons.access_time, - size: 4, - color: Colors.white, - ), - ); - } - if (message.status == MessageSendingStatus.FAILED || - message.status == MessageSendingStatus.FAILED_UPDATE || - message.status == MessageSendingStatus.FAILED_DELETE) { - return CircleAvatar( - radius: 4, - backgroundColor: Color(0xffd0021B).withOpacity(.1), - child: Icon( - Icons.error_outline, - size: 4, - color: Colors.white, - ), - ); - } - - return SizedBox(); - } -} diff --git a/lib/src/stream_channel.dart b/lib/src/stream_channel.dart deleted file mode 100644 index 21ee99f3..00000000 --- a/lib/src/stream_channel.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/stream_chat.dart'; - -/// Widget used to provide information about the channel to the widget tree -/// -/// Use [StreamChannel.of] to get the current [StreamChannelState] instance. -class StreamChannel extends StatefulWidget { - StreamChannel({ - Key key, - @required this.child, - @required this.channel, - }) : super( - key: key, - ); - - final Widget child; - final Channel channel; - - /// Use this method to get the current [StreamChannelState] instance - static StreamChannelState of(BuildContext context) { - StreamChannelState streamChannelState; - - streamChannelState = context.findAncestorStateOfType(); - - if (streamChannelState == null) { - throw Exception( - 'You must have a StreamChannel widget at the top of your widget tree'); - } - - return streamChannelState; - } - - @override - StreamChannelState createState() => StreamChannelState(); -} - -class StreamChannelState extends State { - /// Current channel - Channel get channel => widget.channel; - - /// Current channel state stream - Stream get channelStateStream => - widget.channel.state.channelStateStream; - - final BehaviorSubject _queryMessageController = BehaviorSubject(); - - /// The stream notifying the state of queryMessage call - Stream get queryMessage => _queryMessageController.stream; - - bool _paginationEnded = false; - - /// Calls [channel.query] updating [queryMessage] stream - void queryMessages() { - if (_queryMessageController.value == true || _paginationEnded) { - return; - } - - _queryMessageController.add(true); - - String firstId; - if (channel.state.messages.isNotEmpty) { - firstId = channel.state.messages.first.id; - } - - final messageLimit = 50; - - widget.channel - .query( - messagesPagination: PaginationParams( - lessThan: firstId, - limit: messageLimit, - ), - preferOffline: true, - ) - .then((res) { - if (res.messages.isEmpty || res.messages.length < messageLimit) { - _paginationEnded = true; - } - _queryMessageController.add(false); - }).catchError((e, stack) { - _queryMessageController.addError(e, stack); - }); - } - - /// Calls [channel.getReplies] updating [queryMessage] stream - Future getReplies(String parentId) async { - if (_queryMessageController.value == true || _paginationEnded) { - return; - } - - _queryMessageController.add(true); - - String firstId; - if (widget.channel.state.threads.containsKey(parentId)) { - final thread = widget.channel.state.threads[parentId]; - - if (thread != null && thread.isNotEmpty) { - firstId = thread?.first?.id; - } - } - - final messageLimit = 50; - return widget.channel - .getReplies( - parentId, - PaginationParams( - lessThan: firstId, - limit: messageLimit, - ), - preferOffline: true, - ) - .then((res) { - if (res.messages.isEmpty || res.messages.length < messageLimit) { - _paginationEnded = true; - } - _queryMessageController.add(false); - }).catchError((e, stack) { - _queryMessageController.addError(e, stack); - }); - } - - /// Query the channel members and watchers - Future queryMembersAndWatchers() async { - await widget.channel.query( - membersPagination: PaginationParams( - offset: channel.state.members?.length, - limit: 100, - ), - watchersPagination: PaginationParams( - offset: channel.state.watchers?.length, - limit: 100, - ), - ); - } - - @override - void dispose() { - _queryMessageController.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (widget.channel == null) { - return Center( - child: CircularProgressIndicator(), - ); - } - return FutureBuilder( - future: widget.channel.initialized, - initialData: widget.channel.state != null, - builder: (context, snapshot) { - if (!snapshot.hasData || !snapshot.data) { - return Container( - height: 30, - child: Center( - child: CircularProgressIndicator(), - ), - ); - } else if (snapshot.hasError) { - return Container( - height: 30, - child: Center( - child: Text(snapshot.error), - ), - ); - } else { - return widget.child; - } - }, - ); - } -} diff --git a/lib/src/unread_indicator.dart b/lib/src/unread_indicator.dart deleted file mode 100644 index 1b711de7..00000000 --- a/lib/src/unread_indicator.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; - -class UnreadIndicator extends StatelessWidget { - const UnreadIndicator({ - Key key, - @required this.channel, - }) : super(key: key); - - final Channel channel; - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: channel.state.unreadCountStream, - initialData: channel.state.unreadCount, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == 0) { - return SizedBox(); - } - return Padding( - padding: const EdgeInsets.only(left: 8.0), - child: CircleAvatar( - backgroundColor: StreamChatTheme.of(context) - .channelPreviewTheme - .unreadCounterColor, - radius: 6, - child: Text( - '${snapshot.data}', - style: TextStyle(fontSize: 8), - ), - ), - ); - }); - } -} diff --git a/lib/src/user_avatar.dart b/lib/src/user_avatar.dart deleted file mode 100644 index 9b833cfe..00000000 --- a/lib/src/user_avatar.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; - -import '../stream_chat_flutter.dart'; - -class UserAvatar extends StatelessWidget { - const UserAvatar({ - Key key, - @required this.user, - this.constraints, - this.onTap, - }) : super(key: key); - - final User user; - final BoxConstraints constraints; - final void Function(User) onTap; - - @override - Widget build(BuildContext context) { - final hasImage = user.extraData?.containsKey('image') == true && - user.extraData['image'] != null && - user.extraData['image'] != ''; - return GestureDetector( - onTap: () { - if (onTap != null) { - onTap(user); - } - }, - child: ClipRRect( - borderRadius: StreamChatTheme.of(context) - .ownMessageTheme - .avatarTheme - .borderRadius, - child: Container( - constraints: constraints ?? - StreamChatTheme.of(context) - .ownMessageTheme - .avatarTheme - .constraints, - decoration: BoxDecoration( - color: StreamChatTheme.of(context).accentColor, - ), - child: hasImage - ? CachedNetworkImage( - imageUrl: user.extraData['image'], - errorWidget: (_, __, ___) { - return Center( - child: Text( - user.extraData?.containsKey('name') ?? false - ? user.extraData['name'][0] - : '', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ); - }, - fit: BoxFit.cover, - ) - : StreamChatTheme.of(context).defaultUserImage(context, user), - ), - ), - ); - } -} diff --git a/lib/src/utils.dart b/lib/src/utils.dart deleted file mode 100644 index c427099b..00000000 --- a/lib/src/utils.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; - -Future launchURL(BuildContext context, String url) async { - if (await canLaunch(url)) { - await launch(url); - } else { - Scaffold.of(context).showSnackBar( - SnackBar( - content: Text('Cannot launch the url'), - ), - ); - } -} diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 00000000..6a6c74dc --- /dev/null +++ b/melos.yaml @@ -0,0 +1,63 @@ +name: stream_chat_dart + +versioning: + mode: independent + +packages: + - packages/** + +scripts: + + # - Requires `pub global activate tuneup`. + analyze: > + melos exec -c 1 --fail-fast -- \ + pub global run tuneup check + + format: pub global run flutter_plugin_tools format + + + build:examples:ios: > + melos exec -c 1 --scope="*example*" --fail-fast -- \ + flutter build ios --no-codesign + + + build:examples:android: > + melos exec -c 1 --scope="*example*" --fail-fast -- \ + flutter build apk + + # Build any plugin example apps that have MacOS support. + # - Requires `flutter config --enable-macos-desktop` enabled. + # - Requires `flutter channel master && flutter upgrade`. + build:examples:macos: > + melos exec -c 1 --scope="*example*" --dir-exists=macos --fail-fast -- \ + flutter build macos + + + test:dart: > + melos exec -c 1 --fail-fast --no-flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ + flutter pub run test + + test:flutter: > + melos exec -c 1 --fail-fast --flutter --dir-exists=test --ignore="*example*" --ignore="*web*" -- \ + flutter test + + test:web: > + melos exec -c 1 --fail-fast --dir-exists=test --scope="*web*" -- \ + flutter test --platform=chrome + + + lint:pub: > + melos exec -c 5 --fail-fast --no-private --ignore="*example*" -- \ + pub publish --dry-run + + + postclean: > + melos exec -- \ + rm -rf ./build ./android/.gradle ./ios/.symlinks ./ios/Pods ./android/.idea ./.idea ./.dart-tool/build + +dev_dependencies: + pedantic: 1.9.2 + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: ">=1.22.4 <2.0.0" \ No newline at end of file diff --git a/packages/stream_chat/.gitignore b/packages/stream_chat/.gitignore new file mode 100644 index 00000000..2ea557c4 --- /dev/null +++ b/packages/stream_chat/.gitignore @@ -0,0 +1,60 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +coverage/ +coverage_helper_test.dart + +# Web related +lib/generated_plugin_registrant.dart + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ + +# Avoid committing generated Javascript files: +*.dart.js +*.info.json # Produced by the --dump-info flag. +*.js # When generated by dart2js. Don't specify *.js if your + # project includes source files written in JavaScript. +*.js_ +*.js.deps +*.js.map diff --git a/packages/stream_chat/.metadata b/packages/stream_chat/.metadata new file mode 100644 index 00000000..946cda16 --- /dev/null +++ b/packages/stream_chat/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 659dc8129d4edb9166e9a0d600439d135740933f + channel: beta + +project_type: package diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md new file mode 100644 index 00000000..06eb5d23 --- /dev/null +++ b/packages/stream_chat/CHANGELOG.md @@ -0,0 +1,459 @@ +## 1.0.1-beta + +- Fixed pub analysis issues + +## 1.0.0-beta + +- 🛑 **BREAKING** Renamed `Client` to less generic `StreamChatClient` +- 🛑 **BREAKING** Segregated the persistence layer into separate package [stream_chat_persistence](https://pub.dev/packages/stream_chat_persistence) +- 🛑 **BREAKING** Moved `Client.backgroundKeepAlive` to [core package](https://pub.dev/packages/stream_chat_core) +- 🛑 **BREAKING** Moved `Client.showLocalNotification` to [core package](https://pub.dev/packages/stream_chat_core) and renamed it to `StreamChatCore.onBackgroundEventReceived` +- Removed `flutter` dependency. This is now a pure Dart package 🥳 +- Minor improvements and bugfixes + +## 0.2.24+2 + +- Fix reconnection bug while using tokenProvider + +## 0.2.24+1 + +- Stop ws reconnection after calling disconnect + +## 0.2.24 + +- Create enum for push providers +- Add merge helper functions in `Message` and `ChannelModel` for easier data manipulation + +## 0.2.23+3 + +- Remove + notation from userAgent +- Fix optimistic update for totalUnreadCount + +## 0.2.23+2 + +- Do not throw an error when calling queryChannels without an active connection if the offline storage is enabled + +## 0.2.23+1 + +- Throw an error when calling queryChannels without an active connection +- Wait to establish a connection if calling queryChannels while connecting + +## 0.2.23 + +- Add thread_participants in message model + +## 0.2.22 + +- Add thread-less message reply feature (QuotedMessage) + +## 0.2.21+2 + +- Fix but not throwing error during querychannels and persistance disabled +- Fix reaction.updated event handling + +## 0.2.21+1 + +- Fix error in the offline storage queryChannelCids query + +## 0.2.21 + +- Fix channel.hide(clearHistory: true) not clearing local messages +- Add banned field to member + +## 0.2.20 + +- Return offline data only if the backend is unreachable. This avoids the glitch of the ChannelListView because we cannot sort by custom properties. + +## 0.2.19 + +- Added message filters for `Client.search()` + +## 0.2.18 + +- Correctly dispose resources when disposing the client state +- Limit parallel queryChannels with same parameters to 1 +- Added `clearUser` parameter to `client.disconnect` to remove the user instance of the client + +## 0.2.17+1 + +- Do not retry messages when server returns error + +## 0.2.17 + +- Add shadow ban feature + +## 0.2.16 + +- Listen for user.updated events + +## 0.2.15+2 + +- Fix reaction score updates + +## 0.2.15+1 + +- Listen to reaction.updated event + +## 0.2.15 + +- Fix search message response + +## 0.2.14 + +- Add event.extradata + +## 0.2.13+1 + +- Let user change channel.extradata if the channel is not initialized yet + +## 0.2.13 + +- Add parent_id to events for typing indicators in threads + +## 0.2.12+2 + +- Fix error with reactions with null user + +## 0.2.12 + +- Do not save channels in memory if not being watched. This was leading to some bugs in some specific use-cases. + +## 0.2.11 + +- Fix user.name getter +- Use detached loggers +- Throw error while connecting if it comes from backend +- Fix ws reconnection + +## 0.2.10+2 + +- Fix bug with event filtering + +## 0.2.10+1 + +- Add default limit to pagination + +## 0.2.10 + +- Added `channel.state.unreadCountStream` + +## 0.2.9 + +- Adding a message on `Channel.update` is now optional + +## 0.2.8+1 + +- Fix retry logic + +## 0.2.8 + +- Add missing event types +- Fix local sorting on offline storage + +## 0.2.7+1 + +- `Client.channel` returns an existing channel if available +- Update message in the offline storage if attachment has expired (for the new CDN) +- Fix `GetMessagesByIdResponse` format +- Do not query messages if already existing in offline storage + +## 0.2.6 + +- Experimental support for Flutter web and MacOs + +## 0.2.5+2 + +- Cleaned up Serialization on extra_data + +## 0.2.5+1 + +- Fix `channel.show` api call + +## 0.2.5 + +- Add `channelType` and `channelId` properties to event object + +## 0.2.4+2 + +- Fix query members messing channel state + +## 0.2.4+1 + +- Do not resync if there is no channel in offlinestorage + +## 0.2.4 + +- Add null-safety to ws disconnect +- Add pagination parameters to queryUsers request + +## 0.2.3+3 + +- Fix reaction add/remove logic + +## 0.2.3+2 + +- Skip system messages during unreadCount computation + +## 0.2.3+1 + +- Removed moor_ffi from dependencies in favor of moor/ffi + +## 0.2.3 + +- Fix reject invite payload + +- Add multi-tenant properties to channel and user + +## 0.2.2+1 + +- Fix queryChannels payload + +## 0.2.2 + +- Fix add/remove/invite members api calls + +## 0.2.1 + +- Add `isMutedStream` to `Channel` +- Add `isGroup` to `Channel` +- Add `isDistinct` to `Channel` + +## 0.2.0+2 + +- Fix search messages response class + +## 0.2.0+1 + +- Fix offline members update +- Add channel mutes +- Fix default channel sort + +## 0.2.0 + +- Add `lastMessage` getter to Channel.state +- Add `isSystem` property to Message +- Incremental websocket reconnection timeout +- Add translate message api call +- Add queryMembers api call +- Add user list to client state +- Synchronize channel members status +- Add offline storage +- Add push notifications helper functions + +## 0.2.0-alpha+23 + +- Add `lastMessage` getter to `Channel.state` + +## 0.2.0-alpha+22 + +- Add `isSystem` property to Message + +## 0.2.0-alpha+21 + +- Incremental websocket reconnection timeout + +## 0.2.0-alpha+20 + +- More robust offline storage insertions + +## 0.2.0-alpha+19 + +- Add translate message api call +- Add queryMembers api call + +## 0.2.0-alpha+18 + +- Revert moor_ffi version to 0.5.0 + +## 0.2.0-alpha+17 + +- Add user list to client + +- Synchronize channel members status + +## 0.2.0-alpha+16 + +- Try QueryChannels when `resync` endpoint returns an error + +## 0.2.0-alpha+15 + +- Fix receiving reactions + +## 0.2.0-alpha+14 + +- Avoid sending local event for optimistic updates + +## 0.2.0-alpha+13 + +- Fix offline on app first start up + +## 0.2.0-alpha+12 + +- Fix retry mechanism in threads +- Fix delete channel query + +## 0.2.0-alpha+9 + +- Add retry mechanism and retry queue + +## 0.2.0-alpha+8 + +- Add copyWith to Attachment + +## 0.2.0-alpha+7 + +- Add channel deleted/updated event handling + +## 0.2.0-alpha+6 + +- Align with stable release + +## 0.2.0-alpha+5 + +- Rename client parameters + +## 0.2.0-alpha+3 + +- Remove dependencies on notification service + +- Expose some helping method for integrate offline storage with push notifications + +## 0.2.0-alpha+2 + +- Fix unread count + +## 0.2.0-alpha + +- Offline storage + +- Push notifications + +- Minor bug fixes + +## 0.1.30 + +- Add silent property to message + +## 0.1.29 + +- Fix read event handling + +## 0.1.28 + +- Fix bug clearing members when receiving a message + +## 0.1.27 + +- Update dependencies + +## 0.1.26 + +- Remove wrong `members` property from `ChannelModel` + +## 0.1.25 + +- Fix online status + +## 0.1.24 + +- Fix unread count + +## 0.1.22 + +- Add mute/unmute channel + +## 0.1.20 + +- Fix channel query path without id + +## 0.1.19 + +- Fix loading message replies + +## 0.1.18 + +- Export dio error + +## 0.1.17 + +- Ignore current user typing events + +- Add event types + +## 0.1.16 + +- Fix message update + +## 0.1.15 + +- Fix mentions handling + +## 0.1.14 + +- Handle message modification and commands + +## 0.1.13 + +- Add message.updated event handling + +## 0.1.12 + +- Add export multipart_file from dio + +## 0.1.11 + +- Add channel config checks + +## 0.1.10 + +- Rename Channel.channelClients to channels + +## 0.1.9 + +- Fix channel update on message delete + +## 0.1.8 + +- Add delete message handling + +## 0.1.7 + +- Add reaction handling + +## 0.1.6 + +- Add initialized completer + +- Update example + +## 0.1.5 + +- Add `ClientState` and `ChannelClientState` classes to handle channel state updates using events + +- Update example supporting threads + +## 0.1.4 + +- Update some api with wrong or incomplete signatures + +- Add documentation for public apis + +## 0.1.2 + +- add websocket reconnection logic + +- add token expiration mechanism + +## 0.1.1 + +- add typing events handling + +## 0.1.0 + +- a better example can be found in the example/ directory + +- fix some api calls and add missing one + +## 0.0.2 + +- first beta version \ No newline at end of file diff --git a/packages/stream_chat/LICENSE b/packages/stream_chat/LICENSE new file mode 100644 index 00000000..f2d1eaf3 --- /dev/null +++ b/packages/stream_chat/LICENSE @@ -0,0 +1,219 @@ +SOURCE CODE LICENSE AGREEMENT + +IMPORTANT - READ THIS CAREFULLY BEFORE DOWNLOADING, INSTALLING, USING OR +ELECTRONICALLY ACCESSING THIS PROPRIETARY PRODUCT. + +THIS IS A LEGAL AGREEMENT BETWEEN STREAM.IO, INC. (“STREAM.IO”) AND THE +BUSINESS ENTITY OR PERSON FOR WHOM YOU (“YOU”) ARE ACTING (“CUSTOMER”) AS THE +LICENSEE OF THE PROPRIETARY SOFTWARE INTO WHICH THIS AGREEMENT HAS BEEN +INCLUDED (THE “AGREEMENT”). YOU AGREE THAT YOU ARE THE CUSTOMER, OR YOU ARE AN +EMPLOYEE OR AGENT OF CUSTOMER AND ARE ENTERING INTO THIS AGREEMENT FOR LICENSE +OF THE SOFTWARE BY CUSTOMER FOR CUSTOMER’S BUSINESS PURPOSES AS DESCRIBED IN +AND IN ACCORDANCE WITH THIS AGREEMENT. YOU HEREBY AGREE THAT YOU ENTER INTO +THIS AGREEMENT ON BEHALF OF CUSTOMER AND THAT YOU HAVE THE AUTHORITY TO BIND +CUSTOMER TO THIS AGREEMENT. + +STREAM.IO IS WILLING TO LICENSE THE SOFTWARE TO CUSTOMER ONLY ON THE FOLLOWING +CONDITIONS: (1) YOU ARE A CURRENT CUSTOMER OF STREAM.IO; (2) YOU ARE NOT A +COMPETITOR OF STREAM.IO; AND (3) THAT YOU ACCEPT ALL THE TERMS IN THIS +AGREEMENT. BY DOWNLOADING, INSTALLING, CONFIGURING, ACCESSING OR OTHERWISE +USING THE SOFTWARE, INCLUDING ANY UPDATES, UPGRADES, OR NEWER VERSIONS, YOU +REPRESENT, WARRANT AND ACKNOWLEDGE THAT (A) CUSTOMER IS A CURRENT CUSTOMER OF +STREAM.IO; (B) CUSTOMER IS NOT A COMPETITOR OF STREAM.IO; AND THAT (C) YOU HAVE +READ THIS AGREEMENT, UNDERSTAND THIS AGREEMENT, AND THAT CUSTOMER AGREES TO BE +BOUND BY ALL THE TERMS OF THIS AGREEMENT. + +IF YOU DO NOT AGREE TO ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT, +STREAM.IO IS UNWILLING TO LICENSE THE SOFTWARE TO CUSTOMER, AND THEREFORE, DO +NOT COMPLETE THE DOWNLOAD PROCESS, ACCESS OR OTHERWISE USE THE SOFTWARE, AND +CUSTOMER SHOULD IMMEDIATELY RETURN THE SOFTWARE AND CEASE ANY USE OF THE +SOFTWARE. + +1. SOFTWARE. The Stream.io software accompanying this Agreement, may include +Source Code, Executable Object Code, associated media, printed materials and +documentation (collectively, the “Software”). The Software also includes any +updates or upgrades to or new versions of the original Software, if and when +made available to you by Stream.io. “Source Code” means computer programming +code in human readable form that is not suitable for machine execution without +the intervening steps of interpretation or compilation. “Executable Object +Code" means the computer programming code in any other form than Source Code +that is not readily perceivable by humans and suitable for machine execution +without the intervening steps of interpretation or compilation. “Site” means a +Customer location controlled by Customer. “Authorized User” means any employee +or contractor of Customer working at the Site, who has signed a written +confidentiality agreement with Customer or is otherwise bound in writing by +confidentiality and use obligations at least as restrictive as those imposed +under this Agreement. + +2. LICENSE GRANT. Subject to the terms and conditions of this Agreement, in +consideration for the representations, warranties, and covenants made by +Customer in this Agreement, Stream.io grants to Customer, during the term of +this Agreement, a personal, non-exclusive, non-transferable, non-sublicensable +license to: + +a. install and use Software Source Code on password protected computers at a Site, +restricted to Authorized Users; + +b. create derivative works, improvements (whether or not patentable), extensions +and other modifications to the Software Source Code (“Modifications”) to build +unique scalable newsfeeds, activity streams, and in-app messaging via Stream’s +application program interface (“API”); + +c. compile the Software Source Code to create Executable Object Code versions of +the Software Source Code and Modifications to build such newsfeeds, activity +streams, and in-app messaging via the API; + +d. install, execute and use such Executable Object Code versions solely for +Customer’s internal business use (including development of websites through +which data generated by Stream services will be streamed (“Apps”)); + +e. use and distribute such Executable Object Code as part of Customer’s Apps; and + +f. make electronic copies of the Software and Modifications as required for backup +or archival purposes. + +3. RESTRICTIONS. Customer is responsible for all activities that occur in +connection with the Software. Customer will not, and will not attempt to: (a) +sublicense or transfer the Software or any Source Code related to the Software +or any of Customer’s rights under this Agreement, except as otherwise provided +in this Agreement, (b) use the Software Source Code for the benefit of a third +party or to operate a service; (c) allow any third party to access or use the +Software Source Code; (d) sublicense or distribute the Software Source Code or +any Modifications in Source Code or other derivative works based on any part of +the Software Source Code; (e) use the Software in any manner that competes with +Stream.io or its business; or (e) otherwise use the Software in any manner that +exceeds the scope of use permitted in this Agreement. Customer shall use the +Software in compliance with any accompanying documentation any laws applicable +to Customer. + +4. OPEN SOURCE. Customer and its Authorized Users shall not use any software or +software components that are open source in conjunction with the Software +Source Code or any Modifications in Source Code or in any way that could +subject the Software to any open source licenses. + +5. CONTRACTORS. Under the rights granted to Customer under this Agreement, +Customer may permit its employees, contractors, and agencies of Customer to +become Authorized Users to exercise the rights to the Software granted to +Customer in accordance with this Agreement solely on behalf of Customer to +provide services to Customer; provided that Customer shall be liable for the +acts and omissions of all Authorized Users to the extent any of such acts or +omissions, if performed by Customer, would constitute a breach of, or otherwise +give rise to liability to Customer under, this Agreement. Customer shall not +and shall not permit any Authorized User to use the Software except as +expressly permitted in this Agreement. + +6. COMPETITIVE PRODUCT DEVELOPMENT. Customer shall not use the Software in any way +to engage in the development of products or services which could be reasonably +construed to provide a complete or partial functional or commercial alternative +to Stream.io’s products or services (a “Competitive Product”). Customer shall +ensure that there is no direct or indirect use of, or sharing of, Software +source code, or other information based upon or derived from the Software to +develop such products or services. Without derogating from the generality of +the foregoing, development of Competitive Products shall include having direct +or indirect access to, supervising, consulting or assisting in the development +of, or producing any specifications, documentation, object code or source code +for, all or part of a Competitive Product. + +7. LIMITATION ON MODIFICATIONS. Notwithstanding any provision in this Agreement, +Modifications may only be created and used by Customer as permitted by this +Agreement and Modification Source Code may not be distributed to third parties. +Customer will not assert against Stream.io, its affiliates, or their customers, +direct or indirect, agents and contractors, in any way, any patent rights that +Customer may obtain relating to any Modifications for Stream.io, its +affiliates’, or their customers’, direct or indirect, agents’ and contractors’ +manufacture, use, import, offer for sale or sale of any Stream.io products or +services. + +8. DELIVERY AND ACCEPTANCE. The Software will be delivered electronically pursuant +to Stream.io standard download procedures. The Software is deemed accepted upon +delivery. + +9. IMPLEMENTATION AND SUPPORT. Stream.io has no obligation under this Agreement to +provide any support or consultation concerning the Software. + +10. TERM AND TERMINATION. The term of this Agreement begins when the Software is +downloaded or accessed and shall continue until terminated. Either party may +terminate this Agreement upon written notice. This Agreement shall +automatically terminate if Customer is or becomes a competitor of Stream.io or +makes or sells any Competitive Products. Upon termination of this Agreement for +any reason, (a) all rights granted to Customer in this Agreement immediately +cease to exist, (b) Customer must promptly discontinue all use of the Software +and return to Stream.io or destroy all copies of the Software in Customer’s +possession or control. Any continued use of the Software by Customer or attempt +by Customer to exercise any rights under this Agreement after this Agreement +has terminated shall be considered copyright infringement and subject Customer +to applicable remedies for copyright infringement. Sections 2, 5, 6, 8 and 9 +shall survive expiration or termination of this Agreement for any reason. + +11. OWNERSHIP. As between the parties, the Software and all worldwide intellectual +property rights and proprietary rights relating thereto or embodied therein, +are the exclusive property of Stream.io and its suppliers. Stream.io and its +suppliers reserve all rights in and to the Software not expressly granted to +Customer in this Agreement, and no other licenses or rights are granted by +implication, estoppel or otherwise. + +12. WARRANTY DISCLAIMER. USE OF THIS SOFTWARE IS ENTIRELY AT YOURS AND CUSTOMER’S +OWN RISK. THE SOFTWARE IS PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND +WHATSOEVER. STREAM.IO DOES NOT MAKE, AND HEREBY DISCLAIMS, ANY WARRANTY OF ANY +KIND, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING WITHOUT +LIMITATION, THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, TITLE, NON-INFRINGEMENT OF THIRD-PARTY RIGHTS, RESULTS, EFFORTS, +QUALITY OR QUIET ENJOYMENT. STREAM.IO DOES NOT WARRANT THAT THE SOFTWARE IS +ERROR-FREE, WILL FUNCTION WITHOUT INTERRUPTION, WILL MEET ANY SPECIFIC NEED +THAT CUSTOMER HAS, THAT ALL DEFECTS WILL BE CORRECTED OR THAT IT IS +SUFFICIENTLY DOCUMENTED TO BE USABLE BY CUSTOMER. TO THE EXTENT THAT STREAM.IO +MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW, THE SCOPE AND +DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER SUCH LAW. +CUSTOMER ACKNOWLEDGES THAT IT HAS RELIED ON NO WARRANTIES OTHER THAN THE +EXPRESS WARRANTIES IN THIS AGREEMENT. + +13. LIMITATION OF LIABILITY. TO THE FULLEST EXTENT PERMISSIBLE BY LAW, STREAM.IO’S +TOTAL LIABILITY FOR ALL DAMAGES ARISING OUT OF OR RELATED TO THE SOFTWARE OR +THIS AGREEMENT, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE) OR OTHERWISE, +SHALL NOT EXCEED $100. IN NO EVENT WILL STREAM.IO BE LIABLE FOR ANY INDIRECT, +CONSEQUENTIAL, EXEMPLARY, PUNITIVE, SPECIAL OR INCIDENTAL DAMAGES OF ANY KIND +WHATSOEVER, INCLUDING ANY LOST DATA AND LOST PROFITS, ARISING FROM OR RELATING +TO THE SOFTWARE EVEN IF STREAM.IO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. CUSTOMER ACKNOWLEDGES THAT THIS PROVISION REFLECTS THE AGREED UPON +ALLOCATION OF RISK FOR THIS AGREEMENT AND THAT STREAM.IO WOULD NOT ENTER INTO +THIS AGREEMENT WITHOUT THESE LIMITATIONS ON ITS LIABILITY. + +14. General. Customer may not assign or transfer this Agreement, by operation of +law or otherwise, or any of its rights under this Agreement (including the +license rights granted to Customer) to any third party without Stream.io’s +prior written consent, which consent will not be unreasonably withheld or +delayed. Stream.io may assign this Agreement, without consent, including, but +limited to, affiliate or any successor to all or substantially all its business +or assets to which this Agreement relates, whether by merger, sale of assets, +sale of stock, reorganization or otherwise. Any attempted assignment or +transfer in violation of the foregoing will be null and void. Stream.io shall +not be liable hereunder by reason of any failure or delay in the performance of +its obligations hereunder for any cause which is beyond the reasonable control. +All notices, consents, and approvals under this Agreement must be delivered in +writing by courier, by electronic mail, or by certified or registered mail, +(postage prepaid and return receipt requested) to the other party at the +address set forth in the customer agreement between Stream.io and Customer and +will be effective upon receipt or when delivery is refused. This Agreement will +be governed by and interpreted in accordance with the laws of the State of +Colorado, without reference to its choice of laws rules. The United Nations +Convention on Contracts for the International Sale of Goods does not apply to +this Agreement. Any action or proceeding arising from or relating to this +Agreement shall be brought in a federal or state court in Denver, Colorado, and +each party irrevocably submits to the jurisdiction and venue of any such court +in any such action or proceeding. All waivers must be in writing. Any waiver or +failure to enforce any provision of this Agreement on one occasion will not be +deemed a waiver of any other provision or of such provision on any other +occasion. If any provision of this Agreement is unenforceable, such provision +will be changed and interpreted to accomplish the objectives of such provision +to the greatest extent possible under applicable law and the remaining +provisions will continue in full force and effect. Customer shall not violate +any applicable law, rule or regulation, including those regarding the export of +technical data. The headings of Sections of this Agreement are for convenience +and are not to be used in interpreting this Agreement. As used in this +Agreement, the word “including” means “including but not limited to.” This +Agreement (including all exhibits and attachments) constitutes the entire +agreement between the parties regarding the subject hereof and supersedes all +prior or contemporaneous agreements, understandings and communication, whether +written or oral. This Agreement may be amended only by a written document +signed by both parties. The terms of any purchase order or similar document +submitted by Customer to Stream.io will have no effect. diff --git a/packages/stream_chat/README.md b/packages/stream_chat/README.md new file mode 100644 index 00000000..e0ed18bf --- /dev/null +++ b/packages/stream_chat/README.md @@ -0,0 +1,114 @@ +# Stream Chat Dart +[![Pub](https://img.shields.io/pub/v/stream_chat.svg)](https://pub.dartlang.org/packages/stream_chat) +![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) +![CI](https://github.com/GetStream/stream-chat-dart/workflows/CI/badge.svg?branch=master) +[![codecov](https://codecov.io/gh/GetStream/stream-chat-dart/branch/master/graph/badge.svg)](https://codecov.io/gh/GetStream/stream-chat-dart) + +stream-chat-dart is the official Dart client for Stream Chat, a service for building chat applications. This library can be used on any Dart project and on both mobile and web apps with Flutter. + +You can sign up for a Stream account at https://getstream.io/chat/ + +## Getting started + +### Add dependency + +```yaml +dependencies: + stream_chat: ^1.0.0-beta +``` + +You should then run `flutter packages get` + +## Example Project + +There is a detailed Flutter example project in the `example` folder. You can directly run and play on it. + +## Setup API Client + +First you need to instantiate a chat client. The Chat client will manage API call, event handling and manage the websocket connection to Stream Chat servers. You should only create the client once and re-use it across your application. + +```dart +final client = StreamChatClient("stream-chat-api-key"); +``` + +### Logging + +By default the Chat Client will write all messages with level Warn or Error to stdout. + +#### Change Logging Level + +During development you might want to enable more logging information, you can change the default log level when constructing the client. + +```dart +final client = StreamChatClient("stream-chat-api-key", logLevel: Level.INFO); +``` + +#### Custom Logger + +You can handle the log messages directly instead of have them written to stdout, this is very convenient if you use an error tracking tool or if you want to centralize your logs into one facility. + +```dart +myLogHandlerFunction = (LogRecord record) { + // do something with the record (ie. send it to Sentry or Fabric) +} + +final client = StreamChatClient("stream-chat-api-key", logHandlerFunction: myLogHandlerFunction); +``` + +### Offline storage + +To add data persistance 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. + +```dart +import 'package:stream_chat_persistence/stream_chat_persistence.dart'; + +final chatPersistentClient = StreamChatPersistenceClient( + logLevel: Level.INFO, + connectionMode: ConnectionMode.background, +); + +final client = StreamChatClient( + apiKey ?? kDefaultStreamApiKey, + logLevel: Level.INFO, +)..chatPersistenceClient = chatPersistentClient; +``` + +## Contributing + +### Code conventions + +- Make sure that you run `dartfmt` before commiting your code +- Make sure all public methods and functions are well documented + +### Running tests + +- run `flutter test` + +### Releasing a new version + +- update the package version on `pubspec.yaml` and `version.dart` + +- add a changelog entry on `CHANGELOG.md` + +- run `flutter pub publish` to publish the package + +### Watch models and generate JSON code + +JSON serialization relies on code generation; make sure to keep that running while you make changes to the library + +```bash +flutter pub run build_runner watch +``` diff --git a/packages/stream_chat/analysis_options.yaml b/packages/stream_chat/analysis_options.yaml new file mode 100644 index 00000000..87c12424 --- /dev/null +++ b/packages/stream_chat/analysis_options.yaml @@ -0,0 +1,62 @@ +include: package:pedantic/analysis_options.yaml + +analyzer: + exclude: + - lib/**/*.g.dart + - example/* + - test/* + +linter: + rules: + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # https://github.com/dart-lang/linter/blob/master/example/all.yaml + # - always_declare_return_types + # - always_specify_types + # - annotate_overrides + # - avoid_as + - avoid_empty_else + - avoid_init_to_null + - avoid_return_types_on_setters + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_types + - cancel_subscriptions + - close_sinks + # - comment_references # we do not presume as to what people want to reference in their dartdocs + # - constant_identifier_names # https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + - empty_constructor_bodies + - empty_statements + - hash_and_equals + - implementation_imports + # - invariant_booleans + # - iterable_contains_unrelated_type + - library_names + # - library_prefixes + # - list_remove_unrelated_type + # - literal_only_boolean_expressions + - non_constant_identifier_names + # - one_member_abstracts + # - only_throw_errors + # - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + - prefer_is_not_empty + # - prefer_mixin # https://github.com/dart-lang/language/issues/32 + - public_member_api_docs + - slash_for_doc_comments + # - sort_constructors_first + # - sort_unnamed_constructors_first + # - super_goes_last # no longer needed w/ Dart 2 + - test_types_in_equals + - throw_in_finally + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + # - unawaited_futures + - unnecessary_brace_in_string_interps + - unnecessary_getters_setters + - unnecessary_statements + - unrelated_type_equality_checks + - valid_regexps \ No newline at end of file diff --git a/packages/stream_chat/build.yaml b/packages/stream_chat/build.yaml new file mode 100644 index 00000000..ddbd70dd --- /dev/null +++ b/packages/stream_chat/build.yaml @@ -0,0 +1,8 @@ +targets: + $default: + builders: + json_serializable: + options: + explicit_to_json: true + field_rename: snake + any_map: true diff --git a/example/.gitignore b/packages/stream_chat/example/.gitignore similarity index 83% rename from example/.gitignore rename to packages/stream_chat/example/.gitignore index 8011d84b..9d532b18 100644 --- a/example/.gitignore +++ b/packages/stream_chat/example/.gitignore @@ -22,6 +22,7 @@ # Flutter/Dart/Pub related **/doc/api/ +**/ios/Flutter/.last_build_id .dart_tool/ .flutter-plugins .flutter-plugins-dependencies @@ -33,7 +34,8 @@ # Web related lib/generated_plugin_registrant.dart -# Exceptions to above rules. -!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +# Symbolication related +app.*.symbols -fvm \ No newline at end of file +# Obfuscation related +app.*.map.json diff --git a/example/.metadata b/packages/stream_chat/example/.metadata similarity index 82% rename from example/.metadata rename to packages/stream_chat/example/.metadata index 01d2dcb9..182cccaf 100644 --- a/example/.metadata +++ b/packages/stream_chat/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: 0b8abb4724aa590dd0f429683339b1e045a1594d + revision: 78910062997c3a836feee883712c241a5fd22983 channel: stable project_type: app diff --git a/packages/stream_chat/example/README.md b/packages/stream_chat/example/README.md new file mode 100644 index 00000000..11089159 --- /dev/null +++ b/packages/stream_chat/example/README.md @@ -0,0 +1,2 @@ +# Stream Chat Dart Example +Please see `lib/` for example code. \ No newline at end of file diff --git a/packages/stream_chat/example/android/.gitignore b/packages/stream_chat/example/android/.gitignore new file mode 100644 index 00000000..0a741cb4 --- /dev/null +++ b/packages/stream_chat/example/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/packages/stream_chat/example/android/app/build.gradle b/packages/stream_chat/example/android/app/build.gradle new file mode 100644 index 00000000..3932aa91 --- /dev/null +++ b/packages/stream_chat/example/android/app/build.gradle @@ -0,0 +1,63 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.example" + minSdkVersion 16 + targetSdkVersion 29 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/example/android/app/src/debug/AndroidManifest.xml b/packages/stream_chat/example/android/app/src/debug/AndroidManifest.xml similarity index 100% rename from example/android/app/src/debug/AndroidManifest.xml rename to packages/stream_chat/example/android/app/src/debug/AndroidManifest.xml diff --git a/packages/stream_chat/example/android/app/src/main/AndroidManifest.xml b/packages/stream_chat/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..55ca830c --- /dev/null +++ b/packages/stream_chat/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/packages/stream_chat/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 00000000..e793a000 --- /dev/null +++ b/packages/stream_chat/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/example/android/app/src/main/res/drawable/launch_background.xml b/packages/stream_chat/example/android/app/src/main/res/drawable/launch_background.xml similarity index 100% rename from example/android/app/src/main/res/drawable/launch_background.xml rename to packages/stream_chat/example/android/app/src/main/res/drawable/launch_background.xml diff --git a/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/stream_chat/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png rename to packages/stream_chat/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/stream_chat/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png rename to packages/stream_chat/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/stream_chat/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png rename to packages/stream_chat/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/stream_chat/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png rename to packages/stream_chat/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/stream_chat/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png rename to packages/stream_chat/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/packages/stream_chat/example/android/app/src/main/res/values/styles.xml b/packages/stream_chat/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..1f83a33f --- /dev/null +++ b/packages/stream_chat/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/example/android/app/src/profile/AndroidManifest.xml b/packages/stream_chat/example/android/app/src/profile/AndroidManifest.xml similarity index 100% rename from example/android/app/src/profile/AndroidManifest.xml rename to packages/stream_chat/example/android/app/src/profile/AndroidManifest.xml diff --git a/example/android/build.gradle b/packages/stream_chat/example/android/build.gradle similarity index 82% rename from example/android/build.gradle rename to packages/stream_chat/example/android/build.gradle index e1cfea50..3100ad2d 100644 --- a/example/android/build.gradle +++ b/packages/stream_chat/example/android/build.gradle @@ -6,9 +6,8 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:4.1.0' + classpath 'com.android.tools.build:gradle:3.5.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.google.gms:google-services:4.3.2' } } diff --git a/example/android/gradle.properties b/packages/stream_chat/example/android/gradle.properties similarity index 100% rename from example/android/gradle.properties rename to packages/stream_chat/example/android/gradle.properties index 38c8d454..a6738207 100644 --- a/example/android/gradle.properties +++ b/packages/stream_chat/example/android/gradle.properties @@ -1,4 +1,4 @@ org.gradle.jvmargs=-Xmx1536M -android.enableR8=true android.useAndroidX=true android.enableJetifier=true +android.enableR8=true diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/stream_chat/example/android/gradle/wrapper/gradle-wrapper.properties similarity index 86% rename from example/android/gradle/wrapper/gradle-wrapper.properties rename to packages/stream_chat/example/android/gradle/wrapper/gradle-wrapper.properties index 4a4c2043..de2ccd60 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/stream_chat/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -#Thu Oct 22 11:03:39 CEST 2020 +#Fri Jun 23 08:50:38 CEST 2017 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/packages/stream_chat/example/android/settings.gradle b/packages/stream_chat/example/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/packages/stream_chat/example/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/ios/.gitignore b/packages/stream_chat/example/ios/.gitignore similarity index 100% rename from example/ios/.gitignore rename to packages/stream_chat/example/ios/.gitignore diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/packages/stream_chat/example/ios/Flutter/AppFrameworkInfo.plist similarity index 100% rename from example/ios/Flutter/AppFrameworkInfo.plist rename to packages/stream_chat/example/ios/Flutter/AppFrameworkInfo.plist diff --git a/example/ios/Flutter/Debug.xcconfig b/packages/stream_chat/example/ios/Flutter/Debug.xcconfig similarity index 100% rename from example/ios/Flutter/Debug.xcconfig rename to packages/stream_chat/example/ios/Flutter/Debug.xcconfig diff --git a/example/ios/Flutter/Release.xcconfig b/packages/stream_chat/example/ios/Flutter/Release.xcconfig similarity index 100% rename from example/ios/Flutter/Release.xcconfig rename to packages/stream_chat/example/ios/Flutter/Release.xcconfig diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to packages/stream_chat/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_chat/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme similarity index 100% rename from example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme rename to packages/stream_chat/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme diff --git a/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/stream_chat/example/ios/Runner.xcworkspace/contents.xcworkspacedata similarity index 67% rename from example/ios/Runner.xcworkspace/contents.xcworkspacedata rename to packages/stream_chat/example/ios/Runner.xcworkspace/contents.xcworkspacedata index 21a3cc14..1d526a16 100644 --- a/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/packages/stream_chat/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,7 +4,4 @@ - - diff --git a/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_chat/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to packages/stream_chat/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_chat/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to packages/stream_chat/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/packages/stream_chat/example/ios/Runner/AppDelegate.swift b/packages/stream_chat/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..70693e4a --- /dev/null +++ b/packages/stream_chat/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png diff --git a/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json similarity index 100% rename from example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png similarity index 100% rename from example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png diff --git a/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md similarity index 100% rename from example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md rename to packages/stream_chat/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md diff --git a/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/stream_chat/example/ios/Runner/Base.lproj/LaunchScreen.storyboard similarity index 100% rename from example/ios/Runner/Base.lproj/LaunchScreen.storyboard rename to packages/stream_chat/example/ios/Runner/Base.lproj/LaunchScreen.storyboard diff --git a/example/ios/Runner/Base.lproj/Main.storyboard b/packages/stream_chat/example/ios/Runner/Base.lproj/Main.storyboard similarity index 100% rename from example/ios/Runner/Base.lproj/Main.storyboard rename to packages/stream_chat/example/ios/Runner/Base.lproj/Main.storyboard diff --git a/example/ios/Runner/Info.plist b/packages/stream_chat/example/ios/Runner/Info.plist similarity index 76% rename from example/ios/Runner/Info.plist rename to packages/stream_chat/example/ios/Runner/Info.plist index ea713691..a060db61 100644 --- a/example/ios/Runner/Info.plist +++ b/packages/stream_chat/example/ios/Runner/Info.plist @@ -22,19 +22,6 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS - NSAppleMusicUsageDescription - Used to send message attachments - NSCameraUsageDescription - Used to send message attachments - NSMicrophoneUsageDescription - Used to send message attachments - NSPhotoLibraryUsageDescription - Used to send message attachments - UIBackgroundModes - - fetch - remote-notification - UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/packages/stream_chat/example/ios/Runner/Runner-Bridging-Header.h b/packages/stream_chat/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/packages/stream_chat/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/stream_chat/example/lib/main.dart b/packages/stream_chat/example/lib/main.dart new file mode 100644 index 00000000..769e6d44 --- /dev/null +++ b/packages/stream_chat/example/lib/main.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; + +Future main() async { + /// Create a new instance of [StreamChatClient] passing the apikey obtained from your + /// project dashboard. + final client = StreamChatClient('b67pax5b2wdq'); + + /// Set the current user. In a production scenario, this should be done using + /// a backend to generate a user token using our server SDK. + /// Please see the following for more information: + /// https://getstream.io/chat/docs/ios_user_setup_and_tokens/ + await client.setUser( + User( + id: 'cool-shadow-7', + extraData: { + 'image': + 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', + }, + ), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', + ); + + /// Creates a channel using the type `messaging` and `godevs`. + /// Channels are containers for holding messages between different members. To + /// learn more about channels and some of our predefined types, checkout our + /// our channel docs: https://getstream.io/chat/docs/initialize_channel/?language=dart + final channel = client.channel('messaging', id: 'godevs'); + + /// `.watch()` is used to create and listen to the channel for updates. If the + /// channel already exists, it will simply listen for new events. + await channel.watch(); + + runApp( + StreamExample( + client: client, + channel: channel, + ), + ); +} + +/// Example using Stream's Low Level Dart client. +class StreamExample extends StatelessWidget { + /// To initialize this example, an instance of [client] and [channel] is required. + const StreamExample({ + Key key, + @required this.client, + @required this.channel, + }) : super(key: key); + + /// Instance of [StreamChatClient] we created earlier. This contains information about + /// our application and connection state. + final StreamChatClient client; + + /// The channel we'd like to observe and participate. + final Channel channel; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Stream Chat Dart Example', + home: HomeScreen(channel: channel), + ); + } +} + +/// Main screen of our application. The layout is comprised of an [AppBar] +/// containing the channel name and a [MessageView] displaying recent messages. +class HomeScreen extends StatelessWidget { + /// [HomeScreen] is constructed using the [Channel] we defined earlier. + const HomeScreen({Key key, @required this.channel}) : super(key: key); + + /// Channel object containing the [Channel.id] we'd like to observe. + final Channel channel; + + @override + Widget build(BuildContext context) { + final messages = channel.state.channelStateStream; + return Scaffold( + appBar: AppBar( + title: Text('Channel: ${channel.id}'), + ), + body: SafeArea( + child: StreamBuilder( + stream: messages, + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.hasData && snapshot.data != null) { + return MessageView( + messages: snapshot.data.messages.reversed.toList(), + channel: channel, + ); + } else if (snapshot.hasError) { + return const Center( + child: Text( + 'There was an error loading messages. Please see logs.', + ), + ); + } + return const Center( + child: SizedBox( + width: 100.0, + height: 100.0, + child: CircularProgressIndicator(), + ), + ); + }, + ), + ), + ); + } +} + +/// UI used to display a list of recent messages and a [TextField] for sending +/// new messages. +class MessageView extends StatefulWidget { + /// Message takes the latest list of messages and the current channel. + const MessageView({ + Key key, + @required this.messages, + @required this.channel, + }) : super(key: key); + + /// List of messages sent in the given channel. + final List messages; + + /// Current channel being observed. + final Channel channel; + + @override + _MessageViewState createState() => _MessageViewState(); +} + +class _MessageViewState extends State { + TextEditingController _controller; + ScrollController _scrollController; + + List get _messages => widget.messages; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + /// Convenience method for scrolling the list view when a new message is sent. + void _updateList() { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = _messages[index]; + if (item.user.id == widget.channel.client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(item.text), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(item.text), + ), + ); + } + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Enter your message', + ), + ), + ), + Material( + type: MaterialType.circle, + color: Colors.blue, + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () async { + // We can send a new message by calling `sendMessage` on + // the current channel. After sending a message, the + // TextField is cleared and the list view is scrolled + // to show the new item. + if (_controller.value.text.isNotEmpty) { + await widget.channel.sendMessage( + Message(text: _controller.value.text), + ); + _controller.clear(); + _updateList(); + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Center( + child: Icon( + Icons.send, + color: Colors.white, + ), + ), + ), + ), + ) + ], + ), + ) + ], + ); + } +} + +/// Helper extension for quickly retrieving the current user id from a [StreamChatClient]. +extension on StreamChatClient { + String get uid => state.user.id; +} diff --git a/packages/stream_chat/example/pubspec.yaml b/packages/stream_chat/example/pubspec.yaml new file mode 100644 index 00000000..2a8b7eb2 --- /dev/null +++ b/packages/stream_chat/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: example +description: A new Flutter project. + +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.0 + stream_chat: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart new file mode 100644 index 00000000..6117b5d7 --- /dev/null +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -0,0 +1,1493 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/api/retry_queue.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/models/channel_state.dart'; +import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:uuid/uuid.dart'; + +import '../client.dart'; +import '../models/event.dart'; +import '../models/member.dart'; +import '../models/message.dart'; +import 'requests.dart'; +import 'responses.dart'; + +/// This a the class that manages a specific channel. +class Channel { + /// Create a channel client instance. + Channel( + this._client, + this.type, + this._id, + this._extraData, + ) : _cid = _id != null ? '$type:$_id' : null { + _client.logger.info('New Channel instance not initialized created'); + } + + /// Create a channel client instance from a [ChannelState] object + Channel.fromState(this._client, ChannelState channelState) { + _cid = channelState.channel.cid; + _id = channelState.channel.id; + type = channelState.channel.type; + + state = ChannelClientState(this, channelState); + _initializedCompleter.complete(true); + _startCleaning(); + + _client.logger.info('New Channel instance initialized created'); + } + + /// This client state + ChannelClientState state; + + /// The channel type + String type; + + String _id; + String _cid; + Map _extraData; + + set extraData(Map extraData) { + if (_initializedCompleter.isCompleted) { + throw Exception( + 'Once the channel is initialized you should use channel.update to update channel data'); + } + _extraData = extraData; + } + + /// Returns true if the channel is muted + bool get isMuted => + _client.state.user?.channelMutes + ?.any((element) => element.channel.cid == cid) == + true; + + /// Returns true if the channel is muted as a stream + Stream get isMutedStream => _client.state.userStream?.map((event) => + event.channelMutes?.any((element) => element.channel.cid == cid) == true); + + /// True if the channel is a group + bool get isGroup => memberCount != 2; + + /// True if the channel is distinct + bool get isDistinct => id?.startsWith('!members') == true; + + /// Channel configuration + ChannelConfig get config => state?._channelState?.channel?.config; + + /// Channel configuration as a stream + Stream get configStream => + state?.channelStateStream?.map((cs) => cs.channel?.config); + + /// Channel user creator + User get createdBy => state?._channelState?.channel?.createdBy; + + /// Channel user creator as a stream + Stream get createdByStream => + state?.channelStateStream?.map((cs) => cs.channel?.createdBy); + + /// Channel frozen status + bool get frozen => state?._channelState?.channel?.frozen; + + /// Channel frozen status as a stream + Stream get frozenStream => + state?.channelStateStream?.map((cs) => cs.channel?.frozen); + + /// Channel creation date + DateTime get createdAt => state?._channelState?.channel?.createdAt; + + /// Channel creation date as a stream + Stream get createdAtStream => + state?.channelStateStream?.map((cs) => cs.channel?.createdAt); + + /// Channel last message date + DateTime get lastMessageAt => state?._channelState?.channel?.lastMessageAt; + + /// Channel last message date as a stream + Stream get lastMessageAtStream => + state?.channelStateStream?.map((cs) => cs.channel?.lastMessageAt); + + /// Channel updated date + DateTime get updatedAt => state?._channelState?.channel?.updatedAt; + + /// Channel updated date as a stream + Stream get updatedAtStream => + state?.channelStateStream?.map((cs) => cs.channel?.updatedAt); + + /// Channel deletion date + DateTime get deletedAt => state?._channelState?.channel?.deletedAt; + + /// Channel deletion date as a stream + Stream get deletedAtStream => + state?.channelStateStream?.map((cs) => cs.channel?.deletedAt); + + /// Channel member count + int get memberCount => state?._channelState?.channel?.memberCount; + + /// Channel member count as a stream + Stream get memberCountStream => + state?.channelStateStream?.map((cs) => cs.channel?.memberCount); + + /// Channel id + String get id => state?._channelState?.channel?.id ?? _id; + + /// Channel id as a stream + Stream get idStream => + state?.channelStateStream?.map((cs) => cs.channel?.id ?? _id); + + /// Channel cid + String get cid => state?._channelState?.channel?.cid ?? _cid; + + /// Channel team + String get team => state?._channelState?.channel?.team; + + /// Channel cid as a stream + Stream get cidStream => + state?.channelStateStream?.map((cs) => cs.channel?.cid ?? _cid); + + /// Channel extra data + Map get extraData => + state?._channelState?.channel?.extraData; + + /// Channel extra data as a stream + Stream> get extraDataStream => + state?.channelStateStream?.map((cs) => cs.channel?.extraData); + + /// The main Stream chat client + StreamChatClient get client => _client; + final StreamChatClient _client; + + String get _channelURL => '/channels/$type/$id'; + + final Completer _initializedCompleter = Completer(); + + /// True if this is initialized + /// Call [watch] to initialize the client or instantiate it using [Channel.fromState] + Future get initialized => _initializedCompleter.future; + + /// Send a message to this channel + Future sendMessage(Message message) async { + final messageId = message.id ?? Uuid().v4(); + final quotedMessage = state?.messages?.firstWhere( + (m) => m.id == message?.quotedMessageId, + orElse: () => null, + ); + final newMessage = message.copyWith( + createdAt: message.createdAt ?? DateTime.now(), + user: _client.state.user, + id: messageId, + quotedMessage: quotedMessage, + status: MessageSendingStatus.sending, + ); + + if (message.parentId != null && message.id == null) { + final parentMessage = + state.messages.firstWhere((m) => m.id == message.parentId); + + state?.addMessage(parentMessage.copyWith( + replyCount: parentMessage.replyCount + 1, + )); + } + + state?.addMessage(newMessage); + + try { + final response = await _client.post( + '$_channelURL/message', + data: { + 'message': message + .copyWith( + id: messageId, + ) + .toJson() + }, + ); + + final res = _client.decode(response.data, SendMessageResponse.fromJson); + + state?.addMessage(res.message); + + return res; + } catch (error) { + if (error is DioError && error.type != DioErrorType.RESPONSE) { + state?.retryQueue?.add([newMessage]); + } + rethrow; + } + } + + /// Send a file to this channel + Future sendFile(MultipartFile file) async { + final response = await _client.post( + '$_channelURL/file', + data: FormData.fromMap({'file': file}), + ); + return _client.decode(response.data, SendFileResponse.fromJson); + } + + /// Send an image to this channel + Future sendImage(MultipartFile file) async { + final response = await _client.post( + '$_channelURL/image', + data: FormData.fromMap({'file': file}), + ); + return _client.decode(response.data, SendImageResponse.fromJson); + } + + /// Delete a file from this channel + Future deleteFile(String url) async { + final response = await _client + .delete('$_channelURL/file', queryParameters: {'url': url}); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Delete an image from this channel + Future deleteImage(String url) async { + final response = await _client + .delete('$_channelURL/image', queryParameters: {'url': url}); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Send an event on this channel + Future sendEvent(Event event) { + _checkInitialized(); + return _client.post( + '$_channelURL/event', + data: {'event': event.toJson()}, + ).then((res) { + return _client.decode(res.data, EmptyResponse.fromJson); + }); + } + + /// Send a reaction to this channel + /// Set [enforceUnique] to true to remove the existing user reaction + Future sendReaction( + Message message, + String type, { + Map extraData = const {}, + bool enforceUnique = false, + }) async { + final messageId = message.id; + final data = Map.from(extraData) + ..addAll({ + 'type': type, + }); + + final res = await _client.post( + '/messages/$messageId/reaction', + data: { + 'reaction': data, + 'enforce_unique': enforceUnique, + }, + ); + return _client.decode(res.data, SendReactionResponse.fromJson); + } + + /// Delete a reaction from this channel + Future deleteReaction(Message message, Reaction reaction) { + _checkInitialized(); + + return client + .delete('/messages/${message.id}/reaction/${reaction.type}') + .then((res) => _client.decode(res.data, EmptyResponse.fromJson)); + } + + /// Edit the channel custom data + Future update( + Map channelData, [ + Message updateMessage, + ]) async { + final response = await _client.post(_channelURL, data: { + if (updateMessage != null) + 'message': updateMessage.copyWith(updatedAt: DateTime.now()).toJson(), + 'data': channelData, + }); + return _client.decode(response.data, UpdateChannelResponse.fromJson); + } + + /// Delete this channel. Messages are permanently removed. + Future delete() async { + final response = await _client.delete(_channelURL); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Removes all messages from the channel + Future truncate() async { + final response = await _client.post('$_channelURL/truncate'); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Accept invitation to the channel + Future acceptInvite([Message message]) async { + final res = await _client.post(_channelURL, + data: {'accept_invite': true, 'message': message?.toJson()}); + return _client.decode(res.data, AcceptInviteResponse.fromJson); + } + + /// Reject invitation to the channel + Future rejectInvite([Message message]) async { + final res = await _client.post(_channelURL, + data: {'reject_invite': true, 'message': message?.toJson()}); + return _client.decode(res.data, RejectInviteResponse.fromJson); + } + + /// Add members to the channel + Future addMembers( + List memberIds, [ + Message message, + ]) async { + final res = await _client.post(_channelURL, data: { + 'add_members': memberIds, + 'message': message?.toJson(), + }); + return _client.decode(res.data, AddMembersResponse.fromJson); + } + + /// Invite members to the channel + Future inviteMembers( + List memberIds, [ + Message message, + ]) async { + final res = await _client.post(_channelURL, data: { + 'invites': memberIds, + 'message': message?.toJson(), + }); + return _client.decode(res.data, InviteMembersResponse.fromJson); + } + + /// Remove members from the channel + Future removeMembers( + List memberIds, [ + Message message, + ]) async { + final res = await _client.post(_channelURL, data: { + 'remove_members': memberIds, + 'message': message?.toJson(), + }); + return _client.decode(res.data, RemoveMembersResponse.fromJson); + } + + /// Send action for a specific message of this channel + Future sendAction( + Message message, + Map formData, + ) async { + _checkInitialized(); + + final messageId = message.id; + final response = await _client.post('/messages/$messageId/action', data: { + 'id': id, + 'type': type, + 'form_data': formData, + 'message_id': messageId, + }); + + final res = _client.decode(response.data, SendActionResponse.fromJson); + + if (res.message != null) { + state.addMessage(res.message); + } else { + final oldIndex = state.messages?.indexWhere((m) => m.id == messageId); + + Message oldMessage; + if (oldIndex != null && oldIndex != -1) { + oldMessage = state.messages[oldIndex]; + state.updateChannelState(state._channelState.copyWith( + messages: state.messages..remove(oldMessage), + )); + } else { + oldMessage = state.threads.values + .expand((messages) => messages) + .firstWhere((m) => m.id == messageId, orElse: () => null); + if (oldMessage?.parentId != null) { + final parentMessage = state.messages.firstWhere( + (element) => element.id == oldMessage.parentId, + orElse: () => null, + ); + if (parentMessage != null) { + state.addMessage(parentMessage.copyWith( + replyCount: parentMessage.replyCount - 1)); + } + state.updateThreadInfo(oldMessage.parentId, + state.threads[oldMessage.parentId]..remove(oldMessage)); + } + } + + await _client.chatPersistenceClient?.deleteMessageById(messageId); + } + + return res; + } + + /// Mark all channel messages as read + Future markRead() async { + _checkInitialized(); + client.state.totalUnreadCount = + max(0, (client.state.totalUnreadCount ?? 0) - (state.unreadCount ?? 0)); + state._unreadCountController.add(0); + final response = await _client.post('$_channelURL/read', data: {}); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Loads the initial channel state and watches for changes + Future watch([Map options = const {}]) async { + final watchOptions = Map.from({ + 'state': true, + 'watch': true, + 'presence': false, + }) + ..addAll(options); + + var response; + + try { + response = await query(options: watchOptions); + } catch (error, stackTrace) { + if (!_initializedCompleter.isCompleted) { + _initializedCompleter.completeError(error, stackTrace); + } + rethrow; + } + + if (state == null) { + _initState(response); + } + + return response; + } + + void _initState(ChannelState channelState) { + state = ChannelClientState(this, channelState); + client.state.channels[cid] = this; + if (!_initializedCompleter.isCompleted) { + _initializedCompleter.complete(true); + } + _startCleaning(); + } + + /// Stop watching the channel + Future stopWatching() async { + final response = await _client.post( + '$_channelURL/stop-watching', + data: {}, + ); + return _client.decode(response?.data, EmptyResponse.fromJson); + } + + /// List the message replies for a parent message + /// Set [preferOffline] to true to avoid the api call if the data is already in the offline storage + Future getReplies( + String parentId, + PaginationParams options, { + bool preferOffline = false, + }) async { + final cachedReplies = await _client.chatPersistenceClient?.getReplies( + parentId, + options: options, + ); + if (cachedReplies != null && cachedReplies.isNotEmpty) { + state?.updateThreadInfo(parentId, cachedReplies); + if (preferOffline) { + return QueryRepliesResponse()..messages = cachedReplies; + } + } + + final response = await _client.get('/messages/$parentId/replies', + queryParameters: options.toJson()); + + final repliesResponse = _client.decode( + response.data, + QueryRepliesResponse.fromJson, + ); + + state?.updateThreadInfo(parentId, repliesResponse.messages); + + return repliesResponse; + } + + /// List the reactions for a message in the channel + Future getReactions( + String messageID, + PaginationParams options, + ) async { + final response = await _client.get( + '/messages/$messageID/reactions', + queryParameters: options.toJson(), + ); + return _client.decode( + response.data, QueryReactionsResponse.fromJson); + } + + /// Retrieves a list of messages by ID + Future getMessagesById( + List messageIDs) async { + final response = await _client.get( + '$_channelURL/messages', + queryParameters: {'ids': messageIDs.join(',')}, + ); + + final res = _client.decode( + response.data, + GetMessagesByIdResponse.fromJson, + ); + + state?.updateChannelState(ChannelState(messages: res.messages)); + + return res; + } + + /// Retrieves a list of messages by ID + Future translateMessage( + String messageId, + String language, + ) async { + final response = await _client.post( + '/messages/$messageId/translate', + data: { + 'language': language, + }, + ); + return _client.decode( + response.data, + TranslateMessageResponse.fromJson, + ); + } + + /// Creates a new channel + Future create() async { + return query(options: { + 'watch': false, + 'state': false, + 'presence': false, + }); + } + + /// Query the API, get messages, members or other channel fields + /// Set [preferOffline] to true to avoid the api call if the data is already in the offline storage + Future query({ + Map options = const {}, + PaginationParams messagesPagination, + PaginationParams membersPagination, + PaginationParams watchersPagination, + bool preferOffline = false, + }) async { + var path = '/channels/$type'; + if (id != null) { + path = '$path/$id'; + } + path = '$path/query'; + + final payload = Map.from({ + 'state': true, + }) + ..addAll(options); + + if (_extraData != null) { + payload['data'] = _extraData; + } + + if (messagesPagination != null) { + payload['messages'] = messagesPagination.toJson(); + } + if (membersPagination != null) { + payload['members'] = membersPagination.toJson(); + } + if (watchersPagination != null) { + payload['watchers'] = watchersPagination.toJson(); + } + + if (preferOffline && cid != null) { + final updatedState = + await _client.chatPersistenceClient?.getChannelStateByCid( + cid, + messagePagination: messagesPagination, + ); + if (updatedState != null && updatedState.messages.isNotEmpty) { + if (state == null) { + _initState(updatedState); + } else { + state?.updateChannelState(updatedState); + } + return updatedState; + } + } + + try { + final response = await _client.post(path, data: payload); + final updatedState = _client.decode(response.data, ChannelState.fromJson); + + if (_id == null) { + _id = updatedState.channel.id; + _cid = updatedState.channel.cid; + } + + state?.updateChannelState(updatedState); + return updatedState; + } catch (e) { + if (!_client.persistenceEnabled) { + rethrow; + } + return _client.chatPersistenceClient?.getChannelStateByCid( + cid, + messagePagination: messagesPagination, + ); + } + } + + /// Query channel members + Future queryMembers({ + Map filter, + List sort, + PaginationParams pagination, + }) async { + final payload = { + 'sort': sort, + 'filter_conditions': filter, + 'type': type, + }; + + if (pagination != null) { + payload.addAll(pagination.toJson()); + } + + if (id != null) { + payload['id'] = id; + } else if (state?.members?.isNotEmpty == true) { + payload['members'] = state.members; + } + + final rawRes = await _client.get('/members', queryParameters: { + 'payload': jsonEncode(payload), + }); + final response = _client.decode(rawRes.data, QueryMembersResponse.fromJson); + return response; + } + + /// Mutes the channel + Future mute({Duration expiration}) async { + final response = await _client.post('/moderation/mute/channel', data: { + 'channel_cid': cid, + if (expiration != null) 'expiration': expiration.inMilliseconds, + }); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Unmutes the channel + Future unmute() async { + final response = await _client.post('/moderation/unmute/channel', data: { + 'channel_cid': cid, + }); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Bans a user from the channel + Future banUser( + String userID, + Map options, + ) async { + _checkInitialized(); + final opts = Map.from(options) + ..addAll({ + 'type': type, + 'id': id, + }); + return _client.banUser(userID, opts); + } + + /// Remove the ban for a user in the channel + Future unbanUser(String userID) async { + _checkInitialized(); + return _client.unbanUser(userID, { + 'type': type, + 'id': id, + }); + } + + /// Shadow bans a user from the channel + Future shadowBan( + String userID, + Map options, + ) async { + _checkInitialized(); + final opts = Map.from(options) + ..addAll({ + 'type': type, + 'id': id, + }); + return _client.shadowBan(userID, opts); + } + + /// Remove the shadow ban for a user in the channel + Future removeShadowBan(String userID) async { + _checkInitialized(); + return _client.removeShadowBan(userID, { + 'type': type, + 'id': id, + }); + } + + /// Hides the channel from [StreamChatClient.queryChannels] for the user until a message is added + /// If [clearHistory] is set to true - all messages will be removed for the user + Future hide({bool clearHistory = false}) async { + _checkInitialized(); + final response = await _client + .post('$_channelURL/hide', data: {'clear_history': clearHistory}); + + if (clearHistory == true) { + state.truncate(); + await _client.chatPersistenceClient?.deleteMessageByCid(_cid); + } + + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Removes the hidden status for the channel + Future show() async { + _checkInitialized(); + final response = await _client.post('$_channelURL/show'); + return _client.decode(response.data, EmptyResponse.fromJson); + } + + /// Stream of [Event] coming from websocket connection specific for the channel + /// Pass an eventType as parameter in order to filter just a type of event + Stream on([ + String eventType, + String eventType2, + String eventType3, + String eventType4, + ]) { + return _client + .on( + eventType, + eventType2, + eventType3, + eventType4, + ) + .where((e) => e.cid == cid); + } + + DateTime _lastTypingEvent; + + /// First of the [EventType.typingStart] and [EventType.typingStop] events based on the users keystrokes. + /// Call this on every keystroke. + Future keyStroke([String parentId]) async { + if (config?.typingEvents == false) { + return; + } + + client.logger.info('start typing'); + final now = DateTime.now(); + + if (_lastTypingEvent == null || + now.difference(_lastTypingEvent).inSeconds >= 2) { + _lastTypingEvent = now; + await sendEvent(Event( + type: EventType.typingStart, + parentId: parentId, + )); + } + } + + /// Sets last typing to null and sends the typing.stop event + Future stopTyping([String parentId]) async { + if (config?.typingEvents == false) { + return; + } + + client.logger.info('stop typing'); + _lastTypingEvent = null; + await sendEvent(Event( + type: EventType.typingStop, + parentId: parentId, + )); + } + + Timer _cleaningTimer; + + void _startCleaning() { + if (config?.typingEvents == false) { + return; + } + + _cleaningTimer = Timer.periodic(Duration(milliseconds: 500), (_) { + final now = DateTime.now(); + + if (_lastTypingEvent != null && + now.difference(_lastTypingEvent).inSeconds > 1) { + stopTyping(); + } + + state._clean(); + }); + } + + /// Call this method to dispose the channel client + void dispose() { + _cleaningTimer.cancel(); + state.dispose(); + } + + void _checkInitialized() { + if (!_initializedCompleter.isCompleted) { + throw Exception( + "Channel $cid hasn't been initialized yet. Make sure to call .watch() or to instantiate the client using [Channel.fromState]"); + } + } +} + +/// The class that handles the state of the channel listening to the events +class ChannelClientState { + final _subscriptions = []; + + /// Creates a new instance listening to events and updating the state + ChannelClientState(this._channel, ChannelState channelState) { + retryQueue = RetryQueue( + channel: _channel, + logger: Logger('RETRY QUEUE ${_channel.cid}'), + ); + + _checkExpiredAttachmentMessages(channelState); + + _channelStateController = BehaviorSubject.seeded(channelState); + + _listenTypingEvents(); + + _listenMessageNew(); + + _listenMessageDeleted(); + + _listenMessageUpdated(); + + _listenReactions(); + + _listenReactionDeleted(); + + _listenReadEvents(); + + _listenChannelTruncated(); + + _listenChannelUpdated(); + + _listenMemberAdded(); + + _listenMemberRemoved(); + + _computeInitialUnread(); + + _channel._client.chatPersistenceClient + ?.getChannelThreads(_channel.cid) + ?.then((threads) { + _threads = threads; + retryFailedMessages(); + }); + } + + void _computeInitialUnread() { + final userRead = channelState?.read?.firstWhere( + (r) => r.user.id == _channel._client.state?.user?.id, + orElse: () => null, + ); + if (userRead != null) { + _unreadCountController.add(userRead.unreadMessages ?? 0); + } + } + + void _checkExpiredAttachmentMessages(ChannelState channelState) { + final expiredAttachmentMessagesId = channelState.messages + ?.where((m) => + !_updatedMessagesIds.contains(m.id) && + m.attachments?.isNotEmpty == true && + m.attachments?.any((e) { + final url = e.imageUrl ?? e.assetUrl; + if (url == null || !url.contains('stream-io-cdn.com')) { + return false; + } + final expiration = + DateTime.parse(Uri.parse(url).queryParameters['Expires']); + return expiration.isBefore(DateTime.now()); + }) == + true) + ?.map((e) => e.id) + ?.toList(); + if (expiredAttachmentMessagesId?.isNotEmpty == true) { + _channel.getMessagesById(expiredAttachmentMessagesId); + _updatedMessagesIds.addAll(expiredAttachmentMessagesId); + } + } + + void _listenMemberAdded() { + _subscriptions.add(_channel.on(EventType.memberAdded).listen((Event e) { + final member = e.member; + updateChannelState(channelState.copyWith( + members: [ + ...channelState.members, + member, + ], + )); + })); + } + + void _listenMemberRemoved() { + _subscriptions.add(_channel.on(EventType.memberRemoved).listen((Event e) { + final user = e.user; + updateChannelState(channelState.copyWith( + members: List.from( + channelState.members..removeWhere((m) => m.userId == user.id)), + )); + })); + } + + void _listenChannelUpdated() { + _subscriptions.add(_channel.on(EventType.channelUpdated).listen((Event e) { + final channel = e.channel; + updateChannelState(channelState.copyWith( + channel: channel, + members: channel.members, + )); + })); + } + + void _listenChannelTruncated() { + _subscriptions.add(_channel + .on(EventType.channelTruncated, EventType.notificationChannelTruncated) + .listen((event) async { + final channel = event.channel; + await _channel._client.chatPersistenceClient + ?.deleteMessageByCid(channel.cid); + truncate(); + })); + } + + /// Flag which indicates if [ChannelClientState] contain latest/recent messages or not. + /// This flag should be managed by UI sdks. + /// When false, any new message (received by WebSocket event - [EventType.messageNew]) will not + /// be pushed on to message list. + bool get isUpToDate => _isUpToDateController.value; + + set isUpToDate(bool isUpToDate) => _isUpToDateController.add(isUpToDate); + + /// [isUpToDate] flag count as a stream + Stream get isUpToDateStream => _isUpToDateController.stream; + + final BehaviorSubject _isUpToDateController = + BehaviorSubject.seeded(true); + + /// The retry queue associated to this channel + RetryQueue retryQueue; + + /// Retry failed message + Future retryFailedMessages() async { + final failedMessages = + [...messages, ...threads.values.expand((v) => v)] + .where((message) => + message.status != null && + message.status != MessageSendingStatus.sent && + message.createdAt.isBefore(DateTime.now().subtract(Duration( + seconds: 1, + )))) + .toList(); + + retryQueue.add(failedMessages); + } + + void _listenReactionDeleted() { + _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { + final reaction = event.reaction; + final message = event.message; + _removeMessageReaction(message, reaction); + })); + } + + void _removeMessageReaction(Message message, Reaction reaction) { + if (message.parentId == null || message.showInChannel == true) { + _channelState = _channelState.copyWith( + messages: _channelState?.messages?.map((m) { + if (m.id == message.id) { + return _removeReactionFromMessage(m, reaction); + } + return m; + })?.toList(), + ); + } + + if (message.parentId != null) { + final newThreads = threads; + if (newThreads.containsKey(message.parentId)) { + newThreads[message.parentId] = newThreads[message.parentId].map((m) { + if (m.id == message.id) { + return _removeReactionFromMessage(m, reaction); + } + return m; + }).toList(); + _threads = newThreads; + } + } + } + + void _listenReactions() { + _subscriptions.add(_channel + .on( + EventType.reactionNew, + ) + .listen((event) { + final message = event.message; + _addMessageReaction(message, event.reaction); + })); + } + + void _addMessageReaction(Message message, Reaction reaction) { + if (message.parentId == null || message.showInChannel == true) { + _channelState = _channelState.copyWith( + messages: _channelState.messages.map((m) { + if (message.id == m.id) { + return _addReactionToMessage(m, reaction); + } + return m; + }).toList(), + ); + } + + if (message.parentId != null) { + final newThreads = threads; + if (newThreads.containsKey(message.parentId)) { + newThreads[message.parentId] = newThreads[message.parentId].map((m) { + if (message.id == m.id) { + return _addReactionToMessage(m, reaction); + } + return m; + }).toList(); + _threads = newThreads; + } + } + } + + void _listenMessageUpdated() { + _subscriptions.add(_channel + .on( + EventType.messageUpdated, + EventType.reactionUpdated, + ) + .listen((event) { + final message = event.message; + addMessage(message.copyWith( + ownReactions: message.latestReactions + .where( + (element) => element.user?.id == _channel._client.state.user.id) + .toList(), + )); + })); + } + + void _listenMessageDeleted() { + _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { + final message = event.message; + addMessage(message); + })); + } + + void _listenMessageNew() { + _subscriptions.add(_channel + .on( + EventType.messageNew, + EventType.notificationMessageNew, + ) + .listen((event) { + final message = event.message; + if (isUpToDate || + (message.parentId != null && message.showInChannel != true)) { + addMessage(message); + } + + if (_countMessageAsUnread(message)) { + _unreadCountController.add(_unreadCountController.value + 1); + } + })); + } + + /// Add a message to this channel + void addMessage(Message message) { + if (message.parentId == null || message.showInChannel == true) { + final newMessages = List.from(_channelState.messages); + + final oldIndex = newMessages.indexWhere((m) => m.id == message.id); + if (oldIndex != -1) { + newMessages[oldIndex] = newMessages[oldIndex].merge(message); + } else { + newMessages.add(message); + } + + _channelState = _channelState.copyWith( + messages: newMessages, + channel: _channelState.channel.copyWith( + lastMessageAt: message.createdAt, + ), + ); + } + + if (message.parentId != null) { + updateThreadInfo(message.parentId, [message]); + } + } + + void _listenReadEvents() { + if (_channel.config?.readEvents == false) { + return; + } + + _subscriptions.add(_channel + .on( + EventType.messageRead, + EventType.notificationMarkRead, + ) + .listen((event) { + final readList = List.from(_channelState?.read ?? []); + final userReadIndex = read?.indexWhere((r) => r.user.id == event.user.id); + + if (userReadIndex != null && userReadIndex != -1) { + final userRead = readList.removeAt(userReadIndex); + if (userRead.user?.id == _channel._client.state.user.id) { + _unreadCountController.add(0); + } + readList.add(Read( + user: event.user, + lastRead: event.createdAt, + )); + _channelState = _channelState.copyWith(read: readList); + } + })); + } + + Message _addReactionToMessage(Message message, Reaction reaction) { + final newMessage = message.copyWith( + latestReactions: message.latestReactions..add(reaction), + reactionCounts: { + ...message.reactionCounts ?? {}, + reaction.type: (message.reactionCounts == null + ? 0 + : message.reactionCounts[reaction.type] ?? 0) + + 1, + }, + reactionScores: { + ...message.reactionScores ?? {}, + reaction.type: (message.reactionScores == null + ? 0 + : message.reactionScores[reaction.type] ?? 0) + + reaction.score, + }, + ); + + if (reaction.user.id == _channel.client.state.user.id) { + return newMessage.copyWith( + ownReactions: message.ownReactions..add(reaction), + ); + } + + return newMessage; + } + + Message _removeReactionFromMessage(Message message, Reaction reaction) { + final newMessage = message.copyWith( + latestReactions: message.latestReactions + ..removeWhere( + (r) => r.type == reaction.type && r.userId == reaction.userId), + reactionCounts: { + ...message.reactionCounts, + reaction.type: (message.reactionCounts[reaction.type] ?? 0) - 1, + }, + reactionScores: { + ...message.reactionScores ?? {}, + reaction.type: max( + (message.reactionScores == null + ? 0 + : message.reactionScores[reaction.type] ?? 0) - + reaction.score, + 0), + }, + ); + + newMessage.reactionCounts.removeWhere((_, v) => v <= 0); + + if (reaction.user.id == _channel.client.state.user.id) { + return newMessage.copyWith( + ownReactions: message.ownReactions + ..removeWhere((r) => r.type == reaction.type), + ); + } + + return newMessage; + } + + /// Channel message list + List get messages => _channelState.messages; + + /// Channel message list as a stream + Stream> get messagesStream => + channelStateStream.map((cs) => cs.messages); + + /// Get channel last message + Message get lastMessage => _channelState.messages?.isNotEmpty == true + ? _channelState.messages.last + : null; + + /// Get channel last message + Stream get lastMessageStream => messagesStream + .map((event) => event?.isNotEmpty == true ? event.last : null); + + /// Channel members list + List get members => _channelState.members + .map((e) => e.copyWith(user: _channel.client.state.users[e.user.id])) + .toList(); + + /// Channel members list as a stream + Stream> get membersStream => CombineLatestStream.combine2< + List, Map, List>( + channelStateStream.map((cs) => cs.members), + _channel.client.state.usersStream, + (members, users) { + return members + .map((e) => e.copyWith(user: users[e.user.id])) + .toList(); + }, + ); + + /// Channel watcher count + int get watcherCount => _channelState.watcherCount; + + /// Channel watcher count as a stream + Stream get watcherCountStream => + channelStateStream.map((cs) => cs.watcherCount); + + /// Channel watchers list + List get watchers => _channelState.watchers + .map((e) => _channel.client.state.users[e.id] ?? e) + .toList(); + + /// Channel watchers list as a stream + Stream> get watchersStream => + CombineLatestStream.combine2, Map, List>( + channelStateStream.map((cs) => cs.watchers), + _channel.client.state.usersStream, + (watchers, users) { + return watchers.map((e) => users[e.id] ?? e).toList(); + }, + ); + + /// Channel read list + List get read => _channelState.read; + + /// Channel read list as a stream + Stream> get readStream => channelStateStream.map((cs) => cs.read); + + final BehaviorSubject _unreadCountController = BehaviorSubject.seeded(0); + + /// Unread count getter as a stream + Stream get unreadCountStream => _unreadCountController.stream; + + /// Unread count getter + int get unreadCount => _unreadCountController.value; + + bool _countMessageAsUnread(Message message) { + final userId = _channel.client.state?.user?.id; + final userIsMuted = _channel.client.state.user.mutes.firstWhere( + (m) => m.user?.id == message.user.id, + orElse: () => null, + ) != + null; + return message.silent != true && + message.shadowed != true && + message.user.id != userId && + !userIsMuted; + } + + /// Update threads with updated information about messages + void updateThreadInfo(String parentId, List messages) { + final newThreads = Map>.from(threads); + + if (newThreads.containsKey(parentId)) { + newThreads[parentId] = [ + ...newThreads[parentId] + ?.where( + (newMessage) => !messages.any((m) => m.id == newMessage.id)) + ?.toList() ?? + [], + ...messages, + ]; + + newThreads[parentId].sort(_sortByCreatedAt); + } else { + newThreads[parentId] = messages; + } + + _threads = newThreads; + } + + /// Delete all channel messages + void truncate() { + _channelState = _channelState.copyWith( + messages: [], + ); + } + + final List _updatedMessagesIds = []; + + /// Update channelState with updated information + void updateChannelState(ChannelState updatedState) { + final newMessages = [ + ...updatedState?.messages ?? [], + ..._channelState?.messages + ?.where((m) => + updatedState.messages + ?.any((newMessage) => newMessage.id == m.id) != + true) + ?.toList() ?? + [], + ]; + + newMessages.sort(_sortByCreatedAt); + + final newWatchers = [ + ...updatedState?.watchers ?? [], + ..._channelState?.watchers + ?.where((w) => + updatedState.watchers + ?.any((newWatcher) => newWatcher.id == w.id) != + true) + ?.toList() ?? + [], + ]; + + final newMembers = [ + ...updatedState?.members ?? [], + ]; + + final newReads = [ + ...updatedState?.read ?? [], + ..._channelState?.read + ?.where((r) => + updatedState.read + ?.any((newRead) => newRead.user.id == r.user.id) != + true) + ?.toList() ?? + [], + ]; + + _checkExpiredAttachmentMessages(updatedState); + + _channelState = _channelState.copyWith( + messages: newMessages, + channel: _channelState.channel?.merge(updatedState.channel), + watchers: newWatchers, + watcherCount: updatedState.watcherCount, + members: newMembers, + read: newReads, + ); + } + + int _sortByCreatedAt(a, b) { + if (a.createdAt == null) { + return 1; + } + + if (b.createdAt == null) { + return -1; + } + + return a.createdAt.compareTo(b.createdAt); + } + + /// The channel state related to this client + ChannelState get _channelState => _channelStateController.value; + + /// The channel state related to this client as a stream + Stream get channelStateStream => _channelStateController.stream; + + /// The channel state related to this client + ChannelState get channelState => _channelStateController.value; + BehaviorSubject _channelStateController; + + set _channelState(ChannelState v) { + _channelStateController.add(v); + _channel._client.chatPersistenceClient?.updateChannelState(v); + } + + /// The channel threads related to this channel + Map> get threads => _threadsController.value; + + /// The channel threads related to this channel as a stream + Stream>> get threadsStream => + _threadsController.stream; + final BehaviorSubject>> _threadsController = + BehaviorSubject.seeded({}); + + set _threads(Map> v) { + _channel._client.chatPersistenceClient?.updateMessages( + _channel.cid, + v.values.expand((v) => v).toList(), + ); + _threadsController.add(v); + } + + /// Channel related typing users last value + List get typingEvents => _typingEventsController.value; + + /// Channel related typing users stream + Stream> get typingEventsStream => _typingEventsController.stream; + final BehaviorSubject> _typingEventsController = + BehaviorSubject.seeded([]); + + final Channel _channel; + final Map _typings = {}; + + void _listenTypingEvents() { + if (_channel.config?.typingEvents == false) { + return; + } + + _subscriptions.add(_channel.on(EventType.typingStart).listen((event) { + if (event.user.id != _channel.client.state.user.id) { + _typings[event.user] = DateTime.now(); + _typingEventsController.add(_typings.keys.toList()); + } + })); + + _subscriptions.add(_channel.on(EventType.typingStop).listen((event) { + if (event.user.id != _channel.client.state.user.id) { + _typings.remove(event.user); + _typingEventsController.add(_typings.keys.toList()); + } + })); + } + + void _clean() { + final now = DateTime.now(); + _typings.forEach((user, lastTypingEvent) { + if (now.difference(lastTypingEvent).inSeconds > 7) { + _channel.client.handleEvent( + Event( + type: EventType.typingStop, + user: user, + cid: _channel.cid, + ), + ); + } + }); + _typingEventsController.add(_typings.keys.toList()); + } + + /// Call this method to dispose this object + void dispose() { + _unreadCountController.close(); + retryQueue.dispose(); + _subscriptions.forEach((s) => s.cancel()); + _channelStateController.close(); + _isUpToDateController.close(); + _threadsController.close(); + _typingEventsController.close(); + } +} diff --git a/packages/stream_chat/lib/src/api/connection_status.dart b/packages/stream_chat/lib/src/api/connection_status.dart new file mode 100644 index 00000000..28a8e8d4 --- /dev/null +++ b/packages/stream_chat/lib/src/api/connection_status.dart @@ -0,0 +1,11 @@ +/// Used to notify the WS connection status +enum ConnectionStatus { + /// WS is connected and everything is good + connected, + + /// WS is connecting (usually reconnecting) + connecting, + + /// WS is disconnected and it's not reconnecting + disconnected, +} diff --git a/packages/stream_chat/lib/src/api/requests.dart b/packages/stream_chat/lib/src/api/requests.dart new file mode 100644 index 00000000..fdf1f34f --- /dev/null +++ b/packages/stream_chat/lib/src/api/requests.dart @@ -0,0 +1,97 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'requests.g.dart'; + +/// Sorting options +@JsonSerializable(createFactory: false) +class SortOption { + /// Ascending order + static const ASC = 1; + + /// Descending order + static const DESC = -1; + + /// A sorting field name + final String field; + + /// A sorting direction + final int direction; + + /// Creates a new SortOption instance + /// + /// For example: + /// ```dart + /// // Sort channels by the last message date: + /// final sorting = SortOption("last_message_at") + /// ``` + const SortOption(this.field, {this.direction = DESC}); + + /// Serialize model to json + Map toJson() => _$SortOptionToJson(this); +} + +/// Pagination options. +@JsonSerializable(createFactory: false, includeIfNull: false) +class PaginationParams { + /// The amount of items requested from the APIs. + final int limit; + + /// The offset of requesting items. + final int offset; + + /// Filter on ids greater than the given value. + @JsonKey(name: 'id_gt') + final String greaterThan; + + /// Filter on ids greater than or equal to the given value. + @JsonKey(name: 'id_gte') + final String greaterThanOrEqual; + + /// Filter on ids smaller than the given value. + @JsonKey(name: 'id_lt') + final String lessThan; + + /// Filter on ids smaller than or equal to the given value. + @JsonKey(name: 'id_lte') + final String lessThanOrEqual; + + /// Creates a new PaginationParams instance + /// + /// For example: + /// ```dart + /// // limit to 50 + /// final paginationParams = PaginationParams(limit: 50); + /// + /// // limit to 50 with offset + /// final paginationParams = PaginationParams(limit: 50, offset: 50); + /// ``` + const PaginationParams({ + this.limit = 10, + this.offset, + this.greaterThan, + this.greaterThanOrEqual, + this.lessThan, + this.lessThanOrEqual, + }); + + /// Serialize model to json + Map toJson() => _$PaginationParamsToJson(this); + + /// Creates a copy of [PaginationParams] with specified attributes overridden. + PaginationParams copyWith({ + int limit, + int offset, + String greaterThan, + String greaterThanOrEqual, + String lessThan, + String lessThanOrEqual, + }) => + PaginationParams( + limit: limit ?? this.limit, + offset: offset ?? this.offset, + greaterThan: greaterThan ?? this.greaterThan, + greaterThanOrEqual: greaterThanOrEqual ?? this.greaterThanOrEqual, + lessThan: lessThan ?? this.lessThan, + lessThanOrEqual: lessThanOrEqual ?? this.lessThanOrEqual, + ); +} diff --git a/packages/stream_chat/lib/src/api/requests.g.dart b/packages/stream_chat/lib/src/api/requests.g.dart new file mode 100644 index 00000000..ef790776 --- /dev/null +++ b/packages/stream_chat/lib/src/api/requests.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'requests.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Map _$SortOptionToJson(SortOption instance) => + { + 'field': instance.field, + 'direction': instance.direction, + }; + +Map _$PaginationParamsToJson(PaginationParams instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('limit', instance.limit); + writeNotNull('offset', instance.offset); + writeNotNull('id_gt', instance.greaterThan); + writeNotNull('id_gte', instance.greaterThanOrEqual); + writeNotNull('id_lt', instance.lessThan); + writeNotNull('id_lte', instance.lessThanOrEqual); + return val; +} diff --git a/packages/stream_chat/lib/src/api/responses.dart b/packages/stream_chat/lib/src/api/responses.dart new file mode 100644 index 00000000..e27ac2c9 --- /dev/null +++ b/packages/stream_chat/lib/src/api/responses.dart @@ -0,0 +1,375 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/client.dart'; +import 'package:stream_chat/src/models/device.dart'; +import 'package:stream_chat/src/models/event.dart'; + +import '../models/channel_model.dart'; +import '../models/channel_state.dart'; +import '../models/member.dart'; +import '../models/message.dart'; +import '../models/reaction.dart'; +import '../models/read.dart'; +import '../models/user.dart'; + +part 'responses.g.dart'; + +class _BaseResponse { + String duration; +} + +/// Model response for [StreamChatClient.resync] api call +@JsonSerializable(createToJson: false) +class SyncResponse extends _BaseResponse { + /// The list of events + List events; + + /// Create a new instance from a json + static SyncResponse fromJson(Map json) => + _$SyncResponseFromJson(json); +} + +/// Model response for [StreamChatClient.queryChannels] api call +@JsonSerializable(createToJson: false) +class QueryChannelsResponse extends _BaseResponse { + /// List of channels state returned by the query + List channels; + + /// Create a new instance from a json + static QueryChannelsResponse fromJson(Map json) => + _$QueryChannelsResponseFromJson(json); +} + +/// Model response for [StreamChatClient.queryChannels] api call +@JsonSerializable(createToJson: false) +class TranslateMessageResponse extends _BaseResponse { + /// List of channels state returned by the query + TranslatedMessage message; + + /// Create a new instance from a json + static TranslateMessageResponse fromJson(Map json) => + _$TranslateMessageResponseFromJson(json); +} + +/// Model response for [StreamChatClient.queryChannels] api call +@JsonSerializable(createToJson: false) +class QueryMembersResponse extends _BaseResponse { + /// List of channels state returned by the query + List members; + + /// Create a new instance from a json + static QueryMembersResponse fromJson(Map json) => + _$QueryMembersResponseFromJson(json); +} + +/// Model response for [StreamChatClient.queryUsers] api call +@JsonSerializable(createToJson: false) +class QueryUsersResponse extends _BaseResponse { + /// List of users returned by the query + List users; + + /// Create a new instance from a json + static QueryUsersResponse fromJson(Map json) => + _$QueryUsersResponseFromJson(json); +} + +/// Model response for [channel.getReactions] api call +@JsonSerializable(createToJson: false) +class QueryReactionsResponse extends _BaseResponse { + /// List of reactions returned by the query + List reactions; + + /// Create a new instance from a json + static QueryReactionsResponse fromJson(Map json) => + _$QueryReactionsResponseFromJson(json); +} + +/// Model response for [Channel.getReplies] api call +@JsonSerializable(createToJson: false) +class QueryRepliesResponse extends _BaseResponse { + /// List of messages returned by the api call + List messages; + + /// Create a new instance from a json + static QueryRepliesResponse fromJson(Map json) => + _$QueryRepliesResponseFromJson(json); +} + +/// Model response for [StreamChatClient.getDevices] api call +@JsonSerializable(createToJson: false) +class ListDevicesResponse extends _BaseResponse { + /// List of user devices + List devices; + + /// Create a new instance from a json + static ListDevicesResponse fromJson(Map json) => + _$ListDevicesResponseFromJson(json); +} + +/// Model response for [Channel.sendFile] api call +@JsonSerializable(createToJson: false) +class SendFileResponse extends _BaseResponse { + /// The url of the uploaded file + String file; + + /// Create a new instance from a json + static SendFileResponse fromJson(Map json) => + _$SendFileResponseFromJson(json); +} + +/// Model response for [Channel.sendImage] api call +@JsonSerializable(createToJson: false) +class SendImageResponse extends _BaseResponse { + /// The url of the uploaded file + String file; + + /// Create a new instance from a json + static SendImageResponse fromJson(Map json) => + _$SendImageResponseFromJson(json); +} + +/// Model response for [Channel.sendReaction] api call +@JsonSerializable(createToJson: false) +class SendReactionResponse extends _BaseResponse { + /// Message returned by the api call + Message message; + + /// The reaction created by the api call + Reaction reaction; + + /// Create a new instance from a json + static SendReactionResponse fromJson(Map json) => + _$SendReactionResponseFromJson(json); +} + +/// Model response for [StreamChatClient.setGuestUser] api call +@JsonSerializable(createToJson: false) +class SetGuestUserResponse extends _BaseResponse { + /// Guest user access token + String accessToken; + + /// Guest user + User user; + + /// Create a new instance from a json + static SetGuestUserResponse fromJson(Map json) => + _$SetGuestUserResponseFromJson(json); +} + +/// Model response for [StreamChatClient.updateUser] api call +@JsonSerializable(createToJson: false) +class UpdateUsersResponse extends _BaseResponse { + /// Updated users + Map users; + + /// Create a new instance from a json + static UpdateUsersResponse fromJson(Map json) => + _$UpdateUsersResponseFromJson(json); +} + +/// Model response for [StreamChatClient.updateMessage] api call +@JsonSerializable(createToJson: false) +class UpdateMessageResponse extends _BaseResponse { + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static UpdateMessageResponse fromJson(Map json) => + _$UpdateMessageResponseFromJson(json); +} + +/// Model response for [Channel.sendMessage] api call +@JsonSerializable(createToJson: false) +class SendMessageResponse extends _BaseResponse { + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static SendMessageResponse fromJson(Map json) => + _$SendMessageResponseFromJson(json); +} + +/// Model response for [StreamChatClient.getMessage] api call +@JsonSerializable(createToJson: false) +class GetMessageResponse extends _BaseResponse { + /// Message returned by the api call + Message message; + + /// Channel of the message + ChannelModel channel; + + /// Create a new instance from a json + static GetMessageResponse fromJson(Map json) { + final res = _$GetMessageResponseFromJson(json); + final jsonChannel = res.message?.extraData?.remove('channel'); + if (jsonChannel != null) { + res.channel = ChannelModel.fromJson(jsonChannel); + } + return res; + } +} + +/// Model response for [StreamChatClient.search] api call +@JsonSerializable(createToJson: false) +class SearchMessagesResponse extends _BaseResponse { + /// List of messages returned by the api call + List results; + + /// Create a new instance from a json + static SearchMessagesResponse fromJson(Map json) => + _$SearchMessagesResponseFromJson(json); +} + +/// Model response for [Channel.getMessagesById] api call +@JsonSerializable(createToJson: false) +class GetMessagesByIdResponse extends _BaseResponse { + /// Message returned by the api call + List messages; + + /// Create a new instance from a json + static GetMessagesByIdResponse fromJson(Map json) => + _$GetMessagesByIdResponseFromJson(json); +} + +/// Model response for [Channel.update] api call +@JsonSerializable(createToJson: false) +class UpdateChannelResponse extends _BaseResponse { + /// Updated channel + ChannelModel channel; + + /// Channel members + List members; + + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static UpdateChannelResponse fromJson(Map json) => + _$UpdateChannelResponseFromJson(json); +} + +/// Model response for [Channel.inviteMembers] api call +@JsonSerializable(createToJson: false) +class InviteMembersResponse extends _BaseResponse { + /// Updated channel + ChannelModel channel; + + /// Channel members + List members; + + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static InviteMembersResponse fromJson(Map json) => + _$InviteMembersResponseFromJson(json); +} + +/// Model response for [Channel.removeMembers] api call +@JsonSerializable(createToJson: false) +class RemoveMembersResponse extends _BaseResponse { + /// Updated channel + ChannelModel channel; + + /// Channel members + List members; + + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static RemoveMembersResponse fromJson(Map json) => + _$RemoveMembersResponseFromJson(json); +} + +/// Model response for [Channel.sendAction] api call +@JsonSerializable(createToJson: false) +class SendActionResponse extends _BaseResponse { + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static SendActionResponse fromJson(Map json) => + _$SendActionResponseFromJson(json); +} + +/// Model response for [Channel.addMembers] api call +@JsonSerializable(createToJson: false) +class AddMembersResponse extends _BaseResponse { + /// Updated channel + ChannelModel channel; + + /// Channel members + List members; + + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static AddMembersResponse fromJson(Map json) => + _$AddMembersResponseFromJson(json); +} + +/// Model response for [Channel.acceptInvite] api call +@JsonSerializable(createToJson: false) +class AcceptInviteResponse extends _BaseResponse { + /// Updated channel + ChannelModel channel; + + /// Channel members + List members; + + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static AcceptInviteResponse fromJson(Map json) => + _$AcceptInviteResponseFromJson(json); +} + +/// Model response for [Channel.rejectInvite] api call +@JsonSerializable(createToJson: false) +class RejectInviteResponse extends _BaseResponse { + /// Updated channel + ChannelModel channel; + + /// Channel members + List members; + + /// Message returned by the api call + Message message; + + /// Create a new instance from a json + static RejectInviteResponse fromJson(Map json) => + _$RejectInviteResponseFromJson(json); +} + +/// Model response for empty responses +@JsonSerializable(createToJson: false) +class EmptyResponse extends _BaseResponse { + /// Create a new instance from a json + static EmptyResponse fromJson(Map json) => + _$EmptyResponseFromJson(json); +} + +/// Model response for [Channel.query] api call +@JsonSerializable(createToJson: false) +class ChannelStateResponse extends _BaseResponse { + /// Updated channel + ChannelModel channel; + + /// List of messages returned by the api call + List messages; + + /// Channel members + List members; + + /// Number of users watching the channel + int watcherCount; + + /// List of read states + List read; + + /// Create a new instance from a json + static ChannelStateResponse fromJson(Map json) => + _$ChannelStateResponseFromJson(json); +} diff --git a/packages/stream_chat/lib/src/api/responses.g.dart b/packages/stream_chat/lib/src/api/responses.g.dart new file mode 100644 index 00000000..db5fba9e --- /dev/null +++ b/packages/stream_chat/lib/src/api/responses.g.dart @@ -0,0 +1,382 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'responses.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +SyncResponse _$SyncResponseFromJson(Map json) { + return SyncResponse() + ..duration = json['duration'] as String + ..events = (json['events'] as List) + ?.map((e) => e == null + ? null + : Event.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} + +QueryChannelsResponse _$QueryChannelsResponseFromJson(Map json) { + return QueryChannelsResponse() + ..duration = json['duration'] as String + ..channels = (json['channels'] as List) + ?.map((e) => e == null ? null : ChannelState.fromJson(e as Map)) + ?.toList(); +} + +TranslateMessageResponse _$TranslateMessageResponseFromJson(Map json) { + return TranslateMessageResponse() + ..duration = json['duration'] as String + ..message = json['message'] == null + ? null + : TranslatedMessage.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +QueryMembersResponse _$QueryMembersResponseFromJson(Map json) { + return QueryMembersResponse() + ..duration = json['duration'] as String + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} + +QueryUsersResponse _$QueryUsersResponseFromJson(Map json) { + return QueryUsersResponse() + ..duration = json['duration'] as String + ..users = (json['users'] as List) + ?.map((e) => e == null + ? null + : User.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} + +QueryReactionsResponse _$QueryReactionsResponseFromJson(Map json) { + return QueryReactionsResponse() + ..duration = json['duration'] as String + ..reactions = (json['reactions'] as List) + ?.map((e) => e == null + ? null + : Reaction.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} + +QueryRepliesResponse _$QueryRepliesResponseFromJson(Map json) { + return QueryRepliesResponse() + ..duration = json['duration'] as String + ..messages = (json['messages'] as List) + ?.map((e) => e == null + ? null + : Message.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} + +ListDevicesResponse _$ListDevicesResponseFromJson(Map json) { + return ListDevicesResponse() + ..duration = json['duration'] as String + ..devices = (json['devices'] as List) + ?.map((e) => e == null + ? null + : Device.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} + +SendFileResponse _$SendFileResponseFromJson(Map json) { + return SendFileResponse() + ..duration = json['duration'] as String + ..file = json['file'] as String; +} + +SendImageResponse _$SendImageResponseFromJson(Map json) { + return SendImageResponse() + ..duration = json['duration'] as String + ..file = json['file'] as String; +} + +SendReactionResponse _$SendReactionResponseFromJson(Map json) { + return SendReactionResponse() + ..duration = json['duration'] as String + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..reaction = json['reaction'] == null + ? null + : Reaction.fromJson((json['reaction'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +SetGuestUserResponse _$SetGuestUserResponseFromJson(Map json) { + return SetGuestUserResponse() + ..duration = json['duration'] as String + ..accessToken = json['access_token'] as String + ..user = json['user'] == null + ? null + : User.fromJson((json['user'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) { + return UpdateUsersResponse() + ..duration = json['duration'] as String + ..users = (json['users'] as Map)?.map( + (k, e) => MapEntry( + k as String, + e == null + ? null + : User.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))), + ); +} + +UpdateMessageResponse _$UpdateMessageResponseFromJson(Map json) { + return UpdateMessageResponse() + ..duration = json['duration'] as String + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +SendMessageResponse _$SendMessageResponseFromJson(Map json) { + return SendMessageResponse() + ..duration = json['duration'] as String + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +GetMessageResponse _$GetMessageResponseFromJson(Map json) { + return GetMessageResponse() + ..duration = json['duration'] as String + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +SearchMessagesResponse _$SearchMessagesResponseFromJson(Map json) { + return SearchMessagesResponse() + ..duration = json['duration'] as String + ..results = (json['results'] as List) + ?.map((e) => e == null ? null : GetMessageResponse.fromJson(e as Map)) + ?.toList(); +} + +GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson(Map json) { + return GetMessagesByIdResponse() + ..duration = json['duration'] as String + ..messages = (json['messages'] as List) + ?.map((e) => e == null + ? null + : Message.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} + +UpdateChannelResponse _$UpdateChannelResponseFromJson(Map json) { + return UpdateChannelResponse() + ..duration = json['duration'] as String + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +InviteMembersResponse _$InviteMembersResponseFromJson(Map json) { + return InviteMembersResponse() + ..duration = json['duration'] as String + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +RemoveMembersResponse _$RemoveMembersResponseFromJson(Map json) { + return RemoveMembersResponse() + ..duration = json['duration'] as String + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +SendActionResponse _$SendActionResponseFromJson(Map json) { + return SendActionResponse() + ..duration = json['duration'] as String + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +AddMembersResponse _$AddMembersResponseFromJson(Map json) { + return AddMembersResponse() + ..duration = json['duration'] as String + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +AcceptInviteResponse _$AcceptInviteResponseFromJson(Map json) { + return AcceptInviteResponse() + ..duration = json['duration'] as String + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +RejectInviteResponse _$RejectInviteResponseFromJson(Map json) { + return RejectInviteResponse() + ..duration = json['duration'] as String + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..message = json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )); +} + +EmptyResponse _$EmptyResponseFromJson(Map json) { + return EmptyResponse()..duration = json['duration'] as String; +} + +ChannelStateResponse _$ChannelStateResponseFromJson(Map json) { + return ChannelStateResponse() + ..duration = json['duration'] as String + ..channel = json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )) + ..messages = (json['messages'] as List) + ?.map((e) => e == null + ? null + : Message.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..members = (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList() + ..watcherCount = json['watcher_count'] as int + ..read = (json['read'] as List) + ?.map((e) => e == null + ? null + : Read.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(); +} diff --git a/packages/stream_chat/lib/src/api/retry_policy.dart b/packages/stream_chat/lib/src/api/retry_policy.dart new file mode 100644 index 00000000..2c59ec48 --- /dev/null +++ b/packages/stream_chat/lib/src/api/retry_policy.dart @@ -0,0 +1,38 @@ +import 'package:meta/meta.dart'; +import 'package:stream_chat/src/client.dart'; +import 'package:stream_chat/src/exceptions.dart'; + +/// The retry options +class RetryPolicy { + /// Instantiate a new RetryPolicy + RetryPolicy({ + @required this.shouldRetry, + @required this.retryTimeout, + this.attempt, + }); + + /// The number of attempts tried so far + int attempt = 0; + + /// This function evaluates if we should retry the failure + final bool Function(StreamChatClient client, int attempt, ApiError apiError) + shouldRetry; + + /// In the case that we want to retry a failed request the retryTimeout method is called to determine the timeout + final Duration Function( + StreamChatClient client, int attempt, ApiError apiError) retryTimeout; + + /// Creates a copy of [RetryPolicy] with specified attributes overridden. + RetryPolicy copyWith({ + bool Function(StreamChatClient client, int attempt, ApiError apiError) + shouldRetry, + Duration Function(StreamChatClient client, int attempt, ApiError apiError) + retryTimeout, + int attempt, + }) => + RetryPolicy( + retryTimeout: retryTimeout ?? this.retryTimeout, + shouldRetry: shouldRetry ?? this.shouldRetry, + attempt: attempt ?? this.attempt, + ); +} diff --git a/packages/stream_chat/lib/src/api/retry_queue.dart b/packages/stream_chat/lib/src/api/retry_queue.dart new file mode 100644 index 00000000..534d9c08 --- /dev/null +++ b/packages/stream_chat/lib/src/api/retry_queue.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:meta/meta.dart'; +import 'package:logging/logging.dart'; +import 'package:stream_chat/src/api/channel.dart'; +import 'package:stream_chat/src/api/retry_policy.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/exceptions.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// The retry queue associated to a channel +class RetryQueue { + /// The channel of this queue + final Channel channel; + + /// The logger associated to this queue + final Logger logger; + + /// Instantiate a new RetryQueue object + RetryQueue({ + @required this.channel, + this.logger, + }) { + _retryPolicy = channel.client.retryPolicy; + + _listenConnectionRecovered(); + + _listenFailedEvents(); + } + + final _subscriptions = []; + + void _listenConnectionRecovered() { + _subscriptions + .add(channel.client.on(EventType.connectionRecovered).listen((event) { + if (!_isRetrying && event.online) { + _startRetrying(); + } + })); + } + + final HeapPriorityQueue _messageQueue = HeapPriorityQueue(_byDate); + bool _isRetrying = false; + RetryPolicy _retryPolicy; + + /// Add a list of messages + void add(List messages) { + final messageList = _messageQueue.toList(); + _messageQueue.addAll(messages + .where((element) => !messageList.any((m) => m.id == element.id))); + + if (_messageQueue.isNotEmpty && !_isRetrying) { + _startRetrying(); + } + } + + Future _startRetrying() async { + logger?.info('start retrying'); + _isRetrying = true; + final retryPolicy = _retryPolicy.copyWith(attempt: 0); + + while (_messageQueue.isNotEmpty) { + final message = _messageQueue.first; + try { + logger?.info('retry attempt ${retryPolicy.attempt}'); + await _sendMessage(message); + logger?.info('message sent - removing it from the queue'); + _messageQueue.remove(message); + logger?.info('now ${_messageQueue.length} messages in the queue'); + retryPolicy.attempt = 0; + } catch (error) { + ApiError apiError; + if (error is DioError) { + if (error.type == DioErrorType.RESPONSE) { + _messageQueue.remove(message); + return; + } + apiError = ApiError( + error.response?.data, + error.response?.statusCode, + ); + } else if (error is ApiError) { + apiError = error; + if (apiError.status?.toString()?.startsWith('4') == true) { + _messageQueue.remove(message); + return; + } + } + + if (!retryPolicy.shouldRetry( + channel.client, + retryPolicy.attempt, + apiError, + )) { + _messageQueue.toList().forEach(_sendFailedEvent); + _isRetrying = false; + return; + } + + retryPolicy.attempt++; + final timeout = retryPolicy.retryTimeout( + channel.client, + retryPolicy.attempt, + apiError, + ); + await Future.delayed(timeout); + } + } + _isRetrying = false; + } + + void _sendFailedEvent(Message message) { + final newStatus = message.status == MessageSendingStatus.sending + ? MessageSendingStatus.failed + : (message.status == MessageSendingStatus.updating + ? MessageSendingStatus.failed_update + : MessageSendingStatus.failed_delete); + channel.state.addMessage(message.copyWith( + status: newStatus, + )); + } + + Future _sendMessage(Message message) async { + if (message.status == MessageSendingStatus.failed_update || + message.status == MessageSendingStatus.updating) { + await channel.client.updateMessage( + message, + channel.cid, + ); + } else if (message.status == MessageSendingStatus.failed || + message.status == MessageSendingStatus.sending) { + await channel.sendMessage( + message, + ); + } else if (message.status == MessageSendingStatus.failed_delete || + message.status == MessageSendingStatus.deleting) { + await channel.client.deleteMessage( + message, + channel.cid, + ); + } + } + + void _listenFailedEvents() { + _subscriptions.add(channel.on().listen((event) { + final messageList = _messageQueue.toList(); + if (event.message != null) { + final messageIndex = + messageList.indexWhere((m) => m.id == event.message.id); + if (messageIndex == -1 && + [ + MessageSendingStatus.failed_update, + MessageSendingStatus.failed, + MessageSendingStatus.failed_delete, + ].contains(event.message.status)) { + logger?.info('add message from events'); + add([event.message]); + } else if (messageIndex != -1 && + [ + MessageSendingStatus.sent, + null, + ].contains(event.message.status)) { + _messageQueue.remove(messageList[messageIndex]); + } + } + })); + } + + /// Call this method to dispose this object + void dispose() { + _messageQueue.clear(); + _subscriptions.forEach((s) => s.cancel()); + } + + static int _byDate(Message m1, Message m2) { + final date1 = _getMessageDate(m1); + final date2 = _getMessageDate(m2); + + return date1.compareTo(date2); + } + + static DateTime _getMessageDate(Message m1) { + switch (m1.status) { + case MessageSendingStatus.failed_delete: + case MessageSendingStatus.deleting: + return m1.deletedAt; + + case MessageSendingStatus.failed: + case MessageSendingStatus.sending: + return m1.createdAt; + + case MessageSendingStatus.failed_update: + case MessageSendingStatus.updating: + return m1.updatedAt; + default: + return null; + } + } +} diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_html.dart b/packages/stream_chat/lib/src/api/web_socket_channel_html.dart new file mode 100644 index 00000000..9ddd83e5 --- /dev/null +++ b/packages/stream_chat/lib/src/api/web_socket_channel_html.dart @@ -0,0 +1,7 @@ +import 'package:web_socket_channel/html.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Html version of websocket implementation +/// Used in Flutter web version +WebSocketChannel connectWebSocket(String url, {Iterable protocols}) => + HtmlWebSocketChannel.connect(url, protocols: protocols); diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_io.dart b/packages/stream_chat/lib/src/api/web_socket_channel_io.dart new file mode 100644 index 00000000..ed37ba7f --- /dev/null +++ b/packages/stream_chat/lib/src/api/web_socket_channel_io.dart @@ -0,0 +1,7 @@ +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// IO version of websocket implementation +/// Used in Flutter mobile version +WebSocketChannel connectWebSocket(String url, {Iterable protocols}) => + IOWebSocketChannel.connect(url, protocols: protocols); diff --git a/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart b/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart new file mode 100644 index 00000000..7e2e47bd --- /dev/null +++ b/packages/stream_chat/lib/src/api/web_socket_channel_stub.dart @@ -0,0 +1,9 @@ +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Stub version of websocket implementation +/// Used just for conditional library import +WebSocketChannel connectWebSocket(String url, + {Iterable protocols, + Map headers, + Duration pingInterval}) => + throw UnimplementedError(); diff --git a/packages/stream_chat/lib/src/api/websocket.dart b/packages/stream_chat/lib/src/api/websocket.dart new file mode 100644 index 00000000..15102c6e --- /dev/null +++ b/packages/stream_chat/lib/src/api/websocket.dart @@ -0,0 +1,316 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; + +import 'package:meta/meta.dart'; +import 'package:logging/logging.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +import '../models/event.dart'; +import '../models/user.dart'; +import 'connection_status.dart'; +import 'web_socket_channel_stub.dart' + if (dart.library.html) 'web_socket_channel_html.dart' + if (dart.library.io) 'web_socket_channel_io.dart'; + +/// Typedef which exposes an [Event] as the only parameter. +typedef EventHandler = void Function(Event); + +/// Typedef used for connecting to a websocket. Method returns a [WebSocketChannel] +/// and accepts a connection [url] and an optional [Iterable] of `protocols`. +typedef ConnectWebSocket = WebSocketChannel Function(String url, + {Iterable protocols}); + +// TODO: parse error even +// TODO: if parsing an error into an event fails we should not hide the original error +/// A WebSocket connection that reconnects upon failure. +class WebSocket { + /// Creates a new websocket + /// To connect the WS call [connect] + WebSocket({ + @required this.baseUrl, + this.user, + this.connectParams, + this.connectPayload, + this.handler, + this.logger, + this.connectFunc = connectWebSocket, + this.reconnectionMonitorInterval = 1, + this.healthCheckInterval = 20, + this.reconnectionMonitorTimeout = 40, + }) { + final qs = Map.from(connectParams); + + final data = Map.from(connectPayload); + + data['user_details'] = user.toJson(); + qs['json'] = json.encode(data); + + if (baseUrl.startsWith('https')) { + _path = baseUrl.replaceFirst('https://', ''); + _path = Uri.https(_path, 'connect', qs) + .toString() + .replaceFirst('https', 'wss'); + } else if (baseUrl.startsWith('http')) { + _path = baseUrl.replaceFirst('http://', ''); + _path = + Uri.http(_path, 'connect', qs).toString().replaceFirst('http', 'ws'); + } else { + _path = Uri.https(baseUrl, 'connect', qs) + .toString() + .replaceFirst('https', 'wss'); + } + } + + /// WS base url + final String baseUrl; + + /// User performing the WS connection + final User user; + + /// Querystring connection parameters + final Map connectParams; + + /// WS connection payload + final Map connectPayload; + + /// Functions that will be called every time a new event is received from the connection + final EventHandler handler; + + /// A WS specific logger instance + final Logger logger; + + /// Connection function + /// Used only for testing purpose + @visibleForTesting + final ConnectWebSocket connectFunc; + + /// Interval of the reconnection monitor timer + /// This checks that it received a new event in the last [reconnectionMonitorTimeout] seconds, + /// otherwise it considers the connection unhealthy and reconnects the WS + final int reconnectionMonitorInterval; + + /// Interval of the health event sending timer + /// This sends a health event every [healthCheckInterval] seconds in order to + /// make the server aware that the client is still listening + final int healthCheckInterval; + + /// The timeout that uses the reconnection monitor timer to consider the connection unhealthy + final int reconnectionMonitorTimeout; + + final _connectionStatusController = + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set _connectionStatus(ConnectionStatus status) => + _connectionStatusController.add(status); + + /// The current connection status value + ConnectionStatus get connectionStatus => _connectionStatusController.value; + + /// This notifies of connection status changes + Stream get connectionStatusStream => + _connectionStatusController.stream; + + String _path; + int _retryAttempt = 1; + WebSocketChannel _channel; + Timer _healthCheck, _reconnectionMonitor; + DateTime _lastEventAt; + bool _manuallyDisconnected = false, + _connecting = false, + _reconnecting = false; + + Event _decodeEvent(String source) { + return Event.fromJson(json.decode(source)); + } + + Completer _connectionCompleter = Completer(); + + /// Connect the WS using the parameters passed in the constructor + Future connect() { + _manuallyDisconnected = false; + + if (_connecting) { + logger.severe('already connecting'); + return null; + } + + _connecting = true; + _connectionStatus = ConnectionStatus.connecting; + + logger.info('connecting to $_path'); + + _channel = connectFunc(_path); + _channel.stream.listen( + (data) { + final jsonData = json.decode(data); + if (jsonData['error'] != null) { + return _onConnectionError(jsonData['error']); + } + _onData(data); + }, + onError: (error, stacktrace) { + _onConnectionError(error, stacktrace); + }, + onDone: () { + _onDone(); + }, + ); + return _connectionCompleter.future; + } + + void _onDone() { + _connecting = false; + if (_manuallyDisconnected) { + return; + } + + logger.info( + 'connection closed | closeCode: ${_channel.closeCode} | closedReason: ${_channel.closeReason}'); + + if (!_reconnecting) { + _reconnect(); + } + } + + void _onData(data) { + final event = _decodeEvent(data); + logger.info('received new event: $data'); + + if (_lastEventAt == null) { + logger.info('connection estabilished'); + _connecting = false; + _reconnecting = false; + _lastEventAt = DateTime.now(); + + _connectionStatus = ConnectionStatus.connected; + _retryAttempt = 1; + + if (!_connectionCompleter.isCompleted) { + _connectionCompleter.complete(event); + } + + _startReconnectionMonitor(); + _startHealthCheck(); + } + + handler(event); + _lastEventAt = DateTime.now(); + } + + Future _onConnectionError(error, [stacktrace]) async { + logger.severe('error connecting'); + logger.severe(error); + if (stacktrace != null) { + logger.severe(stacktrace); + } + _connecting = false; + + if (!_reconnecting) { + _connectionStatus = ConnectionStatus.disconnected; + } + + if (!_connectionCompleter.isCompleted) { + _cancelTimers(); + _connectionCompleter.completeError(error, stacktrace); + } else if (!_reconnecting) { + return _reconnect(); + } + } + + void _startReconnectionMonitor() { + final reconnectionTimer = (_) { + final now = DateTime.now(); + if (_lastEventAt != null && + now.difference(_lastEventAt).inSeconds > reconnectionMonitorTimeout) { + _channel.sink.close(); + } + }; + + _reconnectionMonitor = Timer.periodic( + Duration(seconds: reconnectionMonitorInterval), + reconnectionTimer, + ); + + reconnectionTimer(_reconnectionMonitor); + } + + void _reconnectTimer() async { + if (!_reconnecting) { + return; + } + if (_connecting) { + logger.info('already connecting'); + return; + } + + logger.info('reconnecting..'); + + _cancelTimers(); + + try { + await connect(); + } catch (e) { + logger.log(Level.SEVERE, e.toString()); + } + await Future.delayed( + Duration(seconds: min(_retryAttempt * 5, 25)), + () { + _reconnectTimer(); + _retryAttempt++; + }, + ); + } + + Future _reconnect() async { + logger.info('reconnect'); + if (!_reconnecting) { + _reconnecting = true; + _connectionStatus = ConnectionStatus.connecting; + } + + _reconnectTimer(); + } + + void _cancelTimers() { + _lastEventAt = null; + if (_healthCheck != null) { + _healthCheck.cancel(); + } + if (_reconnectionMonitor != null) { + _reconnectionMonitor.cancel(); + } + } + + void _startHealthCheck() { + logger.info('start health check monitor'); + + final healthCheckTimer = (_) { + logger.info('sending health.check'); + _channel.sink.add("{'type': 'health.check'}"); + }; + + _healthCheck = Timer.periodic( + Duration(seconds: healthCheckInterval), + healthCheckTimer, + ); + + healthCheckTimer(_healthCheck); + } + + /// Disconnects the WS and releases eventual resources + Future disconnect() async { + if (_manuallyDisconnected) { + return; + } + logger.info('disconnecting'); + _connectionCompleter = Completer(); + _cancelTimers(); + _reconnecting = false; + _manuallyDisconnected = true; + _connectionStatus = ConnectionStatus.disconnected; + await _connectionStatusController.close(); + return _channel.sink.close(); + } +} diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart new file mode 100644 index 00000000..723b38b6 --- /dev/null +++ b/packages/stream_chat/lib/src/client.dart @@ -0,0 +1,1410 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:pedantic/pedantic.dart' show unawaited; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/api/retry_policy.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/models/own_user.dart'; +import 'package:stream_chat/version.dart'; +import 'package:uuid/uuid.dart'; + +import 'api/channel.dart'; +import 'api/connection_status.dart'; +import 'api/requests.dart'; +import 'api/responses.dart'; +import 'api/websocket.dart'; +import 'db/chat_persistence_client.dart'; +import 'exceptions.dart'; +import 'models/event.dart'; +import 'models/message.dart'; +import 'models/user.dart'; + +/// Handler function used for logging records. Function requires a single [LogRecord] +/// as the only parameter. +typedef LogHandlerFunction = void Function(LogRecord record); + +/// Used for decoding [Map] data to a generic type `T`. +typedef DecoderFunction = T Function(Map); + +/// A function which can be used to request a Stream Chat API token from your +/// own backend server. Function requires a single [userId]. +typedef TokenProvider = Future Function(String userId); + +/// Provider used to send push notifications. +enum PushProvider { + /// Send notifications using Google's Firebase Cloud Messaging + firebase, + + /// Send notifications using Apple's Push Notification service + apn +} + +extension on PushProvider { + /// Returns the string notion for [PushProvider]. + String get name { + if (this == PushProvider.apn) { + return 'apn'; + } else { + return 'firebase'; + } + } +} + +/// The official Dart client for Stream Chat, +/// a service for building chat applications. +/// This library can be used on any Dart project and on both mobile and web apps with Flutter. +/// You can sign up for a Stream account at https://getstream.io/chat/ +/// +/// The Chat client will manage API call, event handling and manage the +/// websocket connection to Stream Chat servers. +/// +/// ```dart +/// final client = StreamChatClient("stream-chat-api-key"); +/// ``` +class StreamChatClient { + /// Create a client instance with default options. + /// You should only create the client once and re-use it across your application. + StreamChatClient( + this.apiKey, { + this.tokenProvider, + this.baseURL = _defaultBaseURL, + this.logLevel = Level.WARNING, + this.logHandlerFunction, + Duration connectTimeout = const Duration(seconds: 6), + Duration receiveTimeout = const Duration(seconds: 6), + Dio httpClient, + RetryPolicy retryPolicy, + }) { + _retryPolicy ??= RetryPolicy( + retryTimeout: (StreamChatClient client, int attempt, ApiError error) => + Duration(seconds: 1 * attempt), + shouldRetry: (StreamChatClient client, int attempt, ApiError error) => + attempt < 5, + ); + + state = ClientState(this); + + _setupLogger(); + _setupDio(httpClient, receiveTimeout, connectTimeout); + + logger.info('instantiating new client'); + } + + /// Chat persistence client + ChatPersistenceClient chatPersistenceClient; + + /// Whether the chat persistence is available or not + bool get persistenceEnabled => chatPersistenceClient != null; + + RetryPolicy _retryPolicy; + + bool _synced = false; + + /// The retry policy options getter + RetryPolicy get retryPolicy => _retryPolicy; + + /// This client state + ClientState state; + + /// By default the Chat client will write all messages with level Warn or Error to stdout. + /// During development you might want to enable more logging information, you can change the default log level when constructing the client. + /// + /// ```dart + /// final client = StreamChatClient("stream-chat-api-key", logLevel: Level.INFO); + /// ``` + final Level logLevel; + + /// Client specific logger instance. + /// Refer to the class [Logger] to learn more about the specific implementation. + final Logger logger = Logger.detached('📡'); + + /// A function that has a parameter of type [LogRecord]. + /// This is called on every new log record. + /// By default the client will use the handler returned by [_getDefaultLogHandler]. + /// Setting it you can handle the log messages directly instead of have them written to stdout, + /// this is very convenient if you use an error tracking tool or if you want to centralize your logs into one facility. + /// + /// ```dart + /// myLogHandlerFunction = (LogRecord record) { + /// // do something with the record (ie. send it to Sentry or Fabric) + /// } + /// + /// final client = StreamChatClient("stream-chat-api-key", logHandlerFunction: myLogHandlerFunction); + ///``` + LogHandlerFunction logHandlerFunction; + + /// Your project Stream Chat api key. + /// Find your API keys here https://getstream.io/dashboard/ + String apiKey; + + /// Your project Stream Chat base url. + final String baseURL; + + /// A function in which you send a request to your own backend to get a Stream Chat API token. + /// The token will be the return value of the function. + /// It's used by the client to refresh the token once expired or to set the user without a predefined token using [setUserWithProvider]. + final TokenProvider tokenProvider; + + /// [Dio] httpClient + /// It's be chosen because it's easy to use and supports interesting features out of the box + /// (Interceptors, Global configuration, FormData, File downloading etc.) + @visibleForTesting + Dio httpClient = Dio(); + + static const _defaultBaseURL = 'chat-us-east-1.stream-io-api.com'; + static const _tokenExpiredErrorCode = 40; + StreamSubscription _connectionStatusSubscription; + Future Function(ConnectionStatus) _connectionStatusHandler; + + final BehaviorSubject _controller = BehaviorSubject(); + + /// Stream of [Event] coming from websocket connection + /// Listen to this or use the [on] method to filter specific event types + Stream get stream => _controller.stream; + + final _wsConnectionStatusController = + BehaviorSubject.seeded(ConnectionStatus.disconnected); + + set _wsConnectionStatus(ConnectionStatus status) => + _wsConnectionStatusController.add(status); + + /// The current status value of the websocket connection + ConnectionStatus get wsConnectionStatus => + _wsConnectionStatusController.value; + + /// This notifies the connection status of the websocket connection. + /// Listen to this to get notified when the websocket tries to reconnect. + Stream get wsConnectionStatusStream => + _wsConnectionStatusController.stream; + + /// The current user token + String token; + + /// The id of the current websocket connection + String get connectionId => _connectionId; + + bool _anonymous = false; + String _connectionId; + WebSocket _ws; + + bool get _hasConnectionId => _connectionId != null; + + void _setupDio( + Dio httpClient, + Duration receiveTimeout, + Duration connectTimeout, + ) { + logger.info('http client setup'); + + this.httpClient = httpClient ?? Dio(); + + String url; + if (!baseURL.startsWith('https') && !baseURL.startsWith('http')) { + url = Uri.https(baseURL, '').toString(); + } else { + url = baseURL; + } + + this.httpClient.options.baseUrl = url; + this.httpClient.options.receiveTimeout = receiveTimeout.inMilliseconds; + this.httpClient.options.connectTimeout = connectTimeout.inMilliseconds; + this.httpClient.interceptors.add( + InterceptorsWrapper( + onRequest: (options) async { + options.queryParameters.addAll(_commonQueryParams); + options.headers.addAll(_httpHeaders); + + if (_connectionId != null && + (options.data is Map || options.data == null)) { + options.data = { + 'connection_id': _connectionId, + ...(options.data ?? {}), + }; + } + + var stringData = options.data.toString(); + + if (options.data is FormData) { + final multiPart = (options.data as FormData).files[0]?.value; + stringData = + '${multiPart?.filename} - ${multiPart?.contentType}'; + } + + logger.info(''' + + method: ${options.method} + url: ${options.uri} + headers: ${options.headers} + data: $stringData + + '''); + + return options; + }, + onError: _tokenExpiredInterceptor, + ), + ); + } + + Future _tokenExpiredInterceptor(DioError err) async { + final apiError = ApiError( + err.response?.data, + err.response?.statusCode, + ); + + if (apiError.code == _tokenExpiredErrorCode) { + logger.info('token expired'); + + if (tokenProvider != null) { + httpClient.lock(); + final userId = state.user.id; + + await _disconnect(); + + final newToken = await tokenProvider(userId); + await Future.delayed(Duration(seconds: 4)); + token = newToken; + + httpClient.unlock(); + + await setUser(User(id: userId), newToken); + + try { + return await httpClient.request( + err.request.path, + cancelToken: err.request.cancelToken, + data: err.request.data, + onReceiveProgress: err.request.onReceiveProgress, + onSendProgress: err.request.onSendProgress, + queryParameters: err.request.queryParameters, + options: err.request, + ); + } catch (err) { + return err; + } + } + } + + return err; + } + + LogHandlerFunction _getDefaultLogHandler() { + final levelEmojiMapper = { + Level.INFO.name: 'ℹ️', + Level.WARNING.name: '⚠️', + Level.SEVERE.name: '🚨', + }; + return (LogRecord record) { + print( + '(${record.time}) ${levelEmojiMapper[record.level.name] ?? record.level.name} ${record.loggerName} ${record.message}'); + if (record.stackTrace != null) { + print(record.stackTrace); + } + }; + } + + Logger _detachedLogger( + String name, + ) { + return Logger.detached(name) + ..level = logLevel + ..onRecord.listen(logHandlerFunction ?? _getDefaultLogHandler()); + } + + void _setupLogger() { + logger.level = logLevel; + + logHandlerFunction ??= _getDefaultLogHandler(); + + logger.onRecord.listen(logHandlerFunction); + + logger.info('logger setup'); + } + + /// Call this function to dispose the client + void dispose() async { + await chatPersistenceClient?.disconnect(); + await _disconnect(); + httpClient.close(); + await _controller.close(); + state.dispose(); + await _wsConnectionStatusController.close(); + } + + Map get _httpHeaders => { + 'Authorization': token, + 'stream-auth-type': _authType, + 'x-stream-client': _userAgent, + 'Content-Encoding': 'gzip', + }; + + /// Set the current user, this triggers a connection to the API. + /// It returns a [Future] that resolves when the connection is setup. + Future setUser(User user, String token) async { + if (_connectCompleter != null && !_connectCompleter.isCompleted) { + logger.warning('Already connecting'); + throw Exception('Already connecting'); + } + + _connectCompleter = Completer(); + + logger.info('set user'); + state.user = OwnUser.fromJson(user.toJson()); + this.token = token; + _anonymous = false; + + return connect().then((event) { + _connectCompleter.complete(event); + return event; + }).catchError((e, s) { + _connectCompleter.completeError(e, s); + throw e; + }); + } + + /// Set the current user using the [tokenProvider] to fetch the token. + /// It returns a [Future] that resolves when the connection is setup. + Future setUserWithProvider(User user) async { + if (tokenProvider == null) { + throw Exception(''' + TokenProvider must be provided in the constructor in order to use `setUserWithProvider` method. + Use `setUser` providing a token. + '''); + } + final token = await tokenProvider(user.id); + return setUser(user, token); + } + + /// Stream of [Event] coming from websocket connection + /// Pass an eventType as parameter in order to filter just a type of event + Stream on([ + String eventType, + String eventType2, + String eventType3, + String eventType4, + ]) => + stream.where((event) => + eventType == null || + (event.type != null && + (event.type == eventType || + event.type == eventType2 || + event.type == eventType3 || + event.type == eventType4))); + + /// Method called to add a new event to the [_controller]. + void handleEvent(Event event) async { + logger.info('handle new event: ${event.toJson()}'); + if (event.connectionId != null) { + _connectionId = event.connectionId; + } + + if (!event.isLocal) { + if (_synced && event.createdAt != null) { + await chatPersistenceClient?.updateConnectionInfo(event); + await chatPersistenceClient?.updateLastSyncAt(event.createdAt); + } + } + + if (event.user != null) { + state._updateUser(event.user); + } + + if (event.me != null) { + state.user = event.me; + } + _controller.add(event); + } + + Completer _connectCompleter; + + /// Connect the client websocket + Future connect() async { + logger.info('connecting'); + if (wsConnectionStatus == ConnectionStatus.connecting) { + logger.warning('Already connecting'); + throw Exception('Already connecting'); + } + + if (wsConnectionStatus == ConnectionStatus.connected) { + logger.warning('Already connected'); + throw Exception('Already connected'); + } + + _wsConnectionStatus = ConnectionStatus.connecting; + + if (persistenceEnabled) { + await chatPersistenceClient.connect(state.user.id); + } + + _ws = WebSocket( + baseUrl: baseURL, + user: state.user, + connectParams: { + 'api_key': apiKey, + 'authorization': token, + 'stream-auth-type': _authType, + 'x-stream-client': _userAgent, + }, + connectPayload: { + 'user_id': state.user.id, + 'server_determines_connection_id': true, + }, + handler: handleEvent, + logger: _detachedLogger('🔌'), + ); + + _connectionStatusHandler = (ConnectionStatus status) async { + _wsConnectionStatus = status; + handleEvent( + Event( + type: EventType.connectionChanged, + online: status == ConnectionStatus.connected, + ), + ); + + if (status == ConnectionStatus.connected && + state.channels?.isNotEmpty == true) { + unawaited(queryChannels(filter: { + 'cid': { + '\$in': state.channels.keys.toList(), + }, + }).then( + (_) async { + await resync(); + handleEvent(Event( + type: EventType.connectionRecovered, + online: true, + )); + }, + )); + } else { + _synced = false; + } + }; + + _connectionStatusSubscription = + _ws.connectionStatusStream.listen(_connectionStatusHandler); + + var event = await chatPersistenceClient?.getConnectionInfo(); + + await _ws.connect().then((e) async { + await chatPersistenceClient?.updateConnectionInfo(e); + event = e; + await resync(); + }).catchError((err, stacktrace) { + logger.severe('error connecting ws', err, stacktrace); + if (err is Map) { + throw err; + } + }); + + return event; + } + + /// Get the events missed while offline to sync the offline storage + Future resync([List cids]) async { + final lastSyncAt = await chatPersistenceClient?.getLastSyncAt(); + + if (lastSyncAt == null) { + _synced = true; + return; + } + + cids ??= await chatPersistenceClient?.getChannelCids(); + + if (cids?.isEmpty == true) { + return; + } + + try { + final rawRes = await post('/sync', data: { + 'channel_cids': cids, + 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), + }); + logger.fine('rawRes: $rawRes'); + + final res = decode( + rawRes.data, + SyncResponse.fromJson, + ); + + res.events.sort((a, b) => a.createdAt.compareTo(b.createdAt)); + + res.events.forEach((element) { + logger.fine('element.type: ${element.type}'); + logger.fine('element.message.text: ${element.message?.text}'); + }); + + res.events.forEach((event) { + handleEvent(event); + }); + + await chatPersistenceClient?.updateLastSyncAt(DateTime.now()); + _synced = true; + } catch (error) { + logger.severe('Error during resync $error'); + } + } + + String _asMap(sort) { + return sort?.map((s) => s.toJson().toString())?.join(''); + } + + final _queryChannelsStreams = >>{}; + + /// Requests channels with a given query. + Future> queryChannels({ + Map filter, + List sort, + Map options, + PaginationParams paginationParams = const PaginationParams(limit: 10), + int messageLimit, + bool onlyOffline = false, + bool waitForConnect = true, + }) async { + if (waitForConnect) { + if (_connectCompleter != null && !_connectCompleter.isCompleted) { + logger.info('awaiting connection completer'); + await _connectCompleter.future; + } + if (wsConnectionStatus != ConnectionStatus.connected) { + final errorMessage = + 'You cannot use queryChannels without an active connection. Please call setUser to connect the client.'; + if (persistenceEnabled) { + logger.warning( + '$errorMessage\nTrying to retrieve channels from the offline storage.'); + onlyOffline = true; + } else { + throw Exception(errorMessage); + } + } + } + + final hash = base64.encode(utf8.encode( + '$filter${_asMap(sort)}$options${paginationParams?.toJson()}$messageLimit$onlyOffline')); + if (_queryChannelsStreams.containsKey(hash)) { + return _queryChannelsStreams[hash]; + } + + final newQueryChannelsStream = _doQueryChannels( + filter: filter, + sort: sort, + options: options, + paginationParams: paginationParams, + messageLimit: messageLimit, + onlyOffline: onlyOffline, + ).whenComplete(() { + _queryChannelsStreams.remove(hash); + }); + + _queryChannelsStreams[hash] = newQueryChannelsStream; + + return newQueryChannelsStream; + } + + Future> _doQueryChannels({ + @required Map filter, + @required List sort, + @required Map options, + @required int messageLimit, + PaginationParams paginationParams = const PaginationParams(limit: 10), + bool onlyOffline = false, + }) async { + logger.info('Query channel start'); + final defaultOptions = { + 'state': true, + 'watch': true, + 'presence': false, + }; + + var payload = { + 'filter_conditions': filter, + 'sort': sort, + }; + + if (messageLimit != null) { + payload['message_limit'] = messageLimit; + } + + payload.addAll(defaultOptions); + + if (options != null) { + payload.addAll(options); + } + + if (paginationParams != null) { + payload.addAll(paginationParams.toJson()); + } + + if (onlyOffline) { + return _queryChannelsOffline( + filter: filter, + sort: sort, + paginationParams: paginationParams, + ); + } + + try { + final response = await get( + '/channels', + queryParameters: { + 'payload': jsonEncode(payload), + }, + ); + + final res = decode( + response.data, + QueryChannelsResponse.fromJson, + ); + + final users = res.channels + ?.expand((channel) => channel.members.map((member) => member.user)) + ?.toList(); + + if (users != null) { + state._updateUsers(users); + } + + logger.info('Got ${res.channels?.length} channels from api'); + + if (res.channels?.isEmpty != false && + (paginationParams?.offset ?? 0) == 0) { + logger.warning('''We could not find any channel for this query. + Please make sure to take a look at the Flutter tutorial: https://getstream.io/chat/flutter/tutorial + If your application already has users and channels, you might need to adjust your query channel as explained in the docs https://getstream.io/chat/docs/query_channels/?language=dart'''); + } + + final newChannels = Map.from(state.channels ?? {}); + final channels = []; + + if (res.channels != null) { + for (final channelState in res.channels) { + final channel = newChannels[channelState.channel.cid]; + if (channel != null) { + channel.state?.updateChannelState(channelState); + channels.add(channel); + } else { + final newChannel = Channel.fromState(this, channelState); + await chatPersistenceClient + ?.updateChannelState(newChannel.state.channelState); + newChannel.state?.updateChannelState(channelState); + newChannels[newChannel.cid] = newChannel; + channels.add(newChannel); + } + } + } + + state.channels = newChannels; + + await chatPersistenceClient?.updateChannelQueries( + filter, + res.channels.map((c) => c.channel.cid).toList(), + paginationParams?.offset == null || paginationParams.offset == 0, + ); + + return channels; + } catch (e) { + if (!persistenceEnabled) { + rethrow; + } + return _queryChannelsOffline( + filter: filter, + sort: sort, + paginationParams: paginationParams, + ); + } + } + + dynamic _parseError(DioError error) { + if (error.type == DioErrorType.RESPONSE) { + final apiError = + ApiError(error.response?.data, error.response?.statusCode); + logger.severe('apiError: ${apiError.toString()}'); + return apiError; + } + + return error; + } + + Future> _queryChannelsOffline({ + @required Map filter, + @required List sort, + PaginationParams paginationParams = const PaginationParams(limit: 10), + }) async { + final offlineChannels = await chatPersistenceClient?.getChannelStates( + filter: filter, + sort: sort, + paginationParams: paginationParams, + ) ?? + []; + final newChannels = Map.from(state.channels ?? {}); + logger.info('Got ${offlineChannels.length} channels from storage'); + final channels = offlineChannels.map((channelState) { + final channel = newChannels[channelState.channel.cid]; + if (channel != null) { + channel.state?.updateChannelState(channelState); + return channel; + } else { + final newChannel = Channel.fromState(this, channelState); + chatPersistenceClient + ?.updateChannelState(newChannel.state.channelState); + newChannels[newChannel.cid] = newChannel; + return newChannel; + } + }).toList(); + + if (channels.isNotEmpty) { + state.channels = newChannels; + } + return channels; + } + + /// Handy method to make http GET request with error parsing. + Future> get( + String path, { + Map queryParameters, + }) async { + try { + final response = await httpClient.get( + path, + queryParameters: queryParameters, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http POST request with error parsing. + Future> post( + String path, { + dynamic data, + }) async { + try { + final response = await httpClient.post(path, data: data); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http DELETE request with error parsing. + Future> delete( + String path, { + Map queryParameters, + }) async { + try { + final response = await httpClient.delete(path, + queryParameters: queryParameters); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PATCH request with error parsing. + Future> patch( + String path, { + Map queryParameters, + dynamic data, + }) async { + try { + final response = await httpClient.patch( + path, + queryParameters: queryParameters, + data: data, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Handy method to make http PUT request with error parsing. + Future> put( + String path, { + Map queryParameters, + dynamic data, + }) async { + try { + final response = await httpClient.put( + path, + queryParameters: queryParameters, + data: data, + ); + return response; + } on DioError catch (error) { + throw _parseError(error); + } + } + + /// Used to log errors and stacktrace in case of bad json deserialization + T decode(String j, DecoderFunction decoderFunction) { + try { + if (j == null) { + return null; + } + return decoderFunction(json.decode(j)); + } catch (error, stacktrace) { + logger.severe('Error decoding response', error, stacktrace); + rethrow; + } + } + + String get _authType => _anonymous ? 'anonymous' : 'jwt'; + + // TODO: get the right version of the lib from the build toolchain + String get _userAgent => + 'stream-chat-dart-client-${PACKAGE_VERSION.split('+')[0]}'; + + Map get _commonQueryParams => { + 'user_id': state.user?.id, + 'api_key': apiKey, + 'connection_id': _connectionId, + }; + + /// Set the current user with an anonymous id, this triggers a connection to the API. + /// It returns a [Future] that resolves when the connection is setup. + Future setAnonymousUser() async { + if (_connectCompleter != null && !_connectCompleter.isCompleted) { + logger.warning('Already connecting'); + throw Exception('Already connecting'); + } + + _connectCompleter = Completer(); + + _anonymous = true; + final uuid = Uuid(); + state.user = OwnUser(id: uuid.v4()); + + return connect().then((event) { + _connectCompleter.complete(event); + return event; + }).catchError((e, s) { + _connectCompleter.completeError(e, s); + throw e; + }); + } + + /// Set the current user as guest, this triggers a connection to the API. + /// It returns a [Future] that resolves when the connection is setup. + Future setGuestUser(User user) async { + _anonymous = true; + final response = await post('/guest', data: {'user': user.toJson()}) + .then((res) => decode( + res.data, SetGuestUserResponse.fromJson)) + .whenComplete(() => _anonymous = false); + return setUser( + response.user, + response.accessToken, + ); + } + + /// Closes the websocket connection and resets the client + /// If [flushChatPersistence] is true the client deletes all offline user's data + /// If [clearUser] is true the client unsets the current user + Future disconnect({ + bool flushChatPersistence = false, + bool clearUser = false, + }) async { + logger.info( + 'Disconnecting flushOfflineStorage: $flushChatPersistence; clearUser: $clearUser'); + + await chatPersistenceClient?.disconnect(flush: flushChatPersistence); + chatPersistenceClient = null; + + if (clearUser == true) { + state.dispose(); + state = ClientState(this); + } + + await _disconnect(); + } + + Future _disconnect() async { + logger.info('Client disconnecting'); + + await _ws?.disconnect(); + await _connectionStatusSubscription?.cancel(); + } + + /// Requests users with a given query. + Future queryUsers({ + Map filter, + List sort, + Map options, + PaginationParams pagination, + }) async { + final defaultOptions = { + 'presence': _hasConnectionId, + }; + + final payload = { + 'filter_conditions': filter ?? {}, + 'sort': sort, + }; + + payload.addAll(defaultOptions); + + if (pagination != null) { + payload.addAll(pagination.toJson()); + } + + if (options != null) { + payload.addAll(options); + } + + final rawRes = await get( + '/users', + queryParameters: { + 'payload': jsonEncode(payload), + }, + ); + + final response = decode( + rawRes.data, + QueryUsersResponse.fromJson, + ); + + state?._updateUsers(response.users); + + return response; + } + + /// A message search. + Future search( + Map filters, + List sort, + String query, + PaginationParams paginationParams, { + Map messageFilters, + }) async { + final payload = { + 'filter_conditions': filters, + if (messageFilters != null) ...{ + 'message_filter_conditions': messageFilters, + }, + 'query': query, + 'sort': sort, + }; + + if (paginationParams != null) { + payload.addAll(paginationParams.toJson()); + } + + final response = await get('/search', + queryParameters: {'payload': json.encode(payload)}); + return decode( + response.data, SearchMessagesResponse.fromJson); + } + + /// Add a device for Push Notifications. + Future addDevice(String id, PushProvider pushProvider) async { + final response = await post('/devices', data: { + 'id': id, + 'push_provider': pushProvider.name, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Gets a list of user devices. + Future getDevices() async { + final response = await get('/devices'); + return decode( + response.data, ListDevicesResponse.fromJson); + } + + /// Remove a user's device. + Future removeDevice(String id) async { + final response = await delete('/devices', queryParameters: { + 'id': id, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Get a development token + String devToken(String userId) { + final payload = json.encode({'user_id': userId}); + final payloadBytes = utf8.encode(payload); + final payloadB64 = base64.encode(payloadBytes); + return 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.$payloadB64.devtoken'; + } + + /// Returns a channel client with the given type, id and custom data. + Channel channel( + String type, { + String id, + Map extraData, + }) { + if (type != null && + id != null && + state.channels?.containsKey('$type:$id') == true) { + return state.channels['$type:$id']; + } + + return Channel(this, type, id, extraData); + } + + /// Update or Create the given user object. + Future updateUser(User user) async { + return updateUsers([user]); + } + + /// Batch update a list of users + Future updateUsers(List users) async { + final response = await post('/users', data: { + 'users': users.asMap().map((_, u) => MapEntry(u.id, u.toJson())), + }); + return decode( + response.data, + UpdateUsersResponse.fromJson, + ); + } + + /// Bans a user from all channels + Future banUser( + String targetUserID, [ + Map options = const {}, + ]) async { + final data = Map.from(options) + ..addAll({ + 'target_user_id': targetUserID, + }); + final response = await post( + '/moderation/ban', + data: data, + ); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Remove global ban for a user + Future unbanUser( + String targetUserID, [ + Map options = const {}, + ]) async { + final data = Map.from(options) + ..addAll({ + 'target_user_id': targetUserID, + }); + final response = await delete( + '/moderation/ban', + queryParameters: data, + ); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Shadow bans a user + Future shadowBan( + String targetID, [ + Map options = const {}, + ]) async { + return banUser(targetID, { + 'shadow': true, + ...options, + }); + } + + /// Removes shadow ban from a user + Future removeShadowBan( + String targetID, [ + Map options = const {}, + ]) async { + return unbanUser(targetID, { + 'shadow': true, + ...options, + }); + } + + /// Mutes a user + Future muteUser(String targetID) async { + final response = await post('/moderation/mute', data: { + 'target_id': targetID, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Unmutes a user + Future unmuteUser(String targetID) async { + final response = await post('/moderation/unmute', data: { + 'target_id': targetID, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Flag a message + Future flagMessage(String messageID) async { + final response = await post('/moderation/flag', data: { + 'target_message_id': messageID, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Unflag a message + Future unflagMessage(String messageId) async { + final response = await post('/moderation/unflag', data: { + 'target_message_id': messageId, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Flag a user + Future flagUser(String userId) async { + final response = await post('/moderation/flag', data: { + 'target_user_id': userId, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Unflag a message + Future unflagUser(String userId) async { + final response = await post('/moderation/unflag', data: { + 'target_user_id': userId, + }); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Mark all channels for this user as read + Future markAllRead() async { + final response = await post('/channels/read'); + return decode(response.data, EmptyResponse.fromJson); + } + + /// Update the given message + Future updateMessage( + Message message, [ + String cid, + ]) async { + message = message.copyWith( + status: MessageSendingStatus.updating, + updatedAt: message.updatedAt ?? DateTime.now(), + ); + + final channel = state?.channels != null ? state?.channels[cid] : null; + channel?.state?.addMessage(message); + + return post('/messages/${message.id}', data: {'message': message}) + .then((res) { + final updateMessageResponse = decode( + res?.data, + UpdateMessageResponse.fromJson, + ); + + channel?.state?.addMessage(updateMessageResponse?.message?.copyWith( + ownReactions: message.ownReactions, + )); + + return updateMessageResponse; + }).catchError((error) { + if (error is DioError && + error.type != DioErrorType.RESPONSE && + state?.channels != null) { + channel?.state?.retryQueue?.add([message]); + } + throw error; + }); + } + + /// Deletes the given message + Future deleteMessage(Message message, [String cid]) async { + if (message.status == MessageSendingStatus.failed) { + state.channels[cid].state.addMessage(message.copyWith( + type: 'deleted', + status: MessageSendingStatus.sent, + )); + return EmptyResponse(); + } + + try { + message = message.copyWith( + type: 'deleted', + status: MessageSendingStatus.deleting, + deletedAt: message.deletedAt ?? DateTime.now(), + ); + + if (state?.channels != null) { + state.channels[cid]?.state?.addMessage(message); + } + + final response = await delete('/messages/${message.id}'); + + if (state?.channels != null) { + state.channels[cid]?.state + ?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); + } + + return decode(response.data, EmptyResponse.fromJson); + } catch (error) { + if (error is DioError && + error.type != DioErrorType.RESPONSE && + state?.channels != null) { + state.channels[cid]?.state?.retryQueue?.add([message]); + } + rethrow; + } + } + + /// Get a message by id + Future getMessage(String messageId) async { + final response = await get('/messages/$messageId'); + return decode(response.data, GetMessageResponse.fromJson); + } +} + +/// The class that handles the state of the channel listening to the events +class ClientState { + final _subscriptions = []; + + /// Creates a new instance listening to events and updating the state + ClientState(this._client) { + _subscriptions.addAll([ + _client + .on() + .where((event) => event.me != null) + .map((e) => e.me) + .listen((user) { + _userController.add(user); + if (user.totalUnreadCount != null) { + _totalUnreadCountController.add(user.totalUnreadCount); + } + + if (user.unreadChannels != null) { + _unreadChannelsController.add(user.unreadChannels); + } + }), + _client + .on() + .where((event) => event.unreadChannels != null) + .map((e) => e.unreadChannels) + .listen((unreadChannels) { + _unreadChannelsController.add(unreadChannels); + }), + _client + .on() + .where((event) => event.totalUnreadCount != null) + .map((e) => e.totalUnreadCount) + .listen((totalUnreadCount) { + _totalUnreadCountController.add(totalUnreadCount); + }), + ]); + + _listenChannelDeleted(); + + _listenChannelHidden(); + + _listenUserUpdated(); + } + + /// Used internally for optimistic update of unread count + set totalUnreadCount(int unreadCount) { + _totalUnreadCountController?.add(unreadCount ?? 0); + } + + void _listenChannelHidden() { + _subscriptions.add(_client.on(EventType.channelHidden).listen((event) { + _client.chatPersistenceClient?.deleteChannels([event.cid]); + if (channels != null) { + channels = channels..removeWhere((cid, ch) => cid == event.cid); + } + })); + } + + void _listenUserUpdated() { + _subscriptions.add(_client.on(EventType.userUpdated).listen((event) { + if (event.user.id == user.id) { + user = OwnUser.fromJson(event.user.toJson()); + } + _updateUser(event.user); + })); + } + + void _listenChannelDeleted() { + _subscriptions.add(_client + .on( + EventType.channelDeleted, + EventType.notificationRemovedFromChannel, + EventType.notificationChannelDeleted, + ) + .listen((Event event) async { + final eventChannel = event.channel; + await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); + if (channels != null) { + channels = channels..remove(eventChannel.cid); + } + })); + } + + final StreamChatClient _client; + + /// Update user information + set user(OwnUser user) { + _userController.add(user); + } + + void _updateUsers(List users) { + users?.forEach(_updateUser); + } + + void _updateUser(User user) { + final newUsers = { + ...users ?? {}, + user.id: user, + }; + _usersController.add(newUsers); + } + + /// The current user + OwnUser get user => _userController.value; + + /// The current user as a stream + Stream get userStream => _userController.stream; + + /// The current user + Map get users => _usersController.value; + + /// The current user as a stream + Stream> get usersStream => _usersController.stream; + + /// The current unread channels count + int get unreadChannels => _unreadChannelsController.value; + + /// The current unread channels count as a stream + Stream get unreadChannelsStream => _unreadChannelsController.stream; + + /// The current total unread messages count + int get totalUnreadCount => _totalUnreadCountController.value; + + /// The current total unread messages count as a stream + Stream get totalUnreadCountStream => _totalUnreadCountController.stream; + + /// The current list of channels in memory as a stream + Stream> get channelsStream => _channelsController.stream; + + /// The current list of channels in memory + Map get channels => _channelsController.value; + + set channels(Map v) { + _channelsController.add(v); + } + + final BehaviorSubject> _channelsController = + BehaviorSubject.seeded({}); + final BehaviorSubject _userController = BehaviorSubject(); + final BehaviorSubject> _usersController = + BehaviorSubject.seeded({}); + final BehaviorSubject _unreadChannelsController = BehaviorSubject(); + final BehaviorSubject _totalUnreadCountController = BehaviorSubject(); + + /// Call this method to dispose this object + void dispose() { + _subscriptions.forEach((s) => s.cancel()); + _userController.close(); + _unreadChannelsController.close(); + _totalUnreadCountController.close(); + channels.values.forEach((c) => c.dispose()); + _channelsController.close(); + } +} diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart new file mode 100644 index 00000000..ac65783d --- /dev/null +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -0,0 +1,228 @@ +import 'package:stream_chat/src/api/requests.dart'; +import 'package:stream_chat/src/models/channel_model.dart'; +import 'package:stream_chat/src/models/channel_state.dart'; +import 'package:stream_chat/src/models/event.dart'; +import 'package:stream_chat/src/models/member.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/reaction.dart'; +import 'package:stream_chat/src/models/read.dart'; +import 'package:stream_chat/src/models/user.dart'; + +/// A simple client used for persisting chat data locally. +abstract class ChatPersistenceClient { + /// Creates a new connection to the client + Future connect(String userId); + + /// Closes the client connection + /// If [flush] is true, the data will also be deleted + Future disconnect({bool flush = false}); + + /// Get stored replies by messageId + Future> getReplies( + String parentId, { + PaginationParams options, + }); + + /// Get stored connection event + Future getConnectionInfo(); + + /// Get stored lastSyncAt + Future getLastSyncAt(); + + /// Update stored connection event + Future updateConnectionInfo(Event event); + + /// Update stored lastSyncAt + Future updateLastSyncAt(DateTime lastSyncAt); + + /// Get the channel cids saved in the offline storage + Future> getChannelCids(); + + /// Get stored [ChannelModel]s by providing channel [cid] + Future getChannelByCid(String cid); + + /// Get stored channel [Member]s by providing channel [cid] + Future> getMembersByCid(String cid); + + /// Get stored channel [Read]s by providing channel [cid] + Future> getReadsByCid(String cid); + + /// Get stored [Message]s by providing channel [cid] + /// + /// Optionally, you can [messagePagination] + /// for filtering out messages + Future> getMessagesByCid( + String cid, { + PaginationParams messagePagination, + }); + + /// Get [ChannelState] data by providing channel [cid] + Future getChannelStateByCid( + String cid, { + PaginationParams messagePagination, + }) async { + final members = await getMembersByCid(cid); + final reads = await getReadsByCid(cid); + final channel = await getChannelByCid(cid); + final messages = await getMessagesByCid( + cid, + messagePagination: messagePagination, + ); + return ChannelState( + members: members, + read: reads, + messages: messages, + channel: channel, + ); + } + + /// Get all the stored [ChannelState]s + /// + /// Optionally, pass [filter], [sort], [paginationParams] + /// for filtering out states. + Future> getChannelStates({ + Map filter, + List sort = const [], + PaginationParams paginationParams, + }); + + /// Update list of channel queries. + /// + /// If [clearQueryCache] is true before the insert + /// the list of matching rows will be deleted + Future updateChannelQueries( + Map filter, + List cids, + bool clearQueryCache, + ); + + /// Remove a message by [messageId] + Future deleteMessageById(String messageId) { + return deleteMessageByIds([messageId]); + } + + /// Remove a message by [messageIds] + Future deleteMessageByIds(List messageIds); + + /// Remove a message by channel [cid] + Future deleteMessageByCid(String cid) { + return deleteMessageByCids([cid]); + } + + /// Remove a message by message [cids] + Future deleteMessageByCids(List cids); + + /// Remove a channel by [cid] + Future deleteChannels(List cids); + + /// Updates the message data of a particular channel [cid] with + /// the new [messages] data + Future updateMessages(String cid, List messages); + + /// Returns all the threads by parent message of a particular channel by + /// providing channel [cid] + Future>> getChannelThreads(String cid); + + /// Updates all the channels using the new [channels] data. + Future updateChannels(List channels); + + /// Updates all the members of a particular channle [cid] + /// with the new [members] data + Future updateMembers(String cid, List members); + + /// Updates the read data of a particular channel [cid] with + /// the new [reads] data + Future updateReads(String cid, List reads); + + /// Updates the users data with the new [users] data + Future updateUsers(List users); + + /// Updates the reactions data with the new [reactions] data + Future updateReactions(List reactions); + + /// Deletes all the reactions by [messageIds] + Future deleteReactionsByMessageId(List messageIds); + + /// Deletes all the members by channel [cids] + Future deleteMembersByCids(List cids); + + /// Update the channel state data using [channelState] + Future updateChannelState(ChannelState channelState) { + return updateChannelStates([channelState]); + } + + /// Update list of channel states + Future updateChannelStates(List channelStates) async { + final deleteReactions = deleteReactionsByMessageId(channelStates + .expand((it) => it.messages) + .map((m) => m.id) + .toList(growable: false)); + + final deleteMembers = deleteMembersByCids( + channelStates.map((it) => it.channel.cid).toList(growable: false), + ); + + await Future.wait([ + deleteReactions, + deleteMembers, + ]); + + final channels = channelStates.map((it) { + return it.channel; + }).where((it) => it != null); + + final reactions = channelStates.expand((it) => it.messages).expand((it) { + return [ + if (it.ownReactions != null) + ...it.ownReactions.where((r) => r.userId != null), + if (it.latestReactions != null) + ...it.latestReactions.where((r) => r.userId != null) + ]; + }).where((it) => it != null); + + final users = channelStates + .map((cs) => [ + cs.channel?.createdBy, + ...cs.messages?.map((m) { + return [ + m.user, + if (m.latestReactions != null) + ...m.latestReactions.map((r) => r.user), + if (m.ownReactions != null) + ...m.ownReactions.map((r) => r.user), + ]; + })?.expand((v) => v), + if (cs.read != null) ...cs.read.map((r) => r.user), + if (cs.members != null) ...cs.members.map((m) => m.user), + ]) + .expand((it) => it) + .where((it) => it != null); + + final updateMessagesFuture = channelStates.map((it) { + final cid = it.channel.cid; + final messages = it.messages.where((it) => it != null); + return updateMessages(cid, messages.toList(growable: false)); + }).toList(growable: false); + + final updateReadsFuture = channelStates.map((it) { + final cid = it.channel.cid; + final reads = it.read?.where((it) => it != null) ?? []; + return updateReads(cid, reads.toList(growable: false)); + }).toList(growable: false); + + final updateMembersFuture = channelStates.map((it) { + final cid = it.channel.cid; + final members = it.members.where((it) => it != null); + return updateMembers(cid, members.toList(growable: false)); + }).toList(growable: false); + + await Future.wait([ + ...updateMessagesFuture, + ...updateReadsFuture, + ...updateMembersFuture, + updateUsers(users.toList(growable: false)), + updateChannels(channels.toList(growable: false)), + updateReactions(reactions.toList(growable: false)), + ]); + } +} diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart new file mode 100644 index 00000000..fd93af2a --- /dev/null +++ b/packages/stream_chat/lib/src/event_type.dart @@ -0,0 +1,94 @@ +/// This class defines some basic event types +class EventType { + /// Indicates any type of events + static const String any = '*'; + + /// Event sent when a user starts typing a message + static const String typingStart = 'typing.start'; + + /// Event sent when a user stops typing a message + static const String typingStop = 'typing.stop'; + + /// Event sent when receiving a new message + static const String messageNew = 'message.new'; + + /// Event sent when receiving a new message + static const String notificationMessageNew = 'notification.message_new'; + + /// Event sent when the unread count changes + static const String notificationMarkRead = 'notification.mark_read'; + + /// Event sent when deleting a new message + static const String messageDeleted = 'message.deleted'; + + /// Event sent when receiving a new reaction + static const String reactionNew = 'reaction.new'; + + /// Event sent when deleting a reaction + static const String reactionDeleted = 'reaction.deleted'; + + /// Event sent when updating a reaction + static const String reactionUpdated = 'reaction.updated'; + + /// Event sent when updating a message + static const String messageUpdated = 'message.updated'; + + /// Event sent when reading a message + static const String messageRead = 'message.read'; + + /// Event sent when a channel is deleted + static const String channelDeleted = 'channel.deleted'; + + /// Event sent when a channel is deleted + static const String notificationChannelDeleted = + 'notification.channel_deleted'; + + /// Event sent when a channel is truncated + static const String channelTruncated = 'channel.truncated'; + + /// Event sent when a channel is truncated + static const String notificationChannelTruncated = + 'notification.channel_truncated'; + + /// Event sent when the user is added to a channel + static const String notificationAddedToChannel = + 'notification.added_to_channel'; + + /// Event sent when the user is removed to a channel + static const String notificationRemovedFromChannel = + 'notification.removed_from_channel'; + + /// Event sent when a channel is updated + static const String channelUpdated = 'channel.updated'; + + /// Event sent when a user is updated + static const String userUpdated = 'user.updated'; + + /// Event sent when a member is added to a channel + static const String memberAdded = 'member.added'; + + /// Event sent when a member is removed to a channel + static const String memberRemoved = 'member.removed'; + + /// Event sent when a channel is hidden + static const String channelHidden = 'channel.hidden'; + + /// Event sent when a channel is visible + static const String channelVisible = 'channel.visible'; + + /// Event sent when the connection status changes + static const String connectionChanged = 'connection.changed'; + + /// Event sent when the connection is recovered + static const String connectionRecovered = 'connection.recovered'; + + /// Event sent when the user is accepts an invite + static const String notificationInviteAccepted = + 'notification.invite_accepted'; + + /// Event sent when the user is invited + static const String notificationInvited = 'notification.invited'; + + /// Event sent when the user's mutes list is updated + static const String notificationMutesUpdated = 'notification.mutes_updated'; +} diff --git a/packages/stream_chat/lib/src/exceptions.dart b/packages/stream_chat/lib/src/exceptions.dart new file mode 100644 index 00000000..9ba27e79 --- /dev/null +++ b/packages/stream_chat/lib/src/exceptions.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; + +/// Exception related to api calls +class ApiError extends Error { + /// Raw body of the response + final String body; + + /// Json parsed body + final Map jsonData; + + /// Http status code of the response + final int status; + + /// Stream specific error code + int get code => _code; + int _code; + + static Map _decode(String body) { + try { + if (body == null) { + return null; + } + return json.decode(body); + } on FormatException { + return null; + } + } + + /// Creates a new ApiError instance using the response body and status code + ApiError(this.body, this.status) : jsonData = _decode(body) { + if (jsonData != null && jsonData.containsKey('code')) { + _code = jsonData['code']; + } + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ApiError && + runtimeType == other.runtimeType && + body == other.body && + jsonData == other.jsonData && + status == other.status && + _code == other._code; + + @override + int get hashCode => + body.hashCode ^ jsonData.hashCode ^ status.hashCode ^ _code.hashCode; + + @override + String toString() { + return 'ApiError{body: $body, jsonData: $jsonData, status: $status, code: $_code}'; + } +} diff --git a/packages/stream_chat/lib/src/models/action.dart b/packages/stream_chat/lib/src/models/action.dart new file mode 100644 index 00000000..34bb8f7e --- /dev/null +++ b/packages/stream_chat/lib/src/models/action.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'action.g.dart'; + +/// The class that contains the information about an action +@JsonSerializable() +class Action { + /// The name of the action + final String name; + + /// The style of the action + final String style; + + /// The test of the action + final String text; + + /// The type of the action + final String type; + + /// The value of the action + final String value; + + /// Constructor used for json serialization + Action({this.name, this.style, this.text, this.type, this.value}); + + /// Create a new instance from a json + factory Action.fromJson(Map json) => _$ActionFromJson(json); + + /// Serialize to json + Map toJson() => _$ActionToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/action.g.dart b/packages/stream_chat/lib/src/models/action.g.dart new file mode 100644 index 00000000..3c567843 --- /dev/null +++ b/packages/stream_chat/lib/src/models/action.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'action.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Action _$ActionFromJson(Map json) { + return Action( + name: json['name'] as String, + style: json['style'] as String, + text: json['text'] as String, + type: json['type'] as String, + value: json['value'] as String, + ); +} + +Map _$ActionToJson(Action instance) => { + 'name': instance.name, + 'style': instance.style, + 'text': instance.text, + 'type': instance.type, + 'value': instance.value, + }; diff --git a/packages/stream_chat/lib/src/models/attachment.dart b/packages/stream_chat/lib/src/models/attachment.dart new file mode 100644 index 00000000..8546fb9d --- /dev/null +++ b/packages/stream_chat/lib/src/models/attachment.dart @@ -0,0 +1,207 @@ +// ignore_for_file: public_member_api_docs + +import 'package:json_annotation/json_annotation.dart'; + +import 'action.dart'; +import 'serialization.dart'; + +part 'attachment.g.dart'; + +/// The class that contains the information about an attachment +@JsonSerializable(includeIfNull: false) +class Attachment { + ///The attachment type based on the URL resource. This can be: audio, image or video + final String type; + + ///The link to which the attachment message points to. + final String titleLink; + + /// The attachment title + final String title; + + /// The URL to the attached file thumbnail. You can use this to represent the attached link. + final String thumbUrl; + + /// The attachment text. It will be displayed in the channel next to the original message. + final String text; + + /// Optional text that appears above the attachment block + final String pretext; + + /// The original URL that was used to scrape this attachment. + final String ogScrapeUrl; + + /// The URL to the attached image. This is present for URL pointing to an image article (eg. Unsplash) + final String imageUrl; + final String footerIcon; + final String footer; + final dynamic fields; + final String fallback; + final String color; + + /// The name of the author. + final String authorName; + final String authorLink; + final String authorIcon; + + /// The URL to the audio, video or image related to the URL. + final String assetUrl; + + /// Actions from a command + final List actions; + + final Uri localUri; + + /// Map of custom channel extraData + @JsonKey(includeIfNull: false) + final Map extraData; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static const topLevelFields = [ + 'type', + 'title_link', + 'title', + 'thumb_url', + 'text', + 'pretext', + 'og_scrape_url', + 'image_url', + 'footer_icon', + 'footer', + 'fields', + 'fallback', + 'color', + 'author_name', + 'author_link', + 'author_icon', + 'asset_url', + 'actions', + ]; + + /// Constructor used for json serialization + Attachment({ + this.type, + this.titleLink, + this.title, + this.thumbUrl, + this.text, + this.pretext, + this.ogScrapeUrl, + this.imageUrl, + this.footerIcon, + this.footer, + this.fields, + this.fallback, + this.color, + this.authorName, + this.authorLink, + this.authorIcon, + this.assetUrl, + this.actions, + this.extraData, + this.localUri, + }); + + /// Create a new instance from a json + factory Attachment.fromJson(Map json) { + return _$AttachmentFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + } + + /// Serialize to json + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$AttachmentToJson(this), topLevelFields); + + Attachment copyWith({ + String type, + String titleLink, + String title, + String thumbUrl, + String text, + String pretext, + String ogScrapeUrl, + String imageUrl, + String footerIcon, + String footer, + dynamic fields, + String fallback, + String color, + String authorName, + String authorLink, + String authorIcon, + String assetUrl, + List actions, + Uri localUri, + Map extraData, + }) => + Attachment( + type: type ?? this.type, + titleLink: titleLink ?? this.titleLink, + title: title ?? this.title, + thumbUrl: thumbUrl ?? this.thumbUrl, + text: text ?? this.text, + pretext: pretext ?? this.pretext, + ogScrapeUrl: ogScrapeUrl ?? this.ogScrapeUrl, + imageUrl: imageUrl ?? this.imageUrl, + footerIcon: footerIcon ?? this.footerIcon, + footer: footer ?? this.footer, + fields: fields ?? this.fields, + fallback: fallback ?? this.fallback, + color: color ?? this.color, + authorName: authorName ?? this.authorName, + authorLink: authorLink ?? this.authorLink, + authorIcon: authorIcon ?? this.authorIcon, + assetUrl: assetUrl ?? this.assetUrl, + actions: actions ?? this.actions, + localUri: localUri ?? this.localUri, + extraData: extraData ?? this.extraData, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Attachment && + runtimeType == other.runtimeType && + type == other.type && + titleLink == other.titleLink && + title == other.title && + thumbUrl == other.thumbUrl && + text == other.text && + pretext == other.pretext && + ogScrapeUrl == other.ogScrapeUrl && + imageUrl == other.imageUrl && + footerIcon == other.footerIcon && + footer == other.footer && + fields == other.fields && + fallback == other.fallback && + color == other.color && + authorName == other.authorName && + authorLink == other.authorLink && + authorIcon == other.authorIcon && + assetUrl == other.assetUrl && + actions == other.actions && + extraData == other.extraData; + + @override + int get hashCode => + type.hashCode ^ + titleLink.hashCode ^ + title.hashCode ^ + thumbUrl.hashCode ^ + text.hashCode ^ + pretext.hashCode ^ + ogScrapeUrl.hashCode ^ + imageUrl.hashCode ^ + footerIcon.hashCode ^ + footer.hashCode ^ + fields.hashCode ^ + fallback.hashCode ^ + color.hashCode ^ + authorName.hashCode ^ + authorLink.hashCode ^ + authorIcon.hashCode ^ + assetUrl.hashCode ^ + actions.hashCode ^ + extraData.hashCode; +} diff --git a/packages/stream_chat/lib/src/models/attachment.g.dart b/packages/stream_chat/lib/src/models/attachment.g.dart new file mode 100644 index 00000000..db276d69 --- /dev/null +++ b/packages/stream_chat/lib/src/models/attachment.g.dart @@ -0,0 +1,74 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'attachment.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Attachment _$AttachmentFromJson(Map json) { + return Attachment( + type: json['type'] as String, + titleLink: json['title_link'] as String, + title: json['title'] as String, + thumbUrl: json['thumb_url'] as String, + text: json['text'] as String, + pretext: json['pretext'] as String, + ogScrapeUrl: json['og_scrape_url'] as String, + imageUrl: json['image_url'] as String, + footerIcon: json['footer_icon'] as String, + footer: json['footer'] as String, + fields: json['fields'], + fallback: json['fallback'] as String, + color: json['color'] as String, + authorName: json['author_name'] as String, + authorLink: json['author_link'] as String, + authorIcon: json['author_icon'] as String, + assetUrl: json['asset_url'] as String, + actions: (json['actions'] as List) + ?.map((e) => e == null + ? null + : Action.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + localUri: json['local_uri'] == null + ? null + : Uri.parse(json['local_uri'] as String), + ); +} + +Map _$AttachmentToJson(Attachment instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('type', instance.type); + writeNotNull('title_link', instance.titleLink); + writeNotNull('title', instance.title); + writeNotNull('thumb_url', instance.thumbUrl); + writeNotNull('text', instance.text); + writeNotNull('pretext', instance.pretext); + writeNotNull('og_scrape_url', instance.ogScrapeUrl); + writeNotNull('image_url', instance.imageUrl); + writeNotNull('footer_icon', instance.footerIcon); + writeNotNull('footer', instance.footer); + writeNotNull('fields', instance.fields); + writeNotNull('fallback', instance.fallback); + writeNotNull('color', instance.color); + writeNotNull('author_name', instance.authorName); + writeNotNull('author_link', instance.authorLink); + writeNotNull('author_icon', instance.authorIcon); + writeNotNull('asset_url', instance.assetUrl); + writeNotNull('actions', instance.actions?.map((e) => e?.toJson())?.toList()); + writeNotNull('local_uri', instance.localUri?.toString()); + writeNotNull('extra_data', instance.extraData); + return val; +} diff --git a/packages/stream_chat/lib/src/models/channel_config.dart b/packages/stream_chat/lib/src/models/channel_config.dart new file mode 100644 index 00000000..e455930f --- /dev/null +++ b/packages/stream_chat/lib/src/models/channel_config.dart @@ -0,0 +1,84 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'command.dart'; + +part 'channel_config.g.dart'; + +/// The class that contains the information about the configuration of a channel +@JsonSerializable() +class ChannelConfig { + /// Moderation configuration + final String automod; + + /// List of available commands + final List commands; + + /// True if the channel should send connect events + final bool connectEvents; + + /// Date of channel creation + final DateTime createdAt; + + /// Date of last channel update + final DateTime updatedAt; + + /// Max channel message length + final int maxMessageLength; + + /// Duration of message retention + final String messageRetention; + + /// True if users can be muted + final bool mutes; + + /// Name of the channel + final String name; + + /// True if reaction are active for this channel + final bool reactions; + + /// True if readEvents are active for this channel + final bool readEvents; + + /// True if reply message are active for this channel + final bool replies; + + /// True if it's possible to perform a search in this channel + final bool search; + + /// True if typing events should be sent for this channel + final bool typingEvents; + + /// True if it's possible to upload files to this channel + final bool uploads; + + /// True if urls appears as attachments + final bool urlEnrichment; + + /// Constructor used for json serialization + ChannelConfig({ + this.automod, + this.commands, + this.connectEvents, + this.createdAt, + this.updatedAt, + this.maxMessageLength, + this.messageRetention, + this.mutes, + this.name, + this.reactions, + this.readEvents, + this.replies, + this.search, + this.typingEvents, + this.uploads, + this.urlEnrichment, + }); + + /// Create a new instance from a json + factory ChannelConfig.fromJson(Map json) => + _$ChannelConfigFromJson(json); + + /// Serialize to json + Map toJson() => _$ChannelConfigToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/channel_config.g.dart b/packages/stream_chat/lib/src/models/channel_config.g.dart new file mode 100644 index 00000000..e8152c80 --- /dev/null +++ b/packages/stream_chat/lib/src/models/channel_config.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_config.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChannelConfig _$ChannelConfigFromJson(Map json) { + return ChannelConfig( + automod: json['automod'] as String, + commands: (json['commands'] as List) + ?.map((e) => e == null + ? null + : Command.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + connectEvents: json['connect_events'] as bool, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + maxMessageLength: json['max_message_length'] as int, + messageRetention: json['message_retention'] as String, + mutes: json['mutes'] as bool, + name: json['name'] as String, + reactions: json['reactions'] as bool, + readEvents: json['read_events'] as bool, + replies: json['replies'] as bool, + search: json['search'] as bool, + typingEvents: json['typing_events'] as bool, + uploads: json['uploads'] as bool, + urlEnrichment: json['url_enrichment'] as bool, + ); +} + +Map _$ChannelConfigToJson(ChannelConfig instance) => + { + 'automod': instance.automod, + 'commands': instance.commands?.map((e) => e?.toJson())?.toList(), + 'connect_events': instance.connectEvents, + 'created_at': instance.createdAt?.toIso8601String(), + 'updated_at': instance.updatedAt?.toIso8601String(), + 'max_message_length': instance.maxMessageLength, + 'message_retention': instance.messageRetention, + 'mutes': instance.mutes, + 'name': instance.name, + 'reactions': instance.reactions, + 'read_events': instance.readEvents, + 'replies': instance.replies, + 'search': instance.search, + 'typing_events': instance.typingEvents, + 'uploads': instance.uploads, + 'url_enrichment': instance.urlEnrichment, + }; diff --git a/packages/stream_chat/lib/src/models/channel_model.dart b/packages/stream_chat/lib/src/models/channel_model.dart new file mode 100644 index 00000000..046e29c5 --- /dev/null +++ b/packages/stream_chat/lib/src/models/channel_model.dart @@ -0,0 +1,166 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'channel_config.dart'; +import 'serialization.dart'; +import 'user.dart'; + +part 'channel_model.g.dart'; + +/// The class that contains the information about a channel +@JsonSerializable() +class ChannelModel { + /// The id of this channel + final String id; + + /// The type of this channel + final String type; + + /// The cid of this channel + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final String cid; + + /// The channel configuration data + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final ChannelConfig config; + + /// The user that created this channel + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final User createdBy; + + /// True if this channel is frozen + @JsonKey(includeIfNull: false) + final bool frozen; + + /// The date of the last message + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime lastMessageAt; + + /// The date of channel creation + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime createdAt; + + /// The date of the last channel update + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime updatedAt; + + /// The date of channel deletion + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime deletedAt; + + /// The count of this channel members + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final int memberCount; + + /// Map of custom channel extraData + @JsonKey(includeIfNull: false) + final Map extraData; + + /// The team the channel belongs to + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final String team; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static const topLevelFields = [ + 'id', + 'type', + 'cid', + 'config', + 'created_by', + 'frozen', + 'last_message_at', + 'created_at', + 'updated_at', + 'deleted_at', + 'member_count', + 'team', + ]; + + /// Constructor used for json serialization + ChannelModel({ + this.id, + this.type, + this.cid, + this.config, + this.createdBy, + this.frozen, + this.lastMessageAt, + this.createdAt, + this.updatedAt, + this.deletedAt, + this.memberCount, + this.extraData, + this.team, + }); + + /// Shortcut for channel name + String get name => + extraData?.containsKey('name') == true ? extraData['name'] : cid; + + /// Create a new instance from a json + factory ChannelModel.fromJson(Map json) { + return _$ChannelModelFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + } + + /// Serialize to json + Map toJson() { + return Serialization.moveFromExtraDataToRoot( + _$ChannelModelToJson(this), + topLevelFields, + ); + } + + /// Creates a copy of [ChannelModel] with specified attributes overridden. + ChannelModel copyWith({ + String id, + String type, + String cid, + ChannelConfig config, + User createdBy, + bool frozen, + DateTime lastMessageAt, + DateTime createdAt, + DateTime updatedAt, + DateTime deletedAt, + int memberCount, + Map extraData, + String team, + }) => + ChannelModel( + id: id ?? this.id, + type: type ?? this.type, + cid: cid ?? this.cid, + config: config ?? this.config, + createdBy: createdBy ?? this.createdBy, + frozen: frozen ?? this.frozen, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + memberCount: memberCount ?? this.memberCount, + extraData: extraData ?? this.extraData, + team: team ?? this.team, + ); + + /// Returns a new [ChannelModel] that is a combination of this channelModel and the given + /// [other] channelModel. + ChannelModel merge(ChannelModel other) { + if (other == null) return this; + return copyWith( + id: other.id, + type: other.type, + cid: other.cid, + config: other.config, + createdBy: other.createdBy, + frozen: other.frozen, + lastMessageAt: other.lastMessageAt, + createdAt: other.createdAt, + updatedAt: other.updatedAt, + deletedAt: other.deletedAt, + memberCount: other.memberCount, + extraData: other.extraData, + team: other.team, + ); + } +} diff --git a/packages/stream_chat/lib/src/models/channel_model.g.dart b/packages/stream_chat/lib/src/models/channel_model.g.dart new file mode 100644 index 00000000..4f535f46 --- /dev/null +++ b/packages/stream_chat/lib/src/models/channel_model.g.dart @@ -0,0 +1,69 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChannelModel _$ChannelModelFromJson(Map json) { + return ChannelModel( + id: json['id'] as String, + type: json['type'] as String, + cid: json['cid'] as String, + config: json['config'] == null + ? null + : ChannelConfig.fromJson((json['config'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + createdBy: json['created_by'] == null + ? null + : User.fromJson((json['created_by'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + frozen: json['frozen'] as bool, + lastMessageAt: json['last_message_at'] == null + ? null + : DateTime.parse(json['last_message_at'] as String), + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + memberCount: json['member_count'] as int, + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + team: json['team'] as String, + ); +} + +Map _$ChannelModelToJson(ChannelModel instance) { + final val = { + 'id': instance.id, + 'type': instance.type, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('cid', readonly(instance.cid)); + writeNotNull('config', readonly(instance.config)); + writeNotNull('created_by', readonly(instance.createdBy)); + writeNotNull('frozen', instance.frozen); + writeNotNull('last_message_at', readonly(instance.lastMessageAt)); + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('updated_at', readonly(instance.updatedAt)); + writeNotNull('deleted_at', readonly(instance.deletedAt)); + writeNotNull('member_count', readonly(instance.memberCount)); + writeNotNull('extra_data', instance.extraData); + writeNotNull('team', readonly(instance.team)); + return val; +} diff --git a/packages/stream_chat/lib/src/models/channel_state.dart b/packages/stream_chat/lib/src/models/channel_state.dart new file mode 100644 index 00000000..59e7a4f1 --- /dev/null +++ b/packages/stream_chat/lib/src/models/channel_state.dart @@ -0,0 +1,66 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../models/read.dart'; +import '../models/user.dart'; +import 'channel_model.dart'; +import 'member.dart'; +import 'message.dart'; + +part 'channel_state.g.dart'; + +/// The class that contains the information about a command +@JsonSerializable() +class ChannelState { + /// The channel to which this state belongs + final ChannelModel channel; + + /// A paginated list of channel messages + final List messages; + + /// A paginated list of channel members + final List members; + + /// The count of users watching the channel + final int watcherCount; + + /// A paginated list of users watching the channel + final List watchers; + + /// The list of channel reads + final List read; + + /// Constructor used for json serialization + ChannelState({ + this.channel, + this.messages = const [], + this.members = const [], + this.watcherCount, + this.watchers = const [], + this.read = const [], + }); + + /// Create a new instance from a json + static ChannelState fromJson(Map json) => + _$ChannelStateFromJson(json); + + /// Serialize to json + Map toJson() => _$ChannelStateToJson(this); + + /// Creates a copy of [ChannelState] with specified attributes overridden. + ChannelState copyWith({ + ChannelModel channel, + List messages, + List members, + int watcherCount, + List watchers, + List read, + }) => + ChannelState( + channel: channel ?? this.channel, + messages: messages ?? this.messages, + members: members ?? this.members, + watcherCount: watcherCount ?? this.watcherCount, + watchers: watchers ?? this.watchers, + read: read ?? this.read, + ); +} diff --git a/packages/stream_chat/lib/src/models/channel_state.g.dart b/packages/stream_chat/lib/src/models/channel_state.g.dart new file mode 100644 index 00000000..d053768e --- /dev/null +++ b/packages/stream_chat/lib/src/models/channel_state.g.dart @@ -0,0 +1,56 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChannelState _$ChannelStateFromJson(Map json) { + return ChannelState( + channel: json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + messages: (json['messages'] as List) + ?.map((e) => e == null + ? null + : Message.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + members: (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + watcherCount: json['watcher_count'] as int, + watchers: (json['watchers'] as List) + ?.map((e) => e == null + ? null + : User.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + read: (json['read'] as List) + ?.map((e) => e == null + ? null + : Read.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + ); +} + +Map _$ChannelStateToJson(ChannelState instance) => + { + 'channel': instance.channel?.toJson(), + 'messages': instance.messages?.map((e) => e?.toJson())?.toList(), + 'members': instance.members?.map((e) => e?.toJson())?.toList(), + 'watcher_count': instance.watcherCount, + 'watchers': instance.watchers?.map((e) => e?.toJson())?.toList(), + 'read': instance.read?.map((e) => e?.toJson())?.toList(), + }; diff --git a/packages/stream_chat/lib/src/models/command.dart b/packages/stream_chat/lib/src/models/command.dart new file mode 100644 index 00000000..420f2195 --- /dev/null +++ b/packages/stream_chat/lib/src/models/command.dart @@ -0,0 +1,30 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'command.g.dart'; + +/// The class that contains the information about a command +@JsonSerializable() +class Command { + /// The name of the command + final String name; + + /// The description explaining the command + final String description; + + /// The arguments of the command + final String args; + + /// Constructor used for json serialization + Command({ + this.name, + this.description, + this.args, + }); + + /// Create a new instance from a json + factory Command.fromJson(Map json) => + _$CommandFromJson(json); + + /// Serialize to json + Map toJson() => _$CommandToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/command.g.dart b/packages/stream_chat/lib/src/models/command.g.dart new file mode 100644 index 00000000..f32e8e8a --- /dev/null +++ b/packages/stream_chat/lib/src/models/command.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'command.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Command _$CommandFromJson(Map json) { + return Command( + name: json['name'] as String, + description: json['description'] as String, + args: json['args'] as String, + ); +} + +Map _$CommandToJson(Command instance) => { + 'name': instance.name, + 'description': instance.description, + 'args': instance.args, + }; diff --git a/packages/stream_chat/lib/src/models/device.dart b/packages/stream_chat/lib/src/models/device.dart new file mode 100644 index 00000000..97247222 --- /dev/null +++ b/packages/stream_chat/lib/src/models/device.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'device.g.dart'; + +/// The class that contains the information about a device +@JsonSerializable() +class Device { + /// The id of the device + final String id; + + /// The notification push provider + final String pushProvider; + + /// Constructor used for json serialization + Device({ + this.id, + this.pushProvider, + }); + + /// Create a new instance from a json + factory Device.fromJson(Map json) => _$DeviceFromJson(json); + + /// Serialize to json + Map toJson() => _$DeviceToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/device.g.dart b/packages/stream_chat/lib/src/models/device.g.dart new file mode 100644 index 00000000..bac60856 --- /dev/null +++ b/packages/stream_chat/lib/src/models/device.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'device.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Device _$DeviceFromJson(Map json) { + return Device( + id: json['id'] as String, + pushProvider: json['push_provider'] as String, + ); +} + +Map _$DeviceToJson(Device instance) => { + 'id': instance.id, + 'push_provider': instance.pushProvider, + }; diff --git a/packages/stream_chat/lib/src/models/event.dart b/packages/stream_chat/lib/src/models/event.dart new file mode 100644 index 00000000..03995d67 --- /dev/null +++ b/packages/stream_chat/lib/src/models/event.dart @@ -0,0 +1,190 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/models/channel_model.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/serialization.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import '../event_type.dart'; +import 'member.dart'; +import 'own_user.dart'; +import 'reaction.dart'; +import 'user.dart'; + +part 'event.g.dart'; + +/// The class that contains the information about an event +@JsonSerializable() +class Event { + /// The type of the event + /// [EventType] contains some predefined constant types + final String type; + + /// The channel cid to which the event belongs + final String cid; + + /// The channel id to which the event belongs + final String channelId; + + /// The channel type to which the event belongs + final String channelType; + + /// The connection id in which the event has been sent + final String connectionId; + + /// The date of creation of the event + final DateTime createdAt; + + /// User object of the health check user + final OwnUser me; + + /// User object of the current user + final User user; + + /// The message sent with the event + final Message message; + + /// The channel sent with the event + final EventChannel channel; + + /// The member sent with the event + final Member member; + + /// The reaction sent with the event + final Reaction reaction; + + /// The number of unread messages for current user + final int totalUnreadCount; + + /// User total unread channels + final int unreadChannels; + + /// Online status + final bool online; + + /// The id of the parent message of a thread + final String parentId; + + /// True if the event is generated by this client + bool isLocal; + + /// Map of custom channel extraData + @JsonKey(includeIfNull: false) + final Map extraData; + + /// Constructor used for json serialization + Event({ + this.type, + this.cid, + this.connectionId, + this.createdAt, + this.me, + this.user, + this.message, + this.totalUnreadCount, + this.unreadChannels, + this.reaction, + this.online, + this.channel, + this.member, + this.channelId, + this.channelType, + this.parentId, + this.extraData, + }) : isLocal = true; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static final topLevelFields = [ + 'type', + 'cid', + 'connection_id', + 'created_at', + 'me', + 'user', + 'message', + 'total_unread_count', + 'unread_channels', + 'reaction', + 'online', + 'channel', + 'member', + 'channel_id', + 'channel_type', + 'parent_id', + 'is_local', + ]; + + /// Create a new instance from a json + factory Event.fromJson(Map json) { + return _$EventFromJson(Serialization.moveToExtraDataFromRoot( + json, + topLevelFields, + )) + ..isLocal = false; + } + + /// Serialize to json + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$EventToJson(this), + topLevelFields, + ); +} + +/// The channel embedded in the event object +@JsonSerializable() +class EventChannel extends ChannelModel { + /// A paginated list of channel members + final List members; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static final topLevelFields = [ + 'members', + ...ChannelModel.topLevelFields, + ]; + + /// Constructor used for json serialization + EventChannel({ + this.members, + String id, + String type, + String cid, + ChannelConfig config, + User createdBy, + bool frozen, + DateTime lastMessageAt, + DateTime createdAt, + DateTime updatedAt, + DateTime deletedAt, + int memberCount, + Map extraData, + }) : super( + id: id, + type: type, + cid: cid, + config: config, + createdBy: createdBy, + frozen: frozen, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + memberCount: memberCount, + extraData: extraData, + ); + + /// Create a new instance from a json + factory EventChannel.fromJson(Map json) { + return _$EventChannelFromJson(Serialization.moveToExtraDataFromRoot( + json, + topLevelFields, + )); + } + + /// Serialize to json + @override + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$EventChannelToJson(this), + topLevelFields, + ); +} diff --git a/packages/stream_chat/lib/src/models/event.g.dart b/packages/stream_chat/lib/src/models/event.g.dart new file mode 100644 index 00000000..aaef8181 --- /dev/null +++ b/packages/stream_chat/lib/src/models/event.g.dart @@ -0,0 +1,156 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'event.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Event _$EventFromJson(Map json) { + return Event( + type: json['type'] as String, + cid: json['cid'] as String, + connectionId: json['connection_id'] as String, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + me: json['me'] == null + ? null + : OwnUser.fromJson((json['me'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + user: json['user'] == null + ? null + : User.fromJson((json['user'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + message: json['message'] == null + ? null + : Message.fromJson((json['message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + totalUnreadCount: json['total_unread_count'] as int, + unreadChannels: json['unread_channels'] as int, + reaction: json['reaction'] == null + ? null + : Reaction.fromJson((json['reaction'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + online: json['online'] as bool, + channel: json['channel'] == null + ? null + : EventChannel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + member: json['member'] == null + ? null + : Member.fromJson((json['member'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + channelId: json['channel_id'] as String, + channelType: json['channel_type'] as String, + parentId: json['parent_id'] as String, + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + )..isLocal = json['is_local'] as bool; +} + +Map _$EventToJson(Event instance) { + final val = { + 'type': instance.type, + 'cid': instance.cid, + 'channel_id': instance.channelId, + 'channel_type': instance.channelType, + 'connection_id': instance.connectionId, + 'created_at': instance.createdAt?.toIso8601String(), + 'me': instance.me?.toJson(), + 'user': instance.user?.toJson(), + 'message': instance.message?.toJson(), + 'channel': instance.channel?.toJson(), + 'member': instance.member?.toJson(), + 'reaction': instance.reaction?.toJson(), + 'total_unread_count': instance.totalUnreadCount, + 'unread_channels': instance.unreadChannels, + 'online': instance.online, + 'parent_id': instance.parentId, + 'is_local': instance.isLocal, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('extra_data', instance.extraData); + return val; +} + +EventChannel _$EventChannelFromJson(Map json) { + return EventChannel( + members: (json['members'] as List) + ?.map((e) => e == null + ? null + : Member.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + id: json['id'] as String, + type: json['type'] as String, + cid: json['cid'] as String, + config: json['config'] == null + ? null + : ChannelConfig.fromJson((json['config'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + createdBy: json['created_by'] == null + ? null + : User.fromJson((json['created_by'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + frozen: json['frozen'] as bool, + lastMessageAt: json['last_message_at'] == null + ? null + : DateTime.parse(json['last_message_at'] as String), + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + memberCount: json['member_count'] as int, + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + ); +} + +Map _$EventChannelToJson(EventChannel instance) { + final val = { + 'id': instance.id, + 'type': instance.type, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('cid', readonly(instance.cid)); + writeNotNull('config', readonly(instance.config)); + writeNotNull('created_by', readonly(instance.createdBy)); + writeNotNull('frozen', instance.frozen); + writeNotNull('last_message_at', readonly(instance.lastMessageAt)); + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('updated_at', readonly(instance.updatedAt)); + writeNotNull('deleted_at', readonly(instance.deletedAt)); + writeNotNull('member_count', readonly(instance.memberCount)); + writeNotNull('extra_data', instance.extraData); + val['members'] = instance.members?.map((e) => e?.toJson())?.toList(); + return val; +} diff --git a/packages/stream_chat/lib/src/models/member.dart b/packages/stream_chat/lib/src/models/member.dart new file mode 100644 index 00000000..511b7a7b --- /dev/null +++ b/packages/stream_chat/lib/src/models/member.dart @@ -0,0 +1,96 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../models/user.dart'; + +part 'member.g.dart'; + +/// The class that contains the information about the user membership in a channel +@JsonSerializable() +class Member { + /// The interested user + final User user; + + /// The date in which the user accepted the invite to the channel + final DateTime inviteAcceptedAt; + + /// The date in which the user rejected the invite to the channel + final DateTime inviteRejectedAt; + + /// True if the user has been invited to the channel + final bool invited; + + /// The role of the user in the channel + final String role; + + /// The id of the interested user + final String userId; + + /// True if the user is a moderator of the channel + final bool isModerator; + + /// True if the member is banned from the channel + final bool banned; + + /// True if the member is shadow banned from the channel + final bool shadowBanned; + + /// The date of creation + final DateTime createdAt; + + /// The last date of update + final DateTime updatedAt; + + /// Constructor used for json serialization + Member({ + this.user, + this.inviteAcceptedAt, + this.inviteRejectedAt, + this.invited, + this.role, + this.userId, + this.isModerator, + this.createdAt, + this.updatedAt, + this.banned, + this.shadowBanned, + }); + + /// Create a new instance from a json + factory Member.fromJson(Map json) { + final member = _$MemberFromJson(json); + return member.copyWith( + userId: member.user?.id, + ); + } + + /// Creates a copy of [Member] with specified attributes overridden. + Member copyWith({ + User user, + DateTime inviteAcceptedAt, + DateTime inviteRejectedAt, + bool invited, + String role, + String userId, + bool isModerator, + DateTime createdAt, + DateTime updatedAt, + bool banned, + bool shadowBanned, + }) => + Member( + user: user ?? this.user, + inviteAcceptedAt: inviteAcceptedAt ?? this.inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt ?? this.inviteRejectedAt, + invited: invited ?? this.invited, + banned: banned ?? this.banned, + shadowBanned: shadowBanned ?? this.shadowBanned, + role: role ?? this.role, + userId: userId ?? this.userId, + isModerator: isModerator ?? this.isModerator, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + + /// Serialize to json + Map toJson() => _$MemberToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/member.g.dart b/packages/stream_chat/lib/src/models/member.g.dart new file mode 100644 index 00000000..3ac8778e --- /dev/null +++ b/packages/stream_chat/lib/src/models/member.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'member.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Member _$MemberFromJson(Map json) { + return Member( + user: json['user'] == null + ? null + : User.fromJson((json['user'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + inviteAcceptedAt: json['invite_accepted_at'] == null + ? null + : DateTime.parse(json['invite_accepted_at'] as String), + inviteRejectedAt: json['invite_rejected_at'] == null + ? null + : DateTime.parse(json['invite_rejected_at'] as String), + invited: json['invited'] as bool, + role: json['role'] as String, + userId: json['user_id'] as String, + isModerator: json['is_moderator'] as bool, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + banned: json['banned'] as bool, + shadowBanned: json['shadow_banned'] as bool, + ); +} + +Map _$MemberToJson(Member instance) => { + 'user': instance.user?.toJson(), + 'invite_accepted_at': instance.inviteAcceptedAt?.toIso8601String(), + 'invite_rejected_at': instance.inviteRejectedAt?.toIso8601String(), + 'invited': instance.invited, + 'role': instance.role, + 'user_id': instance.userId, + 'is_moderator': instance.isModerator, + 'banned': instance.banned, + 'shadow_banned': instance.shadowBanned, + 'created_at': instance.createdAt?.toIso8601String(), + 'updated_at': instance.updatedAt?.toIso8601String(), + }; diff --git a/packages/stream_chat/lib/src/models/message.dart b/packages/stream_chat/lib/src/models/message.dart new file mode 100644 index 00000000..ceff9652 --- /dev/null +++ b/packages/stream_chat/lib/src/models/message.dart @@ -0,0 +1,317 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'attachment.dart'; +import 'reaction.dart'; +import 'serialization.dart'; +import 'user.dart'; + +part 'message.g.dart'; + +/// Enum defining the status of a sending message +enum MessageSendingStatus { + /// Message is being sent + sending, + + /// Message is being updated + updating, + + /// Message is being deleted + deleting, + + /// Message failed to send + failed, + + /// Message failed to updated + failed_update, + + /// Message failed to delete + failed_delete, + + /// Message correctly sent + sent, +} + +/// The class that contains the information about a message +@JsonSerializable() +class Message { + /// The message ID. This is either created by Stream or set client side when the message is added. + final String id; + + /// The text of this message + final String text; + + /// The status of a sending message + @JsonKey(ignore: true) + final MessageSendingStatus status; + + /// The message type + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final String type; + + /// The list of attachments, either provided by the user or generated from a command or as a result of URL scraping. + @JsonKey(includeIfNull: false) + final List attachments; + + /// The list of user mentioned in the message + @JsonKey(toJson: Serialization.userIds) + final List mentionedUsers; + + /// A map describing the count of number of every reaction + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final Map reactionCounts; + + /// A map describing the count of score of every reaction + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final Map reactionScores; + + /// The latest reactions to the message created by any user. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final List latestReactions; + + /// The reactions added to the message by the current user. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final List ownReactions; + + /// The ID of the parent message, if the message is a thread reply. + final String parentId; + + /// A quoted reply message + @JsonKey(toJson: Serialization.readOnly) + final Message quotedMessage; + + /// The ID of the quoted message, if the message is a quoted reply. + final String quotedMessageId; + + /// Reserved field indicating the number of replies for this message. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final int replyCount; + + /// Reserved field indicating the thread participants for this message. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final List threadParticipants; + + /// Check if this message needs to show in the channel. + final bool showInChannel; + + /// If true the message is silent + final bool silent; + + /// If true the message is shadowed + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final bool shadowed; + + /// A used command name. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final String command; + + /// Reserved field indicating when the message was created. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime createdAt; + + /// Reserved field indicating when the message was updated last time. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime updatedAt; + + /// User who sent the message + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final User user; + + /// Message custom extraData + @JsonKey(includeIfNull: false) + final Map extraData; + + /// True if the message is a system info + bool get isSystem => type == 'system'; + + /// True if the message has been deleted + bool get isDeleted => type == 'deleted'; + + /// True if the message is ephemeral + bool get isEphemeral => type == 'ephemeral'; + + /// Reserved field indicating when the message was deleted. + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime deletedAt; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static const topLevelFields = [ + 'id', + 'text', + 'type', + 'silent', + 'attachments', + 'latest_reactions', + 'shadowed', + 'own_reactions', + 'mentioned_users', + 'reaction_counts', + 'reaction_scores', + 'silent', + 'parent_id', + 'quoted_message', + 'quoted_message_id', + 'reply_count', + 'thread_participants', + 'show_in_channel', + 'command', + 'created_at', + 'updated_at', + 'deleted_at', + 'user', + ]; + + /// Constructor used for json serialization + Message({ + this.id, + this.text, + this.type, + this.attachments, + this.mentionedUsers, + this.silent, + this.shadowed, + this.reactionCounts, + this.reactionScores, + this.latestReactions, + this.ownReactions, + this.parentId, + this.quotedMessage, + this.quotedMessageId, + this.replyCount = 0, + this.threadParticipants, + this.showInChannel, + this.command, + this.createdAt, + this.updatedAt, + this.user, + this.extraData, + this.deletedAt, + this.status = MessageSendingStatus.sent, + }); + + /// Create a new instance from a json + factory Message.fromJson(Map json) => _$MessageFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + + /// Serialize to json + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$MessageToJson(this), topLevelFields); + + /// Creates a copy of [Message] with specified attributes overridden. + Message copyWith({ + String id, + String text, + String type, + List attachments, + List mentionedUsers, + Map reactionCounts, + Map reactionScores, + List latestReactions, + List ownReactions, + String parentId, + Message quotedMessage, + String quotedMessageId, + int replyCount, + List threadParticipants, + bool showInChannel, + bool shadowed, + bool silent, + String command, + DateTime createdAt, + DateTime updatedAt, + DateTime deletedAt, + User user, + Map extraData, + MessageSendingStatus status, + }) => + Message( + id: id ?? this.id, + text: text ?? this.text, + type: type ?? this.type, + attachments: attachments ?? this.attachments, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, + reactionCounts: reactionCounts ?? this.reactionCounts, + reactionScores: reactionScores ?? this.reactionScores, + latestReactions: latestReactions ?? this.latestReactions, + ownReactions: ownReactions ?? this.ownReactions, + parentId: parentId ?? this.parentId, + quotedMessage: quotedMessage ?? this.quotedMessage, + quotedMessageId: quotedMessageId ?? this.quotedMessageId, + replyCount: replyCount ?? this.replyCount, + threadParticipants: threadParticipants ?? this.threadParticipants, + showInChannel: showInChannel ?? this.showInChannel, + command: command ?? this.command, + createdAt: createdAt ?? this.createdAt, + silent: silent ?? this.silent, + extraData: extraData ?? this.extraData, + user: user ?? this.user, + shadowed: shadowed ?? this.shadowed, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + status: status ?? this.status, + ); + + /// Returns a new [Message] that is a combination of this message and the given + /// [other] message. + Message merge(Message other) { + if (other == null) return this; + return copyWith( + id: other.id, + text: other.text, + type: other.type, + attachments: other.attachments, + mentionedUsers: other.mentionedUsers, + reactionCounts: other.reactionCounts, + reactionScores: other.reactionScores, + latestReactions: other.latestReactions, + ownReactions: other.ownReactions, + parentId: other.parentId, + quotedMessage: other.quotedMessage, + quotedMessageId: other.quotedMessageId, + replyCount: other.replyCount, + threadParticipants: other.threadParticipants, + showInChannel: other.showInChannel, + command: other.command, + createdAt: other.createdAt, + silent: other.silent, + extraData: other.extraData, + user: other.user, + shadowed: other.shadowed, + updatedAt: other.updatedAt, + deletedAt: other.deletedAt, + status: other.status, + ); + } +} + +/// A translated message +/// It has an additional property called [i18n] +@JsonSerializable() +class TranslatedMessage extends Message { + /// Constructor used for json serialization + TranslatedMessage(this.i18n); + + /// A Map of + final Map i18n; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static final topLevelFields = [ + 'i18n', + ...Message.topLevelFields, + ]; + + /// Create a new instance from a json + factory TranslatedMessage.fromJson(Map json) { + return _$TranslatedMessageFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields), + ); + } + + /// Serialize to json + @override + Map toJson() => Serialization.moveFromExtraDataToRoot( + _$TranslatedMessageToJson(this), + topLevelFields, + ); +} diff --git a/packages/stream_chat/lib/src/models/message.g.dart b/packages/stream_chat/lib/src/models/message.g.dart new file mode 100644 index 00000000..c30d8fe5 --- /dev/null +++ b/packages/stream_chat/lib/src/models/message.g.dart @@ -0,0 +1,135 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Message _$MessageFromJson(Map json) { + return Message( + id: json['id'] as String, + text: json['text'] as String, + type: json['type'] as String, + attachments: (json['attachments'] as List) + ?.map((e) => e == null + ? null + : Attachment.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + mentionedUsers: (json['mentioned_users'] as List) + ?.map((e) => e == null + ? null + : User.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + silent: json['silent'] as bool, + shadowed: json['shadowed'] as bool, + reactionCounts: (json['reaction_counts'] as Map)?.map( + (k, e) => MapEntry(k as String, e as int), + ), + reactionScores: (json['reaction_scores'] as Map)?.map( + (k, e) => MapEntry(k as String, e as int), + ), + latestReactions: (json['latest_reactions'] as List) + ?.map((e) => e == null + ? null + : Reaction.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + ownReactions: (json['own_reactions'] as List) + ?.map((e) => e == null + ? null + : Reaction.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + parentId: json['parent_id'] as String, + quotedMessage: json['quoted_message'] == null + ? null + : Message.fromJson((json['quoted_message'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + quotedMessageId: json['quoted_message_id'] as String, + replyCount: json['reply_count'] as int, + threadParticipants: (json['thread_participants'] as List) + ?.map((e) => e == null + ? null + : User.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + showInChannel: json['show_in_channel'] as bool, + command: json['command'] as String, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + user: json['user'] == null + ? null + : User.fromJson((json['user'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + deletedAt: json['deleted_at'] == null + ? null + : DateTime.parse(json['deleted_at'] as String), + ); +} + +Map _$MessageToJson(Message instance) { + final val = { + 'id': instance.id, + 'text': instance.text, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('type', readonly(instance.type)); + writeNotNull( + 'attachments', instance.attachments?.map((e) => e?.toJson())?.toList()); + val['mentioned_users'] = Serialization.userIds(instance.mentionedUsers); + writeNotNull('reaction_counts', readonly(instance.reactionCounts)); + writeNotNull('reaction_scores', readonly(instance.reactionScores)); + writeNotNull('latest_reactions', readonly(instance.latestReactions)); + writeNotNull('own_reactions', readonly(instance.ownReactions)); + val['parent_id'] = instance.parentId; + val['quoted_message'] = readonly(instance.quotedMessage); + val['quoted_message_id'] = instance.quotedMessageId; + writeNotNull('reply_count', readonly(instance.replyCount)); + writeNotNull('thread_participants', readonly(instance.threadParticipants)); + val['show_in_channel'] = instance.showInChannel; + val['silent'] = instance.silent; + writeNotNull('shadowed', readonly(instance.shadowed)); + writeNotNull('command', readonly(instance.command)); + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('updated_at', readonly(instance.updatedAt)); + writeNotNull('user', readonly(instance.user)); + writeNotNull('extra_data', instance.extraData); + writeNotNull('deleted_at', readonly(instance.deletedAt)); + return val; +} + +TranslatedMessage _$TranslatedMessageFromJson(Map json) { + return TranslatedMessage( + (json['i18n'] as Map)?.map( + (k, e) => MapEntry(k as String, e as String), + ), + ); +} + +Map _$TranslatedMessageToJson(TranslatedMessage instance) => + { + 'i18n': instance.i18n, + }; diff --git a/packages/stream_chat/lib/src/models/mute.dart b/packages/stream_chat/lib/src/models/mute.dart new file mode 100644 index 00000000..f33a9956 --- /dev/null +++ b/packages/stream_chat/lib/src/models/mute.dart @@ -0,0 +1,36 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/models/channel_model.dart'; + +import 'serialization.dart'; +import 'user.dart'; + +part 'mute.g.dart'; + +/// The class that contains the information about a muted user +@JsonSerializable() +class Mute { + /// The user that performed the muting action + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final User user; + + /// The target user + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final ChannelModel channel; + + /// The date in which the use was muted + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime createdAt; + + /// The date of the last update + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime updatedAt; + + /// Constructor used for json serialization + Mute({this.user, this.channel, this.createdAt, this.updatedAt}); + + /// Create a new instance from a json + factory Mute.fromJson(Map json) => _$MuteFromJson(json); + + /// Serialize to json + Map toJson() => _$MuteToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/mute.g.dart b/packages/stream_chat/lib/src/models/mute.g.dart new file mode 100644 index 00000000..9d0b9318 --- /dev/null +++ b/packages/stream_chat/lib/src/models/mute.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mute.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Mute _$MuteFromJson(Map json) { + return Mute( + user: json['user'] == null + ? null + : User.fromJson((json['user'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + channel: json['channel'] == null + ? null + : ChannelModel.fromJson((json['channel'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + ); +} + +Map _$MuteToJson(Mute instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('user', readonly(instance.user)); + writeNotNull('channel', readonly(instance.channel)); + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('updated_at', readonly(instance.updatedAt)); + return val; +} diff --git a/packages/stream_chat/lib/src/models/own_user.dart b/packages/stream_chat/lib/src/models/own_user.dart new file mode 100644 index 00000000..3246788c --- /dev/null +++ b/packages/stream_chat/lib/src/models/own_user.dart @@ -0,0 +1,83 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'device.dart'; +import 'mute.dart'; +import 'serialization.dart'; +import 'user.dart'; + +part 'own_user.g.dart'; + +/// The class that defines the own user model +/// This object can be found in [Event] +@JsonSerializable() +class OwnUser extends User { + /// List of user devices + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final List devices; + + /// List of users muted by the user + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final List mutes; + + /// List of users muted by the user + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final List channelMutes; + + /// Total unread messages by the user + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final int totalUnreadCount; + + /// Total unread channels by the user + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final int unreadChannels; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static final topLevelFields = [ + 'devices', + 'mutes', + 'total_unread_count', + 'unread_channels', + 'channel_mutes', + ...User.topLevelFields, + ]; + + /// Constructor used for json serialization + OwnUser({ + this.devices, + this.mutes, + this.totalUnreadCount, + this.unreadChannels, + this.channelMutes, + String id, + String role, + DateTime createdAt, + DateTime updatedAt, + DateTime lastActive, + bool online, + Map extraData, + bool banned, + }) : super( + id: id, + role: role, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + extraData: extraData, + banned: banned, + ); + + /// Create a new instance from a json + factory OwnUser.fromJson(Map json) { + return _$OwnUserFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + } + + /// Serialize to json + @override + Map toJson() { + return Serialization.moveFromExtraDataToRoot( + _$OwnUserToJson(this), topLevelFields); + } +} diff --git a/packages/stream_chat/lib/src/models/own_user.g.dart b/packages/stream_chat/lib/src/models/own_user.g.dart new file mode 100644 index 00000000..887e6b28 --- /dev/null +++ b/packages/stream_chat/lib/src/models/own_user.g.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'own_user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +OwnUser _$OwnUserFromJson(Map json) { + return OwnUser( + devices: (json['devices'] as List) + ?.map((e) => e == null + ? null + : Device.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + mutes: (json['mutes'] as List) + ?.map((e) => e == null + ? null + : Mute.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + totalUnreadCount: json['total_unread_count'] as int, + unreadChannels: json['unread_channels'] as int, + channelMutes: (json['channel_mutes'] as List) + ?.map((e) => e == null + ? null + : Mute.fromJson((e as Map)?.map( + (k, e) => MapEntry(k as String, e), + ))) + ?.toList(), + id: json['id'] as String, + role: json['role'] as String, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + lastActive: json['last_active'] == null + ? null + : DateTime.parse(json['last_active'] as String), + online: json['online'] as bool, + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + banned: json['banned'] as bool, + ); +} + +Map _$OwnUserToJson(OwnUser instance) { + final val = { + 'id': instance.id, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('role', readonly(instance.role)); + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('updated_at', readonly(instance.updatedAt)); + writeNotNull('last_active', readonly(instance.lastActive)); + writeNotNull('online', readonly(instance.online)); + writeNotNull('banned', readonly(instance.banned)); + writeNotNull('extra_data', instance.extraData); + writeNotNull('devices', readonly(instance.devices)); + writeNotNull('mutes', readonly(instance.mutes)); + writeNotNull('channel_mutes', readonly(instance.channelMutes)); + writeNotNull('total_unread_count', readonly(instance.totalUnreadCount)); + writeNotNull('unread_channels', readonly(instance.unreadChannels)); + return val; +} diff --git a/packages/stream_chat/lib/src/models/reaction.dart b/packages/stream_chat/lib/src/models/reaction.dart new file mode 100644 index 00000000..9a4869f7 --- /dev/null +++ b/packages/stream_chat/lib/src/models/reaction.dart @@ -0,0 +1,68 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'serialization.dart'; +import 'user.dart'; + +part 'reaction.g.dart'; + +/// The class that defines a reaction +@JsonSerializable() +class Reaction { + /// The messageId to which the reaction belongs + final String messageId; + + /// The type of the reaction + final String type; + + /// The date of the reaction + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime createdAt; + + /// The user that sent the reaction + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final User user; + + /// The score of the reaction (ie. number of reactions sent) + final int score; + + /// The userId that sent the reaction + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final String userId; + + /// Reaction custom extraData + @JsonKey(includeIfNull: false) + final Map extraData; + + /// Map of custom user extraData + static const topLevelFields = [ + 'message_id', + 'created_at', + 'type', + 'user', + 'user_id', + 'score', + ]; + + /// Constructor used for json serialization + Reaction({ + this.messageId, + this.createdAt, + this.type, + this.user, + this.userId, + this.score, + this.extraData, + }); + + /// Create a new instance from a json + factory Reaction.fromJson(Map json) { + return _$ReactionFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + } + + /// Serialize to json + Map toJson() { + return Serialization.moveFromExtraDataToRoot( + _$ReactionToJson(this), topLevelFields); + } +} diff --git a/packages/stream_chat/lib/src/models/reaction.g.dart b/packages/stream_chat/lib/src/models/reaction.g.dart new file mode 100644 index 00000000..a270af01 --- /dev/null +++ b/packages/stream_chat/lib/src/models/reaction.g.dart @@ -0,0 +1,47 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reaction.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Reaction _$ReactionFromJson(Map json) { + return Reaction( + messageId: json['message_id'] as String, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + type: json['type'] as String, + user: json['user'] == null + ? null + : User.fromJson((json['user'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + userId: json['user_id'] as String, + score: json['score'] as int, + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + ); +} + +Map _$ReactionToJson(Reaction instance) { + final val = { + 'message_id': instance.messageId, + 'type': instance.type, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('user', readonly(instance.user)); + val['score'] = instance.score; + writeNotNull('user_id', readonly(instance.userId)); + writeNotNull('extra_data', instance.extraData); + return val; +} diff --git a/packages/stream_chat/lib/src/models/read.dart b/packages/stream_chat/lib/src/models/read.dart new file mode 100644 index 00000000..ae85293d --- /dev/null +++ b/packages/stream_chat/lib/src/models/read.dart @@ -0,0 +1,31 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'user.dart'; + +part 'read.g.dart'; + +/// The class that defines a read event +@JsonSerializable() +class Read { + /// Date of the read event + final DateTime lastRead; + + /// User who sent the event + final User user; + + /// Number of unread messages + final int unreadMessages; + + /// Constructor used for json serialization + Read({ + this.lastRead, + this.user, + this.unreadMessages, + }); + + /// Create a new instance from a json + factory Read.fromJson(Map json) => _$ReadFromJson(json); + + /// Serialize to json + Map toJson() => _$ReadToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/read.g.dart b/packages/stream_chat/lib/src/models/read.g.dart new file mode 100644 index 00000000..d04ae146 --- /dev/null +++ b/packages/stream_chat/lib/src/models/read.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'read.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Read _$ReadFromJson(Map json) { + return Read( + lastRead: json['last_read'] == null + ? null + : DateTime.parse(json['last_read'] as String), + user: json['user'] == null + ? null + : User.fromJson((json['user'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + unreadMessages: json['unread_messages'] as int, + ); +} + +Map _$ReadToJson(Read instance) => { + 'last_read': instance.lastRead?.toIso8601String(), + 'user': instance.user?.toJson(), + 'unread_messages': instance.unreadMessages, + }; diff --git a/packages/stream_chat/lib/src/models/serialization.dart b/packages/stream_chat/lib/src/models/serialization.dart new file mode 100644 index 00000000..239ccb50 --- /dev/null +++ b/packages/stream_chat/lib/src/models/serialization.dart @@ -0,0 +1,49 @@ +import 'user.dart'; + +/// Used to avoid to serialize properties to json +Null readonly(_) => null; + +/// Helper class for serialization to and from json +class Serialization { + /// Used to avoid to serialize properties to json + static const Function readOnly = readonly; + + /// List of users to list of userIds + static List userIds(List users) { + return users?.map((u) => u.id)?.toList(); + } + + /// Takes unknown json keys and puts them in the `extra_data` key + static Map moveToExtraDataFromRoot( + Map json, + List topLevelFields, + ) { + if (json == null) return null; + + final jsonClone = Map.from(json); + + final extraDataMap = Map.from(json) + ..removeWhere( + (key, value) => topLevelFields.contains(key), + ); + final rootFields = jsonClone + ..removeWhere((key, value) => extraDataMap.keys.contains(key)); + return rootFields + ..addAll({ + 'extra_data': extraDataMap, + }); + } + + /// Takes values in `extra_data` key and puts them on the root level of the json map + static Map moveFromExtraDataToRoot( + Map json, + List topLevelFields, + ) { + final jsonClone = Map.from(json); + return jsonClone + ..addAll({ + if (json['extra_data'] != null) ...json['extra_data'], + }) + ..remove('extra_data'); + } +} diff --git a/packages/stream_chat/lib/src/models/user.dart b/packages/stream_chat/lib/src/models/user.dart new file mode 100644 index 00000000..00ed1200 --- /dev/null +++ b/packages/stream_chat/lib/src/models/user.dart @@ -0,0 +1,108 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'serialization.dart'; + +part 'user.g.dart'; + +/// The class that defines the user model +@JsonSerializable() +class User { + /// User id + final String id; + + /// User role + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final String role; + + /// User role + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final List teams; + + /// Date of user creation + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime createdAt; + + /// Date of last user update + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime updatedAt; + + /// Date of last user connection + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final DateTime lastActive; + + /// True if user is online + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final bool online; + + /// True if user is banned from the chat + @JsonKey(includeIfNull: false, toJson: Serialization.readOnly) + final bool banned; + + /// Map of custom user extraData + @JsonKey(includeIfNull: false) + final Map extraData; + + /// Known top level fields. + /// Useful for [Serialization] methods. + static const topLevelFields = [ + 'id', + 'role', + 'created_at', + 'updated_at', + 'last_active', + 'online', + 'banned', + 'teams', + ]; + + /// Use this named constructor to create a new user instance + User.init( + this.id, { + this.online, + this.extraData, + }) : createdAt = null, + updatedAt = null, + lastActive = null, + banned = null, + teams = null, + role = null; + + /// Constructor used for json serialization + User({ + this.id, + this.role, + this.createdAt, + this.updatedAt, + this.lastActive, + this.online, + this.extraData, + this.banned, + this.teams, + }); + + /// Shortcut for user name + String get name => + (extraData?.containsKey('name') == true && extraData['name'] != '') + ? extraData['name'] + : id; + + /// Create a new instance from a json + factory User.fromJson(Map json) { + return _$UserFromJson( + Serialization.moveToExtraDataFromRoot(json, topLevelFields)); + } + + /// Serialize to json + Map toJson() { + return Serialization.moveFromExtraDataToRoot( + _$UserToJson(this), topLevelFields); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is User && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} diff --git a/packages/stream_chat/lib/src/models/user.g.dart b/packages/stream_chat/lib/src/models/user.g.dart new file mode 100644 index 00000000..b27935a7 --- /dev/null +++ b/packages/stream_chat/lib/src/models/user.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +User _$UserFromJson(Map json) { + return User( + id: json['id'] as String, + role: json['role'] as String, + createdAt: json['created_at'] == null + ? null + : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null + ? null + : DateTime.parse(json['updated_at'] as String), + lastActive: json['last_active'] == null + ? null + : DateTime.parse(json['last_active'] as String), + online: json['online'] as bool, + extraData: (json['extra_data'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + ), + banned: json['banned'] as bool, + teams: (json['teams'] as List)?.map((e) => e as String)?.toList(), + ); +} + +Map _$UserToJson(User instance) { + final val = { + 'id': instance.id, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('role', readonly(instance.role)); + writeNotNull('teams', readonly(instance.teams)); + writeNotNull('created_at', readonly(instance.createdAt)); + writeNotNull('updated_at', readonly(instance.updatedAt)); + writeNotNull('last_active', readonly(instance.lastActive)); + writeNotNull('online', readonly(instance.online)); + writeNotNull('banned', readonly(instance.banned)); + writeNotNull('extra_data', instance.extraData); + return val; +} diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart new file mode 100644 index 00000000..590175c4 --- /dev/null +++ b/packages/stream_chat/lib/stream_chat.dart @@ -0,0 +1,29 @@ +library stream_chat; + +export 'package:dio/src/dio_error.dart'; +export 'package:dio/src/multipart_file.dart'; +export 'package:logging/logging.dart' show Logger, Level; + +export './src/api/channel.dart'; +export './src/api/connection_status.dart'; +export './src/api/requests.dart'; +export './src/api/requests.dart'; +export './src/api/responses.dart'; +export './src/client.dart'; +export './src/event_type.dart'; +export './src/models/action.dart'; +export './src/models/attachment.dart'; +export './src/models/channel_config.dart'; +export './src/models/channel_model.dart'; +export './src/models/channel_state.dart'; +export './src/models/command.dart'; +export './src/models/device.dart'; +export './src/models/event.dart'; +export './src/models/member.dart'; +export './src/models/message.dart'; +export './src/models/mute.dart'; +export './src/models/own_user.dart'; +export './src/models/reaction.dart'; +export './src/models/read.dart'; +export './src/models/user.dart'; +export './src/db/chat_persistence_client.dart'; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart new file mode 100644 index 00000000..1a44ac24 --- /dev/null +++ b/packages/stream_chat/lib/version.dart @@ -0,0 +1,5 @@ +import 'package:stream_chat/src/client.dart'; + +/// Current package version +/// Used in [StreamChatClient] to build the `x-stream-client` header +const PACKAGE_VERSION = '1.0.1-beta'; diff --git a/packages/stream_chat/peanut.yaml b/packages/stream_chat/peanut.yaml new file mode 100644 index 00000000..97d20f52 --- /dev/null +++ b/packages/stream_chat/peanut.yaml @@ -0,0 +1,3 @@ +# Configuration for https://pub.dev/packages/peanut +directories: + - example/web diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml new file mode 100644 index 00000000..ac466e0f --- /dev/null +++ b/packages/stream_chat/pubspec.yaml @@ -0,0 +1,27 @@ +name: stream_chat +homepage: https://getstream.io/ +description: The official Dart client for Stream Chat, a service for building chat applications. +version: 1.0.1-beta +repository: https://github.com/GetStream/stream-chat-flutter +issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + json_annotation: ^3.0.1 + logging: ^0.11.4 + dio: ^3.0.10 + web_socket_channel: ^1.1.0 + uuid: ^2.2.2 + async: ^2.4.2 + rxdart: ^0.25.0 + collection: ^1.14.13 + pedantic: ^1.9.2 + meta: ^1.2.4 + +dev_dependencies: + build_runner: ^1.10.0 + json_serializable: ^3.3.0 + test: ^1.15.7 + mockito: ^4.1.1 diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart new file mode 100644 index 00000000..4b274a94 --- /dev/null +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -0,0 +1,2099 @@ +import 'package:dio/dio.dart'; +import 'package:dio/native_imp.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat/src/api/requests.dart'; +import 'package:stream_chat/src/client.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/models/event.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/reaction.dart'; +import 'package:test/test.dart'; + +class MockDio extends Mock implements DioForNative {} + +class MockHttpClientAdapter extends Mock implements HttpClientAdapter {} + +void main() { + group('src/api/channel', () { + group('message', () { + test('sendMessage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final message = Message( + text: 'hey', + id: 'test', + ); + + when(mockDio.post('/channels/messaging/testid/message', data: { + 'message': message.toJson(), + })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.sendMessage(message); + + verify( + mockDio.post('/channels/messaging/testid/message', data: { + 'message': message.toJson(), + })).called(1); + }); + + test('markRead', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + when(mockDio.post('/channels/messaging/testid/read', data: {})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.markRead(); + + verify(mockDio.post('/channels/messaging/testid/read', + data: {})).called(1); + }); + + test('getReplies', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final pagination = PaginationParams(); + + when(mockDio.get('/messages/messageid/replies', + queryParameters: pagination.toJson())) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.getReplies('messageid', pagination); + + verify(mockDio.get('/messages/messageid/replies', + queryParameters: pagination.toJson())) + .called(1); + }); + + test('sendAction', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + final Map data = {'test': true}; + + when(mockDio.post('/messages/messageid/action', data: { + 'id': 'testid', + 'type': 'messaging', + 'form_data': data, + 'message_id': 'messageid', + })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.sendAction(Message(id: 'messageid'), data); + + verify(mockDio.post('/messages/messageid/action', data: { + 'id': 'testid', + 'type': 'messaging', + 'form_data': data, + 'message_id': 'messageid', + })).called(1); + }); + + test('getMessagesById', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final messageIds = ['a', 'b']; + + when(mockDio.get('/channels/messaging/testid/messages', + queryParameters: {'ids': messageIds.join(',')})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.getMessagesById(messageIds); + + verify(mockDio.get('/channels/messaging/testid/messages', + queryParameters: {'ids': messageIds.join(',')})).called(1); + }); + + test('sendFile', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final file = MultipartFile.fromString('file'); + + when(mockDio.post('/channels/messaging/testid/file', + data: argThat(isA(), named: 'data'))) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.sendFile(file); + + verify(mockDio.post('/channels/messaging/testid/file', + data: argThat(isA(), named: 'data'))) + .called(1); + }); + + test('sendImage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final file = MultipartFile.fromString('file'); + + when(mockDio.post('/channels/messaging/testid/image', + data: argThat(isA(), named: 'data'))) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.sendImage(file); + + verify(mockDio.post('/channels/messaging/testid/image', + data: argThat(isA(), named: 'data'))) + .called(1); + }); + + test('deleteFile', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final url = 'url'; + + when(mockDio.delete('/channels/messaging/testid/file', + queryParameters: {'url': url})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.deleteFile(url); + + verify(mockDio.delete('/channels/messaging/testid/file', + queryParameters: {'url': url})).called(1); + }); + + test('deleteImage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final url = 'url'; + + when(mockDio.delete('/channels/messaging/testid/image', + queryParameters: {'url': url})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.deleteImage(url); + + verify(mockDio.delete('/channels/messaging/testid/image', + queryParameters: {'url': url})).called(1); + }); + }); + + test('sendEvent', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + final event = Event(type: EventType.any); + + when(mockDio.post('/channels/messaging/testid/event', + data: {'event': event.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.sendEvent(event); + + verify(mockDio.post('/channels/messaging/testid/event', + data: {'event': event.toJson()})).called(1); + }); + + test('keyStroke', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + final event = Event(type: EventType.typingStart); + + when(mockDio.post('/channels/messaging/testid/event', + data: {'event': event.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.keyStroke(); + + verify(mockDio.post('/channels/messaging/testid/event', + data: {'event': event.toJson()})).called(1); + }); + + test('stopTyping', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + final event = Event(type: EventType.typingStop); + + when(mockDio.post('/channels/messaging/testid/event', + data: {'event': event.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.stopTyping(); + + verify(mockDio.post('/channels/messaging/testid/event', + data: {'event': event.toJson()})).called(1); + }); + + group('reactions', () { + test('sendReaction', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final reactionType = 'test'; + + when(mockDio.post( + '/messages/messageid/reaction', + data: { + 'reaction': { + 'type': reactionType, + }, + 'enforce_unique': false, + }, + )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.sendReaction( + Message( + id: 'messageid', + ), + reactionType, + ); + + verify(mockDio.post('/messages/messageid/reaction', data: { + 'reaction': { + 'type': reactionType, + }, + 'enforce_unique': false, + })).called(1); + }); + + test('deleteReaction', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + when(mockDio.delete('/messages/messageid/reaction/test')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.deleteReaction( + Message(id: 'messageid'), + Reaction(type: 'test'), + ); + + verify(mockDio.delete('/messages/messageid/reaction/test')) + .called(1); + }); + + test('getReactions', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final pagination = PaginationParams(); + + when(mockDio.get('/messages/messageid/reactions', + queryParameters: pagination.toJson())) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.getReactions('messageid', pagination); + + verify(mockDio.get('/messages/messageid/reactions', + queryParameters: pagination.toJson())) + .called(1); + }); + }); + + group('channel', () { + test('addMembers', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final members = ['vishal']; + final message = Message(text: 'test'); + + when(mockDio.post('/channels/messaging/testid', + data: {'add_members': members, 'message': message.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.addMembers(members, message); + + verify(mockDio.post('/channels/messaging/testid', + data: {'add_members': members, 'message': message.toJson()})) + .called(1); + }); + + test('acceptInvite', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final message = Message(text: 'test'); + + when(mockDio.post('/channels/messaging/testid', + data: {'accept_invite': true, 'message': message.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.acceptInvite(message); + + verify(mockDio.post('/channels/messaging/testid', + data: {'accept_invite': true, 'message': message.toJson()})) + .called(1); + }); + + group('query', () { + test('without id', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging'); + final Map options = { + 'watch': true, + 'state': false, + 'presence': true, + }; + + when(mockDio.post('/channels/messaging/query', data: options)) + .thenAnswer((_) async { + return Response(data: r''' + { + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "messages": [ + { + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] + }, + { + "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", + "text": "Few can name a topfull mother that isn't a breezeless damage.", + "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.153518Z", + "updated_at": "2020-01-28T22:17:31.153518Z", + "mentioned_users": [] + }, + { + "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", + "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.155428Z", + "updated_at": "2020-01-28T22:17:31.155428Z", + "mentioned_users": [] + }, + { + "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", + "text": "The carbons could be said to resemble smartish hoods.", + "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.157811Z", + "updated_at": "2020-01-28T22:17:31.157811Z", + "mentioned_users": [] + }, + { + "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", + "text": "Their software was, in this moment, a prolix feature.", + "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.158391Z", + "updated_at": "2020-01-28T22:17:31.158391Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "last_read": "2020-01-28T22:17:31.016937728Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "last_read": "2020-01-28T22:17:31.018856448Z" + } + ], + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ] + } + ''', statusCode: 200); + }); + + final response = await channelClient.query(options: options); + + verify(mockDio.post('/channels/messaging/query', + data: options)) + .called(1); + expect(channelClient.id, response.channel.id); + expect(channelClient.cid, response.channel.cid); + }); + + test('with id', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final Map options = { + 'state': false, + }; + + when(mockDio.post('/channels/messaging/testid/query', + data: options)) + .thenAnswer((_) async => Response(data: r''' + { + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "messages": [ + { + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] + }, + { + "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", + "text": "Few can name a topfull mother that isn't a breezeless damage.", + "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.153518Z", + "updated_at": "2020-01-28T22:17:31.153518Z", + "mentioned_users": [] + }, + { + "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", + "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.155428Z", + "updated_at": "2020-01-28T22:17:31.155428Z", + "mentioned_users": [] + }, + { + "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", + "text": "The carbons could be said to resemble smartish hoods.", + "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.157811Z", + "updated_at": "2020-01-28T22:17:31.157811Z", + "mentioned_users": [] + }, + { + "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", + "text": "Their software was, in this moment, a prolix feature.", + "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.158391Z", + "updated_at": "2020-01-28T22:17:31.158391Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "last_read": "2020-01-28T22:17:31.016937728Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "last_read": "2020-01-28T22:17:31.018856448Z" + } + ], + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ] + } + ''', statusCode: 200)); + + await channelClient.query(options: options); + + verify(mockDio.post('/channels/messaging/testid/query', + data: options)) + .called(1); + }); + }); + + test('create', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging'); + final Map options = { + 'watch': false, + 'state': false, + 'presence': false, + }; + + when(mockDio.post('/channels/messaging/query', data: options)) + .thenAnswer((_) async => Response(data: r''' + { + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "messages": [ + { + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] + }, + { + "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", + "text": "Few can name a topfull mother that isn't a breezeless damage.", + "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.153518Z", + "updated_at": "2020-01-28T22:17:31.153518Z", + "mentioned_users": [] + }, + { + "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", + "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.155428Z", + "updated_at": "2020-01-28T22:17:31.155428Z", + "mentioned_users": [] + }, + { + "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", + "text": "The carbons could be said to resemble smartish hoods.", + "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.157811Z", + "updated_at": "2020-01-28T22:17:31.157811Z", + "mentioned_users": [] + }, + { + "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", + "text": "Their software was, in this moment, a prolix feature.", + "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.158391Z", + "updated_at": "2020-01-28T22:17:31.158391Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "last_read": "2020-01-28T22:17:31.016937728Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "last_read": "2020-01-28T22:17:31.018856448Z" + } + ], + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ] + } + ''', statusCode: 200)); + + final response = await channelClient.create(); + + verify(mockDio.post('/channels/messaging/query', data: options)) + .called(1); + expect(channelClient.id, response.channel.id); + expect(channelClient.cid, response.channel.cid); + }); + + test('watch', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging'); + final options = { + 'watch': true, + 'state': true, + 'presence': true, + }; + + when(mockDio.post('/channels/messaging/query', data: options)) + .thenAnswer((_) async => Response(data: r''' + { + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "messages": [ + { + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] + }, + { + "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", + "text": "Few can name a topfull mother that isn't a breezeless damage.", + "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.153518Z", + "updated_at": "2020-01-28T22:17:31.153518Z", + "mentioned_users": [] + }, + { + "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", + "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.155428Z", + "updated_at": "2020-01-28T22:17:31.155428Z", + "mentioned_users": [] + }, + { + "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", + "text": "The carbons could be said to resemble smartish hoods.", + "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.157811Z", + "updated_at": "2020-01-28T22:17:31.157811Z", + "mentioned_users": [] + }, + { + "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", + "text": "Their software was, in this moment, a prolix feature.", + "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.158391Z", + "updated_at": "2020-01-28T22:17:31.158391Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "last_read": "2020-01-28T22:17:31.016937728Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "last_read": "2020-01-28T22:17:31.018856448Z" + } + ], + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ] + } + ''', statusCode: 200)); + + final response = await channelClient.watch({'presence': true}); + + verify(mockDio.post('/channels/messaging/query', data: options)) + .called(1); + expect(channelClient.id, response.channel.id); + expect(channelClient.cid, response.channel.cid); + }); + + test('stopWatching', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + '/channels/messaging/testid/stop-watching', + data: {}, + )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.stopWatching(); + + verify(mockDio.post( + '/channels/messaging/testid/stop-watching', + data: {}, + )).called(1); + }); + + test('update', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final message = Message(text: 'test'); + + when(mockDio.post('/channels/messaging/testid', data: { + 'message': message.toJson(), + 'data': {'test': true}, + })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.update({'test': true}, message); + + verify(mockDio.post('/channels/messaging/testid', data: { + 'message': message.toJson(), + 'data': {'test': true}, + })).called(1); + }); + + test('delete', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.delete('/channels/messaging/testid')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.delete(); + + verify(mockDio.delete('/channels/messaging/testid')).called(1); + }); + + test('truncate', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post('/channels/messaging/testid/truncate')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.truncate(); + + verify(mockDio.post('/channels/messaging/testid/truncate')) + .called(1); + }); + + test('rejectInvite', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final message = Message(text: 'test'); + + when(mockDio.post('/channels/messaging/testid', + data: {'reject_invite': true, 'message': message.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.rejectInvite(message); + + verify(mockDio.post('/channels/messaging/testid', + data: {'reject_invite': true, 'message': message.toJson()})) + .called(1); + }); + + test('inviteMembers', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final members = ['vishal']; + final message = Message(text: 'test'); + + when(mockDio.post('/channels/messaging/testid', + data: {'invites': members, 'message': message.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.inviteMembers(members, message); + + verify(mockDio.post('/channels/messaging/testid', + data: {'invites': members, 'message': message.toJson()})).called(1); + }); + + test('removeMembers', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + final members = ['vishal']; + final message = Message(text: 'test'); + + when(mockDio.post('/channels/messaging/testid', + data: {'remove_members': members, 'message': message.toJson()})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.removeMembers(members, message); + + verify(mockDio.post('/channels/messaging/testid', + data: {'remove_members': members, 'message': message.toJson()})) + .called(1); + }); + + test('hide', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + when(mockDio.post('/channels/messaging/testid/hide', + data: {'clear_history': true})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.hide(clearHistory: true); + + verify(mockDio.post('/channels/messaging/testid/hide', + data: {'clear_history': true})).called(1); + }); + + test('show', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + when(mockDio.post('/channels/messaging/testid/show')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.show(); + + verify(mockDio.post('/channels/messaging/testid/show')) + .called(1); + }); + + test('banUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + when(mockDio.post('/moderation/ban', data: { + 'test': true, + 'target_user_id': 'test-id', + 'type': 'messaging', + 'id': 'testid', + })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + final Map options = {'test': true}; + await channelClient.banUser('test-id', options); + + verify(mockDio.post('/moderation/ban', data: { + 'test': true, + 'target_user_id': 'test-id', + 'type': 'messaging', + 'id': 'testid', + })).called(1); + }); + + test('unbanUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + tokenProvider: (_) async => '', + ); + final channelClient = client.channel('messaging', id: 'testid'); + + when(mockDio.post( + any, + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + data: '{}', + statusCode: 200, + )); + await channelClient.watch(); + + when(mockDio.delete('/moderation/ban', queryParameters: { + 'target_user_id': 'test-id', + 'type': 'messaging', + 'id': 'testid', + })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await channelClient.unbanUser('test-id'); + + verify(mockDio.delete('/moderation/ban', queryParameters: { + 'target_user_id': 'test-id', + 'type': 'messaging', + 'id': 'testid', + })).called(1); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/api/requests_test.dart b/packages/stream_chat/test/src/api/requests_test.dart new file mode 100644 index 00000000..6f7324ed --- /dev/null +++ b/packages/stream_chat/test/src/api/requests_test.dart @@ -0,0 +1,18 @@ +import 'package:test/test.dart'; +import 'package:stream_chat/stream_chat.dart'; + +void main() { + group('src/api/requests', () { + test('SortOption', () { + final option = SortOption('name'); + final j = option.toJson(); + expect(j, {'field': 'name', 'direction': -1}); + }); + + test('PaginationParams', () { + final option = PaginationParams(); + final j = option.toJson(); + expect(j, {'limit': 10}); + }); + }); +} diff --git a/packages/stream_chat/test/src/api/responses_test.dart b/packages/stream_chat/test/src/api/responses_test.dart new file mode 100644 index 00000000..bfd4e5a7 --- /dev/null +++ b/packages/stream_chat/test/src/api/responses_test.dart @@ -0,0 +1,4336 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/api/responses.dart'; +import 'package:stream_chat/src/models/device.dart'; +import 'package:stream_chat/src/models/member.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/reaction.dart'; +import 'package:stream_chat/src/models/read.dart'; +import 'package:stream_chat/stream_chat.dart'; + +void main() { + group('src/api/responses', () { + test('QueryChannelsResponse', () { + const jsonExample = r'''{ + "channels": [ + { + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "messages": [ + { + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] + }, + { + "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", + "text": "Few can name a topfull mother that isn't a breezeless damage.", + "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.153518Z", + "updated_at": "2020-01-28T22:17:31.153518Z", + "mentioned_users": [] + }, + { + "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", + "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.155428Z", + "updated_at": "2020-01-28T22:17:31.155428Z", + "mentioned_users": [] + }, + { + "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", + "text": "The carbons could be said to resemble smartish hoods.", + "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.157811Z", + "updated_at": "2020-01-28T22:17:31.157811Z", + "mentioned_users": [] + }, + { + "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", + "text": "Their software was, in this moment, a prolix feature.", + "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.158391Z", + "updated_at": "2020-01-28T22:17:31.158391Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "last_read": "2020-01-28T22:17:31.016937728Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "last_read": "2020-01-28T22:17:31.018856448Z" + } + ], + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ] + }, + { + "channel": { + "id": "spring-voice-7", + "type": "messaging", + "cid": "messaging:spring-voice-7", + "last_message_at": "2020-01-28T22:17:31.194334Z", + "created_at": "2020-01-28T22:17:30.858437Z", + "updated_at": "2020-01-28T22:17:30.858438Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "messages": [ + { + "id": "9db3ef01-e779-4279-8c54-ffd021eccec4", + "text": "A lustred seal is an alto of the mind.", + "html": "\u003cp\u003eA lustred seal is an alto of the mind.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.167579Z", + "updated_at": "2020-01-28T22:17:31.167579Z", + "mentioned_users": [] + }, + { + "id": "3232e92f-a96f-4b5e-bacb-3565e7155dc4", + "text": "https://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Punisher Marvel GIF by NETFLIX - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.gif", + "text": "See What's Next in entertainment and Netflix original series, movies, TV, docs, and comedies. You can stream Netflix anytime, anywhere, on any device.", + "image_url": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.gif", + "thumb_url": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.gif", + "asset_url": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.168454Z", + "updated_at": "2020-01-28T22:17:31.168454Z", + "mentioned_users": [] + }, + { + "id": "ae6e196c-0a66-4941-9832-d66a40c95699", + "text": "A height of the parallelogram is assumed to be a sunfast cone.", + "html": "\u003cp\u003eA height of the parallelogram is assumed to be a sunfast cone.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.17494Z", + "updated_at": "2020-01-28T22:17:31.17494Z", + "mentioned_users": [] + }, + { + "id": "38fee958-1efe-456b-b2d2-7bd1ba975eab", + "text": "In modern times the braided bridge reveals itself as a daisied burn to those who look.", + "html": "\u003cp\u003eIn modern times the braided bridge reveals itself as a daisied burn to those who look.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "38fee958-1efe-456b-b2d2-7bd1ba975eab", + "user_id": "spring-voice-7", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "type": "like", + "score": 1, + "created_at": "2020-01-28T22:17:31.213167Z", + "updated_at": "2020-01-28T22:17:31.213167Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "like": 1 + }, + "reaction_scores": { + "like": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.184974Z", + "updated_at": "2020-01-28T22:17:31.215223Z", + "mentioned_users": [] + }, + { + "id": "8dbc9bec-35c4-4c42-942a-5c97bd4400c0", + "text": "Nowhere is it disputed that some posit the unfiled japan to be less than fretted.", + "html": "\u003cp\u003eNowhere is it disputed that some posit the unfiled japan to be less than fretted.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "8dbc9bec-35c4-4c42-942a-5c97bd4400c0", + "user_id": "spring-voice-7", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.20517Z", + "updated_at": "2020-01-28T22:17:31.20517Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.18983Z", + "updated_at": "2020-01-28T22:17:31.207426Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "members": [] + }, + { + "channel": { + "id": "!members-1xFd7ZzcZ3c3ypDAOvvHynN4Yqy1TV5vw08XTxa4usg", + "type": "messaging", + "cid": "messaging:!members-1xFd7ZzcZ3c3ypDAOvvHynN4Yqy1TV5vw08XTxa4usg", + "last_message_at": "2020-01-28T22:17:31.179366Z", + "created_at": "2020-01-28T22:17:30.951407Z", + "updated_at": "2020-01-28T22:17:30.951407Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Robin Papa", + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg" + }, + "messages": [ + { + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + { + "id": "0fe600b6-9cb7-4d1b-a9ba-eef7dd133b26", + "text": "A plagal ease without britishes is truly a james of premorse entrances.", + "html": "\u003cp\u003eA plagal ease without britishes is truly a james of premorse entrances.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "0fe600b6-9cb7-4d1b-a9ba-eef7dd133b26", + "user_id": "spring-voice-7", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "type": "wow", + "score": 1, + "created_at": "2020-01-28T22:17:31.118131Z", + "updated_at": "2020-01-28T22:17:31.118131Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "wow": 1 + }, + "reaction_scores": { + "wow": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.1022Z", + "updated_at": "2020-01-28T22:17:31.123766Z", + "mentioned_users": [] + }, + { + "id": "4905cb95-c4db-42df-ac6c-5d6531f5f67b", + "text": "A lustred seal is an alto of the mind.", + "html": "\u003cp\u003eA lustred seal is an alto of the mind.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "4905cb95-c4db-42df-ac6c-5d6531f5f67b", + "user_id": "spring-voice-7", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.135966Z", + "updated_at": "2020-01-28T22:17:31.135966Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.103279Z", + "updated_at": "2020-01-28T22:17:31.142756Z", + "mentioned_users": [] + }, + { + "id": "549888c4-0d13-48ca-aaa5-7da8effb9c6f", + "text": "Before aftershaves, snowflakes were only deer.", + "html": "\u003cp\u003eBefore aftershaves, snowflakes were only deer.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.10644Z", + "updated_at": "2020-01-28T22:17:31.10644Z", + "mentioned_users": [] + }, + { + "id": "c74784e7-07ef-4b41-a8e3-b2b0e0b6b7ce", + "text": "https://unsplash.com/photos/JdGtvgzQmgQ", + "html": "\u003cp\u003e\u003ca href=\"https://unsplash.com/photos/JdGtvgzQmgQ\" rel=\"nofollow\"\u003ehttps://unsplash.com/photos/JdGtvgzQmgQ\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "image", + "title": "Photo by Markus Spiske on Unsplash", + "title_link": "https://unsplash.com/photos/JdGtvgzQmgQ", + "text": "THIS IS NOT A BOT – Save Your Internet – Demo against Uploadfilter – Article 13 #CensorshipMachine – March 16. 2019, Nürnberg, Germany. Download this photo by Markus Spiske on Unsplash", + "image_url": "https://images.unsplash.com/photo-1554272014-73b77edeb47f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "thumb_url": "https://images.unsplash.com/photo-1554272014-73b77edeb47f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "og_scrape_url": "https://unsplash.com/photos/JdGtvgzQmgQ" + } + ], + "latest_reactions": [ + { + "message_id": "c74784e7-07ef-4b41-a8e3-b2b0e0b6b7ce", + "user_id": "spring-voice-7", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "name": "Spring voice", + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice" + }, + "type": "sad", + "score": 1, + "created_at": "2020-01-28T22:17:31.131489Z", + "updated_at": "2020-01-28T22:17:31.131489Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "sad": 1 + }, + "reaction_scores": { + "sad": 1 + }, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.11065Z", + "updated_at": "2020-01-28T22:17:31.133783Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "last_read": "2020-01-28T22:17:30.966485504Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "last_read": "2020-01-28T22:17:30.968339456Z" + } + ], + "members": [ + { + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "name": "Robin Papa", + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.95443Z", + "updated_at": "2020-01-28T22:17:30.95443Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:30.95443Z", + "updated_at": "2020-01-28T22:17:30.95443Z" + } + ] + }, + { + "channel": { + "id": "!members-6ngG6o5HlqKK7B_YIWnamzM4O9IqkfuaFOLO0QVPP-0", + "type": "messaging", + "cid": "messaging:!members-6ngG6o5HlqKK7B_YIWnamzM4O9IqkfuaFOLO0QVPP-0", + "last_message_at": "2020-01-28T22:17:31.171308Z", + "created_at": "2020-01-28T22:17:30.891989Z", + "updated_at": "2020-01-28T22:17:30.891989Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 5, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "image": "https://getstream.imgix.net/images/rn-chat-tutorial/caterpillar_01.png", + "name": "Family" + }, + "messages": [ + { + "id": "0bee6d7f-f479-47bb-998c-f449f6f30d74", + "text": "A described discovery's great-grandfather comes with it the thought that the topmost meter is a tsunami.", + "html": "\u003cp\u003eA described discovery’s great-grandfather comes with it the thought that the topmost meter is a tsunami.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "b816b921-ed3a-4d32-8e9e-f6eca413a7b0", + "role": "user", + "created_at": "2020-01-28T22:17:30.8148Z", + "updated_at": "2020-01-28T22:17:31.08123Z", + "banned": false, + "online": false, + "name": "Micheal Murphy", + "image": "https://randomuser.me/api/portraits/men/95.jpg" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.068584Z", + "updated_at": "2020-01-28T22:17:31.068584Z", + "mentioned_users": [] + }, + { + "id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", + "text": "The biggest driver reveals itself as a sclerosed mom to those who look.", + "html": "\u003cp\u003eThe biggest driver reveals itself as a sclerosed mom to those who look.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", + "user_id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }, + "type": "wow", + "score": 1, + "created_at": "2020-01-28T22:17:31.108742Z", + "updated_at": "2020-01-28T22:17:31.108742Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "wow": 1 + }, + "reaction_scores": { + "wow": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.077583Z", + "updated_at": "2020-01-28T22:17:31.111709Z", + "mentioned_users": [] + }, + { + "id": "358b8ab3-dd34-4baf-939e-f03617742486", + "text": "A spongy jail without bengals is truly a hawk of rattish canvases.", + "html": "\u003cp\u003eA spongy jail without bengals is truly a hawk of rattish canvases.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "b816b921-ed3a-4d32-8e9e-f6eca413a7b0", + "role": "user", + "created_at": "2020-01-28T22:17:30.8148Z", + "updated_at": "2020-01-28T22:17:31.08123Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/men/95.jpg", + "name": "Micheal Murphy" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.08593Z", + "updated_at": "2020-01-28T22:17:31.08593Z", + "mentioned_users": [] + }, + { + "id": "5a559749-2f45-476e-9610-8eae92f3a5c6", + "text": "https://unsplash.com/photos/4v7ubW7jz1Q", + "html": "\u003cp\u003e\u003ca href=\"https://unsplash.com/photos/4v7ubW7jz1Q\" rel=\"nofollow\"\u003ehttps://unsplash.com/photos/4v7ubW7jz1Q\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }, + "attachments": [ + { + "type": "image", + "title": "Photo by Joel Filipe on Unsplash", + "title_link": "https://unsplash.com/photos/4v7ubW7jz1Q", + "text": "Download this photo by Joel Filipe on Unsplash", + "image_url": "https://images.unsplash.com/photo-1557389352-e721da78ad9f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "thumb_url": "https://images.unsplash.com/photo-1557389352-e721da78ad9f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "og_scrape_url": "https://unsplash.com/photos/4v7ubW7jz1Q" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.090006Z", + "updated_at": "2020-01-28T22:17:31.090006Z", + "mentioned_users": [] + }, + { + "id": "da57c33b-ae12-438c-a672-4e5bad0d1467", + "text": "https://unsplash.com/photos/XttWKETqCCQ", + "html": "\u003cp\u003e\u003ca href=\"https://unsplash.com/photos/XttWKETqCCQ\" rel=\"nofollow\"\u003ehttps://unsplash.com/photos/XttWKETqCCQ\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "6e60091e-2173-4aa8-a6d2-1a79e4cad790", + "role": "user", + "created_at": "2020-01-28T22:17:30.818618Z", + "updated_at": "2020-01-28T22:17:31.056057Z", + "banned": false, + "online": false, + "image": "https://images.unsplash.com/photo-1502378735452-bc7d86632805?ixlib=rb-0.3.5\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=200\u0026fit=max\u0026s=aa3a807e1bbdfd4364d1f449eaa96d82", + "name": "Carys Metz" + }, + "attachments": [ + { + "type": "image", + "title": "Photo by Olena Sergienko on Unsplash", + "title_link": "https://unsplash.com/photos/XttWKETqCCQ", + "text": "Download this photo by Olena Sergienko on Unsplash", + "image_url": "https://images.unsplash.com/photo-1557053910-d9eadeed1c58?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "thumb_url": "https://images.unsplash.com/photo-1557053910-d9eadeed1c58?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "og_scrape_url": "https://unsplash.com/photos/XttWKETqCCQ" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.171308Z", + "updated_at": "2020-01-28T22:17:31.171308Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "name": "Spring voice", + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice" + }, + "last_read": "2020-01-28T22:17:30.916570624Z" + }, + { + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "name": "Daisy Morgan", + "image": "https://randomuser.me/api/portraits/women/45.jpg" + }, + "last_read": "2020-01-28T22:17:30.90931712Z" + }, + { + "user": { + "id": "b816b921-ed3a-4d32-8e9e-f6eca413a7b0", + "role": "user", + "created_at": "2020-01-28T22:17:30.8148Z", + "updated_at": "2020-01-28T22:17:31.08123Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/men/95.jpg", + "name": "Micheal Murphy" + }, + "last_read": "2020-01-28T22:17:30.911188736Z" + }, + { + "user": { + "id": "6e60091e-2173-4aa8-a6d2-1a79e4cad790", + "role": "user", + "created_at": "2020-01-28T22:17:30.818618Z", + "updated_at": "2020-01-28T22:17:31.056057Z", + "banned": false, + "online": false, + "image": "https://images.unsplash.com/photo-1502378735452-bc7d86632805?ixlib=rb-0.3.5\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=200\u0026fit=max\u0026s=aa3a807e1bbdfd4364d1f449eaa96d82", + "name": "Carys Metz" + }, + "last_read": "2020-01-28T22:17:30.912969472Z" + }, + { + "user": { + "id": "dbcd6837-5d93-4b6e-ab27-ee13a490b873", + "role": "user", + "created_at": "2020-01-28T22:17:30.822448Z", + "updated_at": "2020-01-28T22:17:30.823463Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjAwNjM3NjY5MF5BMl5BanBnXkFtZTcwMjM4NTYwOQ@@._V1_UY256_CR0,0,172,256_AL_.jpg", + "name": "Dakota Fanning" + }, + "last_read": "2020-01-28T22:17:30.914806272Z" + } + ], + "members": [ + { + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.895522Z", + "updated_at": "2020-01-28T22:17:30.895522Z" + }, + { + "user": { + "id": "6e60091e-2173-4aa8-a6d2-1a79e4cad790", + "role": "user", + "created_at": "2020-01-28T22:17:30.818618Z", + "updated_at": "2020-01-28T22:17:31.056057Z", + "banned": false, + "online": false, + "image": "https://images.unsplash.com/photo-1502378735452-bc7d86632805?ixlib=rb-0.3.5\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=200\u0026fit=max\u0026s=aa3a807e1bbdfd4364d1f449eaa96d82", + "name": "Carys Metz" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.895522Z", + "updated_at": "2020-01-28T22:17:30.895522Z" + }, + { + "user": { + "id": "b816b921-ed3a-4d32-8e9e-f6eca413a7b0", + "role": "user", + "created_at": "2020-01-28T22:17:30.8148Z", + "updated_at": "2020-01-28T22:17:31.08123Z", + "banned": false, + "online": false, + "name": "Micheal Murphy", + "image": "https://randomuser.me/api/portraits/men/95.jpg" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.895522Z", + "updated_at": "2020-01-28T22:17:30.895522Z" + }, + { + "user": { + "id": "dbcd6837-5d93-4b6e-ab27-ee13a490b873", + "role": "user", + "created_at": "2020-01-28T22:17:30.822448Z", + "updated_at": "2020-01-28T22:17:30.823463Z", + "banned": false, + "online": false, + "name": "Dakota Fanning", + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjAwNjM3NjY5MF5BMl5BanBnXkFtZTcwMjM4NTYwOQ@@._V1_UY256_CR0,0,172,256_AL_.jpg" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.895523Z", + "updated_at": "2020-01-28T22:17:30.895523Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:30.895523Z", + "updated_at": "2020-01-28T22:17:30.895523Z" + } + ] + }, + { + "channel": { + "id": "still-union-5", + "type": "messaging", + "cid": "messaging:still-union-5", + "last_message_at": "2020-01-24T14:46:19.683016Z", + "created_at": "2020-01-24T14:46:18.808933Z", + "updated_at": "2020-01-24T14:46:18.808933Z", + "created_by": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "frozen": false, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "messages": [ + { + "id": "f77e582d-c4b3-461c-89de-9612a3bee8b1", + "text": "Some posit the phylloid cycle to be less than slimmer.", + "html": "\u003cp\u003eSome posit the phylloid cycle to be less than slimmer.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "f77e582d-c4b3-461c-89de-9612a3bee8b1", + "user_id": "still-union-5", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "type": "angry", + "score": 1, + "created_at": "2020-01-24T14:46:19.14806Z", + "updated_at": "2020-01-24T14:46:19.14806Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "angry": 1 + }, + "reaction_scores": { + "angry": 1 + }, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.127738Z", + "updated_at": "2020-01-24T14:46:19.149795Z", + "mentioned_users": [] + }, + { + "id": "82e2fbe7-4367-49e3-b80f-fc66e31e5afb", + "text": "A smarty panda is a cactus of the mind.", + "html": "\u003cp\u003eA smarty panda is a cactus of the mind.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.165153Z", + "updated_at": "2020-01-24T14:46:19.165154Z", + "mentioned_users": [] + }, + { + "id": "5a5ec35e-c925-4244-9d4c-098b32dd7fce", + "text": "https://unsplash.com/photos/4v7ubW7jz1Q", + "html": "\u003cp\u003e\u003ca href=\"https://unsplash.com/photos/4v7ubW7jz1Q\" rel=\"nofollow\"\u003ehttps://unsplash.com/photos/4v7ubW7jz1Q\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [ + { + "type": "image", + "title": "Photo by Joel Filipe on Unsplash", + "title_link": "https://unsplash.com/photos/4v7ubW7jz1Q", + "text": "Download this photo by Joel Filipe on Unsplash", + "image_url": "https://images.unsplash.com/photo-1557389352-e721da78ad9f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "thumb_url": "https://images.unsplash.com/photo-1557389352-e721da78ad9f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "og_scrape_url": "https://unsplash.com/photos/4v7ubW7jz1Q" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-24T14:46:19.168615Z", + "updated_at": "2020-01-24T14:46:19.168615Z", + "mentioned_users": [] + }, + { + "id": "bf27a1a7-911f-4432-8fe4-1d77a7736ef1", + "text": "A volumed ease's eyelash comes with it the thought that the unwiped energy is a flute.", + "html": "\u003cp\u003eA volumed ease’s eyelash comes with it the thought that the unwiped energy is a flute.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "name": "Still union", + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-24T14:46:19.171548Z", + "updated_at": "2020-01-24T14:46:19.171548Z", + "mentioned_users": [] + }, + { + "id": "9cecc024-a5fc-469f-a3a7-69f8bbeda40b", + "text": "https://www.youtube.com/watch?v=sCtixpIWBto", + "html": "\u003cp\u003e\u003ca href=\"https://www.youtube.com/watch?v=sCtixpIWBto\" rel=\"nofollow\"\u003ehttps://www.youtube.com/watch?v=sCtixpIWBto\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "name": "Still union", + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union" + }, + "attachments": [ + { + "type": "video", + "author_name": "YouTube", + "title": "Rachmaninoff - Prelude in C Sharp Minor (Op. 3 No. 2)", + "title_link": "https://www.youtube.com/watch?v=sCtixpIWBto", + "text": "Rachmaninoff - Prelude in C Sharp Minor (Op. 3 No. 2) Click the 🔔bell to always be notified on new uploads! ♫ Listen on Spotify: http://spoti.fi/2LdpqK7 ♫ MI...", + "image_url": "https://i.ytimg.com/vi/sCtixpIWBto/maxresdefault.jpg", + "thumb_url": "https://i.ytimg.com/vi/sCtixpIWBto/maxresdefault.jpg", + "asset_url": "https://www.youtube.com/embed/sCtixpIWBto", + "og_scrape_url": "https://www.youtube.com/watch?v=sCtixpIWBto" + } + ], + "latest_reactions": [ + { + "message_id": "9cecc024-a5fc-469f-a3a7-69f8bbeda40b", + "user_id": "still-union-5", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "type": "like", + "score": 1, + "created_at": "2020-01-24T14:46:19.703554Z", + "updated_at": "2020-01-24T14:46:19.703554Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "like": 1 + }, + "reaction_scores": { + "like": 1 + }, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.683016Z", + "updated_at": "2020-01-24T14:46:19.705034Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "members": [] + }, + { + "channel": { + "id": "!members-9CAnYw3rnRNrqnSyxvA1rQyL6HLVTt1KUgAz1CeoEvo", + "type": "messaging", + "cid": "messaging:!members-9CAnYw3rnRNrqnSyxvA1rQyL6HLVTt1KUgAz1CeoEvo", + "last_message_at": "2020-01-24T14:46:19.168507Z", + "created_at": "2020-01-24T14:46:18.846673Z", + "updated_at": "2020-01-24T14:46:18.846673Z", + "created_by": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "frozen": false, + "member_count": 5, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "image": "https://getstream.imgix.net/images/rn-chat-tutorial/caterpillar_01.png", + "name": "Family" + }, + "messages": [ + { + "id": "a5ee396a-34bf-42d9-8388-819e84d770e2", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "7cdf7f97-c336-4263-9430-42a1f4d165c8", + "role": "user", + "created_at": "2020-01-24T14:46:18.764869Z", + "updated_at": "2020-01-24T14:46:19.007916Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.014504Z", + "updated_at": "2020-01-24T14:46:19.014505Z", + "mentioned_users": [] + }, + { + "id": "31bdba04-b86f-48d6-91d9-972bb50e3ffe", + "text": "A chiefly care is a pressure of the mind.", + "html": "\u003cp\u003eA chiefly care is a pressure of the mind.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "5a46018e-252b-4598-a7e8-40949c3be4d6", + "role": "user", + "created_at": "2020-01-24T14:46:18.783718Z", + "updated_at": "2020-01-24T14:46:19.096044Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/44.jpg", + "name": "June Cha" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-24T14:46:19.050641Z", + "updated_at": "2020-01-24T14:46:19.050641Z", + "mentioned_users": [] + }, + { + "id": "bc08ad2e-4da0-4d9f-b349-4e9b85d45899", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "694bc286-80d5-4be5-81f8-068028d561e4", + "role": "user", + "created_at": "2020-01-24T14:46:18.760346Z", + "updated_at": "2020-01-24T14:46:19.132067Z", + "banned": false, + "online": false, + "name": "Adelle Charles", + "image": "https://pbs.twimg.com/profile_images/1108790938640531456/Bl2JvdG_.png" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "bc08ad2e-4da0-4d9f-b349-4e9b85d45899", + "user_id": "694bc286-80d5-4be5-81f8-068028d561e4", + "user": { + "id": "694bc286-80d5-4be5-81f8-068028d561e4", + "role": "user", + "created_at": "2020-01-24T14:46:18.760346Z", + "updated_at": "2020-01-24T14:46:19.132067Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/1108790938640531456/Bl2JvdG_.png", + "name": "Adelle Charles" + }, + "type": "angry", + "score": 1, + "created_at": "2020-01-24T14:46:19.08354Z", + "updated_at": "2020-01-24T14:46:19.08354Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "angry": 1 + }, + "reaction_scores": { + "angry": 1 + }, + "reply_count": 1, + "created_at": "2020-01-24T14:46:19.055407Z", + "updated_at": "2020-01-24T14:46:19.08681Z", + "mentioned_users": [] + }, + { + "id": "9b0063ad-cd62-4dc7-ba21-3feee5853122", + "text": "A height of the parallelogram is assumed to be a sunfast cone.", + "html": "\u003cp\u003eA height of the parallelogram is assumed to be a sunfast cone.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "9b0063ad-cd62-4dc7-ba21-3feee5853122", + "user_id": "still-union-5", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-24T14:46:19.094595Z", + "updated_at": "2020-01-24T14:46:19.094595Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.068177Z", + "updated_at": "2020-01-24T14:46:19.096185Z", + "mentioned_users": [] + }, + { + "id": "4f11cd19-66cc-4fc4-b153-9eced2518e21", + "text": "Few can name a topfull mother that isn't a breezeless damage.", + "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "694bc286-80d5-4be5-81f8-068028d561e4", + "role": "user", + "created_at": "2020-01-24T14:46:18.760346Z", + "updated_at": "2020-01-24T14:46:19.132067Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/1108790938640531456/Bl2JvdG_.png", + "name": "Adelle Charles" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "4f11cd19-66cc-4fc4-b153-9eced2518e21", + "user_id": "still-union-5", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "type": "haha", + "score": 1, + "created_at": "2020-01-24T14:46:19.110667Z", + "updated_at": "2020-01-24T14:46:19.110667Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "haha": 1 + }, + "reaction_scores": { + "haha": 1 + }, + "reply_count": 1, + "created_at": "2020-01-24T14:46:19.077713Z", + "updated_at": "2020-01-24T14:46:19.113185Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "0f345a0c-3f35-4e2f-805e-77f53f8332cd", + "role": "user", + "created_at": "2020-01-24T14:46:18.779973Z", + "updated_at": "2020-01-24T14:46:19.157321Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMDc2M2NkMTctNmQ0MS00MjQxLWFkMGItNGY1Y2Y3NzYzZjg1XkEyXkFqcGdeQXVyNjAzMTgxNjY@._V1_UY256_CR74,0,172,256_AL_.jpg", + "name": "Zoe McLellan" + }, + "last_read": "2020-01-24T14:46:18.863312384Z" + }, + { + "user": { + "id": "5a46018e-252b-4598-a7e8-40949c3be4d6", + "role": "user", + "created_at": "2020-01-24T14:46:18.783718Z", + "updated_at": "2020-01-24T14:46:19.096044Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/44.jpg", + "name": "June Cha" + }, + "last_read": "2020-01-24T14:46:18.8644352Z" + }, + { + "user": { + "id": "7cdf7f97-c336-4263-9430-42a1f4d165c8", + "role": "user", + "created_at": "2020-01-24T14:46:18.764869Z", + "updated_at": "2020-01-24T14:46:19.007916Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "last_read": "2020-01-24T14:46:18.866511104Z" + }, + { + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "last_read": "2020-01-24T14:46:18.867566336Z" + }, + { + "user": { + "id": "694bc286-80d5-4be5-81f8-068028d561e4", + "role": "user", + "created_at": "2020-01-24T14:46:18.760346Z", + "updated_at": "2020-01-24T14:46:19.132067Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/1108790938640531456/Bl2JvdG_.png", + "name": "Adelle Charles" + }, + "last_read": "2020-01-24T14:46:18.865476096Z" + } + ], + "members": [ + { + "user": { + "id": "0f345a0c-3f35-4e2f-805e-77f53f8332cd", + "role": "user", + "created_at": "2020-01-24T14:46:18.779973Z", + "updated_at": "2020-01-24T14:46:19.157321Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMDc2M2NkMTctNmQ0MS00MjQxLWFkMGItNGY1Y2Y3NzYzZjg1XkEyXkFqcGdeQXVyNjAzMTgxNjY@._V1_UY256_CR74,0,172,256_AL_.jpg", + "name": "Zoe McLellan" + }, + "role": "member", + "created_at": "2020-01-24T14:46:18.850178Z", + "updated_at": "2020-01-24T14:46:18.850178Z" + }, + { + "user": { + "id": "5a46018e-252b-4598-a7e8-40949c3be4d6", + "role": "user", + "created_at": "2020-01-24T14:46:18.783718Z", + "updated_at": "2020-01-24T14:46:19.096044Z", + "banned": false, + "online": false, + "name": "June Cha", + "image": "https://randomuser.me/api/portraits/women/44.jpg" + }, + "role": "member", + "created_at": "2020-01-24T14:46:18.850179Z", + "updated_at": "2020-01-24T14:46:18.850179Z" + }, + { + "user": { + "id": "694bc286-80d5-4be5-81f8-068028d561e4", + "role": "user", + "created_at": "2020-01-24T14:46:18.760346Z", + "updated_at": "2020-01-24T14:46:19.132067Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/1108790938640531456/Bl2JvdG_.png", + "name": "Adelle Charles" + }, + "role": "member", + "created_at": "2020-01-24T14:46:18.850179Z", + "updated_at": "2020-01-24T14:46:18.850179Z" + }, + { + "user": { + "id": "7cdf7f97-c336-4263-9430-42a1f4d165c8", + "role": "user", + "created_at": "2020-01-24T14:46:18.764869Z", + "updated_at": "2020-01-24T14:46:19.007916Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "role": "member", + "created_at": "2020-01-24T14:46:18.850179Z", + "updated_at": "2020-01-24T14:46:18.850179Z" + }, + { + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "role": "owner", + "created_at": "2020-01-24T14:46:18.85018Z", + "updated_at": "2020-01-24T14:46:18.85018Z" + } + ] + }, + { + "channel": { + "id": "!members-Hc19Pf6huW0QTLRkQscRvline00uxqpj2OAYIcxTPFw", + "type": "messaging", + "cid": "messaging:!members-Hc19Pf6huW0QTLRkQscRvline00uxqpj2OAYIcxTPFw", + "last_message_at": "2020-01-24T14:46:19.16305Z", + "created_at": "2020-01-24T14:46:18.953227Z", + "updated_at": "2020-01-24T14:46:18.953227Z", + "created_by": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "image": "https://randomuser.me/api/portraits/women/8.jpg", + "name": "Jennifer Fritz" + }, + "messages": [ + { + "id": "17b0dc4a-78b3-4e08-860e-11ad2d8ac89d", + "text": "Extending this logic, the witted tree reveals itself as a glummest danger to those who look.", + "html": "\u003cp\u003eExtending this logic, the witted tree reveals itself as a glummest danger to those who look.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "17b0dc4a-78b3-4e08-860e-11ad2d8ac89d", + "user_id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "user": { + "id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "role": "user", + "created_at": "2020-01-24T14:46:18.772459Z", + "updated_at": "2020-01-24T14:46:19.151717Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/8.jpg", + "name": "Jennifer Fritz" + }, + "type": "like", + "score": 1, + "created_at": "2020-01-24T14:46:19.08279Z", + "updated_at": "2020-01-24T14:46:19.08279Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "like": 1 + }, + "reaction_scores": { + "like": 1 + }, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.051688Z", + "updated_at": "2020-01-24T14:46:19.085197Z", + "mentioned_users": [] + }, + { + "id": "ca22e5e3-5f2c-456e-8699-522cba94d177", + "text": "A cricoid melody without replaces is truly a cocktail of unripe badgers.", + "html": "\u003cp\u003eA cricoid melody without replaces is truly a cocktail of unripe badgers.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "role": "user", + "created_at": "2020-01-24T14:46:18.772459Z", + "updated_at": "2020-01-24T14:46:19.151717Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/8.jpg", + "name": "Jennifer Fritz" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "ca22e5e3-5f2c-456e-8699-522cba94d177", + "user_id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "user": { + "id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "role": "user", + "created_at": "2020-01-24T14:46:18.772459Z", + "updated_at": "2020-01-24T14:46:19.151717Z", + "banned": false, + "online": false, + "name": "Jennifer Fritz", + "image": "https://randomuser.me/api/portraits/women/8.jpg" + }, + "type": "like", + "score": 1, + "created_at": "2020-01-24T14:46:19.133825Z", + "updated_at": "2020-01-24T14:46:19.133825Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "like": 1 + }, + "reaction_scores": { + "like": 1 + }, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.10105Z", + "updated_at": "2020-01-24T14:46:19.135877Z", + "mentioned_users": [] + }, + { + "id": "494916be-b66b-48b0-a1c5-bb2cab32eb7a", + "text": "The first towy harmony is, in its own way, a voyage.", + "html": "\u003cp\u003eThe first towy harmony is, in its own way, a voyage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-24T14:46:19.115607Z", + "updated_at": "2020-01-24T14:46:19.115608Z", + "mentioned_users": [] + }, + { + "id": "633c149b-2b1d-461a-8dfe-0f941acc34d4", + "text": "A bicycle can hardly be considered a yearning jar without also being an alley.", + "html": "\u003cp\u003eA bicycle can hardly be considered a yearning jar without also being an alley.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "role": "user", + "created_at": "2020-01-24T14:46:18.772459Z", + "updated_at": "2020-01-24T14:46:19.151717Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/8.jpg", + "name": "Jennifer Fritz" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.135028Z", + "updated_at": "2020-01-24T14:46:19.135028Z", + "mentioned_users": [] + }, + { + "id": "2378fa62-8a5f-4dd3-8ea0-e0276e682c8a", + "text": "https://unsplash.com/photos/4v7ubW7jz1Q", + "html": "\u003cp\u003e\u003ca href=\"https://unsplash.com/photos/4v7ubW7jz1Q\" rel=\"nofollow\"\u003ehttps://unsplash.com/photos/4v7ubW7jz1Q\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "role": "user", + "created_at": "2020-01-24T14:46:18.772459Z", + "updated_at": "2020-01-24T14:46:19.151717Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/8.jpg", + "name": "Jennifer Fritz" + }, + "attachments": [ + { + "type": "image", + "title": "Photo by Joel Filipe on Unsplash", + "title_link": "https://unsplash.com/photos/4v7ubW7jz1Q", + "text": "Download this photo by Joel Filipe on Unsplash", + "image_url": "https://images.unsplash.com/photo-1557389352-e721da78ad9f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "thumb_url": "https://images.unsplash.com/photo-1557389352-e721da78ad9f?ixlib=rb-1.2.1\u0026q=80\u0026fm=jpg\u0026crop=entropy\u0026cs=tinysrgb\u0026w=1080\u0026fit=max\u0026ixid=eyJhcHBfaWQiOjEyMDd9", + "og_scrape_url": "https://unsplash.com/photos/4v7ubW7jz1Q" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.140074Z", + "updated_at": "2020-01-24T14:46:19.140075Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "last_read": "2020-01-24T14:46:18.969139712Z" + }, + { + "user": { + "id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "role": "user", + "created_at": "2020-01-24T14:46:18.772459Z", + "updated_at": "2020-01-24T14:46:19.151717Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/8.jpg", + "name": "Jennifer Fritz" + }, + "last_read": "2020-01-24T14:46:18.96800512Z" + } + ], + "members": [ + { + "user": { + "id": "05ab6a02-9160-47b7-87d9-c6888fa85f83", + "role": "user", + "created_at": "2020-01-24T14:46:18.772459Z", + "updated_at": "2020-01-24T14:46:19.151717Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/8.jpg", + "name": "Jennifer Fritz" + }, + "role": "member", + "created_at": "2020-01-24T14:46:18.956793Z", + "updated_at": "2020-01-24T14:46:18.956793Z" + }, + { + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "role": "owner", + "created_at": "2020-01-24T14:46:18.956793Z", + "updated_at": "2020-01-24T14:46:18.956793Z" + } + ] + }, + { + "channel": { + "id": "!members-og1pgcoaqCSdh6KyIXz4qKB_d22pnWth6Yfbovd8yP0", + "type": "messaging", + "cid": "messaging:!members-og1pgcoaqCSdh6KyIXz4qKB_d22pnWth6Yfbovd8yP0", + "last_message_at": "2020-01-24T14:46:19.124398Z", + "created_at": "2020-01-24T14:46:18.902415Z", + "updated_at": "2020-01-24T14:46:18.902415Z", + "created_by": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Ana De Armas", + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg" + }, + "messages": [ + { + "id": "1aec347f-c1c4-4d83-ab42-76a12d3d3da2", + "text": "Before aftershaves, snowflakes were only deer.", + "html": "\u003cp\u003eBefore aftershaves, snowflakes were only deer.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.057578Z", + "updated_at": "2020-01-24T14:46:19.057578Z", + "mentioned_users": [] + }, + { + "id": "1ec1473d-405d-4a3c-b771-ab09fcbe071f", + "text": "https://giphy.com/gifs/movie-trailer-gemini-man-QsU9X0AxxfSAwsaz7n", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/movie-trailer-gemini-man-QsU9X0AxxfSAwsaz7n\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/movie-trailer-gemini-man-QsU9X0AxxfSAwsaz7n\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "Gemini Man Trailer GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/QsU9X0AxxfSAwsaz7n/giphy.gif", + "text": "Discover \u0026 share this Gemini Man GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/QsU9X0AxxfSAwsaz7n/giphy.gif", + "thumb_url": "https://media.giphy.com/media/QsU9X0AxxfSAwsaz7n/giphy.gif", + "asset_url": "https://media.giphy.com/media/QsU9X0AxxfSAwsaz7n/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/movie-trailer-gemini-man-QsU9X0AxxfSAwsaz7n" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.076425Z", + "updated_at": "2020-01-24T14:46:19.076425Z", + "mentioned_users": [] + }, + { + "id": "3c6b5f22-da06-470e-bb7f-499fa7466de0", + "text": "Their chord was, in this moment, a slipshod parenthesis.", + "html": "\u003cp\u003eTheir chord was, in this moment, a slipshod parenthesis.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "db375ac9-6a3f-4d9c-a5ea-4cd6c169fd09", + "role": "user", + "created_at": "2020-01-24T14:46:18.768629Z", + "updated_at": "2020-01-24T14:46:19.076467Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg", + "name": "Ana De Armas" + }, + "attachments": [], + "latest_reactions": [ + { + "message_id": "3c6b5f22-da06-470e-bb7f-499fa7466de0", + "user_id": "db375ac9-6a3f-4d9c-a5ea-4cd6c169fd09", + "user": { + "id": "db375ac9-6a3f-4d9c-a5ea-4cd6c169fd09", + "role": "user", + "created_at": "2020-01-24T14:46:18.768629Z", + "updated_at": "2020-01-24T14:46:19.076467Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg", + "name": "Ana De Armas" + }, + "type": "like", + "score": 1, + "created_at": "2020-01-24T14:46:19.118307Z", + "updated_at": "2020-01-24T14:46:19.118307Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "like": 1 + }, + "reaction_scores": { + "like": 1 + }, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.078556Z", + "updated_at": "2020-01-24T14:46:19.120491Z", + "mentioned_users": [] + }, + { + "id": "86eb957d-54fd-4ed9-8e80-1bb3212e57ea", + "text": "However, the porcine puffin reveals itself as an uncaught mirror to those who look.", + "html": "\u003cp\u003eHowever, the porcine puffin reveals itself as an uncaught mirror to those who look.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-24T14:46:19.078749Z", + "updated_at": "2020-01-24T14:46:19.07875Z", + "mentioned_users": [] + }, + { + "id": "179eb482-b005-4b34-965c-22b1dbb60a8e", + "text": "A parklike screw without ants is truly a fly of hooly rings.", + "html": "\u003cp\u003eA parklike screw without ants is truly a fly of hooly rings.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "db375ac9-6a3f-4d9c-a5ea-4cd6c169fd09", + "role": "user", + "created_at": "2020-01-24T14:46:18.768629Z", + "updated_at": "2020-01-24T14:46:19.076467Z", + "banned": false, + "online": false, + "name": "Ana De Armas", + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-24T14:46:19.080192Z", + "updated_at": "2020-01-24T14:46:19.080192Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "last_read": "2020-01-24T14:46:18.92038528Z" + }, + { + "user": { + "id": "db375ac9-6a3f-4d9c-a5ea-4cd6c169fd09", + "role": "user", + "created_at": "2020-01-24T14:46:18.768629Z", + "updated_at": "2020-01-24T14:46:19.076467Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg", + "name": "Ana De Armas" + }, + "last_read": "2020-01-24T14:46:18.919357952Z" + } + ], + "members": [ + { + "user": { + "id": "db375ac9-6a3f-4d9c-a5ea-4cd6c169fd09", + "role": "user", + "created_at": "2020-01-24T14:46:18.768629Z", + "updated_at": "2020-01-24T14:46:19.076467Z", + "banned": false, + "online": false, + "image": "https://images-na.ssl-images-amazon.com/images/M/MV5BMjA3NjYzMzE1MV5BMl5BanBnXkFtZTgwNTA4NDY4OTE@._V1_UX172_CR0,0,172,256_AL_.jpg", + "name": "Ana De Armas" + }, + "role": "member", + "created_at": "2020-01-24T14:46:18.905968Z", + "updated_at": "2020-01-24T14:46:18.905968Z" + }, + { + "user": { + "id": "still-union-5", + "role": "user", + "created_at": "2020-01-24T14:46:18.776236Z", + "updated_at": "2020-01-24T14:46:32.065913Z", + "last_active": "2020-01-24T14:46:32.05853Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=still-union-5\u0026amp;name=Still+union", + "name": "Still union" + }, + "role": "owner", + "created_at": "2020-01-24T14:46:18.905968Z", + "updated_at": "2020-01-24T14:46:18.905968Z" + } + ] + }, + { + "channel": { + "id": "holy-sun-6", + "type": "messaging", + "cid": "messaging:holy-sun-6", + "last_message_at": "2020-01-23T00:52:40.316069Z", + "created_at": "2020-01-23T00:52:39.457615Z", + "updated_at": "2020-01-23T00:52:39.457615Z", + "created_by": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "name": "Holy sun", + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun" + }, + "frozen": false, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + } + }, + "messages": [ + { + "id": "d0843e2b-2dd8-404e-a4a4-3730287d144b", + "text": "A robert of the august is assumed to be an accurst fortnight.", + "html": "\u003cp\u003eA robert of the august is assumed to be an accurst fortnight.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-23T00:52:39.828172Z", + "updated_at": "2020-01-23T00:52:39.828172Z", + "mentioned_users": [] + }, + { + "id": "af6c4391-be20-4b25-b824-b4751507cd33", + "text": "Authors often misinterpret the railway as a louvred range, when in actuality it feels more like a pitted fridge.", + "html": "\u003cp\u003eAuthors often misinterpret the railway as a louvred range, when in actuality it feels more like a pitted fridge.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-23T00:52:39.850576Z", + "updated_at": "2020-01-23T00:52:39.850577Z", + "mentioned_users": [] + }, + { + "id": "960d62ad-00c6-4237-9b3c-cd440408ea18", + "text": "A dugout can hardly be considered a soundless acknowledgment without also being a raft.", + "html": "\u003cp\u003eA dugout can hardly be considered a soundless acknowledgment without also being a raft.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-23T00:52:39.873187Z", + "updated_at": "2020-01-23T00:52:39.873187Z", + "mentioned_users": [] + }, + { + "id": "409074d7-c830-4c16-9205-393de08898d3", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-23T00:52:40.191037Z", + "updated_at": "2020-01-23T00:52:40.191037Z", + "mentioned_users": [] + }, + { + "id": "2f1b8944-014a-4753-886f-1042163ada46", + "text": "https://www.youtube.com/watch?v=sCtixpIWBto", + "html": "\u003cp\u003e\u003ca href=\"https://www.youtube.com/watch?v=sCtixpIWBto\" rel=\"nofollow\"\u003ehttps://www.youtube.com/watch?v=sCtixpIWBto\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [ + { + "type": "video", + "author_name": "YouTube", + "title": "Rachmaninoff - Prelude in C Sharp Minor (Op. 3 No. 2)", + "title_link": "https://www.youtube.com/watch?v=sCtixpIWBto", + "text": "Rachmaninoff - Prelude in C Sharp Minor (Op. 3 No. 2) Click the 🔔bell to always be notified on new uploads! ♫ Listen on Spotify: http://spoti.fi/2LdpqK7 ♫ MI...", + "image_url": "https://i.ytimg.com/vi/sCtixpIWBto/maxresdefault.jpg", + "thumb_url": "https://i.ytimg.com/vi/sCtixpIWBto/maxresdefault.jpg", + "asset_url": "https://www.youtube.com/embed/sCtixpIWBto", + "og_scrape_url": "https://www.youtube.com/watch?v=sCtixpIWBto" + } + ], + "latest_reactions": [ + { + "message_id": "2f1b8944-014a-4753-886f-1042163ada46", + "user_id": "holy-sun-6", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "type": "haha", + "score": 1, + "created_at": "2020-01-23T00:52:40.336054Z", + "updated_at": "2020-01-23T00:52:40.336054Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "haha": 1 + }, + "reaction_scores": { + "haha": 1 + }, + "reply_count": 0, + "created_at": "2020-01-23T00:52:40.316069Z", + "updated_at": "2020-01-23T00:52:40.337791Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "members": [] + }, + { + "channel": { + "id": "!members-aOjLAUKF7f4tB6iysBURUm7soQp6BBPpr0chg1KjGAA", + "type": "messaging", + "cid": "messaging:!members-aOjLAUKF7f4tB6iysBURUm7soQp6BBPpr0chg1KjGAA", + "last_message_at": "2020-01-23T00:52:40.193868Z", + "created_at": "2020-01-23T00:52:39.556322Z", + "updated_at": "2020-01-23T00:52:39.556322Z", + "created_by": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "image": "https://randomuser.me/api/portraits/women/65.jpg", + "name": "Renee Sims" + }, + "messages": [ + { + "id": "0fd2c220-4871-49d6-873d-84e7e12782e9", + "text": "A smarty panda is a cactus of the mind.", + "html": "\u003cp\u003eA smarty panda is a cactus of the mind.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "636fdc1b-8b86-47b0-8ea1-8bb7f7c38910", + "role": "user", + "created_at": "2020-01-23T00:52:39.43164Z", + "updated_at": "2020-01-23T00:52:39.798407Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/65.jpg", + "name": "Renee Sims" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-23T00:52:39.763764Z", + "updated_at": "2020-01-23T00:52:39.763764Z", + "mentioned_users": [] + }, + { + "id": "85a2e430-f9af-4e60-a43e-c87e48102aa7", + "text": "We can assume that any instance of a half-brother can be construed as an allowed half-brother.", + "html": "\u003cp\u003eWe can assume that any instance of a half-brother can be construed as an allowed half-brother.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-23T00:52:39.764235Z", + "updated_at": "2020-01-23T00:52:39.764236Z", + "mentioned_users": [] + }, + { + "id": "8bc5aeb6-6b39-488f-95ac-8c4bbb704b0e", + "text": "https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr", + "html": "\u003cp\u003e\u003ca href=\"https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr\" rel=\"nofollow\"\u003ehttps://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [ + { + "type": "image", + "author_name": "Science News", + "title": "Peacock spiders’ superblack spots reflect just 0.5 percent of light", + "title_link": "https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light", + "text": "By manipulating light with tiny structures, patches on peacock spiders appear superblack, helping accentuate the arachnids’ bright colors.", + "image_url": "https://www.sciencenews.org/wp-content/uploads/2019/05/051419_cw_spider_feat.jpg", + "thumb_url": "https://www.sciencenews.org/wp-content/uploads/2019/05/051419_cw_spider_feat.jpg", + "og_scrape_url": "https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr" + } + ], + "latest_reactions": [ + { + "message_id": "8bc5aeb6-6b39-488f-95ac-8c4bbb704b0e", + "user_id": "636fdc1b-8b86-47b0-8ea1-8bb7f7c38910", + "user": { + "id": "636fdc1b-8b86-47b0-8ea1-8bb7f7c38910", + "role": "user", + "created_at": "2020-01-23T00:52:39.43164Z", + "updated_at": "2020-01-23T00:52:39.798407Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/65.jpg", + "name": "Renee Sims" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-23T00:52:40.173357Z", + "updated_at": "2020-01-23T00:52:40.173357Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-23T00:52:40.151326Z", + "updated_at": "2020-01-23T00:52:40.175705Z", + "mentioned_users": [] + }, + { + "id": "4dc8a789-3930-44b7-9707-7302209189fb", + "text": "https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr", + "html": "\u003cp\u003e\u003ca href=\"https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr\" rel=\"nofollow\"\u003ehttps://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "636fdc1b-8b86-47b0-8ea1-8bb7f7c38910", + "role": "user", + "created_at": "2020-01-23T00:52:39.43164Z", + "updated_at": "2020-01-23T00:52:39.798407Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/65.jpg", + "name": "Renee Sims" + }, + "attachments": [ + { + "type": "image", + "author_name": "Science News", + "title": "Peacock spiders’ superblack spots reflect just 0.5 percent of light", + "title_link": "https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light", + "text": "By manipulating light with tiny structures, patches on peacock spiders appear superblack, helping accentuate the arachnids’ bright colors.", + "image_url": "https://www.sciencenews.org/wp-content/uploads/2019/05/051419_cw_spider_feat.jpg", + "thumb_url": "https://www.sciencenews.org/wp-content/uploads/2019/05/051419_cw_spider_feat.jpg", + "og_scrape_url": "https://www.sciencenews.org/article/peacock-spiders-superblack-spots-reflect-just-05-percent-light?tgt=nr" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-23T00:52:40.151921Z", + "updated_at": "2020-01-23T00:52:40.151921Z", + "mentioned_users": [] + }, + { + "id": "793722c6-5bbc-408c-8ba9-e1728e09d7c3", + "text": "https://www.youtube.com/watch?v=cJCtiJydw9U", + "html": "\u003cp\u003e\u003ca href=\"https://www.youtube.com/watch?v=cJCtiJydw9U\" rel=\"nofollow\"\u003ehttps://www.youtube.com/watch?v=cJCtiJydw9U\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "attachments": [ + { + "type": "video", + "author_name": "YouTube", + "title": "まるです11。-I am Maru 11.-", + "title_link": "https://www.youtube.com/watch?v=cJCtiJydw9U", + "text": "Happy 11th birthday!! BGM channel by h/mix -秋山裕和 公式チャンネル- https://www.youtube.com/channel/UCNPMwbX6-SclEmvFX5_ihCw Blog: http://sisinmaru.com/ Instagram: htt...", + "image_url": "https://i.ytimg.com/vi/cJCtiJydw9U/maxresdefault.jpg", + "thumb_url": "https://i.ytimg.com/vi/cJCtiJydw9U/maxresdefault.jpg", + "asset_url": "https://www.youtube.com/embed/cJCtiJydw9U", + "og_scrape_url": "https://www.youtube.com/watch?v=cJCtiJydw9U" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-23T00:52:40.193868Z", + "updated_at": "2020-01-23T00:52:40.193868Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "636fdc1b-8b86-47b0-8ea1-8bb7f7c38910", + "role": "user", + "created_at": "2020-01-23T00:52:39.43164Z", + "updated_at": "2020-01-23T00:52:39.798407Z", + "banned": false, + "online": false, + "name": "Renee Sims", + "image": "https://randomuser.me/api/portraits/women/65.jpg" + }, + "last_read": "2020-01-23T00:52:39.570845184Z" + }, + { + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "last_read": "2020-01-23T00:52:39.572206592Z" + } + ], + "members": [ + { + "user": { + "id": "636fdc1b-8b86-47b0-8ea1-8bb7f7c38910", + "role": "user", + "created_at": "2020-01-23T00:52:39.43164Z", + "updated_at": "2020-01-23T00:52:39.798407Z", + "banned": false, + "online": false, + "name": "Renee Sims", + "image": "https://randomuser.me/api/portraits/women/65.jpg" + }, + "role": "member", + "created_at": "2020-01-23T00:52:39.559506Z", + "updated_at": "2020-01-23T00:52:39.559506Z" + }, + { + "user": { + "id": "holy-sun-6", + "role": "user", + "created_at": "2020-01-23T00:52:39.412193Z", + "updated_at": "2020-01-23T00:52:40.211607Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=holy-sun-6\u0026name=Holy+sun", + "name": "Holy sun" + }, + "role": "owner", + "created_at": "2020-01-23T00:52:39.559506Z", + "updated_at": "2020-01-23T00:52:39.559506Z" + } + ] + } + ], + "duration": "30.85ms" +}'''; + final response = QueryChannelsResponse.fromJson(json.decode(jsonExample)); + expect(response.channels, isA>()); + }); + + test('ChannelStateResponse', () { + const jsonExample = r''' + { + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "messages": [ + { + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] + }, + { + "id": "16e19c46-fb96-4f89-8031-d5bd2ce3bd64", + "text": "Few can name a topfull mother that isn't a breezeless damage.", + "html": "\u003cp\u003eFew can name a topfull mother that isn’t a breezeless damage.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.153518Z", + "updated_at": "2020-01-28T22:17:31.153518Z", + "mentioned_users": [] + }, + { + "id": "38b1b252-c9a6-4aea-a39e-8de3b7f7604b", + "text": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "Moustache Thumbs Up GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "text": "Discover \u0026 share this Pouce Leve GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "thumb_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.gif", + "asset_url": "https://media.giphy.com/media/73Sjhw0N4hyog/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/beard-muscle-73Sjhw0N4hyog" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.155428Z", + "updated_at": "2020-01-28T22:17:31.155428Z", + "mentioned_users": [] + }, + { + "id": "5c7d0c68-72d5-4027-b6b7-711b342d0fc4", + "text": "The carbons could be said to resemble smartish hoods.", + "html": "\u003cp\u003eThe carbons could be said to resemble smartish hoods.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.157811Z", + "updated_at": "2020-01-28T22:17:31.157811Z", + "mentioned_users": [] + }, + { + "id": "5b02535a-0c3c-45fa-9cf8-16f840c5123f", + "text": "Their software was, in this moment, a prolix feature.", + "html": "\u003cp\u003eTheir software was, in this moment, a prolix feature.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.158391Z", + "updated_at": "2020-01-28T22:17:31.158391Z", + "mentioned_users": [] + } + ], + "watcher_count": 1, + "read": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "last_read": "2020-01-28T22:17:31.016937728Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "last_read": "2020-01-28T22:17:31.018856448Z" + } + ], + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ] + } + '''; + final response = ChannelStateResponse.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + expect(response.watcherCount, isA()); + expect(response.members, isA>()); + expect(response.messages, isA>()); + expect(response.read, isA>()); + }); + + test('QueryUsersResponse', () { + const jsonExample = r''' + {"users":[{"id":"wild-breeze-7","role":"user","created_at":"2020-01-12T12:03:19.102029Z","updated_at":"2020-02-03T08:58:33.971562Z","last_active":"2020-02-03T08:58:33.965072Z","banned":false,"online":true,"name":"Wild breeze","image":"https://getstream.io/random_svg/?id=wild-breeze-7\u0026amp;name=Wild+breeze"}],"duration":"2.44ms"} + '''; + final response = QueryUsersResponse.fromJson(json.decode(jsonExample)); + expect(response.users, isA>()); + }); + + test('QueryReactionsResponse', () { + const jsonExample = r''' + {"reactions": [{"message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f","user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680","user": {"id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680","role": "user","created_at": "2020-01-28T22:17:30.83015Z","updated_at": "2020-01-28T22:17:31.19435Z","banned": false,"online": false,"image": "https://randomuser.me/api/portraits/women/2.jpg","name": "Mia Denys"},"type": "love","score": 1,"created_at": "2020-01-28T22:17:31.128376Z","updated_at": "2020-01-28T22:17:31.128376Z"}]} + '''; + final response = + QueryReactionsResponse.fromJson(json.decode(jsonExample)); + expect(response.reactions, isA>()); + }); + + test('QueryRepliesResponse', () { + const jsonExample = r''' + { "messages": [ + { + "id": "9db3ef01-e779-4279-8c54-ffd021eccec4", + "text": "A lustred seal is an alto of the mind.", + "html": "\u003cp\u003eA lustred seal is an alto of the mind.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.167579Z", + "updated_at": "2020-01-28T22:17:31.167579Z", + "mentioned_users": [] + }, + { + "id": "3232e92f-a96f-4b5e-bacb-3565e7155dc4", + "text": "https://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS", + "html": "\u003cp\u003e\u003ca href=\"https://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS\" rel=\"nofollow\"\u003ehttps://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS\u003c/a\u003e\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Punisher Marvel GIF by NETFLIX - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.gif", + "text": "See What's Next in entertainment and Netflix original series, movies, TV, docs, and comedies. You can stream Netflix anytime, anywhere, on any device.", + "image_url": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.gif", + "thumb_url": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.gif", + "asset_url": "https://media.giphy.com/media/l3mZsRS7ZfftbdLdS/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/netflix-marvel-the-punisher-l3mZsRS7ZfftbdLdS" + } + ], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.168454Z", + "updated_at": "2020-01-28T22:17:31.168454Z", + "mentioned_users": [] + } + ]} + '''; + final response = QueryRepliesResponse.fromJson(json.decode(jsonExample)); + expect(response.messages, isA>()); + }); + + test('SearchMessagesResponse', () { + const jsonExample = r''' + { "results": [ + { + "message": { + "id": "9db3ef01-e779-4279-8c54-ffd021eccec4", + "text": "A lustred seal is an alto of the mind.", + "html": "\u003cp\u003eA lustred seal is an alto of the mind.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.167579Z", + "updated_at": "2020-01-28T22:17:31.167579Z", + "mentioned_users": [] + } + }] + } + '''; + final response = + SearchMessagesResponse.fromJson(json.decode(jsonExample)); + expect(response.results, isA>()); + }); + + test('ListDevicesResponse', () { + const jsonExample = + r'''{"devices":[{"push_provider":"firebase","id":"test"}],"duration":"0.35ms"}'''; + final response = ListDevicesResponse.fromJson(json.decode(jsonExample)); + expect(response.devices, isA>()); + }); + + test('SendFileResponse', () { + const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + final response = SendFileResponse.fromJson(json.decode(jsonExample)); + expect(response.file, isA()); + }); + + test('SendImageResponse', () { + const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + final response = SendImageResponse.fromJson(json.decode(jsonExample)); + expect(response.file, isA()); + }); + + test('SendImageResponse', () { + const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + final response = SendImageResponse.fromJson(json.decode(jsonExample)); + expect(response.file, isA()); + }); + + test('EmptyResponse', () { + const jsonExample = r'''{"file": "file-url","duration":"0.35ms"}'''; + final response = EmptyResponse.fromJson(json.decode(jsonExample)); + expect(response.duration, isA()); + }); + + test('SendReactionResponse', () { + const jsonExample = r'''{"message": { + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + "reaction":{ + "message_id": "c74784e7-07ef-4b41-a8e3-b2b0e0b6b7ce", + "user_id": "spring-voice-7", + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "name": "Spring voice", + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice" + }, + "type": "sad", + "score": 1, + "created_at": "2020-01-28T22:17:31.131489Z", + "updated_at": "2020-01-28T22:17:31.131489Z" + },"duration":"0.35ms"}'''; + final response = SendReactionResponse.fromJson(json.decode(jsonExample)); + expect(response.message, isA()); + expect(response.reaction, isA()); + }); + + test('UpdateUsersResponse', () { + const jsonExample = + r'''{"users": {"bbb19d9a-ee50-45bc-84e5-0584e79d0c9e":{ + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }},"duration":"0.35ms"}'''; + final response = UpdateUsersResponse.fromJson(json.decode(jsonExample)); + expect(response.users, isA>()); + }); + + test('SetGuestUserResponse', () { + const jsonExample = + r'{"user":{"id":"guest-ac612aee-25fe-49fb-b1af-969e41f452a0-wild-breeze-7","role":"guest","created_at":"2020-02-03T10:19:01.538434Z","updated_at":"2020-02-03T10:19:01.539543Z","banned":false,"online":false},"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ3Vlc3QtYWM2MTJhZWUtMjVmZS00OWZiLWIxYWYtOTY5ZTQxZjQ1MmEwLXdpbGQtYnJlZXplLTcifQ.mmoFGu7oJjpFsp7nFN78UbIpO7gowbuIbyoppsuvbXA","duration":"4.66ms"}'; + final response = SetGuestUserResponse.fromJson(json.decode(jsonExample)); + expect(response.user, isA()); + expect(response.accessToken, isA()); + }); + + test('GetMessagesByIdResponse', () { + const jsonExample = r'''{"messages":[{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }],"duration":"4.66ms"}'''; + final response = + GetMessagesByIdResponse.fromJson(json.decode(jsonExample)); + expect(response.messages, isA>()); + }); + + test('SendActionResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + },"duration":"4.66ms"}'''; + final response = SendActionResponse.fromJson(json.decode(jsonExample)); + expect(response.message, isA()); + }); + + test('UpdateMessageResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + },"duration":"4.66ms"}'''; + final response = UpdateMessageResponse.fromJson(json.decode(jsonExample)); + expect(response.message, isA()); + }); + + test('SendMessageResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + },"duration":"4.66ms"}'''; + final response = SendMessageResponse.fromJson(json.decode(jsonExample)); + expect(response.message, isA()); + }); + + test('GetMessageResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + },"duration":"4.66ms"}'''; + final response = GetMessageResponse.fromJson(json.decode(jsonExample)); + expect(response.message, isA()); + }); + + test('UpdateChannelResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ], + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "duration":"4.66ms"}'''; + final response = UpdateChannelResponse.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + expect(response.members, isA>()); + expect(response.message, isA()); + }); + + test('InviteMembersResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ], + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "duration":"4.66ms"}'''; + final response = InviteMembersResponse.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + expect(response.members, isA>()); + expect(response.message, isA()); + }); + + test('RemoveMembersResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ], + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "duration":"4.66ms"}'''; + final response = RemoveMembersResponse.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + expect(response.members, isA>()); + expect(response.message, isA()); + }); + + test('AddMembersResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ], + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "duration":"4.66ms"}'''; + final response = AddMembersResponse.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + expect(response.members, isA>()); + expect(response.message, isA()); + }); + + test('AcceptInviteResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ], + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "duration":"4.66ms"}'''; + final response = AcceptInviteResponse.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + expect(response.members, isA>()); + expect(response.message, isA()); + }); + + test('RejectInviteResponse', () { + const jsonExample = r'''{"message":{ + "id": "c6076f11-7768-4a04-bdf2-c43dddd6d666", + "text": "What we don't know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.", + "html": "\u003cp\u003eWhat we don’t know for sure is whether or not a step-daughter of the bear is assumed to be a farci hourglass.\u003c/p\u003e\n", + "type": "regular", + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg", + "name": "Robin Papa" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 1, + "created_at": "2020-01-28T22:17:31.092262Z", + "updated_at": "2020-01-28T22:17:31.092262Z", + "mentioned_users": [] + }, + "members": [ + { + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "role": "member", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + }, + { + "user": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "role": "owner", + "created_at": "2020-01-28T22:17:31.005135Z", + "updated_at": "2020-01-28T22:17:31.005135Z" + } + ], + "channel": { + "id": "!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "type": "messaging", + "cid": "messaging:!members-0LOcD0mZtTan60zHobLmELjdndXsonnBVNzZnB5mTt0", + "last_message_at": "2020-01-28T22:17:31.204287Z", + "created_at": "2020-01-28T22:17:31.00187Z", + "updated_at": "2020-01-28T22:17:31.00187Z", + "created_by": { + "id": "spring-voice-7", + "role": "user", + "created_at": "2020-01-28T22:17:30.834135Z", + "updated_at": "2020-01-28T22:17:31.186771Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?id=spring-voice-7\u0026name=Spring+voice", + "name": "Spring voice" + }, + "frozen": false, + "member_count": 2, + "config": { + "created_at": "2020-01-29T12:59:14.291912835Z", + "updated_at": "2020-01-29T12:59:14.291912991Z", + "name": "messaging", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "Mia Denys", + "image": "https://randomuser.me/api/portraits/women/2.jpg" + }, + "duration":"4.66ms"}'''; + final response = RejectInviteResponse.fromJson(json.decode(jsonExample)); + expect(response.channel, isA()); + expect(response.members, isA>()); + expect(response.message, isA()); + }); + }); +} diff --git a/packages/stream_chat/test/src/api/web_socket_stub_test.dart b/packages/stream_chat/test/src/api/web_socket_stub_test.dart new file mode 100644 index 00000000..e60706dc --- /dev/null +++ b/packages/stream_chat/test/src/api/web_socket_stub_test.dart @@ -0,0 +1,9 @@ +import 'package:test/test.dart'; +import 'package:stream_chat/src/api/web_socket_channel_stub.dart'; + +void main() { + test('src/api/web_socket_stub_test', () { + expect( + () => connectWebSocket('fakeurl'), throwsA(isA())); + }); +} diff --git a/packages/stream_chat/test/src/api/websocket_test.dart b/packages/stream_chat/test/src/api/websocket_test.dart new file mode 100644 index 00000000..d9486477 --- /dev/null +++ b/packages/stream_chat/test/src/api/websocket_test.dart @@ -0,0 +1,353 @@ +import 'dart:async'; + +import 'package:test/test.dart'; +import 'package:logging/logging.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat/src/api/connection_status.dart'; +import 'package:stream_chat/src/api/websocket.dart'; +import 'package:stream_chat/src/models/event.dart'; +import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class Functions { + WebSocketChannel connectFunc( + String url, { + Iterable protocols, + Map headers, + Duration pingInterval, + }) => + null; + + void handleFunc(Event event) => null; +} + +class MockFunctions extends Mock implements Functions {} + +class MockWSChannel extends Mock implements WebSocketChannel {} + +class MockWSSink extends Mock implements WebSocketSink {} + +void main() { + group('src/api/websocket', () { + test('should connect with correct parameters', () async { + final ConnectWebSocket connectFunc = MockFunctions().connectFunc; + + final ws = WebSocket( + baseUrl: 'baseurl', + user: User(id: 'testid'), + logger: Logger('ws'), + connectParams: {'test': 'true'}, + connectPayload: {'payload': 'test'}, + handler: (e) { + print(e); + }, + connectFunc: connectFunc, + ); + + final mockWSChannel = MockWSChannel(); + + final StreamController streamController = + StreamController.broadcast(); + + final computedUrl = + 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; + + when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); + when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); + when(mockWSChannel.stream).thenAnswer((_) { + return streamController.stream; + }); + + final timer = Timer.periodic( + Duration(milliseconds: 100), + (_) => streamController.sink.add('{}'), + ); + + await ws.connect(); + + verify(connectFunc(computedUrl)).called(1); + expect(ws.connectionStatus, ConnectionStatus.connected); + + await streamController.close(); + timer.cancel(); + }); + }); + + test('should connect with correct parameters and handle events', () async { + final handleFunc = MockFunctions().handleFunc; + final ConnectWebSocket connectFunc = MockFunctions().connectFunc; + + final ws = WebSocket( + baseUrl: 'baseurl', + user: User(id: 'testid'), + logger: Logger('ws'), + connectParams: {'test': 'true'}, + connectPayload: {'payload': 'test'}, + handler: handleFunc, + connectFunc: connectFunc, + ); + + final mockWSChannel = MockWSChannel(); + + final StreamController streamController = + StreamController.broadcast(); + + final computedUrl = + 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; + + when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); + when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); + when(mockWSChannel.stream).thenAnswer((_) { + return streamController.stream; + }); + + final connect = ws.connect().then((_) { + streamController.sink.add('{}'); + return Future.delayed(Duration(milliseconds: 200)); + }).then((value) { + verify(connectFunc(computedUrl)).called(1); + verify(handleFunc(any)).called(greaterThan(0)); + + return streamController.close(); + }); + + streamController.sink.add('{}'); + + return connect; + }); + + test('should close correctly the controller', () async { + final handleFunc = MockFunctions().handleFunc; + + final ConnectWebSocket connectFunc = MockFunctions().connectFunc; + + final ws = WebSocket( + baseUrl: 'baseurl', + user: User(id: 'testid'), + logger: Logger('ws'), + connectParams: {'test': 'true'}, + connectPayload: {'payload': 'test'}, + handler: handleFunc, + connectFunc: connectFunc, + ); + + final mockWSChannel = MockWSChannel(); + + final StreamController streamController = + StreamController.broadcast(); + + final computedUrl = + 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; + + when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); + when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); + when(mockWSChannel.stream).thenAnswer((_) { + return streamController.stream; + }); + + final connect = ws.connect().then((_) { + streamController.sink.add('{}'); + return Future.delayed(Duration(milliseconds: 200)); + }).then((value) { + verify(connectFunc(computedUrl)).called(1); + verify(handleFunc(any)).called(greaterThan(0)); + + return streamController.close(); + }); + + streamController.sink.add('{}'); + + return connect; + }); + + test('should run correctly health check', () async { + final handleFunc = MockFunctions().handleFunc; + + final ConnectWebSocket connectFunc = MockFunctions().connectFunc; + + final ws = WebSocket( + baseUrl: 'baseurl', + user: User(id: 'testid'), + logger: Logger('ws'), + connectParams: {'test': 'true'}, + connectPayload: {'payload': 'test'}, + handler: handleFunc, + connectFunc: connectFunc, + ); + + final mockWSChannel = MockWSChannel(); + final mockWSSink = MockWSSink(); + + final StreamController streamController = + StreamController.broadcast(); + + final computedUrl = + 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; + + when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); + when(mockWSChannel.stream).thenAnswer((_) { + return streamController.stream; + }); + when(mockWSChannel.sink).thenReturn(mockWSSink); + + final timer = Timer.periodic( + Duration(milliseconds: 1000), + (_) => streamController.sink.add('{}'), + ); + + final connect = ws.connect().then((_) { + streamController.sink.add('{}'); + return Future.delayed(Duration(milliseconds: 200)); + }).then((value) async { + verify(mockWSSink.add("{'type': 'health.check'}")).called(greaterThan(0)); + + timer.cancel(); + await streamController.close(); + return mockWSSink.close(); + }); + + streamController.sink.add('{}'); + + return connect; + }); + + test('should run correctly reconnection check', () async { + final handleFunc = MockFunctions().handleFunc; + + final ConnectWebSocket connectFunc = MockFunctions().connectFunc; + + Logger.root.level = Level.ALL; + final ws = WebSocket( + baseUrl: 'baseurl', + user: User(id: 'testid'), + logger: Logger('ws'), + connectParams: {'test': 'true'}, + connectPayload: {'payload': 'test'}, + handler: handleFunc, + connectFunc: connectFunc, + reconnectionMonitorTimeout: 1, + reconnectionMonitorInterval: 1, + ); + + final mockWSChannel = MockWSChannel(); + final mockWSSink = MockWSSink(); + + StreamController streamController = + StreamController.broadcast(); + + final computedUrl = + 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; + + when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); + when(mockWSChannel.stream).thenAnswer((_) { + return streamController.stream; + }); + when(mockWSChannel.sink).thenReturn(mockWSSink); + + final connect = ws.connect().then((_) { + streamController.sink.add('{}'); + streamController.close(); + streamController = StreamController.broadcast(); + streamController.sink.add('{}'); + return Future.delayed(Duration(milliseconds: 200)); + }).then((value) async { + verify(mockWSSink.add("{'type': 'health.check'}")).called(greaterThan(0)); + + verify(connectFunc(computedUrl)).called(2); + + await streamController.close(); + return mockWSSink.close(); + }); + + streamController.sink.add('{}'); + + return connect; + }); + + test('should close correctly the controller', () async { + final handleFunc = MockFunctions().handleFunc; + + final ConnectWebSocket connectFunc = MockFunctions().connectFunc; + + final ws = WebSocket( + baseUrl: 'baseurl', + user: User(id: 'testid'), + logger: Logger('ws'), + connectParams: {'test': 'true'}, + connectPayload: {'payload': 'test'}, + handler: handleFunc, + connectFunc: connectFunc, + ); + + final mockWSChannel = MockWSChannel(); + final mockWSSink = MockWSSink(); + + final StreamController streamController = + StreamController.broadcast(); + + final computedUrl = + 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; + + when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); + when(mockWSChannel.stream).thenAnswer((_) { + return streamController.stream; + }); + when(mockWSChannel.sink).thenReturn(mockWSSink); + + final connect = ws.connect().then((_) { + streamController.sink.add('{}'); + return Future.delayed(Duration(milliseconds: 200)); + }).then((value) async { + await ws.disconnect(); + verify(mockWSSink.close()).called(greaterThan(0)); + + await streamController.close(); + await mockWSSink.close(); + }); + + streamController.sink.add('{}'); + + return connect; + }); + + test('should throw an error', () async { + final ConnectWebSocket connectFunc = MockFunctions().connectFunc; + + final ws = WebSocket( + baseUrl: 'baseurl', + user: User(id: 'testid'), + logger: Logger('ws'), + connectParams: {'test': 'true'}, + connectPayload: {'payload': 'test'}, + handler: (e) { + print(e); + }, + connectFunc: connectFunc, + ); + + final mockWSChannel = MockWSChannel(); + + final streamController = StreamController.broadcast(); + + final computedUrl = + 'wss://baseurl/connect?test=true&json=%7B%22payload%22%3A%22test%22%2C%22user_details%22%3A%7B%22id%22%3A%22testid%22%7D%7D'; + + when(connectFunc(computedUrl)).thenAnswer((_) => mockWSChannel); + when(mockWSChannel.sink).thenAnswer((_) => MockWSSink()); + when(mockWSChannel.stream).thenAnswer((_) { + return streamController.stream; + }); + + Future.delayed( + Duration(milliseconds: 1000), + () => streamController.sink.addError('test error'), + ); + + try { + expect(await ws.connect(), throwsA(isA())); + } catch (e) { + verify(connectFunc(computedUrl)).called(greaterThanOrEqualTo(1)); + } + }); +} diff --git a/packages/stream_chat/test/src/client_test.dart b/packages/stream_chat/test/src/client_test.dart new file mode 100644 index 00000000..a1fa2175 --- /dev/null +++ b/packages/stream_chat/test/src/client_test.dart @@ -0,0 +1,939 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:dio/native_imp.dart'; +import 'package:logging/logging.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat/src/api/requests.dart'; +import 'package:stream_chat/src/client.dart'; +import 'package:stream_chat/src/exceptions.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/user.dart'; +import 'package:test/test.dart'; + +class MockDio extends Mock implements DioForNative {} + +class MockHttpClientAdapter extends Mock implements HttpClientAdapter {} + +class Functions { + Future tokenProvider(String userId) => null; +} + +class MockFunctions extends Mock implements Functions {} + +void main() { + group('src/client', () { + group('constructor', () { + final List log = []; + + overridePrint(testFn()) => () { + log.clear(); + final spec = ZoneSpecification(print: (_, __, ___, String msg) { + // Add to log instead of printing to stdout + log.add(msg); + }); + return Zone.current.fork(specification: spec).run(testFn); + }; + + tearDown(() { + log.clear(); + }); + + test('should create the object correctly', () { + final client = StreamChatClient('api-key'); + + expect(client.baseURL, 'chat-us-east-1.stream-io-api.com'); + expect(client.apiKey, 'api-key'); + expect(client.logLevel, Level.WARNING); + expect(client.httpClient.options.connectTimeout, 6000); + expect(client.httpClient.options.receiveTimeout, 6000); + }); + + test('should create the object correctly', overridePrint(() { + final LogHandlerFunction logHandler = (LogRecord record) { + print(record.message); + }; + + final client = StreamChatClient( + 'api-key', + connectTimeout: Duration(seconds: 10), + receiveTimeout: Duration(seconds: 12), + logLevel: Level.INFO, + baseURL: 'test.com', + logHandlerFunction: logHandler, + ); + + expect(client.baseURL, 'test.com'); + expect(client.apiKey, 'api-key'); + expect(Logger.root.level, Level.INFO); + expect(client.httpClient.options.connectTimeout, 10000); + expect(client.httpClient.options.receiveTimeout, 12000); + + client.logger.warning('test'); + client.logger.config('test config'); + + expect([log[log.length - 2], log[log.length - 1]], + ['instantiating new client', 'test']); + })); + + test('Channel', () { + final client = StreamChatClient('test'); + final Map data = {'test': 1}; + final channelClient = client.channel('type', id: 'id', extraData: data); + expect(channelClient.type, 'type'); + expect(channelClient.id, 'id'); + }); + }); + + group('queryChannels', () { + test('should pass right default parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final queryParams = { + 'payload': json.encode({ + "filter_conditions": null, + "sort": null, + "state": true, + "watch": true, + "presence": false, + "limit": 10, + }), + }; + + when(mockDio.get('/channels', queryParameters: queryParams)) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.queryChannels(waitForConnect: false); + + verify(mockDio.get('/channels', queryParameters: queryParams)) + .called(1); + }); + + test('should pass right parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final queryFilter = { + "id": { + "\$in": ["test"], + }, + }; + final sortOptions = []; + final options = {"state": false, "watch": false, "presence": true}; + final paginationParams = PaginationParams( + limit: 10, + offset: 2, + ); + + final queryParams = { + 'payload': json.encode({ + "filter_conditions": queryFilter, + "sort": sortOptions, + } + ..addAll(options) + ..addAll(paginationParams.toJson())), + }; + + when(mockDio.get('/channels', queryParameters: queryParams)) + .thenAnswer((_) async { + return Response(data: '{}', statusCode: 200); + }); + + await client.queryChannels( + filter: queryFilter, + sort: sortOptions, + options: options, + paginationParams: paginationParams, + waitForConnect: false, + ); + + verify(mockDio.get('/channels', queryParameters: queryParams)) + .called(1); + }); + }); + + group('search', () { + test('should pass right default parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final queryParams = { + 'payload': json.encode({ + "filter_conditions": null, + 'query': null, + 'sort': null, + }), + }; + + when(mockDio.get('/search', queryParameters: queryParams)) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.search(null, null, null, null); + + verify(mockDio.get('/search', queryParameters: queryParams)) + .called(1); + }); + + test('should pass right parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final filters = { + "id": { + "\$in": ["test"], + }, + }; + final sortOptions = [SortOption('name')]; + final query = 'query'; + + final queryParams = { + 'payload': json.encode({ + "filter_conditions": filters, + 'query': query, + 'sort': sortOptions, + "limit": 10, + }), + }; + + when(mockDio.get('/search', queryParameters: queryParams)) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.search( + filters, + sortOptions, + query, + PaginationParams(), + ); + + verify(mockDio.get('/search', queryParameters: queryParams)) + .called(1); + }); + }); + + group('devices', () { + test('addDevice', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/devices', data: { + 'id': 'test-id', + 'push_provider': 'firebase', + })).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.addDevice('test-id', PushProvider.firebase); + + verify( + mockDio.post( + '/devices', + data: {'id': 'test-id', 'push_provider': 'firebase'}, + ), + ).called(1); + }); + + test('getDevices', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.get('/devices')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.getDevices(); + + verify(mockDio.get('/devices')).called(1); + }); + + test('removeDevice', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio + .delete('/devices', queryParameters: {'id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.removeDevice('test-id'); + + verify(mockDio.delete('/devices', + queryParameters: {'id': 'test-id'})).called(1); + }); + }); + + test('devToken', () { + final client = StreamChatClient('api-key'); + final token = client.devToken('test'); + + expect( + token, + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGVzdCJ9.devtoken', + ); + }); + + group('queryUsers', () { + test('should pass right default parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final queryParams = { + 'payload': json.encode({ + "filter_conditions": {}, + "sort": null, + "presence": false, + }), + }; + + when(mockDio.get('/users', queryParameters: queryParams)) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.queryUsers(); + + verify(mockDio.get('/users', queryParameters: queryParams)) + .called(1); + }); + + test('should pass right parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final Map queryFilter = { + "id": { + "\$in": ["test"], + }, + }; + final List sortOptions = []; + final options = {"presence": true}; + + final Map queryParams = { + 'payload': json.encode({ + "filter_conditions": queryFilter, + "sort": sortOptions, + }..addAll(options)), + }; + + when(mockDio.get('/users', queryParameters: queryParams)) + .thenAnswer((_) async { + return Response(data: '{}', statusCode: 200); + }); + + await client.queryUsers( + filter: queryFilter, + sort: sortOptions, + options: options, + ); + + verify(mockDio.get('/users', queryParameters: queryParams)) + .called(1); + }); + }); + + group('user', () { + test('setUser should throw exception', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/moderation/flag', + data: {'target_user_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.flagUser('test-id'); + + verify(mockDio.post('/moderation/flag', + data: {'target_user_id': 'test-id'})).called(1); + }); + + test('flagUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + expect(() => client.setUserWithProvider(User(id: 'test-id')), + throwsA(isA())); + }); + + test('unflagUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/moderation/unflag', + data: {'target_user_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.unflagUser('test-id'); + + verify(mockDio.post('/moderation/unflag', + data: {'target_user_id': 'test-id'})).called(1); + }); + + test('updateUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final user = User(id: 'test-id'); + + final data = { + 'users': {user.id: user.toJson()}, + }; + + when(mockDio.post('/users', data: data)) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.updateUser(user); + + verify(mockDio.post('/users', data: data)).called(1); + }); + + test('updateUsers', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final user = User(id: 'test-id'); + final user2 = User(id: 'test-id2'); + + final data = { + 'users': { + user.id: user.toJson(), + user2.id: user2.toJson(), + }, + }; + + when(mockDio.post('/users', data: data)) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.updateUsers([user, user2]); + + verify(mockDio.post('/users', data: data)).called(1); + }); + + test('banUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/moderation/ban', + data: {'test': true, 'target_user_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.banUser('test-id', {'test': true}); + + verify(mockDio.post('/moderation/ban', + data: {'test': true, 'target_user_id': 'test-id'})).called(1); + }); + + test('unbanUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.delete('/moderation/ban', + queryParameters: {'test': true, 'target_user_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.unbanUser('test-id', {'test': true}); + + verify(mockDio.delete('/moderation/ban', + queryParameters: {'test': true, 'target_user_id': 'test-id'})) + .called(1); + }); + + test('muteUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/moderation/mute', + data: {'target_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.muteUser('test-id'); + + verify(mockDio.post('/moderation/mute', + data: {'target_id': 'test-id'})).called(1); + }); + + test('unmuteUser', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/moderation/unmute', + data: {'target_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.unmuteUser('test-id'); + + verify(mockDio.post('/moderation/unmute', + data: {'target_id': 'test-id'})).called(1); + }); + }); + + group('message', () { + test('flagMessage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/moderation/flag', + data: {'target_message_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.flagMessage('test-id'); + + verify(mockDio.post('/moderation/flag', + data: {'target_message_id': 'test-id'})).called(1); + }); + + test('unflagMessage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/moderation/unflag', + data: {'target_message_id': 'test-id'})) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.unflagMessage('test-id'); + + verify(mockDio.post('/moderation/unflag', + data: {'target_message_id': 'test-id'})).called(1); + }); + + test('updateMessage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final message = Message( + id: 'test', + updatedAt: DateTime.now(), + ); + + when(mockDio.post( + '/messages/${message.id}', + data: {'message': message}, + )).thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.updateMessage(message); + + verify(mockDio.post('/messages/${message.id}', + data: {'message': anything})).called(1); + }); + + test('deleteMessage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final messageId = 'test'; + + when(mockDio.delete('/messages/$messageId')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.deleteMessage(Message(id: messageId)); + + verify(mockDio.delete('/messages/$messageId')).called(1); + }); + + test('getMessage', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final messageId = 'test'; + + when(mockDio.get('/messages/$messageId')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.getMessage(messageId); + + verify(mockDio.get('/messages/$messageId')).called(1); + }); + + test('markAllRead', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + when(mockDio.post('/channels/read')) + .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + + await client.markAllRead(); + + verify(mockDio.post('/channels/read')).called(1); + }); + }); + + group('api methods', () { + group('get', () { + test('should put the correct parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final Map queryParams = { + 'test': 1, + }; + + when(mockDio.get('/test', queryParameters: queryParams)) + .thenAnswer((_) async { + return Response(data: '{}', statusCode: 200); + }); + + await client.get('/test', queryParameters: queryParams); + + verify(mockDio.get('/test', queryParameters: queryParams)) + .called(1); + }); + + test('should catch the error', () async { + final dioHttp = Dio(); + final mockHttpClientAdapter = MockHttpClientAdapter(); + dioHttp.httpClientAdapter = mockHttpClientAdapter; + + final client = StreamChatClient( + 'api-key', + httpClient: dioHttp, + ); + + when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( + (_) async => ResponseBody.fromString('test error', 400)); + + expect(client.get('/test'), throwsA(ApiError('test error', 400))); + }); + }); + + group('post', () { + test('should put the correct parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final Map data = { + 'test': 1, + }; + + when(mockDio.post('/test', data: data)).thenAnswer((_) async { + return Response(data: '{}', statusCode: 200); + }); + + await client.post('/test', data: data); + + verify(mockDio.post('/test', data: data)).called(1); + }); + + test('should catch the error', () async { + final dioHttp = Dio(); + final mockHttpClientAdapter = MockHttpClientAdapter(); + dioHttp.httpClientAdapter = mockHttpClientAdapter; + + final client = StreamChatClient( + 'api-key', + httpClient: dioHttp, + ); + + when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( + (_) async => ResponseBody.fromString('test error', 400)); + + expect(client.post('/test'), throwsA(ApiError('test error', 400))); + }); + }); + + group('put', () { + test('should put the correct parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final Map data = { + 'test': 1, + }; + + when(mockDio.put('/test', data: data)).thenAnswer((_) async { + return Response(data: '{}', statusCode: 200); + }); + + await client.put('/test', data: data); + + verify(mockDio.put('/test', data: data)).called(1); + }); + + test('should catch the error', () async { + final dioHttp = Dio(); + final mockHttpClientAdapter = MockHttpClientAdapter(); + dioHttp.httpClientAdapter = mockHttpClientAdapter; + + final client = StreamChatClient( + 'api-key', + httpClient: dioHttp, + ); + + when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( + (_) async => ResponseBody.fromString('test error', 400)); + + expect(client.put('/test'), throwsA(ApiError('test error', 400))); + }); + }); + + group('patch', () { + test('should put the correct parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final Map data = { + 'test': 1, + }; + + when(mockDio.patch('/test', data: data)) + .thenAnswer((_) async { + return Response(data: '{}', statusCode: 200); + }); + + await client.patch('/test', data: data); + + verify(mockDio.patch('/test', data: data)).called(1); + }); + + test('should catch the error', () async { + final dioHttp = Dio(); + final mockHttpClientAdapter = MockHttpClientAdapter(); + dioHttp.httpClientAdapter = mockHttpClientAdapter; + + final client = StreamChatClient( + 'api-key', + httpClient: dioHttp, + ); + + when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( + (_) async => ResponseBody.fromString('test error', 400)); + + expect(client.patch('/test'), throwsA(ApiError('test error', 400))); + }); + }); + + group('delete', () { + test('should put the correct parameters', () async { + final mockDio = MockDio(); + + when(mockDio.options).thenReturn(BaseOptions()); + when(mockDio.interceptors).thenReturn(Interceptors()); + + final client = StreamChatClient( + 'api-key', + httpClient: mockDio, + ); + + final Map queryParams = { + 'test': 1, + }; + + when(mockDio.delete('/test', queryParameters: queryParams)) + .thenAnswer((_) async { + return Response(data: '{}', statusCode: 200); + }); + + await client.delete('/test', queryParameters: queryParams); + + verify(mockDio.delete('/test', queryParameters: queryParams)) + .called(1); + }); + + test('should catch the error', () async { + final dioHttp = Dio(); + final mockHttpClientAdapter = MockHttpClientAdapter(); + dioHttp.httpClientAdapter = mockHttpClientAdapter; + + final client = StreamChatClient( + 'api-key', + httpClient: dioHttp, + ); + + when(mockHttpClientAdapter.fetch(any, any, any)).thenAnswer( + (_) async => ResponseBody.fromString('test error', 400)); + + expect(client.delete('/test'), throwsA(ApiError('test error', 400))); + }); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/action_test.dart b/packages/stream_chat/test/src/models/action_test.dart new file mode 100644 index 00000000..4859fcbe --- /dev/null +++ b/packages/stream_chat/test/src/models/action_test.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/action.dart'; + +void main() { + group('src/models/action', () { + const jsonExample = r'''{ + "name": "name", + "style": "style", + "text": "text", + "type": "type", + "value": "value" + }'''; + + test('should parse json correctly', () { + final action = Action.fromJson(json.decode(jsonExample)); + expect(action.name, 'name'); + expect(action.style, 'style'); + expect(action.text, 'text'); + expect(action.type, 'type'); + expect(action.value, 'value'); + }); + + test('should serialize to json correctly', () { + final action = Action( + name: 'name', + style: 'style', + text: 'text', + type: 'type', + value: 'value', + ); + + expect( + action.toJson(), + { + 'name': 'name', + 'style': 'style', + 'text': 'text', + 'type': 'type', + 'value': 'value', + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/attachment_test.dart b/packages/stream_chat/test/src/models/attachment_test.dart new file mode 100644 index 00000000..b15458ad --- /dev/null +++ b/packages/stream_chat/test/src/models/attachment_test.dart @@ -0,0 +1,69 @@ +import 'package:stream_chat/src/models/attachment.dart'; +import 'package:stream_chat/src/models/action.dart'; +import 'dart:convert'; + +import 'package:test/test.dart'; + +void main() { + group('src/models/attachment', () { + const jsonExample = r'''{ + "type": "giphy", + "title": "awesome", + "title_link": "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti", + "thumb_url": "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif", + "actions": [ + { + "name": "image_action", + "text": "Send", + "style": "primary", + "type": "button", + "value": "send" + }, + { + "name": "image_action", + "text": "Shuffle", + "style": "default", + "type": "button", + "value": "shuffle" + }, + { + "name": "image_action", + "text": "Cancel", + "style": "default", + "type": "button", + "value": "cancel" + } + ] +}'''; + + test('should parse json correctly', () { + final attachment = Attachment.fromJson(json.decode(jsonExample)); + expect(attachment.type, "giphy"); + expect(attachment.title, "awesome"); + expect(attachment.titleLink, + "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti"); + expect(attachment.thumbUrl, + "https://media0.giphy.com/media/3o7TKnCdBx5cMg0qti/giphy.gif"); + expect(attachment.actions, hasLength(3)); + expect(attachment.actions[0], isA()); + }); + + test('should serialize to json correctly', () { + final channel = Attachment( + type: "image", + title: "soo", + titleLink: + "https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti"); + + expect( + channel.toJson(), + { + 'type': 'image', + 'title': 'soo', + 'title_link': + 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti' + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/channel_state_test.dart b/packages/stream_chat/test/src/models/channel_state_test.dart new file mode 100644 index 00000000..b8ce3c80 --- /dev/null +++ b/packages/stream_chat/test/src/models/channel_state_test.dart @@ -0,0 +1,1112 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/channel_config.dart'; +import 'package:stream_chat/src/models/channel_state.dart'; +import 'package:stream_chat/src/models/command.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/user.dart'; +import 'package:stream_chat/stream_chat.dart'; + +void main() { + group('src/models/channel_state', () { + const jsonExample = r'''{ + "channel": { + "id": "dev", + "type": "team", + "cid": "team:dev", + "last_message_at": "2020-01-30T13:43:41.062362Z", + "created_at": "2019-04-03T18:43:33.213373Z", + "updated_at": "2019-04-03T18:43:33.213374Z", + "team": "test", + "created_by": { + "id": "guido", + "role": "user", + "created_at": "2019-04-03T18:43:33.201036Z", + "updated_at": "2019-04-03T18:43:33.204713Z", + "banned": false, + "online": false, + "name": "Guido" + }, + "frozen": true, + "config": { + "created_at": "2019-11-07T22:29:26.776526Z", + "updated_at": "2019-11-07T22:29:48.286746Z", + "name": "team", + "typing_events": true, + "read_events": true, + "connect_events": true, + "search": true, + "reactions": true, + "replies": true, + "mutes": true, + "uploads": true, + "url_enrichment": true, + "message_retention": "infinite", + "max_message_length": 5000, + "automod": "disabled", + "automod_behavior": "flag", + "commands": [ + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + "set": "fun_set" + } + ] + }, + "name": "#dev", + "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", + "example": 1 + }, + "messages": [ + { + "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", + "text": "fasdfa", + "type": "regular", + "status": "SENT", + "silent": false, + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:02.843948Z", + "updated_at": "2020-01-29T03:23:02.843949Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", + "text": "test message", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:07.981091Z", + "updated_at": "2020-01-29T03:23:07.981091Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", + "text": "test message", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:23:11.568022Z", + "updated_at": "2020-01-29T03:23:11.568022Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", + "text": "asdfadf", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:32:57.403566Z", + "updated_at": "2020-01-29T03:32:57.403566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", + "text": "test", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:33:35.294802Z", + "updated_at": "2020-01-29T03:33:35.294802Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", + "text": "hi", + "type": "regular", + "user": { + "id": "withered-cell-0", + "role": "user", + "created_at": "2020-01-29T03:34:01.698106Z", + "updated_at": "2020-01-29T03:34:01.708808Z", + "last_active": "2020-01-29T03:34:01.70353Z", + "banned": false, + "online": false, + "name": "Withered cell", + "image": "https://getstream.io/random_svg/?name=Withered+cell" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:34:27.393296Z", + "updated_at": "2020-01-29T03:34:27.393296Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", + "text": "fantastic", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:34:37.638376Z", + "updated_at": "2020-01-29T03:34:37.638376Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", + "text": "nice to meet you", + "type": "regular", + "user": { + "id": "withered-cell-0", + "role": "user", + "created_at": "2020-01-29T03:34:01.698106Z", + "updated_at": "2020-01-29T03:34:01.708808Z", + "last_active": "2020-01-29T03:34:01.70353Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Withered+cell", + "name": "Withered cell" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:04.301566Z", + "updated_at": "2020-01-29T03:35:04.301566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", + "text": "hey", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:24.939084Z", + "updated_at": "2020-01-29T03:35:24.939085Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", + "text": "hello, everyone", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "name": "Dry meadow", + "image": "https://getstream.io/random_svg/?name=Dry+meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:33.101566Z", + "updated_at": "2020-01-29T03:35:33.101566Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", + "text": "who is there?", + "type": "regular", + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "name": "Dry meadow", + "image": "https://getstream.io/random_svg/?name=Dry+meadow" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T03:35:45.458685Z", + "updated_at": "2020-01-29T03:35:45.458685Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", + "text": "하이", + "type": "regular", + "user": { + "id": "icy-recipe-7", + "role": "user", + "created_at": "2020-01-21T11:36:22.284503Z", + "updated_at": "2020-01-29T07:01:59.69882Z", + "last_active": "2020-01-29T07:01:59.693378Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Icy+recipe", + "name": "Icy recipe" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T07:02:11.535395Z", + "updated_at": "2020-01-29T07:02:11.535395Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", + "text": "what are you doing?", + "type": "regular", + "user": { + "id": "icy-recipe-7", + "role": "user", + "created_at": "2020-01-21T11:36:22.284503Z", + "updated_at": "2020-01-29T07:01:59.69882Z", + "last_active": "2020-01-29T07:01:59.693378Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Icy+recipe", + "name": "Icy recipe" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T07:02:22.485136Z", + "updated_at": "2020-01-29T07:02:22.485136Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", + "text": "👍", + "type": "regular", + "user": { + "id": "throbbing-boat-5", + "role": "user", + "created_at": "2019-07-30T06:29:53.060413Z", + "updated_at": "2020-01-29T14:11:27.80176Z", + "last_active": "2020-01-29T14:11:27.7963Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Throbbing+boat", + "name": "Throbbing boat" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T14:12:04.688552Z", + "updated_at": "2020-01-29T14:12:04.688552Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", + "text": "sdasas", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:36.011315Z", + "updated_at": "2020-01-29T15:29:36.011316Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", + "text": "cjshsa", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:41.677819Z", + "updated_at": "2020-01-29T15:29:41.677819Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", + "text": "nhisagdhsadz", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:43.354177Z", + "updated_at": "2020-01-29T15:29:43.354177Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", + "text": "hvadhsahzd", + "type": "regular", + "user": { + "id": "snowy-credit-3", + "role": "user", + "created_at": "2020-01-29T15:29:03.693312Z", + "updated_at": "2020-01-29T15:29:03.702648Z", + "last_active": "2020-01-29T15:29:03.696144Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Snowy+credit", + "name": "Snowy credit" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T15:29:44.754713Z", + "updated_at": "2020-01-29T15:29:44.754713Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", + "text": "hello", + "type": "regular", + "user": { + "id": "divine-glade-9", + "role": "user", + "created_at": "2020-01-29T17:02:18.312524Z", + "updated_at": "2020-01-29T17:02:18.320187Z", + "last_active": "2020-01-29T17:02:18.315074Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Divine+glade", + "name": "Divine glade" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T17:02:36.933852Z", + "updated_at": "2020-01-29T17:02:36.933852Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", + "text": "hello", + "type": "regular", + "user": { + "id": "red-firefly-9", + "role": "user", + "created_at": "2019-08-02T18:56:39.366516Z", + "updated_at": "2020-01-29T22:13:50.491769Z", + "last_active": "2020-01-29T22:13:50.450215Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Red+firefly", + "name": "Red firefly" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-29T22:14:08.54062Z", + "updated_at": "2020-01-29T22:14:08.54062Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", + "text": "hello", + "type": "regular", + "user": { + "id": "bitter-glade-2", + "role": "user", + "created_at": "2020-01-30T13:08:56.190678Z", + "updated_at": "2020-01-30T13:08:56.200333Z", + "last_active": "2020-01-30T13:08:56.193882Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Bitter+glade", + "name": "Bitter glade" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:11:37.191293Z", + "updated_at": "2020-01-30T13:11:37.191293Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", + "text": "http://jaeger.ui.gtstrm.com/", + "type": "regular", + "user": { + "id": "morning-sea-1", + "role": "user", + "created_at": "2019-07-22T09:19:07.505207Z", + "updated_at": "2020-01-30T13:33:05.831856Z", + "last_active": "2020-01-30T13:33:05.825369Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Morning+sea", + "name": "Morning sea" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:33:16.853116Z", + "updated_at": "2020-01-30T13:33:16.853116Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", + "text": "hi", + "type": "regular", + "user": { + "id": "ancient-salad-0", + "role": "user", + "created_at": "2020-01-30T13:34:29.286813Z", + "updated_at": "2020-01-30T13:34:29.296196Z", + "last_active": "2020-01-30T13:34:29.289964Z", + "banned": false, + "online": true, + "image": "https://getstream.io/random_svg/?name=Ancient+salad", + "name": "Ancient salad" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:36:52.749731Z", + "updated_at": "2020-01-30T13:36:52.749732Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", + "text": "hi", + "type": "regular", + "user": { + "id": "ancient-salad-0", + "role": "user", + "created_at": "2020-01-30T13:34:29.286813Z", + "updated_at": "2020-01-30T13:34:29.296196Z", + "last_active": "2020-01-30T13:34:29.289964Z", + "banned": false, + "online": true, + "image": "https://getstream.io/random_svg/?name=Ancient+salad", + "name": "Ancient salad" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:37:41.631056Z", + "updated_at": "2020-01-30T13:37:41.631056Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", + "text": "😃", + "type": "regular", + "user": { + "id": "proud-sea-7", + "role": "user", + "created_at": "2020-01-30T13:43:03.903006Z", + "updated_at": "2020-01-30T13:43:03.912307Z", + "last_active": "2020-01-30T13:43:03.906236Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Proud+sea", + "name": "Proud sea" + }, + "attachments": [], + "latest_reactions": [], + "own_reactions": [], + "reaction_counts": {}, + "reaction_scores": {}, + "reply_count": 0, + "created_at": "2020-01-30T13:43:41.062362Z", + "updated_at": "2020-01-30T13:43:41.062362Z", + "mentioned_users": [], + "status": "SENT", + "silent": false + } + ], + "watcher_count": 5, + "members": [] + }'''; + + test('should parse json correctly', () { + final channelState = ChannelState.fromJson(json.decode(jsonExample)); + expect(channelState.channel.cid, 'team:dev'); + expect(channelState.channel.id, 'dev'); + expect(channelState.channel.team, 'test'); + expect(channelState.channel.type, 'team'); + expect(channelState.channel.config, isA()); + expect(channelState.channel.config, isNotNull); + expect(channelState.channel.config.commands, hasLength(1)); + expect(channelState.channel.config.commands[0], isA()); + expect(channelState.channel.lastMessageAt, + DateTime.parse("2020-01-30T13:43:41.062362Z")); + expect(channelState.channel.createdAt, + DateTime.parse("2019-04-03T18:43:33.213373Z")); + expect(channelState.channel.updatedAt, + DateTime.parse("2019-04-03T18:43:33.213374Z")); + expect(channelState.channel.createdBy, isA()); + expect(channelState.channel.frozen, true); + expect(channelState.channel.extraData['example'], 1); + expect(channelState.channel.extraData['name'], "#dev"); + expect(channelState.channel.extraData['image'], + "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png"); + expect(channelState.messages, hasLength(25)); + expect(channelState.messages[0], isA()); + expect(channelState.messages[0], isNotNull); + expect(channelState.messages[0].createdAt, + DateTime.parse("2020-01-29T03:23:02.843948Z")); + expect(channelState.messages[0].user, isA()); + expect(channelState.watcherCount, 5); + }); + + test('should serialize to json correctly', () { + const toJsonExample = r''' + { + "channel": { + "id": "dev", + "type": "team", + "frozen": true, + "name": "#dev", + "image": "https://cdn.chrisshort.net/testing-certificate-chains-in-go/GOPHER_MIC_DROP.png", + "example": 1 + }, + "watchers": null, + "read": null, + "messages": [ + { + "id": "dry-meadow-0-2b73cc8b-cd86-4a01-8d40-bd82ad07a030", + "text": "fasdfa", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", + "text": "test message", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", + "text": "test message", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", + "text": "asdfadf", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", + "text": "test", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", + "text": "fantastic", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", + "text": "nice to meet you", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", + "text": "hey", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", + "text": "hello, everyone", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", + "text": "who is there?", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", + "text": "하이", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", + "text": "what are you doing?", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", + "text": "👍", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", + "text": "sdasas", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", + "text": "cjshsa", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", + "text": "nhisagdhsadz", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", + "text": "hvadhsahzd", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", + "text": "hello", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", + "text": "http://jaeger.ui.gtstrm.com/", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", + "text": "hi", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + }, + { + "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", + "text": "😃", + "attachments": [], + "parent_id": null, + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": null, + "mentioned_users": [], + "status": "SENT", + "silent": false + } + ], + "members": [], + "watcher_count": 5 + } + '''; + final j = jsonDecode(jsonExample); + final channelState = ChannelState( + channel: ChannelModel.fromJson(j['channel']), + members: [], + messages: + (j['messages'] as List).map((m) => Message.fromJson(m)).toList(), + read: null, + watcherCount: 5, + watchers: null, + ); + + expect( + channelState.toJson(), + jsonDecode(toJsonExample), + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/channel_test.dart b/packages/stream_chat/test/src/models/channel_test.dart new file mode 100644 index 00000000..69b18197 --- /dev/null +++ b/packages/stream_chat/test/src/models/channel_test.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/channel_model.dart'; + +void main() { + group('src/models/channel', () { + const jsonExample = ''' + { + "id": "test", + "type": "livestream", + "cid": "test:livestream", + "cats": true, + "fruit": ["bananas", "apples"] + } + '''; + + test('should parse json correctly', () { + final channel = ChannelModel.fromJson(json.decode(jsonExample)); + expect(channel.id, equals("test")); + expect(channel.type, equals("livestream")); + expect(channel.cid, equals("test:livestream")); + expect(channel.extraData["cats"], equals(true)); + expect(channel.extraData["fruit"], equals(["bananas", "apples"])); + }); + + test('should serialize to json correctly', () { + final channel = ChannelModel( + type: "type", + id: "id", + cid: "a:a", + extraData: {"name": "cool"}, + ); + + expect( + channel.toJson(), + {'id': 'id', 'type': 'type', 'name': 'cool'}, + ); + }); + + test('should serialize to json correctly when frozen is provided', () { + final channel = ChannelModel( + type: "type", + id: "id", + cid: "a:a", + extraData: {"name": "cool"}, + frozen: false, + ); + + expect( + channel.toJson(), + {'id': 'id', 'type': 'type', 'name': 'cool', 'frozen': false}, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/command_test.dart b/packages/stream_chat/test/src/models/command_test.dart new file mode 100644 index 00000000..dc44f736 --- /dev/null +++ b/packages/stream_chat/test/src/models/command_test.dart @@ -0,0 +1,40 @@ +import 'package:stream_chat/src/models/command.dart'; +import 'dart:convert'; + +import 'package:test/test.dart'; + +void main() { + group('src/models/command', () { + const jsonExample = ''' + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]" + } + '''; + + test('should parse json correctly', () { + final command = Command.fromJson(json.decode(jsonExample)); + expect(command.name, 'giphy'); + expect(command.description, 'Post a random gif to the channel'); + expect(command.args, '[text]'); + }); + + test('should serialize to json correctly', () { + final command = Command( + name: 'giphy', + description: 'Post a random gif to the channel', + args: '[text]', + ); + + expect( + command.toJson(), + { + "name": "giphy", + "description": "Post a random gif to the channel", + "args": "[text]", + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/device_test.dart b/packages/stream_chat/test/src/models/device_test.dart new file mode 100644 index 00000000..3f8de16e --- /dev/null +++ b/packages/stream_chat/test/src/models/device_test.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/device.dart'; + +void main() { + group('src/models/device', () { + const jsonExample = r'''{ + "id": "device-id", + "push_provider": "push-provider" + }'''; + + test('should parse json correctly', () { + final device = Device.fromJson(json.decode(jsonExample)); + expect(device.id, 'device-id'); + expect(device.pushProvider, 'push-provider'); + }); + + test('should serialize to json correctly', () { + final device = Device(id: 'device-id', pushProvider: 'push-provider'); + + expect( + device.toJson(), + { + 'id': 'device-id', + 'push_provider': 'push-provider', + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/event_test.dart b/packages/stream_chat/test/src/models/event_test.dart new file mode 100644 index 00000000..db99b01a --- /dev/null +++ b/packages/stream_chat/test/src/models/event_test.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/event.dart'; +import 'package:stream_chat/src/models/own_user.dart'; +import 'package:stream_chat/stream_chat.dart'; + +void main() { + group('src/models/event', () { + const jsonExample = ''' + { + "type": "type", + "cid": "cid", + "connection_id": "connectionId", + "created_at": "2019-04-03T18:43:33.213374Z", + "me": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + }, + "parent_id": null, + "user": { + "id": "dry-meadow-0", + "role": "user", + "created_at": "2019-03-27T17:40:17.155892Z", + "updated_at": "2020-01-29T03:22:47.641589Z", + "last_active": "2020-01-29T03:22:47.63613Z", + "banned": false, + "online": false, + "image": "https://getstream.io/random_svg/?name=Dry+meadow", + "name": "Dry meadow" + } + } + '''; + + test('should parse json correctly', () { + final event = Event.fromJson(json.decode(jsonExample)); + expect(event.type, 'type'); + expect(event.cid, 'cid'); + expect(event.connectionId, 'connectionId'); + expect(event.createdAt, isA()); + expect(event.me, isA()); + expect(event.user, isA()); + }); + + test('should serialize to json correctly', () { + final event = Event( + user: User(id: 'id'), + type: 'type', + cid: 'cid', + connectionId: 'connectionId', + createdAt: DateTime.parse("2020-01-29T03:22:47.63613Z"), + me: OwnUser(id: 'id2'), + totalUnreadCount: 1, + unreadChannels: 1, + online: true, + ); + + expect( + event.toJson(), + { + 'type': 'type', + 'cid': 'cid', + 'connection_id': 'connectionId', + 'created_at': '2020-01-29T03:22:47.636130Z', + 'me': {'id': 'id2'}, + 'user': {'id': 'id'}, + 'reaction': null, + 'message': null, + 'channel': null, + 'total_unread_count': 1, + 'unread_channels': 1, + 'online': true, + 'is_local': true, + 'member': null, + 'channel_id': null, + 'channel_type': null, + 'parent_id': null, + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/member_test.dart b/packages/stream_chat/test/src/models/member_test.dart new file mode 100644 index 00000000..237f0030 --- /dev/null +++ b/packages/stream_chat/test/src/models/member_test.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/member.dart'; +import 'package:stream_chat/src/models/user.dart'; + +void main() { + group('src/models/member', () { + const jsonExample = ''' + { + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + "role": "user", + "created_at": "2020-01-28T22:17:30.826259Z", + "updated_at": "2020-01-28T22:17:31.101222Z", + "banned": false, + "online": false, + "name": "Robin Papa", + "image": "https://pbs.twimg.com/profile_images/669512187778498560/L7wQctBt.jpg" + }, + "role": "member", + "created_at": "2020-01-28T22:17:30.95443Z", + "updated_at": "2020-01-28T22:17:30.95443Z" + } + '''; + + test('should parse json correctly', () { + final member = Member.fromJson(json.decode(jsonExample)); + expect(member.user, isA()); + expect(member.role, 'member'); + expect(member.createdAt, DateTime.parse("2020-01-28T22:17:30.95443Z")); + expect(member.updatedAt, DateTime.parse("2020-01-28T22:17:30.95443Z")); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/message_test.dart b/packages/stream_chat/test/src/models/message_test.dart new file mode 100644 index 00000000..7541f722 --- /dev/null +++ b/packages/stream_chat/test/src/models/message_test.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/attachment.dart'; +import 'package:stream_chat/src/models/message.dart'; +import 'package:stream_chat/src/models/reaction.dart'; +import 'package:stream_chat/src/models/user.dart'; + +void main() { + group('src/models/message', () { + const jsonExample = r'''{ + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "type": "regular", + "silent": false, + "status": "SENT", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "attachments": [ + { + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + } + ], + "latest_reactions": [ + { + "message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "user": { + "id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680", + "role": "user", + "created_at": "2020-01-28T22:17:30.83015Z", + "updated_at": "2020-01-28T22:17:31.19435Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/2.jpg", + "name": "Mia Denys" + }, + "type": "love", + "score": 1, + "created_at": "2020-01-28T22:17:31.128376Z", + "updated_at": "2020-01-28T22:17:31.128376Z" + } + ], + "own_reactions": [], + "reaction_counts": { + "love": 1 + }, + "reaction_scores": { + "love": 1 + }, + "reply_count": 0, + "created_at": "2020-01-28T22:17:31.107978Z", + "updated_at": "2020-01-28T22:17:31.130506Z", + "mentioned_users": [] + }'''; + + test('should parse json correctly', () { + final message = Message.fromJson(json.decode(jsonExample)); + expect(message.id, "4637f7e4-a06b-42db-ba5a-8d8270dd926f"); + expect(message.text, + "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA"); + expect(message.type, "regular"); + expect(message.user, isA()); + expect(message.silent, isA()); + expect(message.attachments, isA>()); + expect(message.latestReactions, isA>()); + expect(message.ownReactions, isA>()); + expect(message.reactionCounts, {'love': 1}); + expect(message.reactionScores, {'love': 1}); + expect(message.createdAt, DateTime.parse("2020-01-28T22:17:31.107978Z")); + expect(message.updatedAt, DateTime.parse("2020-01-28T22:17:31.130506Z")); + expect(message.mentionedUsers, isA>()); + }); + + test('should serialize to json correctly', () { + final message = Message( + id: "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + text: + "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + silent: false, + attachments: [ + Attachment.fromJson({ + "type": "video", + "author_name": "GIPHY", + "title": "The Lion King Disney GIF - Find \u0026 Share on GIPHY", + "title_link": + "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": + "Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "image_url": + "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "thumb_url": + "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "asset_url": + "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4", + "og_scrape_url": + "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA" + }) + ], + showInChannel: true, + parentId: 'parentId', + extraData: {'hey': 'test'}, + status: MessageSendingStatus.sent, + ); + + expect( + message.toJson(), + json.decode(r''' + { + "id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f", + "text": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "silent": false, + "attachments": [ + { + "type": "video", + "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "title": "The Lion King Disney GIF - Find & Share on GIPHY", + "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "text": "Discover & share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.", + "og_scrape_url": "https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA", + "image_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", + "author_name": "GIPHY", + "asset_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4" + } + ], + "mentioned_users": null, + "parent_id": "parentId", + "quoted_message": null, + "quoted_message_id": null, + "show_in_channel": true, + "hey": "test" + } + '''), + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/reaction_test.dart b/packages/stream_chat/test/src/models/reaction_test.dart new file mode 100644 index 00000000..8bc92081 --- /dev/null +++ b/packages/stream_chat/test/src/models/reaction_test.dart @@ -0,0 +1,72 @@ +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/reaction.dart'; +import 'dart:convert'; + +import 'package:stream_chat/src/models/user.dart'; + +void main() { + group('src/models/reaction', () { + const jsonExample = ''' + { + "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", + "user_id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "user": { + "id": "2de0297c-f3f2-489d-b930-ef77342edccf", + "role": "user", + "created_at": "2020-01-28T22:17:30.810011Z", + "updated_at": "2020-01-28T22:17:31.077195Z", + "banned": false, + "online": false, + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }, + "type": "wow", + "score": 1, + "created_at": "2020-01-28T22:17:31.108742Z", + "updated_at": "2020-01-28T22:17:31.108742Z" + } + '''; + + test('should parse json correctly', () { + final reaction = Reaction.fromJson(json.decode(jsonExample)); + expect(reaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); + expect(reaction.createdAt, DateTime.parse("2020-01-28T22:17:31.108742Z")); + expect(reaction.type, 'wow'); + expect( + reaction.user.toJson(), + User.init("2de0297c-f3f2-489d-b930-ef77342edccf", extraData: { + "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'); + expect(reaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + }); + + test('should serialize to json correctly', () { + final reaction = Reaction( + messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04', + createdAt: DateTime.parse("2020-01-28T22:17:31.108742Z"), + type: 'wow', + user: User.init("2de0297c-f3f2-489d-b930-ef77342edccf", extraData: { + "image": "https://randomuser.me/api/portraits/women/45.jpg", + "name": "Daisy Morgan" + }), + userId: "2de0297c-f3f2-489d-b930-ef77342edccf", + extraData: {'bananas': 'yes'}, + score: 1, + ); + + expect( + reaction.toJson(), + { + "message_id": "76cd8c82-b557-4e48-9d12-87995d3a0e04", + "type": "wow", + "score": 1, + "bananas": 'yes', + }, + ); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/read_test.dart b/packages/stream_chat/test/src/models/read_test.dart new file mode 100644 index 00000000..b15ce66a --- /dev/null +++ b/packages/stream_chat/test/src/models/read_test.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/read.dart'; +import 'package:stream_chat/src/models/user.dart'; + +void main() { + group('src/models/read', () { + const jsonExample = ''' + { + "user": { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e" + }, + "last_read": "2020-01-28T22:17:30.966485504Z", + "unread_messages": 10 + } + '''; + + test('should parse json correctly', () { + final read = Read.fromJson(json.decode(jsonExample)); + expect(read.lastRead, DateTime.parse('2020-01-28T22:17:30.966485504Z')); + expect(read.user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + expect(read.unreadMessages, 10); + }); + + test('should serialize to json correctly', () { + final read = Read( + lastRead: DateTime.parse('2020-01-28T22:17:30.966485504Z'), + user: User.init('bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'), + unreadMessages: 10, + ); + + expect(read.toJson(), { + "user": {"id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e"}, + "last_read": "2020-01-28T22:17:30.966485Z", + 'unread_messages': 10, + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/serialization_test.dart b/packages/stream_chat/test/src/models/serialization_test.dart new file mode 100644 index 00000000..e8a2de3f --- /dev/null +++ b/packages/stream_chat/test/src/models/serialization_test.dart @@ -0,0 +1,60 @@ +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/serialization.dart'; + +void main() { + group('src/models/serialization', () { + test('should move unknown keys from root to dedicate property', () { + final json = { + 'prop1': 'test', + 'prop2': 123, + 'prop3': true, + }; + final result = Serialization.moveToExtraDataFromRoot(json, [ + 'prop1', + 'prop2', + ]); + + expect(result, { + 'prop1': 'test', + 'prop2': 123, + 'extra_data': { + 'prop3': true, + }, + }); + + expect(json, { + 'prop1': 'test', + 'prop2': 123, + 'prop3': true, + }); + }); + + test('should have empty extraData', () { + final result = Serialization.moveToExtraDataFromRoot({ + 'prop1': 'test', + 'prop2': 123, + 'prop3': true, + }, [ + 'prop1', + 'prop2', + 'prop3' + ]); + + expect(result, { + 'prop1': 'test', + 'prop2': 123, + 'prop3': true, + 'extra_data': {}, + }); + }); + + test('should return null', () { + final result = Serialization.moveToExtraDataFromRoot(null, [ + 'prop1', + 'prop2', + ]); + + expect(result, null); + }); + }); +} diff --git a/packages/stream_chat/test/src/models/user_test.dart b/packages/stream_chat/test/src/models/user_test.dart new file mode 100644 index 00000000..6100b218 --- /dev/null +++ b/packages/stream_chat/test/src/models/user_test.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:stream_chat/src/models/user.dart'; + +void main() { + group('src/models/user', () { + const jsonExample = ''' + { + "id": "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e" + } + '''; + + test('should parse json correctly', () { + final user = User.fromJson(json.decode(jsonExample)); + expect(user.id, 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'); + }); + + test('should serialize to json correctly', () { + final user = + User(id: 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', role: "abc"); + + expect(user.toJson(), { + 'id': "bbb19d9a-ee50-45bc-84e5-0584e79d0c9e", + }); + }); + }); +} diff --git a/packages/stream_chat/test/version_test.dart b/packages/stream_chat/test/version_test.dart new file mode 100644 index 00000000..ada2a85c --- /dev/null +++ b/packages/stream_chat/test/version_test.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:stream_chat/version.dart'; + +void prepareTest() { + // https://github.com/flutter/flutter/issues/20907 + if (Directory.current.path.endsWith('/test')) { + Directory.current = Directory.current.parent; + } +} + +void main() { + prepareTest(); + test('stream chat version matches pubspec', () { + final String pubspecPath = '${Directory.current.path}/pubspec.yaml'; + final String pubspec = File(pubspecPath).readAsStringSync(); + final RegExp regex = RegExp('version:\s*(.*)'); + final RegExpMatch match = regex.firstMatch(pubspec); + expect(match, isNotNull); + expect(PACKAGE_VERSION, match.group(1).trim()); + }); +} diff --git a/packages/stream_chat_flutter/.gitignore b/packages/stream_chat_flutter/.gitignore new file mode 100644 index 00000000..1d3bae26 --- /dev/null +++ b/packages/stream_chat_flutter/.gitignore @@ -0,0 +1,65 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +coverage/ +coverage_helper_test.dart + +# Web related +lib/generated_plugin_registrant.dart + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages +# See https://www.dartlang.org/guides/libraries/private-files + +# Files and directories created by pub +.dart_tool/ +.packages +build/ +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock + +# Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. +doc/api/ + +# Avoid committing generated Javascript files: +*.dart.js +*.info.json # Produced by the --dump-info flag. +*.js # When generated by dart2js. Don't specify *.js if your + # project includes source files written in JavaScript. +*.js_ +*.js.deps +*.js.map + +fvm +google-services.json +example/ios/dist +.vscode/ \ No newline at end of file diff --git a/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md similarity index 95% rename from CHANGELOG.md rename to packages/stream_chat_flutter/CHANGELOG.md index 578265ce..ac09329a 100644 --- a/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,11 @@ +## 1.0.0-beta + +- **Refreshed widgets design** +- 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) + ## 0.2.21 - Add `loadingBuilder` in `MessageListView` diff --git a/packages/stream_chat_flutter/LICENSE b/packages/stream_chat_flutter/LICENSE new file mode 100644 index 00000000..f2d1eaf3 --- /dev/null +++ b/packages/stream_chat_flutter/LICENSE @@ -0,0 +1,219 @@ +SOURCE CODE LICENSE AGREEMENT + +IMPORTANT - READ THIS CAREFULLY BEFORE DOWNLOADING, INSTALLING, USING OR +ELECTRONICALLY ACCESSING THIS PROPRIETARY PRODUCT. + +THIS IS A LEGAL AGREEMENT BETWEEN STREAM.IO, INC. (“STREAM.IO”) AND THE +BUSINESS ENTITY OR PERSON FOR WHOM YOU (“YOU”) ARE ACTING (“CUSTOMER”) AS THE +LICENSEE OF THE PROPRIETARY SOFTWARE INTO WHICH THIS AGREEMENT HAS BEEN +INCLUDED (THE “AGREEMENT”). YOU AGREE THAT YOU ARE THE CUSTOMER, OR YOU ARE AN +EMPLOYEE OR AGENT OF CUSTOMER AND ARE ENTERING INTO THIS AGREEMENT FOR LICENSE +OF THE SOFTWARE BY CUSTOMER FOR CUSTOMER’S BUSINESS PURPOSES AS DESCRIBED IN +AND IN ACCORDANCE WITH THIS AGREEMENT. YOU HEREBY AGREE THAT YOU ENTER INTO +THIS AGREEMENT ON BEHALF OF CUSTOMER AND THAT YOU HAVE THE AUTHORITY TO BIND +CUSTOMER TO THIS AGREEMENT. + +STREAM.IO IS WILLING TO LICENSE THE SOFTWARE TO CUSTOMER ONLY ON THE FOLLOWING +CONDITIONS: (1) YOU ARE A CURRENT CUSTOMER OF STREAM.IO; (2) YOU ARE NOT A +COMPETITOR OF STREAM.IO; AND (3) THAT YOU ACCEPT ALL THE TERMS IN THIS +AGREEMENT. BY DOWNLOADING, INSTALLING, CONFIGURING, ACCESSING OR OTHERWISE +USING THE SOFTWARE, INCLUDING ANY UPDATES, UPGRADES, OR NEWER VERSIONS, YOU +REPRESENT, WARRANT AND ACKNOWLEDGE THAT (A) CUSTOMER IS A CURRENT CUSTOMER OF +STREAM.IO; (B) CUSTOMER IS NOT A COMPETITOR OF STREAM.IO; AND THAT (C) YOU HAVE +READ THIS AGREEMENT, UNDERSTAND THIS AGREEMENT, AND THAT CUSTOMER AGREES TO BE +BOUND BY ALL THE TERMS OF THIS AGREEMENT. + +IF YOU DO NOT AGREE TO ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT, +STREAM.IO IS UNWILLING TO LICENSE THE SOFTWARE TO CUSTOMER, AND THEREFORE, DO +NOT COMPLETE THE DOWNLOAD PROCESS, ACCESS OR OTHERWISE USE THE SOFTWARE, AND +CUSTOMER SHOULD IMMEDIATELY RETURN THE SOFTWARE AND CEASE ANY USE OF THE +SOFTWARE. + +1. SOFTWARE. The Stream.io software accompanying this Agreement, may include +Source Code, Executable Object Code, associated media, printed materials and +documentation (collectively, the “Software”). The Software also includes any +updates or upgrades to or new versions of the original Software, if and when +made available to you by Stream.io. “Source Code” means computer programming +code in human readable form that is not suitable for machine execution without +the intervening steps of interpretation or compilation. “Executable Object +Code" means the computer programming code in any other form than Source Code +that is not readily perceivable by humans and suitable for machine execution +without the intervening steps of interpretation or compilation. “Site” means a +Customer location controlled by Customer. “Authorized User” means any employee +or contractor of Customer working at the Site, who has signed a written +confidentiality agreement with Customer or is otherwise bound in writing by +confidentiality and use obligations at least as restrictive as those imposed +under this Agreement. + +2. LICENSE GRANT. Subject to the terms and conditions of this Agreement, in +consideration for the representations, warranties, and covenants made by +Customer in this Agreement, Stream.io grants to Customer, during the term of +this Agreement, a personal, non-exclusive, non-transferable, non-sublicensable +license to: + +a. install and use Software Source Code on password protected computers at a Site, +restricted to Authorized Users; + +b. create derivative works, improvements (whether or not patentable), extensions +and other modifications to the Software Source Code (“Modifications”) to build +unique scalable newsfeeds, activity streams, and in-app messaging via Stream’s +application program interface (“API”); + +c. compile the Software Source Code to create Executable Object Code versions of +the Software Source Code and Modifications to build such newsfeeds, activity +streams, and in-app messaging via the API; + +d. install, execute and use such Executable Object Code versions solely for +Customer’s internal business use (including development of websites through +which data generated by Stream services will be streamed (“Apps”)); + +e. use and distribute such Executable Object Code as part of Customer’s Apps; and + +f. make electronic copies of the Software and Modifications as required for backup +or archival purposes. + +3. RESTRICTIONS. Customer is responsible for all activities that occur in +connection with the Software. Customer will not, and will not attempt to: (a) +sublicense or transfer the Software or any Source Code related to the Software +or any of Customer’s rights under this Agreement, except as otherwise provided +in this Agreement, (b) use the Software Source Code for the benefit of a third +party or to operate a service; (c) allow any third party to access or use the +Software Source Code; (d) sublicense or distribute the Software Source Code or +any Modifications in Source Code or other derivative works based on any part of +the Software Source Code; (e) use the Software in any manner that competes with +Stream.io or its business; or (e) otherwise use the Software in any manner that +exceeds the scope of use permitted in this Agreement. Customer shall use the +Software in compliance with any accompanying documentation any laws applicable +to Customer. + +4. OPEN SOURCE. Customer and its Authorized Users shall not use any software or +software components that are open source in conjunction with the Software +Source Code or any Modifications in Source Code or in any way that could +subject the Software to any open source licenses. + +5. CONTRACTORS. Under the rights granted to Customer under this Agreement, +Customer may permit its employees, contractors, and agencies of Customer to +become Authorized Users to exercise the rights to the Software granted to +Customer in accordance with this Agreement solely on behalf of Customer to +provide services to Customer; provided that Customer shall be liable for the +acts and omissions of all Authorized Users to the extent any of such acts or +omissions, if performed by Customer, would constitute a breach of, or otherwise +give rise to liability to Customer under, this Agreement. Customer shall not +and shall not permit any Authorized User to use the Software except as +expressly permitted in this Agreement. + +6. COMPETITIVE PRODUCT DEVELOPMENT. Customer shall not use the Software in any way +to engage in the development of products or services which could be reasonably +construed to provide a complete or partial functional or commercial alternative +to Stream.io’s products or services (a “Competitive Product”). Customer shall +ensure that there is no direct or indirect use of, or sharing of, Software +source code, or other information based upon or derived from the Software to +develop such products or services. Without derogating from the generality of +the foregoing, development of Competitive Products shall include having direct +or indirect access to, supervising, consulting or assisting in the development +of, or producing any specifications, documentation, object code or source code +for, all or part of a Competitive Product. + +7. LIMITATION ON MODIFICATIONS. Notwithstanding any provision in this Agreement, +Modifications may only be created and used by Customer as permitted by this +Agreement and Modification Source Code may not be distributed to third parties. +Customer will not assert against Stream.io, its affiliates, or their customers, +direct or indirect, agents and contractors, in any way, any patent rights that +Customer may obtain relating to any Modifications for Stream.io, its +affiliates’, or their customers’, direct or indirect, agents’ and contractors’ +manufacture, use, import, offer for sale or sale of any Stream.io products or +services. + +8. DELIVERY AND ACCEPTANCE. The Software will be delivered electronically pursuant +to Stream.io standard download procedures. The Software is deemed accepted upon +delivery. + +9. IMPLEMENTATION AND SUPPORT. Stream.io has no obligation under this Agreement to +provide any support or consultation concerning the Software. + +10. TERM AND TERMINATION. The term of this Agreement begins when the Software is +downloaded or accessed and shall continue until terminated. Either party may +terminate this Agreement upon written notice. This Agreement shall +automatically terminate if Customer is or becomes a competitor of Stream.io or +makes or sells any Competitive Products. Upon termination of this Agreement for +any reason, (a) all rights granted to Customer in this Agreement immediately +cease to exist, (b) Customer must promptly discontinue all use of the Software +and return to Stream.io or destroy all copies of the Software in Customer’s +possession or control. Any continued use of the Software by Customer or attempt +by Customer to exercise any rights under this Agreement after this Agreement +has terminated shall be considered copyright infringement and subject Customer +to applicable remedies for copyright infringement. Sections 2, 5, 6, 8 and 9 +shall survive expiration or termination of this Agreement for any reason. + +11. OWNERSHIP. As between the parties, the Software and all worldwide intellectual +property rights and proprietary rights relating thereto or embodied therein, +are the exclusive property of Stream.io and its suppliers. Stream.io and its +suppliers reserve all rights in and to the Software not expressly granted to +Customer in this Agreement, and no other licenses or rights are granted by +implication, estoppel or otherwise. + +12. WARRANTY DISCLAIMER. USE OF THIS SOFTWARE IS ENTIRELY AT YOURS AND CUSTOMER’S +OWN RISK. THE SOFTWARE IS PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND +WHATSOEVER. STREAM.IO DOES NOT MAKE, AND HEREBY DISCLAIMS, ANY WARRANTY OF ANY +KIND, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING WITHOUT +LIMITATION, THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, TITLE, NON-INFRINGEMENT OF THIRD-PARTY RIGHTS, RESULTS, EFFORTS, +QUALITY OR QUIET ENJOYMENT. STREAM.IO DOES NOT WARRANT THAT THE SOFTWARE IS +ERROR-FREE, WILL FUNCTION WITHOUT INTERRUPTION, WILL MEET ANY SPECIFIC NEED +THAT CUSTOMER HAS, THAT ALL DEFECTS WILL BE CORRECTED OR THAT IT IS +SUFFICIENTLY DOCUMENTED TO BE USABLE BY CUSTOMER. TO THE EXTENT THAT STREAM.IO +MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW, THE SCOPE AND +DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER SUCH LAW. +CUSTOMER ACKNOWLEDGES THAT IT HAS RELIED ON NO WARRANTIES OTHER THAN THE +EXPRESS WARRANTIES IN THIS AGREEMENT. + +13. LIMITATION OF LIABILITY. TO THE FULLEST EXTENT PERMISSIBLE BY LAW, STREAM.IO’S +TOTAL LIABILITY FOR ALL DAMAGES ARISING OUT OF OR RELATED TO THE SOFTWARE OR +THIS AGREEMENT, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE) OR OTHERWISE, +SHALL NOT EXCEED $100. IN NO EVENT WILL STREAM.IO BE LIABLE FOR ANY INDIRECT, +CONSEQUENTIAL, EXEMPLARY, PUNITIVE, SPECIAL OR INCIDENTAL DAMAGES OF ANY KIND +WHATSOEVER, INCLUDING ANY LOST DATA AND LOST PROFITS, ARISING FROM OR RELATING +TO THE SOFTWARE EVEN IF STREAM.IO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. CUSTOMER ACKNOWLEDGES THAT THIS PROVISION REFLECTS THE AGREED UPON +ALLOCATION OF RISK FOR THIS AGREEMENT AND THAT STREAM.IO WOULD NOT ENTER INTO +THIS AGREEMENT WITHOUT THESE LIMITATIONS ON ITS LIABILITY. + +14. General. Customer may not assign or transfer this Agreement, by operation of +law or otherwise, or any of its rights under this Agreement (including the +license rights granted to Customer) to any third party without Stream.io’s +prior written consent, which consent will not be unreasonably withheld or +delayed. Stream.io may assign this Agreement, without consent, including, but +limited to, affiliate or any successor to all or substantially all its business +or assets to which this Agreement relates, whether by merger, sale of assets, +sale of stock, reorganization or otherwise. Any attempted assignment or +transfer in violation of the foregoing will be null and void. Stream.io shall +not be liable hereunder by reason of any failure or delay in the performance of +its obligations hereunder for any cause which is beyond the reasonable control. +All notices, consents, and approvals under this Agreement must be delivered in +writing by courier, by electronic mail, or by certified or registered mail, +(postage prepaid and return receipt requested) to the other party at the +address set forth in the customer agreement between Stream.io and Customer and +will be effective upon receipt or when delivery is refused. This Agreement will +be governed by and interpreted in accordance with the laws of the State of +Colorado, without reference to its choice of laws rules. The United Nations +Convention on Contracts for the International Sale of Goods does not apply to +this Agreement. Any action or proceeding arising from or relating to this +Agreement shall be brought in a federal or state court in Denver, Colorado, and +each party irrevocably submits to the jurisdiction and venue of any such court +in any such action or proceeding. All waivers must be in writing. Any waiver or +failure to enforce any provision of this Agreement on one occasion will not be +deemed a waiver of any other provision or of such provision on any other +occasion. If any provision of this Agreement is unenforceable, such provision +will be changed and interpreted to accomplish the objectives of such provision +to the greatest extent possible under applicable law and the remaining +provisions will continue in full force and effect. Customer shall not violate +any applicable law, rule or regulation, including those regarding the export of +technical data. The headings of Sections of this Agreement are for convenience +and are not to be used in interpreting this Agreement. As used in this +Agreement, the word “including” means “including but not limited to.” This +Agreement (including all exhibits and attachments) constitutes the entire +agreement between the parties regarding the subject hereof and supersedes all +prior or contemporaneous agreements, understandings and communication, whether +written or oral. This Agreement may be amended only by a written document +signed by both parties. The terms of any purchase order or similar document +submitted by Customer to Stream.io will have no effect. diff --git a/packages/stream_chat_flutter/README.md b/packages/stream_chat_flutter/README.md new file mode 100644 index 00000000..3504c78a --- /dev/null +++ b/packages/stream_chat_flutter/README.md @@ -0,0 +1,165 @@ +# Official Flutter SDK for [Stream Chat](https://getstream.io/chat/) + +

+ Flutter Chat +

+ +> The official Flutter components for Stream Chat, a service for +> building chat applications. + +[![Pub](https://img.shields.io/pub/v/stream_chat_flutter.svg)](https://pub.dartlang.org/packages/stream_chat_flutter) +![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) +[![Gitter](https://badges.gitter.im/GetStream/stream-chat-flutter.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +![CI](https://github.com/GetStream/stream-chat-flutter/workflows/CI/badge.svg?branch=master) + + +**Quick Links** + +- [Register](https://getstream.io/chat/trial/) to get an API key for Stream Chat +- [Flutter Chat Tutorial](https://getstream.io/chat/flutter/tutorial/) +- [Chat UI Kit](https://getstream.io/chat/ui-kit/) + +## Flutter Chat Tutorial + +The best place to start is the [Flutter Chat Tutorial](https://getstream.io/chat/flutter/tutorial/). +It teaches you how to use this SDK and also shows how to make frequently required changes. + +## 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/tree/master/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.svg)](https://pub.dartlang.org/packages/stream_chat_flutter) +```yaml +dependencies: + stream_chat_flutter: ^latest_version +``` + +You should then run `flutter packages get` + +### Android + +All set ✅ + +### iOS + +The library uses [flutter file picker plugin](https://github.com/miguelpruivo/flutter_file_picker) to pick +files from the os. +Follow [this wiki](https://github.com/miguelpruivo/flutter_file_picker/wiki/Setup#ios) to fulfill iOS requirements. + +We also use [video_player](https://pub.dev/packages/video_player) to reproduce videos. Follow [this guide](https://pub.dev/packages/video_player#installation) to fulfill the requirements. + +To pick images from the camera, we use the [image_picker](https://pub.dev/packages/image_picker) plugin. +Follow [these instructions](https://pub.dev/packages/image_picker#ios) to check the requirements. + +### Troubleshooting + +It may happen that you have some problems building the app. +If it seems related to the [flutter file picker plugin](https://github.com/miguelpruivo/flutter_file_picker) make sure to check [this page](https://github.com/miguelpruivo/flutter_file_picker/wiki/Troubleshooting) + +## Docs + +This package provides UI components required for integrating Stream Chat into your application. +Alternatively, you may use the core package (stream_chat_flutter_core) which allows more customisation and provides business logic but no UI components. +If you require the maximum amount of control over the API, please use the low level client package: stream_chat. + +### UI Components + +These are the available Widgets that you can use to build your application UI. +Every widget uses the `StreamChat` or `StreamChannel` widgets to manage the state and communicate with Stream services. + +- [ChannelHeader](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelHeader-class.html) +- [ChannelImage](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelImage-class.html) +- [ChannelListView](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelListView-class.html) +- [ChannelName](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelName-class.html) +- [ChannelPreview](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ChannelPreview-class.html) +- [MessageInput](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageInput-class.html) +- [MessageListView](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageListView-class.html) +- [MessageWidget](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/MessageWidget-class.html) +- [StreamChatTheme](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/StreamChatTheme-class.html) +- [ThreadHeader](https://pub.dev/documentation/stream_chat_flutter/latest/stream_chat_flutter/ThreadHeader-class.html) +- ... + +### Customizing styles + +The Flutter SDK comes with a fully designed set of widgets that you can customize to fit with your application style and typography. +Changing the theme of Chat widgets works in a very similar way that `MaterialApp` and `Theme` do. + +Out of the box, all chat widgets use their default styling, and there are two ways to change the styling: + + 1. Initialize the `StreamChatTheme` from your existing `MaterialApp` style + ```dart + class MyApp extends StatelessWidget { + final StreamChatClient client; + + MyApp(this.client); + + @override + Widget build(BuildContext context) { + final theme = ThemeData( + primarySwatch: Colors.green, + ); + + return MaterialApp( + theme: theme, + builder: (context, child) => StreamChat( + child: child, + client: client, + streamChatThemeData: StreamChatThemeData.fromTheme(theme), + ), + home: ChannelListPage(), + ); + } + } + ``` + + 2. Construct a custom theme and provide all the customizations needed + ```dart + class MyApp extends StatelessWidget { + final StreamChatClient client; + + MyApp(this.client); + + @override + Widget build(BuildContext context) { + final theme = ThemeData( + primarySwatch: Colors.green, + ); + + return MaterialApp( + theme: theme, + builder: (context, child) => StreamChat( + child: child, + client: client, + streamChatThemeData: StreamChatThemeData.fromTheme(theme).copyWith( + ownMessageTheme: MessageTheme( + messageBackgroundColor: Colors.black, + messageText: TextStyle( + color: Colors.white, + ), + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + home: ChannelListPage(), + ); + } + } + ``` + +### Offline storage + +By default the library saves information about channels and messages in a SQLite DB. + +Set the property `persistenceEnabled` to false if you don't want to use the offline storage. + +## Contributing + +We welcome code changes that improve this library or fix a problem, +please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. +We are pleased to merge your code into the official repository. +Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. +See our license file for more details. diff --git a/packages/stream_chat_flutter/analysis_options.yaml b/packages/stream_chat_flutter/analysis_options.yaml new file mode 100644 index 00000000..36d1fbb7 --- /dev/null +++ b/packages/stream_chat_flutter/analysis_options.yaml @@ -0,0 +1,63 @@ +include: package:pedantic/analysis_options.yaml + +analyzer: + enable-experiment: + - extension-methods + exclude: + - lib/**/*.g.dart + - example/** + +linter: + rules: + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # https://github.com/dart-lang/linter/blob/master/example/all.yaml + # - always_declare_return_types + # - always_specify_types + # - annotate_overrides + # - avoid_as + - avoid_empty_else + - avoid_init_to_null + - avoid_return_types_on_setters + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_types + - cancel_subscriptions + - close_sinks + # - comment_references # we do not presume as to what people want to reference in their dartdocs + # - constant_identifier_names # https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + - empty_constructor_bodies + - empty_statements + - hash_and_equals + - implementation_imports + # - invariant_booleans + # - iterable_contains_unrelated_type + - library_names + # - library_prefixes + # - list_remove_unrelated_type + # - literal_only_boolean_expressions + - non_constant_identifier_names + # - one_member_abstracts + # - only_throw_errors + # - overridden_fields +# - package_api_docs + - package_names + - package_prefixed_library_names + - prefer_is_not_empty + # - prefer_mixin # https://github.com/dart-lang/language/issues/32 + # - public_member_api_docs + - slash_for_doc_comments + # - sort_constructors_first + # - sort_unnamed_constructors_first + # - super_goes_last # no longer needed w/ Dart 2 + - test_types_in_equals + - throw_in_finally + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + # - unawaited_futures + - unnecessary_brace_in_string_interps + - unnecessary_getters_setters + - unnecessary_statements + - unrelated_type_equality_checks + - valid_regexps diff --git a/packages/stream_chat_flutter/animations/typing_dots.json b/packages/stream_chat_flutter/animations/typing_dots.json new file mode 100644 index 00000000..b210c24b --- /dev/null +++ b/packages/stream_chat_flutter/animations/typing_dots.json @@ -0,0 +1 @@ +{"v":"5.7.1","fr":29.9700012207031,"ip":0,"op":95.0000038694293,"w":132,"h":34,"nm":"Typing indicator","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"Shape Layer 6","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[65.938,24.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[100,100,100],"ix":6}},"ao":0,"shapes":[],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"Shape Layer 5","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[200.438,19.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[142.227,142.227,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[44.691,44.691],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.511611519608,0.511611519608,0.511611519608,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":53,"s":[33]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":53.5,"s":[33.66]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":54,"s":[34.333]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":54.5,"s":[35.019]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":55,"s":[35.72]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":55.5,"s":[36.434]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":56,"s":[37.162]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":56.5,"s":[37.905]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":57,"s":[38.663]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":57.5,"s":[39.436]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":58,"s":[40.225]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":58.5,"s":[41.029]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":59,"s":[41.849]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":59.5,"s":[42.686]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60,"s":[43.54]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":60.5,"s":[44.41]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":61,"s":[45.298]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":61.5,"s":[46.204]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":62,"s":[47.128]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":62.5,"s":[48.07]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":63,"s":[49.031]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":63.5,"s":[50.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":64,"s":[51.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":64.5,"s":[52.032]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65,"s":[53.072]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":65.5,"s":[54.133]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":66,"s":[55.216]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":66.5,"s":[56.32]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":67,"s":[57.446]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":67.5,"s":[58.594]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":68,"s":[59.766]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":68.5,"s":[60.961]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":69,"s":[62.18]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":69.5,"s":[63.423]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70,"s":[64.691]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":70.5,"s":[65.985]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":71,"s":[67.304]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":71.5,"s":[68.65]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":72,"s":[70.022]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":72.5,"s":[71.422]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":73,"s":[72.851]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":73.5,"s":[74.307]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":74,"s":[75.793]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":74.5,"s":[77.308]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75,"s":[78.854]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":75.5,"s":[80.431]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":76,"s":[82.039]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":76.5,"s":[83.679]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":77,"s":[85.353]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":77.5,"s":[87.059]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":78,"s":[88.8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":78.5,"s":[90.575]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":79,"s":[92.386]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":79.5,"s":[94.234]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":80,"s":[96.118]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":80.5,"s":[98.04]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":81,"s":[100]},{"t":91.000003706506,"s":[33]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-59.729,-1.971],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[50.039,50.039],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"Shape Layer 4","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[151.438,19.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[142.227,142.227,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[44.691,44.691],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.511611519608,0.511611519608,0.511611519608,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23,"s":[33]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23.5,"s":[33.66]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[34.333]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24.5,"s":[35.019]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[35.72]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25.5,"s":[36.434]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26,"s":[37.162]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26.5,"s":[37.905]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[38.663]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27.5,"s":[39.436]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28,"s":[40.225]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":28.5,"s":[41.029]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":29,"s":[41.849]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":29.5,"s":[42.686]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30,"s":[43.54]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":30.5,"s":[44.41]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":31,"s":[45.298]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":31.5,"s":[46.204]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":32,"s":[47.128]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":32.5,"s":[48.07]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33,"s":[49.031]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":33.5,"s":[50.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34,"s":[51.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":34.5,"s":[52.032]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":35,"s":[53.072]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":35.5,"s":[54.133]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":36,"s":[55.216]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":36.5,"s":[56.32]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37,"s":[57.446]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":37.5,"s":[58.594]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":38,"s":[59.766]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":38.5,"s":[60.961]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":39,"s":[62.18]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":39.5,"s":[63.423]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40,"s":[64.691]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":40.5,"s":[65.985]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41,"s":[67.304]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":41.5,"s":[68.65]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":42,"s":[70.022]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":42.5,"s":[71.422]},{"i":{"x":[0.686],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":43,"s":[72.851]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0]},"t":44,"s":[89]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":44.5,"s":[77.308]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45,"s":[78.854]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":45.5,"s":[80.431]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":46,"s":[82.039]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":46.5,"s":[83.679]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47,"s":[85.353]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":47.5,"s":[87.059]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":48,"s":[88.8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":48.5,"s":[90.575]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49,"s":[92.386]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":49.5,"s":[94.234]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50,"s":[96.118]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":50.5,"s":[98.04]},{"i":{"x":[0.833],"y":[1]},"o":{"x":[0.167],"y":[0.167]},"t":51,"s":[100]},{"t":85.000003462121,"s":[33]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-59.729,-1.971],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[50.039,50.039],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"Shape Layer 1","sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[101.938,19.75,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":0,"k":[142.227,142.227,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"d":1,"ty":"el","s":{"a":0,"k":[44.691,44.691],"ix":2},"p":{"a":0,"k":[0,0],"ix":3},"nm":"Ellipse Path 1","mn":"ADBE Vector Shape - Ellipse","hd":false},{"ty":"st","c":{"a":0,"k":[1,1,1,1],"ix":3},"o":{"a":0,"k":100,"ix":4},"w":{"a":0,"k":0,"ix":5},"lc":1,"lj":1,"ml":4,"bm":0,"nm":"Stroke 1","mn":"ADBE Vector Graphic - Stroke","hd":false},{"ty":"fl","c":{"a":0,"k":[0.511611519608,0.511611519608,0.511611519608,1],"ix":4},"o":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[33]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0.5,"s":[33.66]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1,"s":[34.333]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":1.5,"s":[35.019]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2,"s":[35.72]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":2.5,"s":[36.434]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":3,"s":[37.162]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":3.5,"s":[37.905]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4,"s":[38.663]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":4.5,"s":[39.436]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5,"s":[40.225]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":5.5,"s":[41.029]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6,"s":[41.849]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":6.5,"s":[42.686]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7,"s":[43.54]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":7.5,"s":[44.41]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8,"s":[45.298]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":8.5,"s":[46.204]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9,"s":[47.128]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":9.5,"s":[48.07]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10,"s":[49.031]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":10.5,"s":[50.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":11,"s":[51.012]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":11.5,"s":[52.032]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12,"s":[53.072]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":12.5,"s":[54.133]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13,"s":[55.216]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":13.5,"s":[56.32]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":14,"s":[57.446]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":14.5,"s":[58.594]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15,"s":[59.766]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":15.5,"s":[60.961]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16,"s":[62.18]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":16.5,"s":[63.423]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":17,"s":[64.691]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":17.5,"s":[65.985]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18,"s":[67.304]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":18.5,"s":[68.65]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":19,"s":[70.022]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":19.5,"s":[71.422]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20,"s":[72.851]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":20.5,"s":[74.307]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21,"s":[75.793]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":21.5,"s":[77.308]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":22,"s":[78.854]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":22.5,"s":[80.431]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23,"s":[82.039]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":23.5,"s":[83.679]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24,"s":[85.353]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":24.5,"s":[87.059]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25,"s":[88.8]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":25.5,"s":[90.575]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26,"s":[92.386]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":26.5,"s":[94.234]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27,"s":[96.118]},{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":27.5,"s":[98.04]},{"i":{"x":[0.816],"y":[0.991]},"o":{"x":[0.328],"y":[0]},"t":28,"s":[100]},{"i":{"x":[0.686],"y":[1]},"o":{"x":[0.352],"y":[0.66]},"t":89,"s":[33.03]},{"t":90.0000036657751,"s":[33]}],"ix":5},"r":1,"bm":0,"nm":"Fill 1","mn":"ADBE Vector Graphic - Fill","hd":false},{"ty":"tr","p":{"a":0,"k":[-59.729,-1.971],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[50.039,50.039],"ix":3},"r":{"a":0,"k":0,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Ellipse 1","np":3,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":900.000036657751,"st":0,"bm":0}],"markers":[]} \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/.gitignore b/packages/stream_chat_flutter/example/.gitignore new file mode 100644 index 00000000..9d532b18 --- /dev/null +++ b/packages/stream_chat_flutter/example/.gitignore @@ -0,0 +1,41 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/stream_chat_flutter/example/.metadata b/packages/stream_chat_flutter/example/.metadata new file mode 100644 index 00000000..cd984dd0 --- /dev/null +++ b/packages/stream_chat_flutter/example/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 9b2d32b605630f28625709ebd9d78ab3016b2bf6 + channel: stable + +project_type: app diff --git a/example/README.md b/packages/stream_chat_flutter/example/README.md similarity index 100% rename from example/README.md rename to packages/stream_chat_flutter/example/README.md diff --git a/packages/stream_chat_flutter/example/android/.gitignore b/packages/stream_chat_flutter/example/android/.gitignore new file mode 100644 index 00000000..0a741cb4 --- /dev/null +++ b/packages/stream_chat_flutter/example/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/example/android/app/build.gradle b/packages/stream_chat_flutter/example/android/app/build.gradle similarity index 80% rename from example/android/app/build.gradle rename to packages/stream_chat_flutter/example/android/app/build.gradle index 4fd90eac..5a4d759a 100644 --- a/example/android/app/build.gradle +++ b/packages/stream_chat_flutter/example/android/app/build.gradle @@ -26,7 +26,7 @@ apply plugin: 'kotlin-android' apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion 28 + compileSdkVersion 29 sourceSets { main.java.srcDirs += 'src/main/kotlin' @@ -40,10 +40,9 @@ android { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.example" minSdkVersion 21 - targetSdkVersion 28 + targetSdkVersion 29 versionCode flutterVersionCode.toInteger() versionName flutterVersionName - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -61,10 +60,4 @@ flutter { dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - testImplementation 'junit:junit:4.12' - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - implementation 'com.google.firebase:firebase-messaging:20.1.2' } - -apply plugin: 'com.google.gms.google-services' diff --git a/packages/stream_chat_flutter/example/android/app/src/debug/AndroidManifest.xml b/packages/stream_chat_flutter/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..c208884f --- /dev/null +++ b/packages/stream_chat_flutter/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/example/android/app/src/main/AndroidManifest.xml b/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml similarity index 58% rename from example/android/app/src/main/AndroidManifest.xml rename to packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml index c5f1e85b..3197993b 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/packages/stream_chat_flutter/example/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ FlutterApplication and put your custom class here. --> - - - - + + + + diff --git a/packages/stream_chat_flutter/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/packages/stream_chat_flutter/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 00000000..e793a000 --- /dev/null +++ b/packages/stream_chat_flutter/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/drawable/launch_background.xml b/packages/stream_chat_flutter/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/packages/stream_chat_flutter/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/packages/stream_chat_flutter/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter/example/android/app/src/main/res/values/styles.xml b/packages/stream_chat_flutter/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..1f83a33f --- /dev/null +++ b/packages/stream_chat_flutter/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/example/android/app/src/profile/AndroidManifest.xml b/packages/stream_chat_flutter/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..c208884f --- /dev/null +++ b/packages/stream_chat_flutter/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/stream_chat_flutter/example/android/build.gradle b/packages/stream_chat_flutter/example/android/build.gradle new file mode 100644 index 00000000..dc5cdbc9 --- /dev/null +++ b/packages/stream_chat_flutter/example/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.3.50' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:4.0.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/stream_chat_flutter/example/android/gradle.properties b/packages/stream_chat_flutter/example/android/gradle.properties new file mode 100644 index 00000000..a6738207 --- /dev/null +++ b/packages/stream_chat_flutter/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/packages/stream_chat_flutter/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/stream_chat_flutter/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..493072b3 --- /dev/null +++ b/packages/stream_chat_flutter/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip diff --git a/packages/stream_chat_flutter/example/android/settings.gradle b/packages/stream_chat_flutter/example/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/packages/stream_chat_flutter/example/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/example/android/settings_aar.gradle b/packages/stream_chat_flutter/example/android/settings_aar.gradle similarity index 100% rename from example/android/settings_aar.gradle rename to packages/stream_chat_flutter/example/android/settings_aar.gradle diff --git a/packages/stream_chat_flutter/example/ios/.gitignore b/packages/stream_chat_flutter/example/ios/.gitignore new file mode 100644 index 00000000..e96ef602 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/stream_chat_flutter/example/ios/Flutter/AppFrameworkInfo.plist b/packages/stream_chat_flutter/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..6b4c0f78 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/packages/stream_chat_flutter/example/ios/Flutter/Debug.xcconfig b/packages/stream_chat_flutter/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..e8efba11 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/stream_chat_flutter/example/ios/Flutter/Release.xcconfig b/packages/stream_chat_flutter/example/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..399e9340 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj similarity index 52% rename from example/ios/Runner.xcodeproj/project.pbxproj rename to packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 58907d4a..1aec2aa0 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -7,39 +7,15 @@ objects = { /* Begin PBXBuildFile section */ - 0BC14C50242B5A7A0028DE94 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BC14C4F242B5A7A0028DE94 /* NotificationService.swift */; }; - 0BC14C54242B5A7A0028DE94 /* Notifications.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 0BC14C4D242B5A7A0028DE94 /* Notifications.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7DEC2743BD66C91B700A3B97 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BB2E5E4E236267EDF0D8817 /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; /* End PBXBuildFile section */ -/* Begin PBXContainerItemProxy section */ - 0BC14C52242B5A7A0028DE94 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 0BC14C4C242B5A7A0028DE94; - remoteInfo = Notifications; - }; -/* End PBXContainerItemProxy section */ - /* Begin PBXCopyFilesBuildPhase section */ - 0BC14C55242B5A7A0028DE94 /* Embed App Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - 0BC14C54242B5A7A0028DE94 /* Notifications.appex in Embed App Extensions */, - ); - name = "Embed App Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -53,21 +29,12 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0BC14C4D242B5A7A0028DE94 /* Notifications.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Notifications.appex; sourceTree = BUILT_PRODUCTS_DIR; }; - 0BC14C4F242B5A7A0028DE94 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; - 0BC14C51242B5A7A0028DE94 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 0BC14C5A242B5ED90028DE94 /* Notifications.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Notifications.entitlements; sourceTree = ""; }; - 0BC14C5B242B5FF50028DE94 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 2452A9E77396497EB4CF3072 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 68F846A6DB42D92393F5F7E0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 7BF51EE28C89025F73A5211F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 8BB2E5E4E236267EDF0D8817 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -78,34 +45,16 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 0BC14C4A242B5A7A0028DE94 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 7DEC2743BD66C91B700A3B97 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 0BC14C4E242B5A7A0028DE94 /* Notifications */ = { - isa = PBXGroup; - children = ( - 0BC14C5A242B5ED90028DE94 /* Notifications.entitlements */, - 0BC14C4F242B5A7A0028DE94 /* NotificationService.swift */, - 0BC14C51242B5A7A0028DE94 /* Info.plist */, - ); - path = Notifications; - sourceTree = ""; - }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -120,12 +69,9 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( - 97C146F01CF9000F007C117D /* Runner */, 9740EEB11CF90186004384FC /* Flutter */, - 0BC14C4E242B5A7A0028DE94 /* Notifications */, + 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - CF168B61BAB91958681C7C21 /* Pods */, - BC09A38346C8B2CD72199469 /* Frameworks */, ); sourceTree = ""; }; @@ -133,7 +79,6 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, - 0BC14C4D242B5A7A0028DE94 /* Notifications.appex */, ); name = Products; sourceTree = ""; @@ -141,12 +86,10 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( - 0BC14C5B242B5FF50028DE94 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, - 97C146F11CF9000F007C117D /* Supporting Files */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -155,69 +98,23 @@ path = Runner; sourceTree = ""; }; - 97C146F11CF9000F007C117D /* Supporting Files */ = { - isa = PBXGroup; - children = ( - ); - name = "Supporting Files"; - sourceTree = ""; - }; - BC09A38346C8B2CD72199469 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 8BB2E5E4E236267EDF0D8817 /* Pods_Runner.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - CF168B61BAB91958681C7C21 /* Pods */ = { - isa = PBXGroup; - children = ( - 2452A9E77396497EB4CF3072 /* Pods-Runner.debug.xcconfig */, - 7BF51EE28C89025F73A5211F /* Pods-Runner.release.xcconfig */, - 68F846A6DB42D92393F5F7E0 /* Pods-Runner.profile.xcconfig */, - ); - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 0BC14C4C242B5A7A0028DE94 /* Notifications */ = { - isa = PBXNativeTarget; - buildConfigurationList = 0BC14C59242B5A7A0028DE94 /* Build configuration list for PBXNativeTarget "Notifications" */; - buildPhases = ( - 0BC14C49242B5A7A0028DE94 /* Sources */, - 0BC14C4A242B5A7A0028DE94 /* Frameworks */, - 0BC14C4B242B5A7A0028DE94 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Notifications; - productName = Notifications; - productReference = 0BC14C4D242B5A7A0028DE94 /* Notifications.appex */; - productType = "com.apple.product-type.app-extension"; - }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 9E02B5C38D6CC2455D9E48E9 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 5702861DACEDB848A3E454E8 /* [CP] Embed Pods Frameworks */, - 0BC14C55242B5A7A0028DE94 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( - 0BC14C53242B5A7A0028DE94 /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -230,25 +127,17 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1140; LastUpgradeCheck = 1020; - ORGANIZATIONNAME = "The Chromium Authors"; + ORGANIZATIONNAME = ""; TargetAttributes = { - 0BC14C4C242B5A7A0028DE94 = { - CreatedOnToolsVersion = 11.4; - DevelopmentTeam = EHV7XZLAHA; - ProvisioningStyle = Manual; - }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; - DevelopmentTeam = EHV7XZLAHA; LastSwiftMigration = 1100; - ProvisioningStyle = Manual; }; }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 3.2"; + compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -261,19 +150,11 @@ projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, - 0BC14C4C242B5A7A0028DE94 /* Notifications */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 0BC14C4B242B5A7A0028DE94 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -302,72 +183,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 5702861DACEDB848A3E454E8 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/Starscream-framework/Starscream.framework", - "${BUILT_PRODUCTS_DIR}/StreamChatClient-framework/StreamChatClient.framework", - "${BUILT_PRODUCTS_DIR}/DKImagePickerController/DKImagePickerController.framework", - "${BUILT_PRODUCTS_DIR}/DKPhotoGallery/DKPhotoGallery.framework", - "${BUILT_PRODUCTS_DIR}/FMDB/FMDB.framework", - "${PODS_ROOT}/../Flutter/Flutter.framework", - "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", - "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", - "${BUILT_PRODUCTS_DIR}/Protobuf/Protobuf.framework", - "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", - "${BUILT_PRODUCTS_DIR}/SwiftyGif/SwiftyGif.framework", - "${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework", - "${BUILT_PRODUCTS_DIR}/flutter_apns/flutter_apns.framework", - "${BUILT_PRODUCTS_DIR}/flutter_keyboard_visibility/flutter_keyboard_visibility.framework", - "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", - "${BUILT_PRODUCTS_DIR}/image_picker/image_picker.framework", - "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", - "${BUILT_PRODUCTS_DIR}/path_provider/path_provider.framework", - "${BUILT_PRODUCTS_DIR}/shared_preferences/shared_preferences.framework", - "${BUILT_PRODUCTS_DIR}/sqflite/sqflite.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3/sqlite3.framework", - "${BUILT_PRODUCTS_DIR}/sqlite3_flutter_libs/sqlite3_flutter_libs.framework", - "${BUILT_PRODUCTS_DIR}/url_launcher/url_launcher.framework", - "${BUILT_PRODUCTS_DIR}/video_player/video_player.framework", - "${BUILT_PRODUCTS_DIR}/wakelock/wakelock.framework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Starscream.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/StreamChatClient.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FMDB.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Flutter.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleUtilities.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FBLPromises.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Protobuf.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyGif.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_apns.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_keyboard_visibility.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqflite.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/sqlite3_flutter_libs.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/wakelock.framework", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -382,39 +197,9 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - 9E02B5C38D6CC2455D9E48E9 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 0BC14C49242B5A7A0028DE94 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 0BC14C50242B5A7A0028DE94 /* NotificationService.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -426,14 +211,6 @@ }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - 0BC14C53242B5A7A0028DE94 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 0BC14C4C242B5A7A0028DE94 /* Notifications */; - targetProxy = 0BC14C52242B5A7A0028DE94 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -454,90 +231,6 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ - 0BC14C56242B5A7A0028DE94 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = Notifications/Notifications.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = EHV7XZLAHA; - ENABLE_BITCODE = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = Notifications/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.3; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter.Notifications; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "flutter example notifications"; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 0BC14C57242B5A7A0028DE94 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = Notifications/Notifications.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = EHV7XZLAHA; - ENABLE_BITCODE = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = Notifications/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.3; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter.Notifications; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "flutter example notifications"; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Release; - }; - 0BC14C58242B5A7A0028DE94 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = Notifications/Notifications.entitlements; - CODE_SIGN_IDENTITY = "iPhone Distribution"; - CODE_SIGN_STYLE = Manual; - DEVELOPMENT_TEAM = EHV7XZLAHA; - ENABLE_BITCODE = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = Notifications/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.3; - LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter.Notifications; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "flutter example notifications"; - SKIP_INSTALL = YES; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Profile; - }; 249021D3217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; buildSettings = { @@ -579,7 +272,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -592,29 +285,22 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = EHV7XZLAHA; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "flutter app example"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -668,7 +354,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -717,7 +403,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 8.0; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -731,29 +417,22 @@ isa = XCBuildConfiguration; baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = EHV7XZLAHA; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "flutter app example"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -765,29 +444,22 @@ isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; - CODE_SIGN_IDENTITY = "iPhone Developer"; - CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = EHV7XZLAHA; ENABLE_BITCODE = NO; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 11.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LIBRARY_SEARCH_PATHS = ( "$(inherited)", "$(PROJECT_DIR)/Flutter", ); - PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = "flutter app example"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -797,16 +469,6 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 0BC14C59242B5A7A0028DE94 /* Build configuration list for PBXNativeTarget "Notifications" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 0BC14C56242B5A7A0028DE94 /* Debug */, - 0BC14C57242B5A7A0028DE94 /* Release */, - 0BC14C58242B5A7A0028DE94 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/example/ios/Notifications/Notifications.entitlements b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 62% rename from example/ios/Notifications/Notifications.entitlements rename to packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist index 00390120..18d98100 100644 --- a/example/ios/Notifications/Notifications.entitlements +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -2,9 +2,7 @@ - com.apple.security.application-groups - - group.io.stream.flutter - + IDEDidComputeMac32BitWarning + diff --git a/example/ios/Runner/Runner.entitlements b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 52% rename from example/ios/Runner/Runner.entitlements rename to packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings index 967ba7f2..f9b0d7c5 100644 --- a/example/ios/Runner/Runner.entitlements +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -2,11 +2,7 @@ - aps-environment - development - com.apple.security.application-groups - - group.io.stream.flutter - + PreviewsEnabled + diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..a28140cf --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/stream_chat_flutter/example/ios/Runner/AppDelegate.swift b/packages/stream_chat_flutter/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..70693e4a --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..28c6bf03 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..2ccbfd96 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..f091b6b0 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cde1211 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..d0ef06e7 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..dcdc2306 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..2ccbfd96 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..c8f9ed8f Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..a6d6b860 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..a6d6b860 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..75b2d164 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..c4df70d3 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..6a84f41e Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..d0e1f585 Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/stream_chat_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/example/ios/Runner/Base.lproj/Main.storyboard b/packages/stream_chat_flutter/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/example/ios/Runner/Info.plist b/packages/stream_chat_flutter/example/ios/Runner/Info.plist new file mode 100644 index 00000000..a74aeed0 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/Info.plist @@ -0,0 +1,63 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + UIBackgroundModes + + fetch + remote-notification + + NSPhotoLibraryUsageDescription + Explain why your app uses photo library + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + NSCameraUsageDescription + Explain why your app uses the camera + + NSMicrophoneUsageDescription + Explain why your app uses the mic + + diff --git a/packages/stream_chat_flutter/example/ios/Runner/Runner-Bridging-Header.h b/packages/stream_chat_flutter/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/packages/stream_chat_flutter/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart new file mode 100644 index 00000000..31ed8711 --- /dev/null +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() async { + /// Create a new instance of [StreamChatClient] passing the apikey obtained from your + /// project dashboard. + final client = StreamChatClient( + 's2dxdhpxd94g', + logLevel: Level.INFO, + ); + + /// Set the current user. In a production scenario, this should be done using + /// a backend to generate a user token using our server SDK. + /// Please see the following for more information: + /// https://getstream.io/chat/docs/ios_user_setup_and_tokens/ + await client.setUser( + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + ); + + final channel = client.channel('messaging', id: 'godevs'); + + // ignore: unawaited_futures + channel.watch(); + + runApp(MyApp(client, channel)); +} + +/// Example application using Stream Chat Flutter widgets. +/// Stream Chat Flutter is a set of Flutter widgets which provide full chat functionalities +/// for building Flutter applications using Stream. +/// If you'd prefer using minimal wrapper widgets for your app, please see our other +/// package, `stream_chat_flutter_core`. +class MyApp extends StatelessWidget { + /// Instance of Stream Client. + /// Stream's [StreamChatClient] can be used to connect to our servers and set the default + /// user for the application. Performing these actions trigger a websocket connection + /// allowing for real-time updates. + final StreamChatClient client; + + /// Instance of the Channel + final Channel channel; + + /// Example using Stream's Flutter package. + /// If you'd prefer using minimal wrapper widgets for your app, please see our other + /// package, `stream_chat_flutter_core`. + MyApp(this.client, this.channel); + + @override + Widget build(BuildContext context) { + return MaterialApp( + builder: (context, widget) { + return StreamChat( + child: widget, + client: client, + ); + }, + home: StreamChannel( + channel: channel, + child: ChannelPage(), + ), + ); + } +} + +/// A list of messages sent in the current channel. +/// +/// This is implemented using [MessageListView], a widget that provides query functionalities +/// fetching the messages from the api and showing them in a listView +class ChannelPage extends StatelessWidget { + /// Creates the page that shows the list of messages + const ChannelPage({ + Key key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ChannelHeader(), + body: Column( + children: [ + Expanded( + child: MessageListView(), + ), + MessageInput(), + ], + ), + ); + } +} diff --git a/example/lib/split_view.dart b/packages/stream_chat_flutter/example/lib/split_view.dart similarity index 96% rename from example/lib/split_view.dart rename to packages/stream_chat_flutter/example/lib/split_view.dart index f99e69fc..dbece419 100644 --- a/example/lib/split_view.dart +++ b/packages/stream_chat_flutter/example/lib/split_view.dart @@ -1,8 +1,9 @@ +// ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() async { - final client = Client( + final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, ); @@ -16,7 +17,7 @@ void main() async { } class MyApp extends StatelessWidget { - final Client client; + final StreamChatClient client; MyApp(this.client); diff --git a/example/lib/single_conversation.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-1.dart similarity index 83% rename from example/lib/single_conversation.dart rename to packages/stream_chat_flutter/example/lib/tutorial-part-1.dart index 2093d38a..8fda6029 100644 --- a/example/lib/single_conversation.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-1.dart @@ -1,3 +1,4 @@ +// ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -5,8 +6,8 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// There are three important things to notice that are common to all Flutter application using StreamChat: /// -/// 1. The Dart API [Client] is initialized with your API Key -/// 2. The current user is set by calling [Client.setUser] +/// 1. The Dart API [StreamChatClient] is initialized with your API Key +/// 2. The current user is set by calling [StreamChatClient.setUser] /// 3. The client is then passed to the top-level [StreamChat] widget /// [StreamChat] is an inherited widget and must be the parent of all Chat related widgets. /// @@ -15,9 +16,9 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// Let's have a look at what we've built: /// -/// - We set up the Chat [Client] with the API key +/// - We set up the Chat [StreamChatClient] with the API key /// -/// - We set the the current user for Chat with [Client.setUser] and a pre-generated user token +/// - We set the the current user for Chat with [StreamChatClient.setUser] and a pre-generated user token /// /// - We make [StreamChat] the root Widget of our application /// @@ -25,7 +26,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// If you now run the simulator you will see a single channel UI. void main() async { - final client = Client( + final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, ); @@ -44,7 +45,7 @@ void main() async { } class MyApp extends StatelessWidget { - final Client client; + final StreamChatClient client; final Channel channel; MyApp(this.client, this.channel); diff --git a/example/lib/multiple_conversation.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-2.dart similarity index 92% rename from example/lib/multiple_conversation.dart rename to packages/stream_chat_flutter/example/lib/tutorial-part-2.dart index 647ae7dd..411b9475 100644 --- a/example/lib/multiple_conversation.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-2.dart @@ -1,3 +1,4 @@ +// ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -19,7 +20,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// In this case we are showing the list of channels the current user is a member and we order them based on the time they had a new message. /// [ChannelListView] handles pagination and updates automatically out of the box when new channels are created or when a new message is added to a channel. void main() async { - final client = Client( + final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, ); @@ -33,7 +34,7 @@ void main() async { } class MyApp extends StatelessWidget { - final Client client; + final StreamChatClient client; MyApp(this.client); @@ -56,9 +57,9 @@ class ChannelListPage extends StatelessWidget { body: ChannelsBloc( child: ChannelListView( filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], - } + // 'members': { + // '\$in': [StreamChat.of(context).user.id], + // } }, sort: [SortOption('last_message_at')], pagination: PaginationParams( diff --git a/example/lib/customize_channel_preview.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart similarity index 86% rename from example/lib/customize_channel_preview.dart rename to packages/stream_chat_flutter/example/lib/tutorial-part-3.dart index 503987ae..6e3e6276 100644 --- a/example/lib/customize_channel_preview.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart @@ -1,3 +1,4 @@ +// ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -20,7 +21,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// - We retrieve the count of unread messages from [Channel.state] void main() async { - final client = Client( + final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, ); @@ -34,7 +35,7 @@ void main() async { } class MyApp extends StatelessWidget { - final Client client; + final StreamChatClient client; MyApp(this.client); @@ -78,18 +79,31 @@ class ChannelListPage extends StatelessWidget { orElse: () => null, ); - final subtitle = (lastMessage == null ? "nothing yet" : lastMessage.text); + final subtitle = (lastMessage == null ? 'nothing yet' : lastMessage.text); final opacity = channel.state.unreadCount > .0 ? 1.0 : 0.5; return ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => StreamChannel( + child: ChannelPage(), + channel: channel, + ), + ), + ); + }, leading: ChannelImage( channel: channel, ), title: ChannelName( - channel: channel, textStyle: StreamChatTheme.of(context).channelPreviewTheme.title.copyWith( - color: Colors.black.withOpacity(opacity), + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(opacity), ), ), subtitle: Text(subtitle), diff --git a/example/lib/threads.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-4.dart similarity index 96% rename from example/lib/threads.dart rename to packages/stream_chat_flutter/example/lib/tutorial-part-4.dart index f89e6fce..2b46d46b 100644 --- a/example/lib/threads.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-4.dart @@ -1,3 +1,4 @@ +// ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -10,7 +11,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// Now we can open threads and create new ones as well, if you long press a message you can tap on Reply and it will open the same [ThreadPage]. void main() async { - final client = Client( + final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, ); @@ -24,7 +25,7 @@ void main() async { } class MyApp extends StatelessWidget { - final Client client; + final StreamChatClient client; MyApp(this.client); diff --git a/example/lib/custom_message.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart similarity index 96% rename from example/lib/custom_message.dart rename to packages/stream_chat_flutter/example/lib/tutorial-part-5.dart index 5da4541a..4d9133ee 100644 --- a/example/lib/custom_message.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-5.dart @@ -1,3 +1,4 @@ +// ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -15,7 +16,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// you can use [StreamChat.of], [StreamChannel.of] and [StreamChatTheme.of] to use the API client directly /// or to retrieve outer scope needed such as messages from the [Channel.state]. void main() async { - final client = Client( + final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, ); @@ -29,7 +30,7 @@ void main() async { } class MyApp extends StatelessWidget { - final Client client; + final StreamChatClient client; MyApp(this.client); diff --git a/example/lib/custom_theme.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart similarity index 80% rename from example/lib/custom_theme.dart rename to packages/stream_chat_flutter/example/lib/tutorial-part-6.dart index 21db1c6c..69e0f26a 100644 --- a/example/lib/custom_theme.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-6.dart @@ -1,3 +1,4 @@ +// ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -19,7 +20,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// We also change the message color posted by the current user. /// You can perform these more granular style changes using [StreamChatTheme.copyWith]. void main() async { - final client = Client( + final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, ); @@ -33,33 +34,36 @@ void main() async { } class MyApp extends StatelessWidget { - final Client client; + final StreamChatClient client; MyApp(this.client); @override Widget build(BuildContext context) { - final theme = ThemeData( - primarySwatch: Colors.green, - ); - - return MaterialApp( - theme: theme, - builder: (context, child) => StreamChat( - child: child, - client: client, - streamChatThemeData: StreamChatThemeData.fromTheme(theme).copyWith( - ownMessageTheme: MessageTheme( - messageBackgroundColor: Colors.black, - messageText: TextStyle( - color: Colors.white, - ), - avatarTheme: AvatarTheme( - borderRadius: BorderRadius.circular(8), - ), - ), + final themeData = ThemeData(primarySwatch: Colors.green); + final defaultTheme = StreamChatThemeData.fromTheme(themeData); + final colorTheme = defaultTheme.colorTheme; + final customTheme = defaultTheme.merge(StreamChatThemeData( + ownMessageTheme: MessageTheme( + messageBackgroundColor: colorTheme.black, + messageText: TextStyle( + color: colorTheme.white, + ), + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(8), ), ), + )); + + return MaterialApp( + theme: themeData, + builder: (context, child) { + return StreamChat( + child: child, + client: client, + streamChatThemeData: customTheme, + ); + }, home: ChannelListPage(), ); } diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml new file mode 100644 index 00000000..7a1863b0 --- /dev/null +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -0,0 +1,79 @@ +name: example +description: A new Flutter project. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + stream_chat_flutter: + path: ../ + stream_chat_persistence: + path: ../../stream_chat_persistence + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/stream_chat_flutter/images/giphy_icon.png b/packages/stream_chat_flutter/images/giphy_icon.png new file mode 100644 index 00000000..ea99e834 Binary files /dev/null and b/packages/stream_chat_flutter/images/giphy_icon.png differ diff --git a/packages/stream_chat_flutter/images/placeholder.png b/packages/stream_chat_flutter/images/placeholder.png new file mode 100644 index 00000000..deca8474 Binary files /dev/null and b/packages/stream_chat_flutter/images/placeholder.png differ diff --git a/lib/src/attachment_error.dart b/packages/stream_chat_flutter/lib/src/attachment_error.dart similarity index 68% rename from lib/src/attachment_error.dart rename to packages/stream_chat_flutter/lib/src/attachment_error.dart index e08867b8..46b7b814 100644 --- a/lib/src/attachment_error.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_error.dart @@ -1,7 +1,8 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import '../stream_chat_flutter.dart'; class AttachmentError extends StatelessWidget { final Attachment attachment; @@ -23,12 +24,12 @@ class AttachmentError extends StatelessWidget { return Center( child: Container( width: size?.width, - height: size?.height, - color: Color(0xffd0021B).withOpacity(.1), + height: size?.height ?? 200, + color: StreamChatTheme.of(context).colorTheme.accentRed.withOpacity(.1), child: Center( child: Icon( Icons.error_outline, - color: Colors.white, + color: StreamChatTheme.of(context).colorTheme.black, ), ), ), diff --git a/lib/src/attachment_title.dart b/packages/stream_chat_flutter/lib/src/attachment_title.dart similarity index 91% rename from lib/src/attachment_title.dart rename to packages/stream_chat_flutter/lib/src/attachment_title.dart index ab9cd2da..af5a1115 100644 --- a/lib/src/attachment_title.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_title.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'stream_chat_theme.dart'; import 'utils.dart'; @@ -32,7 +32,7 @@ class AttachmentTitle extends StatelessWidget { attachment.title, overflow: TextOverflow.ellipsis, style: messageTheme.messageText.copyWith( - color: StreamChatTheme.of(context).accentColor, + color: StreamChatTheme.of(context).colorTheme.accentBlue, fontWeight: FontWeight.bold, ), ), diff --git a/packages/stream_chat_flutter/lib/src/back_button.dart b/packages/stream_chat_flutter/lib/src/back_button.dart new file mode 100644 index 00000000..9d787517 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/back_button.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/unread_indicator.dart'; + +import '../stream_chat_flutter.dart'; + +class StreamBackButton extends StatelessWidget { + const StreamBackButton({ + Key key, + this.onPressed, + this.showUnreads = false, + this.cid, + }) : super(key: key); + + final VoidCallback onPressed; + final bool showUnreads; + + /// Channel cid used to retrieve unread count + final String cid; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Padding( + padding: const EdgeInsets.all(14.0), + child: RawMaterialButton( + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + disabledElevation: 0, + hoverElevation: 0, + onPressed: () { + if (onPressed != null) { + onPressed(); + } else { + Navigator.maybePop(context); + } + }, + child: StreamSvgIcon.left( + size: 24, + color: StreamChatTheme.of(context).colorTheme.black, + ), + ), + ), + if (showUnreads) + Positioned( + top: 7, + right: 7, + child: UnreadIndicator( + cid: cid, + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart new file mode 100644 index 00000000..57b27a2f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_bottom_sheet.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; + +import '../stream_chat_flutter.dart'; +import 'channel_info.dart'; +import 'option_list_tile.dart'; + +class ChannelBottomSheet extends StatefulWidget { + final VoidCallback onViewInfoTap; + + ChannelBottomSheet({this.onViewInfoTap}); + + @override + _ChannelBottomSheetState createState() => _ChannelBottomSheetState(); +} + +class _ChannelBottomSheetState extends State { + bool _showActions = true; + + @override + Widget build(BuildContext context) { + var channel = StreamChannel.of(context).channel; + + var members = channel.state.members; + + var userAsMember = + members.firstWhere((e) => e.user.id == StreamChat.of(context).user.id); + var isOwner = userAsMember.role == 'owner'; + + return Material( + color: StreamChatTheme.of(context).colorTheme.white, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + child: !_showActions + ? SizedBox() + : ListView( + shrinkWrap: true, + children: [ + SizedBox( + height: 24.0, + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: ChannelName( + textStyle: + StreamChatTheme.of(context).textTheme.headlineBold, + ), + ), + ), + SizedBox( + height: 5.0, + ), + Center( + child: ChannelInfo( + showTypingIndicator: false, + channel: StreamChannel.of(context).channel, + textStyle: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle, + ), + ), + SizedBox( + height: 17.0, + ), + if (channel.isDistinct && channel.memberCount == 2) + Column( + children: [ + UserAvatar( + user: members + .firstWhere( + (e) => e.user.id != userAsMember.user.id) + .user, + constraints: BoxConstraints( + maxHeight: 64.0, + maxWidth: 64.0, + ), + borderRadius: BorderRadius.circular(32.0), + onlineIndicatorConstraints: + BoxConstraints.tight(Size(12.0, 12.0)), + ), + SizedBox( + height: 6.0, + ), + Text( + members + .firstWhere( + (e) => e.user.id != userAsMember.user.id) + .user + .name, + style: + StreamChatTheme.of(context).textTheme.footnoteBold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + if (!(channel.isDistinct && channel.memberCount == 2)) + Container( + height: 94.0, + alignment: Alignment.center, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: members.length, + shrinkWrap: true, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + children: [ + UserAvatar( + user: members[index].user, + constraints: BoxConstraints( + maxHeight: 64.0, + maxWidth: 64.0, + ), + borderRadius: BorderRadius.circular(32.0), + onlineIndicatorConstraints: + BoxConstraints.tight(Size(12.0, 12.0)), + ), + SizedBox( + height: 6.0, + ), + Text( + members[index].user.name, + style: StreamChatTheme.of(context) + .textTheme + .footnoteBold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + }, + ), + ), + SizedBox( + height: 24.0, + ), + OptionListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: StreamSvgIcon.user( + color: StreamChatTheme.of(context).colorTheme.grey, + ), + ), + title: 'View Info', + onTap: widget.onViewInfoTap, + ), + if (!channel.isDistinct) + OptionListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: StreamSvgIcon.userRemove( + color: StreamChatTheme.of(context).colorTheme.grey, + ), + ), + title: 'Leave Group', + onTap: () async { + setState(() { + _showActions = false; + }); + await _showLeaveDialog(); + setState(() { + _showActions = true; + }); + }, + ), + if (isOwner) + OptionListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: StreamSvgIcon.delete( + color: StreamChatTheme.of(context).colorTheme.accentRed, + ), + ), + title: 'Delete Conversation', + titleColor: + StreamChatTheme.of(context).colorTheme.accentRed, + onTap: () async { + setState(() { + _showActions = false; + }); + await _showDeleteDialog(); + setState(() { + _showActions = true; + }); + }, + ), + OptionListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: StreamSvgIcon.closeSmall( + color: StreamChatTheme.of(context).colorTheme.grey, + ), + ), + title: 'Cancel', + onTap: () { + Navigator.pop(context); + }, + ), + ], + ), + ); + } + + Future _showDeleteDialog() async { + final res = await showConfirmationDialog( + context, + title: 'Delete Conversation', + okText: 'DELETE', + question: 'Are you sure you want to delete this conversation?', + cancelText: 'CANCEL', + icon: StreamSvgIcon.delete( + color: StreamChatTheme.of(context).colorTheme.accentRed, + ), + ); + var channel = StreamChannel.of(context).channel; + if (res == true) { + await channel.delete(); + Navigator.pop(context); + } + } + + Future _showLeaveDialog() async { + final res = await showConfirmationDialog( + context, + title: 'Leave conversation', + okText: 'LEAVE', + question: 'Are you sure you want to leave this conversation?', + cancelText: 'CANCEL', + icon: StreamSvgIcon.userRemove( + color: StreamChatTheme.of(context).colorTheme.accentRed, + ), + ); + var channel = StreamChannel.of(context).channel; + if (res == true) { + await channel.removeMembers([StreamChat.of(context).user.id]); + Navigator.pop(context); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart new file mode 100644 index 00000000..244729a4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class ChannelFileDisplayScreen extends StatefulWidget { + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sortOptions; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams paginationParams; + + /// The builder used when the file list is empty. + final WidgetBuilder emptyBuilder; + + const ChannelFileDisplayScreen({ + this.sortOptions, + this.paginationParams, + this.emptyBuilder, + }); + + @override + _ChannelFileDisplayScreenState createState() => + _ChannelFileDisplayScreenState(); +} + +class _ChannelFileDisplayScreenState extends State { + @override + void initState() { + super.initState(); + final messageSearchBloc = MessageSearchBloc.of(context); + messageSearchBloc.search( + filter: { + 'cid': { + r'$in': ['messaging:${StreamChannel.of(context).channel.id}'] + } + }, + messageFilter: { + 'attachments.type': { + r'$in': ['file'], + }, + }, + sort: widget.sortOptions, + pagination: widget.paginationParams, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + appBar: AppBar( + brightness: Theme.of(context).brightness, + elevation: 1, + centerTitle: true, + title: Text( + 'Files', + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.black, + fontSize: 16.0), + ), + leading: Center( + child: InkWell( + onTap: () { + Navigator.of(context).pop(); + }, + child: Container( + child: StreamSvgIcon.left( + color: StreamChatTheme.of(context).colorTheme.black, + size: 24.0, + ), + width: 24.0, + height: 24.0, + ), + ), + ), + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + ), + body: _buildMediaGrid(), + ); + } + + Widget _buildMediaGrid() { + final messageSearchBloc = MessageSearchBloc.of(context); + + return StreamBuilder>( + builder: (context, snapshot) { + if (snapshot.data == null) { + return Center( + child: const CircularProgressIndicator(), + ); + } + + if (snapshot.data.isEmpty) { + if (widget.emptyBuilder != null) { + return widget.emptyBuilder(context); + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + StreamSvgIcon.files( + size: 136.0, + color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + ), + SizedBox(height: 16.0), + Text( + 'No Files', + style: TextStyle( + fontSize: 14.0, + color: StreamChatTheme.of(context).colorTheme.black, + ), + ), + SizedBox(height: 8.0), + Text( + 'Files sent in this chat will appear here', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.0, + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5), + ), + ), + ], + ), + ); + } + + final media = {}; + + for (var item in snapshot.data) { + item.message.attachments.where((e) => e.type == 'file').forEach((e) { + media[e] = item.message; + }); + } + + return LazyLoadScrollView( + onEndOfPage: () => messageSearchBloc.loadMore( + filter: { + 'cid': { + r'$in': ['messaging:${StreamChannel.of(context).channel.id}'] + } + }, + messageFilter: { + 'attachments.type': { + r'$in': ['file'] + }, + }, + sort: widget.sortOptions, + pagination: widget.paginationParams.copyWith( + offset: messageSearchBloc.messageResponses?.length ?? 0, + ), + ), + child: ListView.builder( + itemBuilder: (context, position) { + return Padding( + padding: const EdgeInsets.all(1.0), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: FileAttachment( + attachment: media.keys.toList()[position], + ), + ), + ); + }, + itemCount: media.length, + ), + ); + }, + stream: messageSearchBloc.messagesStream, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel_header.dart new file mode 100644 index 00000000..6845a632 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_header.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/back_button.dart'; +import 'package:stream_chat_flutter/src/channel_info.dart'; +import 'package:stream_chat_flutter/src/channel_name.dart'; +import 'package:stream_chat_flutter/src/info_tile.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import './channel_name.dart'; +import '../stream_chat_flutter.dart'; +import 'channel_image.dart'; +import 'connection_status_builder.dart'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_header.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_header_paint.png) +/// +/// It shows the current [Channel] information. +/// +/// ```dart +/// class MyApp extends StatelessWidget { +/// final StreamChatClient client; +/// final Channel channel; +/// +/// MyApp(this.client, this.channel); +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// home: StreamChat( +/// client: client, +/// child: StreamChannel( +/// channel: channel, +/// child: Scaffold( +/// appBar: ChannelHeader(), +/// ), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. +/// However you can also use it as a normal widget. +/// +/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channel. +/// Every part of the widget uses a [StreamBuilder] to render the channel information as soon as it updates. +/// +/// By default the widget shows a backButton that calls [Navigator.pop]. +/// You can disable this button using the [showBackButton] property of just override the behaviour +/// with [onBackPressed]. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. +/// Modify it to change the widget appearance. +class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { + /// True if this header shows the leading back button + final bool showBackButton; + + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback onBackPressed; + + /// Callback to call when the header is tapped. + final VoidCallback onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback onImageTap; + + /// If true the typing indicator will be rendered if a user is typing + final bool showTypingIndicator; + + final bool showConnectionStateTile; + + /// Creates a channel header + ChannelHeader({ + Key key, + this.showBackButton = true, + this.onBackPressed, + this.onTitleTap, + this.showTypingIndicator = true, + this.onImageTap, + this.showConnectionStateTile = false, + }) : preferredSize = Size.fromHeight(kToolbarHeight), + super(key: key); + + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + + return ConnectionStatusBuilder( + statusBuilder: (context, status) { + var statusString = ''; + var showStatus = true; + + switch (status) { + case ConnectionStatus.connected: + statusString = 'Connected'; + showStatus = false; + break; + case ConnectionStatus.connecting: + statusString = 'Reconnecting...'; + break; + case ConnectionStatus.disconnected: + statusString = 'Disconnected'; + break; + } + + return InfoTile( + showMessage: showConnectionStateTile ? showStatus : false, + message: statusString, + child: AppBar( + brightness: Theme.of(context).brightness, + elevation: 1, + leading: showBackButton + ? StreamBackButton( + onPressed: onBackPressed, + showUnreads: true, + ) + : SizedBox(), + backgroundColor: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .color, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 10.0), + child: Center( + child: ChannelImage( + onTap: onImageTap, + ), + ), + ), + ], + centerTitle: true, + title: InkWell( + onTap: onTitleTap, + child: Container( + height: preferredSize.height, + width: preferredSize.width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ChannelName( + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title, + ), + SizedBox(height: 2), + ChannelInfo( + showTypingIndicator: showTypingIndicator, + channel: channel, + textStyle: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + @override + final Size preferredSize; +} diff --git a/lib/src/channel_image.dart b/packages/stream_chat_flutter/lib/src/channel_image.dart similarity index 50% rename from lib/src/channel_image.dart rename to packages/stream_chat_flutter/lib/src/channel_image.dart index 82bea25e..649a5880 100644 --- a/lib/src/channel_image.dart +++ b/packages/stream_chat_flutter/lib/src/channel_image.dart @@ -1,7 +1,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/group_image.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_image.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_image_paint.png) @@ -10,7 +11,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// ```dart /// class MyApp extends StatelessWidget { -/// final Client client; +/// final StreamChatClient client; /// final Channel channel; /// /// MyApp(this.client, this.channel); @@ -49,8 +50,15 @@ class ChannelImage extends StatelessWidget { this.channel, this.constraints, this.onTap, + this.showOnlineStatus = true, + this.borderRadius, + this.selected = false, + this.selectionColor, + this.selectionThickness = 4, }) : super(key: key); + final BorderRadius borderRadius; + /// The channel to show the image of final Channel channel; @@ -60,9 +68,17 @@ class ChannelImage extends StatelessWidget { /// The function called when the image is tapped final VoidCallback onTap; + final bool showOnlineStatus; + + final bool selected; + + final Color selectionColor; + + final double selectionThickness; + @override Widget build(BuildContext context) { - final client = StreamChat.of(context); + final streamChat = StreamChat.of(context); final channel = this.channel ?? StreamChannel.of(context).channel; return StreamBuilder>( stream: channel.extraDataStream, @@ -73,15 +89,57 @@ class ChannelImage extends StatelessWidget { image = snapshot.data['image']; } else if (channel.state.members?.length == 2) { final otherMember = channel.state.members - .firstWhere((member) => member.user.id != client.user.id); - image = otherMember.user.extraData['image']; + .firstWhere((member) => member.user.id != streamChat.user.id); + return StreamBuilder( + stream: streamChat.client.state.usersStream + .map((users) => users[otherMember.userId]), + initialData: otherMember.user, + builder: (context, snapshot) { + return UserAvatar( + borderRadius: borderRadius, + user: snapshot.data ?? otherMember.user, + constraints: constraints ?? + StreamChatTheme.of(context) + .channelPreviewTheme + .avatarTheme + .constraints, + onTap: onTap != null ? (_) => onTap() : null, + selected: selected, + selectionColor: selectionColor ?? + StreamChatTheme.of(context).colorTheme.accentBlue, + selectionThickness: selectionThickness, + ); + }); + } else { + final images = channel.state.members + .where((member) => + member.user.id != streamChat.user.id && + member.user.extraData['image'] != null) + .take(4) + .map((e) => e.user.extraData['image'] as String) + .toList(); + return GroupImage( + images: images, + borderRadius: borderRadius, + constraints: constraints ?? + StreamChatTheme.of(context) + .channelPreviewTheme + .avatarTheme + .constraints, + onTap: onTap, + selected: selected, + selectionColor: selectionColor ?? + StreamChatTheme.of(context).colorTheme.accentBlue, + selectionThickness: selectionThickness, + ); } - return ClipRRect( - borderRadius: StreamChatTheme.of(context) - .channelPreviewTheme - .avatarTheme - .borderRadius, + Widget child = ClipRRect( + borderRadius: borderRadius ?? + StreamChatTheme.of(context) + .channelPreviewTheme + .avatarTheme + .borderRadius, child: Container( constraints: constraints ?? StreamChatTheme.of(context) @@ -89,9 +147,10 @@ class ChannelImage extends StatelessWidget { .avatarTheme .constraints, decoration: BoxDecoration( - color: StreamChatTheme.of(context).accentColor, + color: StreamChatTheme.of(context).colorTheme.accentBlue, ), child: Stack( + alignment: Alignment.center, fit: StackFit.expand, children: [ image != null @@ -104,7 +163,9 @@ class ChannelImage extends StatelessWidget { ? snapshot.data['name'][0] : '', style: TextStyle( - color: Colors.white, + color: StreamChatTheme.of(context) + .colorTheme + .white, fontWeight: FontWeight.bold, ), ), @@ -124,6 +185,30 @@ class ChannelImage extends StatelessWidget { ), ), ); + if (selected) { + child = ClipRRect( + borderRadius: (borderRadius ?? + StreamChatTheme.of(context) + .ownMessageTheme + .avatarTheme + .borderRadius) + + BorderRadius.circular(selectionThickness), + child: Container( + constraints: constraints ?? + StreamChatTheme.of(context) + .ownMessageTheme + .avatarTheme + .constraints, + color: selectionColor ?? + StreamChatTheme.of(context).colorTheme.accentBlue, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: child, + ), + ), + ); + } + return child; }); } } diff --git a/packages/stream_chat_flutter/lib/src/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel_info.dart new file mode 100644 index 00000000..4244c910 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_info.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'connection_status_builder.dart'; + +class ChannelInfo extends StatelessWidget { + final Channel channel; + + /// The style of the text displayed + final TextStyle textStyle; + + /// If true the typing indicator will be rendered if a user is typing + final bool showTypingIndicator; + + const ChannelInfo({ + Key key, + @required this.channel, + this.textStyle, + this.showTypingIndicator = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final client = StreamChat.of(context).client; + return StreamBuilder>( + stream: channel.state.membersStream, + initialData: channel.state.members, + builder: (context, snapshot) { + return ConnectionStatusBuilder( + statusBuilder: (context, status) { + switch (status) { + case ConnectionStatus.connected: + return _buildConnectedTitleState(context, snapshot.data); + case ConnectionStatus.connecting: + return _buildConnectingTitleState(context); + case ConnectionStatus.disconnected: + return _buildDisconnectedTitleState(context, client); + default: + return Offstage(); + } + }, + ); + }, + ); + } + + Widget _buildConnectedTitleState(BuildContext context, List members) { + var alternativeWidget; + + if (channel.memberCount != null && channel.memberCount > 2) { + alternativeWidget = Text( + '${channel.memberCount} Members, ${channel.state.watcherCount} Online', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .lastMessageAt, + ); + } else { + final otherMember = members.firstWhere( + (element) => element.userId != StreamChat.of(context).user.id, + orElse: () => null, + ); + + if (otherMember != null) { + if (otherMember.user.online) { + alternativeWidget = Text( + 'Online', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .lastMessageAt, + ); + } else { + alternativeWidget = Text( + 'Last seen ${Jiffy(otherMember.user.lastActive).fromNow()}', + style: textStyle, + ); + } + } + } + + if (!showTypingIndicator) { + return alternativeWidget ?? Offstage(); + } + + return TypingIndicator( + alignment: Alignment.center, + alternativeWidget: alternativeWidget, + style: textStyle, + ); + } + + Widget _buildConnectingTitleState(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 16, + width: 16, + child: Center( + child: CircularProgressIndicator(), + ), + ), + SizedBox(width: 10), + Text( + 'Searching for Network', + style: textStyle, + ), + ], + ); + } + + Widget _buildDisconnectedTitleState( + BuildContext context, StreamChatClient client) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Offline...', + style: textStyle, + ), + TextButton( + style: TextButton.styleFrom( + padding: const EdgeInsets.all(0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, + ), + ), + onPressed: () async { + await client.disconnect(); + return client.connect(); + }, + child: Text( + 'Try Again', + style: textStyle.copyWith( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel_list_header.dart new file mode 100644 index 00000000..cca4170e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_list_header.dart @@ -0,0 +1,258 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:stream_chat_flutter/src/stream_neumorphic_button.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'connection_status_builder.dart'; +import 'info_tile.dart'; +import 'stream_chat.dart'; + +typedef _TitleBuilder = Widget Function( + BuildContext context, + ConnectionStatus status, + StreamChatClient client, +); + +/// +/// It shows the current [StreamChatClient] status. +/// +/// ```dart +/// class MyApp extends StatelessWidget { +/// final StreamChatClient client; +/// +/// MyApp(this.client); +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// home: StreamChat( +/// client: client, +/// child: Scaffold( +/// appBar: ChannelListHeader(), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. +/// However you can also use it as a normal widget. +/// +/// The widget by default uses the inherited [StreamChatClient] to fetch information about the status. +/// However you can also pass your own [StreamChatClient] if you don't have it in the widget tree. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme] and on its [ChannelTheme.channelHeaderTheme] property. +/// Modify it to change the widget appearance. +class ChannelListHeader extends StatelessWidget implements PreferredSizeWidget { + /// Instantiates a ChannelListHeader + const ChannelListHeader({ + Key key, + this.client, + this.titleBuilder, + this.onUserAvatarTap, + this.onNewChatButtonTap, + this.showConnectionStateTile = false, + this.preNavigationCallback, + }) : super(key: key); + + /// Pass this if you don't have a [StreamChatClient] in your widget tree. + final StreamChatClient client; + + /// Use this to build your own title as per different [ConnectionStatus] + final _TitleBuilder titleBuilder; + + /// Callback to call when pressing the user avatar button. + /// By default it calls Scaffold.of(context).openDrawer() + final Function(User) onUserAvatarTap; + + /// Callback to call when pressing the new chat button. + final VoidCallback onNewChatButtonTap; + + final bool showConnectionStateTile; + + final VoidCallback preNavigationCallback; + + @override + Widget build(BuildContext context) { + final _client = client ?? StreamChat.of(context).client; + final user = _client.state.user; + return ConnectionStatusBuilder( + statusBuilder: (context, status) { + var statusString = ''; + var showStatus = true; + + switch (status) { + case ConnectionStatus.connected: + statusString = 'Connected'; + showStatus = false; + break; + case ConnectionStatus.connecting: + statusString = 'Reconnecting...'; + break; + case ConnectionStatus.disconnected: + statusString = 'Disconnected'; + break; + } + + return InfoTile( + showMessage: showConnectionStateTile ? showStatus : false, + message: statusString, + child: AppBar( + brightness: Theme.of(context).brightness, + elevation: 1, + backgroundColor: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .color, + centerTitle: true, + leading: Center( + child: UserAvatar( + user: user, + showOnlineStatus: false, + onTap: onUserAvatarTap ?? + (_) { + if (preNavigationCallback != null) { + preNavigationCallback(); + } + Scaffold.of(context).openDrawer(); + }, + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + ), + actions: [ + StreamNeumorphicButton( + child: IconButton( + icon: ConnectionStatusBuilder( + statusBuilder: (context, status) { + var color; + switch (status) { + case ConnectionStatus.connected: + color = + StreamChatTheme.of(context).colorTheme.accentBlue; + break; + case ConnectionStatus.connecting: + color = Colors.grey; + break; + case ConnectionStatus.disconnected: + color = Colors.grey; + break; + } + return SvgPicture.asset( + 'svgs/icon_pen_write.svg', + package: 'stream_chat_flutter', + width: 24.0, + height: 24.0, + color: color, + ); + }, + ), + onPressed: onNewChatButtonTap, + ), + ) + ], + title: Builder( + builder: (context) { + if (titleBuilder != null) { + return titleBuilder(context, status, _client); + } + switch (status) { + case ConnectionStatus.connected: + return _buildConnectedTitleState(context); + case ConnectionStatus.connecting: + return _buildConnectingTitleState(context); + case ConnectionStatus.disconnected: + return _buildDisconnectedTitleState(context, _client); + default: + return Offstage(); + } + }, + ), + ), + ); + }, + ); + } + + Widget _buildConnectedTitleState(BuildContext context) => Text( + 'Stream Chat', + style: StreamChatTheme.of(context).textTheme.headlineBold.copyWith( + color: StreamChatTheme.of(context).colorTheme.black, + ), + ); + + Widget _buildConnectingTitleState(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 16, + width: 16, + child: Center( + child: CircularProgressIndicator(), + ), + ), + SizedBox(width: 10), + Text( + 'Searching for Network', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title + .copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildDisconnectedTitleState( + BuildContext context, StreamChatClient client) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Offline...', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title + .copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + TextButton( + onPressed: () async { + await client.disconnect(); + return client.connect(); + }, + child: Text( + 'Try Again', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title + .copyWith( + fontSize: 16, + fontWeight: FontWeight.bold, + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + ), + ), + ], + ); + } + + @override + Size get preferredSize => Size.fromHeight(kToolbarHeight); +} diff --git a/packages/stream_chat_flutter/lib/src/channel_list_view.dart b/packages/stream_chat_flutter/lib/src/channel_list_view.dart new file mode 100644 index 00000000..2da9b434 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_list_view.dart @@ -0,0 +1,741 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:shimmer/shimmer.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import '../stream_chat_flutter.dart'; +import 'channel_bottom_sheet.dart'; +import 'channel_preview.dart'; + +/// Callback called when tapping on a channel +typedef ChannelTapCallback = void Function(Channel, Widget); + +/// Builder used to create a custom [ChannelPreview] from a [Channel] +typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); + +typedef ViewInfoCallback = void Function(Channel); + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_list_view.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_list_view_paint.png) +/// +/// It shows the list of current channels. +/// +/// ```dart +/// class ChannelListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: ChannelListView( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// channelWidget: ChannelPage(), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [StreamChat] ancestor in order to provide the information about the channels. +/// The widget uses a [ListView.custom] to render the list of channels. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class ChannelListView extends StatefulWidget { + /// Instantiate a new ChannelListView + ChannelListView({ + Key key, + this.filter, + this.options, + this.sort, + this.pagination, + this.onChannelTap, + this.onChannelLongPress, + this.channelWidget, + this.channelPreviewBuilder, + this.separatorBuilder, + this.errorBuilder, + this.emptyBuilder, + this.onImageTap, + this.onStartChatPressed, + this.swipeToAction = false, + this.pullToRefresh = true, + this.crossAxisCount = 1, + this.padding, + this.selectedChannels = const [], + this.onViewInfoTap, + }) : super(key: key); + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + /// If true a default swipe to action behaviour will be added to this widget + final bool swipeToAction; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filter; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sort; + + /// Pagination parameters + /// limit: the number of channels to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams pagination; + + /// Function called when tapping on a channel + /// By default it calls [Navigator.push] building a [MaterialPageRoute] + /// with the widget [channelWidget] as child. + final ChannelTapCallback onChannelTap; + + /// Function called when long pressing on a channel + final Function(Channel) onChannelLongPress; + + /// Widget used when opening a channel + final Widget channelWidget; + + /// Builder used to create a custom channel preview + final ChannelPreviewBuilder channelPreviewBuilder; + + /// Builder used to create a custom item separator + final Function(BuildContext, int) separatorBuilder; + + /// The function called when the image is tapped + final Function(Channel) onImageTap; + + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + + /// Callback used in the default empty list widget + final VoidCallback onStartChatPressed; + + /// The number of children in the cross axis. + final int crossAxisCount; + + /// The amount of space by which to inset the children. + final EdgeInsetsGeometry padding; + + final List selectedChannels; + + final ViewInfoCallback onViewInfoTap; + + @override + _ChannelListViewState createState() => _ChannelListViewState(); +} + +class _ChannelListViewState extends State + with WidgetsBindingObserver { + final ScrollController _scrollController = ScrollController(); + final SlidableController _slideController = SlidableController(); + final ChannelListController _channelListController = ChannelListController(); + + @override + Widget build(BuildContext context) { + var child = ChannelListCore( + channelListController: _channelListController, + listBuilder: (context, list) { + return _buildListView(list); + }, + emptyBuilder: (BuildContext context) { + return _buildEmptyWidget(); + }, + errorBuilder: (BuildContext context, dynamic error) { + return _buildErrorWidget(context); + }, + loadingBuilder: (BuildContext context) { + return _buildLoadingWidget(); + }, + pagination: widget.pagination, + options: widget.options, + sort: widget.sort, + filter: widget.filter, + ); + + if (!widget.pullToRefresh) { + return child; + } else { + return RefreshIndicator( + onRefresh: () async { + _channelListController.loadData(); + }, + child: child, + ); + } + } + + Widget _buildListView( + List channels, + ) { + var child; + + if (channels.isNotEmpty) { + if (widget.crossAxisCount > 1) { + child = GridView.builder( + padding: widget.padding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount), + itemCount: channels.length, + physics: AlwaysScrollableScrollPhysics(), + controller: _scrollController, + itemBuilder: (context, index) { + return _gridItemBuilder(context, index, channels); + }, + ); + } else { + child = ListView.separated( + padding: widget.padding, + physics: AlwaysScrollableScrollPhysics(), + itemCount: + channels.isNotEmpty ? channels.length + 1 : channels.length, + separatorBuilder: (_, index) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, index); + } + return _separatorBuilder(context, index); + }, + itemBuilder: (context, index) { + return _listItemBuilder(context, index, channels); + }, + controller: _scrollController, + ); + } + } + + return AnimatedSwitcher( + child: child, + duration: Duration(milliseconds: 500), + ); + } + + Widget _buildEmptyWidget() { + if (widget.emptyBuilder != null) { + return widget.emptyBuilder(context); + } + + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Stack( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: StreamSvgIcon.message( + size: 136, + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Let’s start chatting!', + style: StreamChatTheme.of(context).textTheme.headline, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 52, + ), + child: Text( + 'How about sending your first message to a friend?', + textAlign: TextAlign.center, + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith( + color: + StreamChatTheme.of(context).colorTheme.grey, + ), + ), + ), + ], + ), + ), + if (widget.onStartChatPressed != null) + Positioned( + right: 0, + left: 0, + bottom: 32, + child: Center( + child: FlatButton( + onPressed: widget.onStartChatPressed, + child: Text( + 'Start a chat', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildLoadingWidget() { + return ListView( + padding: widget.padding, + physics: AlwaysScrollableScrollPhysics(), + children: List.generate( + 25, + (i) { + if (widget.crossAxisCount == 1) { + if (i % 2 != 0) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, i); + } + return _separatorBuilder(context, i); + } + } + return _buildLoadingItem(); + }, + ), + ); + } + + Shimmer _buildLoadingItem() { + if (widget.crossAxisCount > 1) { + return Shimmer.fromColors( + baseColor: StreamChatTheme.of(context).colorTheme.greyGainsboro, + highlightColor: StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: Column( + children: [ + SizedBox(height: 4.0), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (int i = 0; i < widget.crossAxisCount; i++) + Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + constraints: BoxConstraints.tightFor( + height: 70, + width: 70, + ), + ), + ], + ), + SizedBox( + height: 16.0, + ), + ], + ), + ); + } else { + return Shimmer.fromColors( + baseColor: StreamChatTheme.of(context).colorTheme.greyGainsboro, + highlightColor: StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: ListTile( + leading: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + shape: BoxShape.circle, + ), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + contentPadding: const EdgeInsets.only( + left: 8, + right: 8, + ), + title: Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 82, + ), + ), + ), + subtitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 238, + ), + ), + ), + Container( + margin: const EdgeInsets.only(left: 16), + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 42, + ), + ), + ], + ), + ), + ); + } + } + + Widget _buildErrorWidget( + BuildContext context, + ) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only( + right: 2.0, + ), + child: Icon(Icons.error_outline), + ), + ), + TextSpan(text: 'Error loading channels'), + ], + ), + style: Theme.of(context).textTheme.headline6, + ), + FlatButton( + onPressed: () { + _channelListController.loadData(); + }, + child: Text('Retry'), + ), + ], + ), + ); + } + + Widget _listItemBuilder(BuildContext context, int i, List channels) { + final channelsProvider = ChannelsBloc.of(context); + if (i < channels.length) { + final channel = channels[i]; + ChannelTapCallback onTap; + if (widget.onChannelTap != null) { + onTap = widget.onChannelTap; + } else { + onTap = (client, _) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + child: widget.channelWidget, + channel: client, + ); + }, + ), + ); + }; + } + + final backgroundColor = StreamChatTheme.of(context).colorTheme.whiteSmoke; + return StreamChannel( + key: ValueKey('CHANNEL-${channel.id}'), + channel: channel, + child: Builder( + builder: (context) { + return Slidable( + controller: _slideController, + enabled: widget.swipeToAction, + actionPane: SlidableBehindActionPane(), + actionExtentRatio: 0.12, + closeOnScroll: true, + secondaryActions: [ + IconSlideAction( + color: backgroundColor, + icon: Icons.more_horiz, + onTap: () { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + builder: (context) { + return StreamChannel( + child: ChannelBottomSheet( + onViewInfoTap: () { + widget.onViewInfoTap(channel); + }, + ), + channel: channel, + ); + }, + ); + }, + ), + if ([ + 'admin', + 'owner', + ].contains(channel.state.members + .firstWhere((m) => m.userId == channel.client.state.user.id, + orElse: () => null) + ?.role)) + IconSlideAction( + color: backgroundColor, + iconWidget: StreamSvgIcon.delete( + color: StreamChatTheme.of(context).colorTheme.accentRed, + ), + onTap: () async { + final res = await showConfirmationDialog( + context, + title: 'Delete Conversation', + okText: 'DELETE', + question: + 'Are you sure you want to delete this conversation?', + cancelText: 'CANCEL', + icon: StreamSvgIcon.delete( + color: + StreamChatTheme.of(context).colorTheme.accentRed, + ), + ); + if (res == true) { + await channel.delete(); + } + }, + ), + ], + child: Container( + color: StreamChatTheme.of(context).colorTheme.whiteSnow, + child: widget.channelPreviewBuilder != null + ? widget.channelPreviewBuilder( + context, + channel, + ) + : ChannelPreview( + onLongPress: widget.onChannelLongPress, + channel: channel, + onImageTap: widget.onImageTap != null + ? () { + widget.onImageTap(channel); + } + : null, + onTap: (channel) { + onTap(channel, widget.channelWidget); + }, + ), + ), + ); + }, + ), + ); + } else { + return _buildQueryProgressIndicator(context, channelsProvider); + } + } + + Widget _gridItemBuilder(BuildContext context, int i, List channels) { + var channel = channels[i]; + + var selected = widget.selectedChannels.contains(channel); + + return Container( + key: ValueKey('CHANNEL-${channel.id}'), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ChannelImage( + channel: channel, + borderRadius: BorderRadius.circular(32), + selected: selected, + constraints: BoxConstraints.tightFor( + width: 64, + height: 64, + ), + onTap: () { + widget.onChannelTap(channel, null); + }, + ), + SizedBox(height: 7), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: StreamChannel( + child: ChannelName( + textStyle: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + channel: channel, + ), + ), + ], + ), + ); + } + + Widget _buildQueryProgressIndicator( + context, + ChannelsBlocState channelsProvider, + ) { + return StreamBuilder( + stream: channelsProvider.queryChannelsLoading, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: StreamChatTheme.of(context) + .colorTheme + .accentRed + .withOpacity(.2), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text('Error loading channels'), + ), + ), + ); + } + return Container( + height: 100, + padding: EdgeInsets.all(32), + child: Center( + child: snapshot.data ? CircularProgressIndicator() : Container(), + ), + ); + }); + } + + Widget _separatorBuilder(context, i) { + var effect = StreamChatTheme.of(context).colorTheme.borderBottom; + + return Container( + height: 1, + color: effect.color.withOpacity(effect.alpha ?? 1.0), + ); + } + + void _listenChannelPagination(ChannelsBlocState channelsProvider) { + if (_scrollController.position.maxScrollExtent == + _scrollController.offset && + _scrollController.offset != 0) { + _channelListController.paginateData(); + } + } + + StreamSubscription _subscription; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + + final channelsBloc = ChannelsBloc.of(context); + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + + _scrollController.addListener(() { + channelsBloc.queryChannelsLoading.first.then((loading) { + if (!loading) { + _listenChannelPagination(channelsBloc); + } + }); + }); + + final client = StreamChat.of(context).client; + + _subscription = client + .on( + EventType.connectionRecovered, + EventType.notificationAddedToChannel, + EventType.notificationMessageNew, + EventType.channelVisible, + ) + .listen((event) { + _channelListController.loadData(); + }); + } + + @override + void didUpdateWidget(ChannelListView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.filter?.toString() != oldWidget.filter?.toString() || + jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.pagination?.toJson()?.toString() != + oldWidget.pagination?.toJson()?.toString() || + widget.options?.toString() != oldWidget.options?.toString()) { + _channelListController.loadData(); + } + } + + @override + void dispose() { + _subscription.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart new file mode 100644 index 00000000..85b571bc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:video_player/video_player.dart'; + +class ChannelMediaDisplayScreen extends StatefulWidget { + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sortOptions; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams paginationParams; + + /// The builder used when the file list is empty. + final WidgetBuilder emptyBuilder; + + final ShowMessageCallback onShowMessage; + + const ChannelMediaDisplayScreen({ + this.sortOptions, + this.paginationParams, + this.emptyBuilder, + this.onShowMessage, + }); + + @override + _ChannelMediaDisplayScreenState createState() => + _ChannelMediaDisplayScreenState(); +} + +class _ChannelMediaDisplayScreenState extends State { + @override + void initState() { + super.initState(); + final messageSearchBloc = MessageSearchBloc.of(context); + messageSearchBloc.search( + filter: { + 'cid': { + r'$in': ['messaging:${StreamChannel.of(context).channel.id}'] + } + }, + messageFilter: { + 'attachments.type': { + r'$in': ['image', 'video'] + }, + }, + sort: widget.sortOptions, + pagination: widget.paginationParams, + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + appBar: AppBar( + brightness: Theme.of(context).brightness, + elevation: 1, + centerTitle: true, + title: Text( + 'Photos & Videos', + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.black, + fontSize: 16.0, + ), + ), + leading: Center( + child: InkWell( + onTap: () { + Navigator.of(context).pop(); + }, + child: Container( + child: StreamSvgIcon.left( + color: StreamChatTheme.of(context).colorTheme.black, + size: 24.0, + ), + width: 24.0, + height: 24.0, + ), + ), + ), + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + ), + body: _buildMediaGrid(), + ); + } + + Widget _buildMediaGrid() { + final messageSearchBloc = MessageSearchBloc.of(context); + + return StreamBuilder>( + builder: (context, snapshot) { + if (snapshot.data == null) { + return Center( + child: const CircularProgressIndicator(), + ); + } + + if (snapshot.data.isEmpty) { + if (widget.emptyBuilder != null) { + return widget.emptyBuilder(context); + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + StreamSvgIcon.pictures( + size: 136.0, + color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + ), + SizedBox(height: 16.0), + Text( + 'No Media', + style: TextStyle( + fontSize: 14.0, + color: StreamChatTheme.of(context).colorTheme.black, + ), + ), + SizedBox(height: 8.0), + Text( + 'Photos or video sent in this chat will \nappear here', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14.0, + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5), + ), + ), + ], + ), + ); + } + + final media = <_AssetPackage>[]; + + for (var item in snapshot.data) { + item.message.attachments + .where((e) => + (e.type == 'image' || e.type == 'video') && + e.ogScrapeUrl == null) + .forEach((e) { + VideoPlayerController controller; + if (e.type == 'video') { + controller = VideoPlayerController.network(e.assetUrl); + controller.initialize(); + } + media.add(_AssetPackage(e, item.message, controller)); + }); + } + + return LazyLoadScrollView( + onEndOfPage: () => messageSearchBloc.loadMore( + filter: { + 'cid': { + r'$in': ['messaging:${StreamChannel.of(context).channel.id}'] + } + }, + messageFilter: { + 'attachments.type': { + r'$in': ['image', 'video'] + }, + }, + sort: widget.sortOptions, + pagination: widget.paginationParams.copyWith( + offset: messageSearchBloc.messageResponses?.length ?? 0, + ), + ), + child: GridView.builder( + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), + itemBuilder: (context, position) { + var channel = StreamChannel.of(context).channel; + return Padding( + padding: const EdgeInsets.all(1.0), + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: + media.map((e) => e.attachment).toList(), + startIndex: position, + message: media[position].message, + sentAt: media[position].message.createdAt, + userName: media[position].message.user.name, + onShowMessage: widget.onShowMessage, + ), + ), + ), + ); + }, + child: media[position].attachment.type == 'image' + ? IgnorePointer( + child: ImageAttachment( + attachment: media[position].attachment, + message: media[position].message, + showTitle: false, + size: Size( + MediaQuery.of(context).size.width * 0.8, + MediaQuery.of(context).size.height * 0.3, + ), + ), + ) + : VideoPlayer(media[position].videoPlayer), + ), + ); + }, + itemCount: media.length, + ), + ); + }, + stream: messageSearchBloc.messagesStream, + ); + } +} + +class _AssetPackage { + Attachment attachment; + Message message; + VideoPlayerController videoPlayer; + + _AssetPackage(this.attachment, this.message, this.videoPlayer); +} diff --git a/packages/stream_chat_flutter/lib/src/channel_name.dart b/packages/stream_chat_flutter/lib/src/channel_name.dart new file mode 100644 index 00000000..7907de9a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_name.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import '../stream_chat_flutter.dart'; + +/// It shows the current [Channel] name using a [Text] widget. +/// +/// The widget uses a [StreamBuilder] to render the channel information image as soon as it updates. +class ChannelName extends StatelessWidget { + /// Instantiate a new ChannelName + const ChannelName({ + Key key, + this.textStyle, + }) : super(key: key); + + /// The style of the text displayed + final TextStyle textStyle; + + @override + Widget build(BuildContext context) { + final client = StreamChat.of(context); + final channel = StreamChannel.of(context).channel; + + return StreamBuilder>( + stream: channel.extraDataStream, + initialData: channel.extraData, + builder: (context, snapshot) { + return _buildName(snapshot.data, channel.state.members, client); + }, + ); + } + + Widget _buildName( + Map extraData, + List members, + StreamChatState client, + ) { + return LayoutBuilder( + builder: (context, constraints) { + String title; + if (extraData['name'] == null) { + final otherMembers = + members.where((member) => member.userId != client.user.id); + if (otherMembers.isNotEmpty) { + final maxWidth = constraints.maxWidth; + final maxChars = maxWidth / textStyle.fontSize; + var currentChars = 0; + final currentMembers = []; + otherMembers.forEach((element) { + final newLength = currentChars + element.user.name.length; + if (newLength < maxChars) { + currentChars = newLength; + currentMembers.add(element); + } + }); + + final exceedingMembers = + otherMembers.length - currentMembers.length; + title = + '${currentMembers.map((e) => e.user.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; + } else { + title = 'No title'; + } + } else { + title = extraData['name']; + } + + return Text( + title, + style: textStyle, + overflow: TextOverflow.ellipsis, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel_preview.dart new file mode 100644 index 00000000..2d29f12b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_preview.dart @@ -0,0 +1,300 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import '../stream_chat_flutter.dart'; +import 'channel_name.dart'; +import 'channel_unread_indicator.dart'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview_paint.png) +/// +/// It shows the current [Channel] preview. +/// +/// The widget uses a [StreamBuilder] to render the channel information image as soon as it updates. +/// +/// Usually you don't use this widget as it's the default channel preview used by [ChannelListView]. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class ChannelPreview extends StatelessWidget { + /// Function called when tapping this widget + final void Function(Channel) onTap; + + /// Function called when long pressing this widget + final void Function(Channel) onLongPress; + + /// Channel displayed + final Channel channel; + + /// The function called when the image is tapped + final VoidCallback onImageTap; + + ChannelPreview({ + @required this.channel, + Key key, + this.onTap, + this.onLongPress, + this.onImageTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, snapshot) { + return Opacity( + opacity: snapshot.data ? 0.5 : 1, + child: ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, + ), + onTap: () { + if (onTap != null) { + onTap(channel); + } + }, + onLongPress: () { + if (onLongPress != null) { + onLongPress(channel); + } + }, + leading: ChannelImage( + onTap: onImageTap, + ), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: ChannelName( + textStyle: + StreamChatTheme.of(context).channelPreviewTheme.title, + ), + ), + StreamBuilder>( + stream: channel.state.membersStream, + initialData: channel.state.members, + builder: (context, snapshot) { + if (!snapshot.hasData || + snapshot.data.isEmpty || + !snapshot.data.any((Member e) => + e.user.id == channel.client.state.user.id)) { + return SizedBox(); + } + return ChannelUnreadIndicator( + channel: channel, + ); + }), + ], + ), + subtitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: _buildSubtitle(context)), + Builder( + builder: (context) { + final lastMessage = channel.state.messages.lastWhere( + (m) => !m.isDeleted && m.shadowed != true, + orElse: () => null, + ); + if (lastMessage?.user?.id == + StreamChat.of(context).user.id) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: SendingIndicator( + message: lastMessage, + size: StreamChatTheme.of(context) + .channelPreviewTheme + .indicatorIconSize, + isMessageRead: channel.state.read + ?.where((element) => + element.user.id != + channel.client.state.user.id) + ?.where((element) => element.lastRead + .isAfter(lastMessage.createdAt)) + ?.isNotEmpty == + true, + ), + ); + } + return SizedBox(); + }, + ), + _buildDate(context), + ], + ), + ), + ); + }); + } + + Widget _buildDate(BuildContext context) { + return StreamBuilder( + stream: channel.lastMessageAtStream, + initialData: channel.lastMessageAt, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return SizedBox(); + } + final lastMessageAt = snapshot.data.toLocal(); + + String stringDate; + final now = DateTime.now(); + + var startOfDay = DateTime(now.year, now.month, now.day); + + if (lastMessageAt.millisecondsSinceEpoch >= + startOfDay.millisecondsSinceEpoch) { + stringDate = Jiffy(lastMessageAt.toLocal()).format('HH:mm'); + } else if (lastMessageAt.millisecondsSinceEpoch >= + startOfDay.subtract(Duration(days: 1)).millisecondsSinceEpoch) { + stringDate = 'Yesterday'; + } else if (startOfDay.difference(lastMessageAt).inDays < 7) { + stringDate = Jiffy(lastMessageAt.toLocal()).EEEE; + } else { + stringDate = Jiffy(lastMessageAt.toLocal()).format('dd/MM/yyyy'); + } + + return Text( + stringDate, + style: StreamChatTheme.of(context).channelPreviewTheme.lastMessageAt, + ); + }, + ); + } + + Widget _buildSubtitle(BuildContext context) { + if (channel.isMuted) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamSvgIcon.mute( + size: 16, + ), + Text( + ' Channel is muted', + style: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .copyWith( + color: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .color, + ), + ), + ], + ); + } + return TypingIndicator( + channel: channel, + alternativeWidget: _buildLastMessage(context), + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + color: + StreamChatTheme.of(context).channelPreviewTheme.subtitle.color, + ), + ); + } + + Widget _buildLastMessage(BuildContext context) { + return StreamBuilder>( + stream: channel.state.messagesStream, + initialData: channel.state.messages, + builder: (context, snapshot) { + final lastMessage = snapshot.data?.lastWhere( + (m) => m.shadowed != true && !m.isDeleted, + orElse: () => null); + if (lastMessage == null) { + return SizedBox(); + } + + var text = lastMessage.text; + if (lastMessage.attachments != null) { + final parts = [ + ...lastMessage.attachments.map((e) { + if (e.type == 'image') { + return '📷'; + } else if (e.type == 'video') { + return '🎬'; + } else if (e.type == 'giphy') { + return '[GIF]'; + } + return e == lastMessage.attachments.last + ? (e.title ?? 'File') + : '${e.title ?? 'File'} , '; + }).where((e) => e != null), + lastMessage.text ?? '', + ]; + + text = parts.join(' '); + } + + return Text.rich( + _getDisplayText( + text, + lastMessage.mentionedUsers, + lastMessage.attachments, + StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + color: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .color, + fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) + ? FontStyle.italic + : FontStyle.normal), + StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + color: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .color, + fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) + ? FontStyle.italic + : FontStyle.normal, + fontWeight: FontWeight.bold), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + ); + } + + TextSpan _getDisplayText( + String text, + List mentions, + List attachments, + TextStyle normalTextStyle, + TextStyle mentionsTextStyle) { + var textList = text.split(' '); + var resList = []; + for (var e in textList) { + if (mentions != null && + mentions.isNotEmpty && + mentions.any((element) => '@${element.name}' == e)) { + resList.add(TextSpan( + text: '$e ', + style: mentionsTextStyle, + )); + } else if (attachments != null && + attachments.isNotEmpty && + attachments + .where((e) => e.title != null) + .any((element) => element.title == e)) { + resList.add(TextSpan( + text: '$e ', + style: normalTextStyle.copyWith(fontStyle: FontStyle.italic), + )); + } else { + resList.add(TextSpan( + text: e == textList.last ? '$e' : '$e ', + style: normalTextStyle, + )); + } + } + + return TextSpan(children: resList); + } +} diff --git a/packages/stream_chat_flutter/lib/src/channel_unread_indicator.dart b/packages/stream_chat_flutter/lib/src/channel_unread_indicator.dart new file mode 100644 index 00000000..cb9dc362 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/channel_unread_indicator.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; + +class ChannelUnreadIndicator extends StatelessWidget { + const ChannelUnreadIndicator({ + Key key, + @required this.channel, + }) : super(key: key); + + final Channel channel; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: channel.state.unreadCountStream, + initialData: channel.state.unreadCount, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == 0) { + return SizedBox(); + } + + return Material( + borderRadius: BorderRadius.circular(8), + color: StreamChatTheme.of(context) + .channelPreviewTheme + .unreadCounterColor, + child: Padding( + padding: const EdgeInsets.only( + left: 5.0, + right: 5.0, + top: 2, + bottom: 1, + ), + child: Center( + child: Text( + '${snapshot.data > 99 ? '99+' : snapshot.data}', + style: TextStyle( + fontSize: 11, + color: Colors.white, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/compress_video_service.dart b/packages/stream_chat_flutter/lib/src/compress_video_service.dart new file mode 100644 index 00000000..4f7bf27d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/compress_video_service.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +import 'package:synchronized/synchronized.dart'; +import 'package:video_compress/video_compress.dart'; + +class ICompressVideoService { + static final ICompressVideoService instance = ICompressVideoService._(); + final _lock = Lock(); + ICompressVideoService._(); + + Future compressVideo(String path) async { + return _lock.synchronized(() { + return VideoCompress.compressVideo( + path, + ); + }); + } +} + +ICompressVideoService get compressVideoService => + ICompressVideoService.instance; diff --git a/packages/stream_chat_flutter/lib/src/connection_status_builder.dart b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart new file mode 100644 index 00000000..01632b1b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/connection_status_builder.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'stream_chat.dart'; + +/// Widget that builds itself based on the latest snapshot of interaction with +/// a [Stream] of type [ConnectionStatus]. +/// +/// The widget will use the closest [StreamChatClient.wsConnectionStatusStream] in case no +/// stream is provided. +class ConnectionStatusBuilder extends StatelessWidget { + /// Creates a new ConnectionStatusBuilder + const ConnectionStatusBuilder({ + Key key, + @required this.statusBuilder, + this.initialStatus = ConnectionStatus.disconnected, + this.connectionStatusStream, + this.errorBuilder, + this.loadingBuilder, + }) : assert(statusBuilder != null), + super(key: key); + + /// The connection status that will be used to create the initial snapshot. + final ConnectionStatus initialStatus; + + /// The asynchronous computation to which this builder is currently connected. + final Stream connectionStatusStream; + + /// The builder that will be used in case of error + final Widget Function(BuildContext context, Object error) errorBuilder; + + /// The builder that will be used in case of loading + final WidgetBuilder loadingBuilder; + + /// The builder that will be used in case of data + final Widget Function(BuildContext context, ConnectionStatus status) + statusBuilder; + + @override + Widget build(BuildContext context) { + final stream = connectionStatusStream ?? + StreamChat.of(context).client.wsConnectionStatusStream; + return StreamBuilder( + initialData: initialStatus, + stream: stream, + builder: (context, snapshot) { + if (snapshot.hasError) { + if (errorBuilder != null) { + return errorBuilder(context, snapshot.error); + } + return Offstage(); + } + if (!snapshot.hasData) { + if (loadingBuilder != null) return loadingBuilder(context); + return Offstage(); + } + return statusBuilder(context, snapshot.data); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/date_divider.dart b/packages/stream_chat_flutter/lib/src/date_divider.dart new file mode 100644 index 00000000..c7cfe296 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/date_divider.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; + +/// It shows a date divider depending on the date difference +class DateDivider extends StatelessWidget { + final DateTime dateTime; + final bool uppercase; + + const DateDivider({ + Key key, + @required this.dateTime, + this.uppercase = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final createdAt = Jiffy(dateTime); + final now = DateTime.now(); + + String dayInfo; + if (Jiffy(createdAt).isSame(now, Units.DAY)) { + dayInfo = 'Today'; + } else if (Jiffy(createdAt) + .isSame(now.subtract(Duration(days: 1)), Units.DAY)) { + dayInfo = 'Yesterday'; + } else if (Jiffy(createdAt).isAfter( + now.subtract(Duration(days: 7)), + Units.DAY, + )) { + dayInfo = createdAt.format('EEEE'); + } else if (Jiffy(createdAt).isAfter( + Jiffy(now).subtract(years: 1), + Units.DAY, + )) { + dayInfo = createdAt.format('MMMM d'); + } else { + dayInfo = createdAt.format('MMMM d'); + } + + if (uppercase) dayInfo = dayInfo.toUpperCase(); + + return Center( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.overlayDark, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + dayInfo, + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context).colorTheme.white, + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/deleted_message.dart b/packages/stream_chat_flutter/lib/src/deleted_message.dart new file mode 100644 index 00000000..bd014770 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/deleted_message.dart @@ -0,0 +1,74 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; + +class DeletedMessage extends StatelessWidget { + const DeletedMessage({ + Key key, + @required this.messageTheme, + this.borderRadiusGeometry, + this.shape, + this.borderSide, + this.reverse = false, + }) : super(key: key); + + /// The theme of the message + final MessageTheme messageTheme; + + /// The border radius of the message text + final BorderRadiusGeometry borderRadiusGeometry; + + /// The shape of the message text + final ShapeBorder shape; + + /// The borderside of the message text + final BorderSide borderSide; + + /// If true the widget will be mirrored + final bool reverse; + + @override + Widget build(BuildContext context) { + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: Material( + color: messageTheme.messageBackgroundColor, + shape: shape ?? + RoundedRectangleBorder( + borderRadius: borderRadiusGeometry ?? BorderRadius.zero, + side: borderSide ?? + BorderSide( + color: Theme.of(context).brightness == Brightness.dark + ? StreamChatTheme.of(context) + .colorTheme + .white + .withAlpha(24) + : StreamChatTheme.of(context) + .colorTheme + .black + .withAlpha(24), + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 16, + ), + child: Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: Text( + 'Message deleted', + style: messageTheme.messageText.copyWith( + fontStyle: FontStyle.italic, + color: messageTheme.createdAt.color, + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart new file mode 100644 index 00000000..75a03925 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -0,0 +1,31 @@ +import 'package:characters/characters.dart'; +import 'package:emojis/emoji.dart'; + +final _emojis = Emoji.all(); + +/// String extension +extension StringExtension on String { + /// Returns the capitalized string + String capitalize() { + return '${this[0].toUpperCase()}${substring(1)}'; + } + + // Emojis guidelines + // 1 to 3 emojis: big size with no text bubble. + // 4+ emojis or emojis+text: standard size with text bubble. + bool get isOnlyEmoji { + final characters = trim().characters; + if (characters.isEmpty) return false; + if (characters.length > 3) return false; + return characters.every((c) => _emojis.map((e) => e.char).contains(c)); + } +} + +/// List extension +extension IterableX on Iterable { + /// Insert any item inBetween the list items + List insertBetween(T item) => expand((e) sync* { + yield item; + yield e; + }).skip(1).toList(growable: false); +} diff --git a/packages/stream_chat_flutter/lib/src/file_attachment.dart b/packages/stream_chat_flutter/lib/src/file_attachment.dart new file mode 100644 index 00000000..209cdb99 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/file_attachment.dart @@ -0,0 +1,207 @@ +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:video_compress/video_compress.dart'; +import 'package:video_player/video_player.dart'; + +import 'media_utils.dart'; + +enum FileAttachmentType { local, online } + +class FileAttachment extends StatefulWidget { + final Attachment attachment; + final Size size; + final Widget trailing; + final FileAttachmentType attachmentType; + final PlatformFile file; + + const FileAttachment({ + Key key, + @required this.attachment, + this.size, + this.trailing, + this.attachmentType = FileAttachmentType.online, + this.file, + }) : super(key: key); + + @override + _FileAttachmentState createState() => _FileAttachmentState(); +} + +class _FileAttachmentState extends State { + VideoPlayerController _controller; + Future _initializeVideoPlayerFuture; + + @override + void initState() { + super.initState(); + if (MediaUtils.getMimeType(widget.attachment.title)?.type == 'video') { + if (widget.attachmentType == FileAttachmentType.online) { + _controller = VideoPlayerController.network( + widget.attachment.assetUrl, + ); + } else { + _controller = VideoPlayerController.file( + File.fromRawPath(widget.file.bytes), + ); + } + + _initializeVideoPlayerFuture = _controller.initialize(); + } + } + + @override + Widget build(BuildContext context) { + return Material( + child: Container( + width: widget.size?.width ?? 100, + height: 56.0, + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: StreamChatTheme.of(context).colorTheme.greyWhisper, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + child: _getFileTypeImage(), + height: 40.0, + width: 33.33, + margin: EdgeInsets.all(8.0), + ), + SizedBox(width: 8.0), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.attachment?.title ?? 'File', + style: StreamChatTheme.of(context).textTheme.bodyBold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 3.0), + Text( + '${getSizeText(widget.attachment.extraData['file_size'])}', + style: StreamChatTheme.of(context) + .textTheme + .footnote + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5)), + ), + ], + ), + ), + SizedBox(width: 8.0), + Material( + type: MaterialType.transparency, + child: widget.trailing ?? + IconButton( + icon: StreamSvgIcon.cloudDownload( + color: StreamChatTheme.of(context).colorTheme.black, + ), + padding: const EdgeInsets.all(8), + visualDensity: VisualDensity.compact, + splashRadius: 16, + onPressed: () { + launchURL(context, widget.attachment.assetUrl); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _getFileTypeImage() { + if ((MediaUtils.getMimeType(widget.attachment.title)?.type == 'image')) { + switch (widget.attachmentType) { + case FileAttachmentType.local: + return Image.memory( + widget.file.bytes, + fit: BoxFit.cover, + errorBuilder: (_, obj, trace) { + return getFileTypeImage(widget.attachment.extraData['other']); + }, + ); + break; + case FileAttachmentType.online: + return CachedNetworkImage( + imageUrl: widget.attachment.imageUrl ?? + widget.attachment.assetUrl ?? + widget.attachment.thumbUrl, + fit: BoxFit.cover, + errorWidget: (_, obj, trace) { + return getFileTypeImage(widget.attachment.extraData['other']); + }, + progressIndicatorBuilder: (context, _, progress) { + return Center( + child: Container( + width: 20.0, + height: 20.0, + child: CircularProgressIndicator( + backgroundColor: + StreamChatTheme.of(context).colorTheme.accentBlue, + ), + ), + ); + }, + ); + break; + } + } + + if ((MediaUtils.getMimeType(widget.attachment.title)?.type == 'video')) { + switch (widget.attachmentType) { + case FileAttachmentType.local: + return FutureBuilder( + future: VideoCompress.getFileThumbnail(widget.file.path), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ); + } + + return Image.file( + snapshot.data, + fit: BoxFit.cover, + ); + }, + ); + break; + case FileAttachmentType.online: + return FutureBuilder( + future: _initializeVideoPlayerFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + return AspectRatio( + aspectRatio: _controller.value.aspectRatio, + child: VideoPlayer(_controller), + ); + } else { + return Center(child: CircularProgressIndicator()); + } + }, + ); + break; + } + } + return getFileTypeImage(widget.attachment.extraData['mime_type']); + } +} diff --git a/packages/stream_chat_flutter/lib/src/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/full_screen_media.dart new file mode 100644 index 00000000..16ab0da5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/full_screen_media.dart @@ -0,0 +1,269 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:stream_chat_flutter/src/image_footer.dart'; +import 'package:stream_chat_flutter/src/image_header.dart'; +import 'package:video_player/video_player.dart'; + +import '../stream_chat_flutter.dart'; + +enum ReturnActionType { none, reply } + +typedef ShowMessageCallback = void Function(Message message, Channel channel); + +/// A full screen image widget +class FullScreenMedia extends StatefulWidget { + /// The url of the image + final List mediaAttachments; + final Message message; + + final int startIndex; + final String userName; + final DateTime sentAt; + final ShowMessageCallback onShowMessage; + + /// Instantiate a new FullScreenImage + const FullScreenMedia({ + Key key, + @required this.mediaAttachments, + this.message, + this.startIndex = 0, + this.userName = '', + this.sentAt, + this.onShowMessage, + }) : super(key: key); + + @override + _FullScreenMediaState createState() => _FullScreenMediaState(); +} + +class _FullScreenMediaState extends State + with SingleTickerProviderStateMixin { + bool _optionsShown = true; + + AnimationController _controller; + PageController _pageController; + + int _currentPage; + + List videoPackages = []; + + @override + void initState() { + super.initState(); + _controller = + AnimationController(vsync: this, duration: Duration(milliseconds: 300)); + _pageController = PageController(initialPage: widget.startIndex); + _currentPage = widget.startIndex; + widget.mediaAttachments + .where((element) => element.type == 'video') + .toList() + .forEach((element) { + videoPackages.add(VideoPackage(context, element, () { + setState(() {}); + })); + }); + } + + @override + Widget build(BuildContext context) { + var videoAttachments = widget.mediaAttachments + .where((element) => element.type == 'video') + .toList(); + + return Scaffold( + resizeToAvoidBottomInset: false, + body: Stack( + children: [ + AnimatedBuilder( + animation: _controller, + builder: (context, snapshot) { + return PageView.builder( + controller: _pageController, + onPageChanged: (val) { + setState(() { + _currentPage = val; + }); + }, + itemBuilder: (context, position) { + if (widget.mediaAttachments[position].type == 'image' || + widget.mediaAttachments[position].type == 'giphy') { + return PhotoView( + imageProvider: CachedNetworkImageProvider( + widget.mediaAttachments[position].imageUrl ?? + widget.mediaAttachments[position].assetUrl ?? + widget.mediaAttachments[position].thumbUrl), + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + heroAttributes: PhotoViewHeroAttributes( + tag: widget.mediaAttachments, + ), + backgroundDecoration: BoxDecoration( + color: ColorTween( + begin: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .color, + end: Colors.black) + .lerp(_controller.value), + ), + onTapUp: (a, b, c) { + setState(() { + _optionsShown = !_optionsShown; + }); + if (_controller.isCompleted) { + _controller.reverse(); + } else { + _controller.forward(); + } + }, + ); + } else if (widget.mediaAttachments[position].type == + 'video') { + var controllerPackage = videoPackages[videoAttachments + .indexOf(widget.mediaAttachments[position])]; + + if (!controllerPackage.initialised) { + return Center( + child: CircularProgressIndicator(), + ); + } + return InkWell( + onTap: () { + setState(() { + _optionsShown = !_optionsShown; + }); + if (_controller.isCompleted) { + _controller.reverse(); + } else { + _controller.forward(); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 50.0, + ), + child: Chewie( + controller: controllerPackage.chewieController, + ), + ), + ); + } + return Container(); + }, + itemCount: widget.mediaAttachments.length, + ); + }), + AnimatedOpacity( + opacity: _optionsShown ? 1.0 : 0.0, + duration: Duration(milliseconds: 300), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ImageHeader( + userName: widget.userName, + sentAt: widget.message.createdAt == null + ? '' + : 'Sent ${getDay(widget.message.createdAt)} at ${Jiffy(widget.sentAt.toLocal()).format('HH:mm')}', + onBackPressed: () { + Navigator.of(context).pop(); + }, + message: widget.message, + urls: widget.mediaAttachments, + currentIndex: _currentPage, + onShowMessage: () { + widget.onShowMessage( + widget.message, StreamChannel.of(context).channel); + }, + ), + ImageFooter( + currentPage: _currentPage, + totalPages: widget.mediaAttachments.length, + mediaAttachments: widget.mediaAttachments, + message: widget.message, + videoPackages: videoPackages, + mediaSelectedCallBack: (val) { + setState(() { + _currentPage = val; + _pageController.animateToPage(val, + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut); + Navigator.pop(context); + }); + }, + ), + ], + ), + ), + ], + ), + ); + } + + String getDay(DateTime dateTime) { + var now = DateTime.now(); + + if (DateTime(dateTime.year, dateTime.month, dateTime.day) == + DateTime(now.year, now.month, now.day)) { + return 'today'; + } else if (DateTime(now.year, now.month, now.day) + .difference(dateTime) + .inHours < + 24) { + return 'yesterday'; + } else { + return 'on ${Jiffy(dateTime).format("MMM do")}'; + } + } + + @override + void dispose() { + videoPackages.forEach((element) { + element.dispose(); + }); + super.dispose(); + } +} + +class VideoPackage { + VideoPlayerController _videoPlayerController; + ChewieController _chewieController; + bool initialised = false; + VoidCallback onInit; + BuildContext context; + + /// + VideoPackage(this.context, Attachment attachment, this.onInit) { + _videoPlayerController = VideoPlayerController.network(attachment.assetUrl); + _videoPlayerController.initialize().whenComplete(() { + initialised = true; + _chewieController = ChewieController( + videoPlayerController: _videoPlayerController, + autoInitialize: false, + aspectRatio: _videoPlayerController.value.aspectRatio, + ); + onInit(); + }); + + VoidCallback errorListener; + errorListener = () { + if (_videoPlayerController.value.hasError) { + Navigator.pop(context); + launchURL(context, attachment.titleLink); + } + _videoPlayerController.removeListener(errorListener); + }; + _videoPlayerController.addListener(errorListener); + } + + VideoPlayerController get videoPlayer => _videoPlayerController; + + ChewieController get chewieController => _chewieController; + + void dispose() { + _videoPlayerController.dispose(); + _chewieController.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/giphy_attachment.dart new file mode 100644 index 00000000..0f89bc75 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/giphy_attachment.dart @@ -0,0 +1,421 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; + +import '../stream_chat_flutter.dart'; +import 'attachment_error.dart'; +import 'full_screen_media.dart'; + +class GiphyAttachment extends StatelessWidget { + final Attachment attachment; + final MessageTheme messageTheme; + final Message message; + final Size size; + final ShowMessageCallback onShowMessage; + final ValueChanged onReturnAction; + + const GiphyAttachment({ + Key key, + this.attachment, + this.messageTheme, + this.message, + this.size, + this.onShowMessage, + this.onReturnAction, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (attachment.thumbUrl == null && + attachment.imageUrl == null && + attachment.assetUrl == null) { + return AttachmentError( + attachment: attachment, + ); + } + + return attachment.actions != null + ? _buildSendingAttachment(context) + : _buildSentAttachment(context); + } + + Widget _buildSendingAttachment(context) { + final streamChannel = StreamChannel.of(context); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Card( + color: StreamChatTheme.of(context).colorTheme.white, + elevation: 2, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + bottomRight: Radius.circular(0.0), + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () async { + _onImageTap(context); + }, + child: ClipRRect( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + child: CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, + ), + ), + ), + ), + Positioned( + bottom: 16, + left: 16, + child: Material( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + child: Row( + children: [ + StreamSvgIcon.lightning( + color: + StreamChatTheme.of(context).colorTheme.white, + size: 16, + ), + Text( + 'GIPHY', + style: TextStyle( + color: StreamChatTheme.of(context) + .colorTheme + .white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ), + ], + ), + if (attachment.title != null) + Container( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Card( + color: Colors.white, + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: StreamSvgIcon.left( + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, + ), + shape: CircleBorder(), + ), + Expanded( + child: Center( + child: Text( + '"${attachment.title}"', + style: TextStyle( + fontStyle: FontStyle.italic, + ), + ), + ), + ), + Card( + color: Colors.white, + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: StreamSvgIcon.right( + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, + ), + shape: CircleBorder(), + ), + ], + ), + ), + ), + SizedBox( + height: 4.0, + ), + Container( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.2), + width: double.infinity, + height: 0.5, + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'cancel', + }); + }, + child: Text( + 'Cancel', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5), + ), + ), + ), + ), + Container( + width: 0.5, + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.2), + height: 50.0, + ), + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'send', + }); + }, + child: Text( + 'Send', + style: TextStyle( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ], + ), + ), + SizedBox( + height: 4.0, + ), + Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon.eye( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5), + size: 16.0, + ), + SizedBox( + width: 8.0, + ), + Text( + 'Only visible to you', + style: StreamChatTheme.of(context) + .textTheme + .footnote + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5)), + ), + ], + ), + ), + ), + ], + ); + } + + void _onImageTap(BuildContext context) async { + var res = await Navigator.push(context, MaterialPageRoute( + builder: (_) { + final channel = StreamChannel.of(context).channel; + + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [ + attachment, + ], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ); + }, + )); + + if (res != null) { + onReturnAction(res); + } + } + + Widget _buildSentAttachment(context) { + return Container( + child: GestureDetector( + onTap: () async { + var res = + await Navigator.push(context, MaterialPageRoute(builder: (_) { + var channel = StreamChannel.of(context).channel; + + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [ + attachment, + ], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ); + })); + + if (res != null) { + onReturnAction(res); + } + }, + child: Stack( + children: [ + CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, + ), + Positioned( + bottom: 8, + left: 8, + child: Material( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + child: Row( + children: [ + StreamSvgIcon.lightning( + color: StreamChatTheme.of(context).colorTheme.white, + size: 16, + ), + Text( + 'GIPHY', + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/group_image.dart b/packages/stream_chat_flutter/lib/src/group_image.dart new file mode 100644 index 00000000..4cfd655a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/group_image.dart @@ -0,0 +1,127 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../stream_chat_flutter.dart'; + +class GroupImage extends StatelessWidget { + const GroupImage({ + Key key, + @required this.images, + this.constraints, + this.onTap, + this.borderRadius, + this.selected = false, + this.selectionColor, + this.selectionThickness = 4, + }) : super(key: key); + + final List images; + final BoxConstraints constraints; + final VoidCallback onTap; + final bool selected; + final BorderRadius borderRadius; + final Color selectionColor; + final double selectionThickness; + + @override + Widget build(BuildContext context) { + var avatar; + final streamChatTheme = StreamChatTheme.of(context); + + avatar = GestureDetector( + onTap: onTap, + child: ClipRRect( + borderRadius: borderRadius ?? + StreamChatTheme.of(context) + .ownMessageTheme + .avatarTheme + .borderRadius, + child: Container( + constraints: constraints ?? + StreamChatTheme.of(context) + .ownMessageTheme + .avatarTheme + .constraints, + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + child: Flex( + direction: Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + fit: FlexFit.tight, + child: Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: images + .take(2) + .map((url) => Flexible( + fit: FlexFit.tight, + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.2, + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + ), + ), + ), + )) + .toList(), + ), + ), + if (images.length > 2) + Flexible( + fit: FlexFit.tight, + child: Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: images + .skip(2) + .map((url) => Flexible( + fit: FlexFit.tight, + child: FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.2, + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + ), + ), + ), + )) + .toList(), + ), + ), + ], + ), + ), + ), + ); + + if (selected) { + avatar = ClipRRect( + borderRadius: (borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + + BorderRadius.circular(selectionThickness), + child: Container( + color: selectionColor ?? + StreamChatTheme.of(context).colorTheme.accentBlue, + height: 64.0, + width: 64.0, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: avatar, + ), + ), + ); + } + + return avatar; + } +} diff --git a/packages/stream_chat_flutter/lib/src/image_actions_modal.dart b/packages/stream_chat_flutter/lib/src/image_actions_modal.dart new file mode 100644 index 00000000..4be1dc1d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/image_actions_modal.dart @@ -0,0 +1,182 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:image_gallery_saver/image_gallery_saver.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../stream_chat_flutter.dart'; +import 'extension.dart'; + +class ImageActionsModal extends StatelessWidget { + final Message message; + final String userName; + final String sentAt; + final List urls; + final currentIndex; + final VoidCallback onShowMessage; + + ImageActionsModal( + {this.message, + this.userName, + this.sentAt, + this.urls, + this.currentIndex, + this.onShowMessage}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: _buildPage(context), + ); + } + + Widget _buildPage(context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(height: kToolbarHeight), + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Container( + width: MediaQuery.of(context).size.width * 0.5, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + ), + child: Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + _buildButton( + context, + 'Reply', + StreamSvgIcon.iconCurveLineLeftUp( + size: 24.0, + color: StreamChatTheme.of(context).colorTheme.grey, + ), + () { + Navigator.pop(context, ReturnActionType.reply); + }, + ), + _buildButton( + context, + 'Show in Chat', + StreamSvgIcon.eye( + size: 24.0, + color: StreamChatTheme.of(context).colorTheme.black, + ), + onShowMessage, + ), + _buildButton( + context, + 'Save ${urls[currentIndex].type == 'video' ? 'Video' : 'Image'}', + StreamSvgIcon.iconSave( + size: 24.0, + color: StreamChatTheme.of(context).colorTheme.grey, + ), + () async { + var url = urls[currentIndex].imageUrl ?? + urls[currentIndex].assetUrl ?? + urls[currentIndex].thumbUrl; + + Navigator.pop(context); + + if (urls[currentIndex].type == 'video') { + await _saveVideo(url); + } else { + await _saveImage(url); + } + }, + ), + if (StreamChat.of(context).user.id == message.user.id) + _buildButton( + context, + 'Delete', + StreamSvgIcon.delete( + size: 24.0, + color: StreamChatTheme.of(context).colorTheme.accentRed, + ), + () { + Navigator.pop(context); + Navigator.pop(context); + StreamChat.of(context).client.deleteMessage( + message, + StreamChannel.of(context).channel.cid, + ); + }, + color: StreamChatTheme.of(context).colorTheme.accentRed, + ), + ] + .map((e) => + Align(alignment: Alignment.centerRight, child: e)) + .insertBetween( + Container( + height: 1, + color: + StreamChatTheme.of(context).colorTheme.greyWhisper, + ), + ), + ), + ), + ), + ) + ], + ); + } + + Widget _buildButton( + context, + String title, + StreamSvgIcon icon, + VoidCallback onTap, { + Color color, + }) { + return Material( + color: StreamChatTheme.of(context).colorTheme.white, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Row( + children: [ + icon, + SizedBox(width: 16), + Text( + title, + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith(color: color), + ), + ], + ), + ), + ), + ); + } + + Future _saveImage(String url) async { + var response = await Dio() + .get(url, options: Options(responseType: ResponseType.bytes)); + final result = await ImageGallerySaver.saveImage( + Uint8List.fromList(response.data), + quality: 60, + name: '${DateTime.now().millisecondsSinceEpoch}'); + return result; + } + + Future _saveVideo(String url) async { + var appDocDir = await getTemporaryDirectory(); + var savePath = + appDocDir.path + '/${DateTime.now().millisecondsSinceEpoch}.mp4'; + await Dio().download(url, savePath); + final result = await ImageGallerySaver.saveFile(savePath); + print(result); + } +} diff --git a/lib/src/image_attachment.dart b/packages/stream_chat_flutter/lib/src/image_attachment.dart similarity index 62% rename from lib/src/image_attachment.dart rename to packages/stream_chat_flutter/lib/src/image_attachment.dart index a4bd1a0f..4be47262 100644 --- a/lib/src/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/image_attachment.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import '../stream_chat_flutter.dart'; import 'attachment_error.dart'; import 'attachment_title.dart'; -import 'full_screen_image.dart'; +import 'full_screen_media.dart'; import 'utils.dart'; class ImageAttachment extends StatelessWidget { @@ -12,13 +12,19 @@ class ImageAttachment extends StatelessWidget { final Message message; final MessageTheme messageTheme; final Size size; + final bool showTitle; + final ShowMessageCallback onShowMessage; + final ValueChanged onReturnAction; const ImageAttachment({ Key key, @required this.attachment, @required this.message, + @required this.size, this.messageTheme, - this.size, + this.showTitle = true, + this.onShowMessage, + this.onReturnAction, }) : super(key: key); @override @@ -30,22 +36,40 @@ class ImageAttachment extends StatelessWidget { attachment: attachment, ); } - return SizedBox.fromSize( - size: size, + return ConstrainedBox( + constraints: BoxConstraints.loose(size), child: Stack( children: [ Column( children: [ Expanded( child: GestureDetector( - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (_) { - return FullScreenImage( - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl, - ); - })); + onTap: () async { + var result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + final channel = StreamChannel.of(context).channel; + + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [ + attachment, + ], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ); + }, + ), + ); + + if (result != null) { + onReturnAction(result); + } }, child: CachedNetworkImage( height: size?.height, @@ -70,7 +94,7 @@ class ImageAttachment extends StatelessWidget { ), ), ), - if (attachment.title != null) + if (showTitle && attachment.title != null) Material( color: messageTheme.messageBackgroundColor, child: AttachmentTitle( @@ -80,7 +104,8 @@ class ImageAttachment extends StatelessWidget { ), ], ), - if (attachment.titleLink != null || attachment.ogScrapeUrl != null) + if (showTitle && + (attachment.titleLink != null || attachment.ogScrapeUrl != null)) Positioned.fill( child: Material( color: Colors.transparent, diff --git a/packages/stream_chat_flutter/lib/src/image_footer.dart b/packages/stream_chat_flutter/lib/src/image_footer.dart new file mode 100644 index 00000000..7b0ed621 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/image_footer.dart @@ -0,0 +1,335 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chewie/chewie.dart'; +import 'package:dio/dio.dart'; +import 'package:esys_flutter_share/esys_flutter_share.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +class ImageFooter extends StatefulWidget implements PreferredSizeWidget { + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback onBackPressed; + + /// Callback to call when the header is tapped. + final VoidCallback onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback onImageTap; + + final int currentPage; + final int totalPages; + + final List mediaAttachments; + final Message message; + + final List videoPackages; + final ValueChanged mediaSelectedCallBack; + + /// Creates a channel header + ImageFooter({ + Key key, + this.onBackPressed, + this.onTitleTap, + this.onImageTap, + this.currentPage = 0, + this.totalPages = 0, + this.mediaAttachments, + this.message, + this.videoPackages, + this.mediaSelectedCallBack, + }) : preferredSize = Size.fromHeight(kToolbarHeight), + super(key: key); + + @override + _ImageFooterState createState() => _ImageFooterState(); + + @override + final Size preferredSize; +} + +class _ImageFooterState extends State { + TextEditingController _searchController; + final TextEditingController _messageController = TextEditingController(); + final FocusNode _messageFocusNode = FocusNode(); + + final List _selectedChannels = []; + + Function modalSetStateCallback; + + @override + void initState() { + super.initState(); + _messageFocusNode.addListener(() { + setState(() {}); + }); + } + + @override + void dispose() { + _searchController?.clear(); + _searchController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox.fromSize( + size: Size( + MediaQuery.of(context).size.width, + MediaQuery.of(context).padding.bottom + widget.preferredSize.height, + ), + child: MediaQuery.removePadding( + context: context, + removeTop: true, + child: BottomAppBar( + color: StreamChatTheme.of(context).colorTheme.white, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: StreamSvgIcon.iconShare( + size: 24.0, + color: StreamChatTheme.of(context).colorTheme.black, + ), + onPressed: () async { + final attachment = + widget.mediaAttachments[widget.currentPage]; + var url = attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl; + var type = attachment.type == 'image' + ? 'jpg' + : url?.split('?')?.first?.split('.')?.last ?? 'jpg'; + var request = await HttpClient().getUrl(Uri.parse(url)); + var response = await request.close(); + var bytes = + await consolidateHttpClientResponseBytes(response); + await Share.file('File', 'image.$type', bytes, 'image/$type'); + }, + ), + InkWell( + onTap: widget.onTitleTap, + child: Container( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${widget.currentPage + 1} of ${widget.totalPages}', + style: + StreamChatTheme.of(context).textTheme.headlineBold, + ), + ], + ), + ), + ), + IconButton( + icon: StreamSvgIcon.iconGrid( + color: StreamChatTheme.of(context).colorTheme.black, + ), + onPressed: () => _showPhotosModal(context), + ), + ], + ), + ), + ), + ); + } + + void _showPhotosModal(context) { + var videoAttachments = widget.mediaAttachments + .where((element) => element.type == 'video') + .toList(); + + showModalBottomSheet( + context: context, + barrierColor: StreamChatTheme.of(context).colorTheme.overlay, + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + builder: (context) { + final crossAxisCount = 3; + final noOfRowToShowInitially = + widget.mediaAttachments.length > crossAxisCount ? 2 : 1; + final size = MediaQuery.of(context).size; + final initialChildSize = + 48 + (size.width * noOfRowToShowInitially) / crossAxisCount; + return DraggableScrollableSheet( + expand: false, + initialChildSize: initialChildSize / size.height, + minChildSize: initialChildSize / size.height, + builder: (context, scrollController) { + return SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Photos', + style: StreamChatTheme.of(context) + .textTheme + .headlineBold, + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: StreamSvgIcon.close( + color: StreamChatTheme.of(context).colorTheme.black, + ), + onPressed: () => Navigator.maybePop(context), + ), + ), + ], + ), + Flexible( + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: widget.mediaAttachments.length, + padding: const EdgeInsets.all(1), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 2.0, + crossAxisSpacing: 2.0, + ), + itemBuilder: (context, index) { + Widget media; + final attachment = widget.mediaAttachments[index]; + + if (attachment.type == 'video') { + var controllerPackage = widget.videoPackages[ + videoAttachments.indexOf(attachment)]; + + media = InkWell( + onTap: () => widget.mediaSelectedCallBack(index), + child: FittedBox( + fit: BoxFit.cover, + child: Chewie( + controller: controllerPackage.chewieController, + ), + ), + ); + } else { + media = InkWell( + onTap: () => widget.mediaSelectedCallBack(index), + child: AspectRatio( + child: CachedNetworkImage( + imageUrl: attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl, + fit: BoxFit.cover, + ), + aspectRatio: 1.0, + ), + ); + } + + return Stack( + children: [ + media, + Padding( + padding: EdgeInsets.all(8.0), + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withOpacity(0.6), + boxShadow: [ + BoxShadow( + blurRadius: 8.0, + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.3), + ), + ], + ), + padding: const EdgeInsets.all(2), + child: UserAvatar( + user: widget.message.user, + constraints: + BoxConstraints.tight(Size(24, 24)), + showOnlineStatus: false, + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ); + }, + ); + }, + ); + } + + /// Sends the current message + Future sendMessage() async { + var text = _messageController.text.trim(); + + final attachments = widget.message.attachments; + + _messageController.clear(); + + for (var channel in _selectedChannels) { + final message = Message( + text: text, + attachments: [attachments[widget.currentPage]], + ); + + await channel.sendMessage(message); + } + + _selectedChannels.clear(); + Navigator.pop(context); + } +} + +/// Used for clipping textfield prefix icon +class IconClipper extends CustomClipper { + @override + Path getClip(Size size) { + var leftX = size.width / 5; + var rightX = 4 * size.width / 5; + var topY = size.height / 5; + var bottomY = 4 * size.height / 5; + + final path = Path(); + path.moveTo(leftX, topY); + path.lineTo(leftX, bottomY); + path.lineTo(rightX, bottomY); + path.lineTo(rightX, topY); + path.lineTo(leftX, topY); + path.lineTo(0.0, 0.0); + path.close(); + return path; + } + + @override + bool shouldReclip(CustomClipper oldClipper) { + return false; + } +} diff --git a/packages/stream_chat_flutter/lib/src/image_group.dart b/packages/stream_chat_flutter/lib/src/image_group.dart new file mode 100644 index 00000000..3a290c60 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/image_group.dart @@ -0,0 +1,142 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/src/full_screen_media.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class ImageGroup extends StatelessWidget { + const ImageGroup({ + Key key, + @required this.images, + @required this.message, + @required this.size, + this.onShowMessage, + }) : super(key: key); + + final List images; + final Message message; + final Size size; + final ShowMessageCallback onShowMessage; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints.loose(size), + child: Flex( + direction: Axis.vertical, + children: [ + Flexible( + flex: 1, + fit: FlexFit.tight, + child: Flex( + crossAxisAlignment: CrossAxisAlignment.stretch, + direction: Axis.horizontal, + children: [ + Flexible( + flex: 1, + fit: FlexFit.tight, + child: _buildImage(context, 0), + ), + Flexible( + flex: 1, + fit: FlexFit.tight, + child: Padding( + padding: const EdgeInsets.only(left: 2.0), + child: _buildImage(context, 1), + ), + ), + ], + ), + ), + if (images.length >= 3) + Flexible( + fit: FlexFit.tight, + flex: 1, + child: Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Flexible( + fit: FlexFit.tight, + flex: 1, + child: _buildImage(context, 2), + ), + if (images.length >= 4) + Flexible( + fit: FlexFit.tight, + flex: 1, + child: Padding( + padding: const EdgeInsets.only(left: 2.0), + child: Stack( + fit: StackFit.expand, + children: [ + _buildImage(context, 3), + if (images.length > 4) + Positioned.fill( + child: GestureDetector( + onTap: () => _onTap(context, 3), + child: Material( + color: Colors.black38, + child: Center( + child: Text( + '+ ${images.length - 4}', + style: TextStyle( + color: Colors.white, + fontSize: 26, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + void _onTap( + BuildContext context, [ + int index, + ]) { + final channel = StreamChannel.of(context).channel; + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: images, + startIndex: index, + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ), + ), + ); + } + + Widget _buildImage(BuildContext context, int index) { + return GestureDetector( + onTap: () => _onTap(context, index), + child: CachedNetworkImage( + imageUrl: images[index].imageUrl ?? + images[index].thumbUrl ?? + images[index].assetUrl, + fit: BoxFit.cover, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/image_header.dart b/packages/stream_chat_flutter/lib/src/image_header.dart new file mode 100644 index 00000000..25eab839 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/image_header.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'image_actions_modal.dart'; + +class ImageHeader extends StatelessWidget implements PreferredSizeWidget { + /// True if this header shows the leading back button + final bool showBackButton; + + /// Callback to call when pressing the back button. + /// By default it calls [Navigator.pop] + final VoidCallback onBackPressed; + + /// Callback to call when pressing the show message button. + final VoidCallback onShowMessage; + + /// Callback to call when the header is tapped. + final VoidCallback onTitleTap; + + /// Callback to call when the image is tapped. + final VoidCallback onImageTap; + + final Message message; + + final String userName; + final String sentAt; + + final List urls; + final currentIndex; + + /// Creates a channel header + ImageHeader({ + Key key, + this.message, + this.urls, + this.currentIndex, + this.showBackButton = true, + this.onBackPressed, + this.onShowMessage, + this.onTitleTap, + this.onImageTap, + this.userName = '', + this.sentAt = '', + }) : preferredSize = Size.fromHeight(kToolbarHeight), + super(key: key); + + @override + Widget build(BuildContext context) { + return AppBar( + brightness: Theme.of(context).brightness, + elevation: 1, + leading: showBackButton + ? IconButton( + icon: StreamSvgIcon.close( + color: StreamChatTheme.of(context).colorTheme.black, + size: 24.0, + ), + onPressed: onBackPressed, + ) + : SizedBox(), + backgroundColor: + StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, + actions: [ + IconButton( + icon: StreamSvgIcon.iconMenuPoint( + color: StreamChatTheme.of(context).colorTheme.black, + ), + onPressed: () { + _showMessageActionModalBottomSheet(context); + }, + ), + ], + centerTitle: true, + title: InkWell( + onTap: onTitleTap, + child: Container( + height: preferredSize.height, + width: preferredSize.width, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + userName, + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + Text( + sentAt, + style: StreamChatTheme.of(context).channelPreviewTheme.subtitle, + ), + ], + ), + ), + ), + ); + } + + @override + final Size preferredSize; + + void _showMessageActionModalBottomSheet(BuildContext context) async { + final channel = StreamChannel.of(context).channel; + + var result = await showDialog( + context: context, + barrierColor: StreamChatTheme.of(context).colorTheme.overlay, + builder: (context) { + return StreamChannel( + channel: channel, + child: ImageActionsModal( + userName: userName, + sentAt: sentAt, + message: message, + urls: urls, + currentIndex: currentIndex, + onShowMessage: onShowMessage, + ), + ); + }, + ); + + if (result != null) { + Navigator.pop(context, result); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/info_tile.dart b/packages/stream_chat_flutter/lib/src/info_tile.dart new file mode 100644 index 00000000..2e12d6b3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/info_tile.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; + +class InfoTile extends StatelessWidget { + final String message; + final Widget child; + final bool showMessage; + final Alignment tileAnchor; + final Alignment childAnchor; + final TextStyle textStyle; + final Color backgroundColor; + + InfoTile( + {this.message, + this.child, + this.showMessage, + this.tileAnchor, + this.childAnchor, + this.textStyle, + this.backgroundColor}); + + @override + Widget build(BuildContext context) { + return PortalEntry( + visible: showMessage, + portalAnchor: tileAnchor ?? Alignment.topCenter, + childAnchor: childAnchor ?? Alignment.bottomCenter, + portal: Container( + height: 25.0, + color: backgroundColor ?? + StreamChatTheme.of(context).colorTheme.grey.withOpacity(0.9), + child: Center( + child: Text( + message, + style: textStyle ?? + StreamChatTheme.of(context).textTheme.body.copyWith( + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + child: child, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_list_view.dart b/packages/stream_chat_flutter/lib/src/media_list_view.dart new file mode 100644 index 00000000..7550e3e4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_list_view.dart @@ -0,0 +1,214 @@ +import 'dart:ui' as ui; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; + +import '../stream_chat_flutter.dart'; + +extension on Duration { + String format() { + final s = '$this'.split('.')[0].padLeft(8, '0'); + if (s.startsWith('00:')) { + return s.replaceFirst('00:', ''); + } + + return s; + } +} + +class MediaListView extends StatefulWidget { + final List selectedIds; + final void Function(AssetEntity media) onSelect; + + const MediaListView({ + Key key, + this.selectedIds = const [], + this.onSelect, + }) : super(key: key); + + @override + _MediaListViewState createState() => _MediaListViewState(); +} + +class _MediaListViewState extends State { + final _media = []; + final ScrollController _scrollController = ScrollController(); + int _currentPage = 0; + + @override + Widget build(BuildContext context) { + return LazyLoadScrollView( + onEndOfPage: () async => _getMedia(), + child: GridView.builder( + itemCount: _media.length, + controller: _scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + ), + itemBuilder: ( + context, + position, + ) { + final media = _media.elementAt(position); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 1.0), + child: InkWell( + child: Stack( + children: [ + AspectRatio( + aspectRatio: 1.0, + child: FadeInImage( + fadeInDuration: Duration(milliseconds: 300), + placeholder: AssetImage( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ), + image: MediaThumbnailProvider( + media: media, + ), + fit: BoxFit.cover, + ), + ), + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + duration: Duration(milliseconds: 300), + opacity: widget.selectedIds.any((id) => id == media.id) + ? 1.0 + : 0.0, + child: Container( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5), + alignment: Alignment.topRight, + padding: const EdgeInsets.only( + top: 8, + right: 8, + ), + child: CircleAvatar( + radius: 12, + backgroundColor: + StreamChatTheme.of(context).colorTheme.white, + child: StreamSvgIcon.check( + size: 24, + color: + StreamChatTheme.of(context).colorTheme.black, + ), + ), + ), + ), + ), + ), + if (media.type == AssetType.video) ...[ + Positioned( + left: 8, + bottom: 10, + child: SvgPicture.asset( + 'svgs/video_call_icon.svg', + package: 'stream_chat_flutter', + ), + ), + Positioned( + right: 4, + bottom: 10, + child: Text( + media.videoDuration.format(), + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.white, + ), + ), + ), + ] + ], + ), + onTap: () { + if (widget.onSelect != null) { + widget.onSelect(media); + } + }, + ), + ); + }, + ), + ); + } + + @override + void initState() { + super.initState(); + _getMedia(); + } + + void _getMedia() async { + final assetList = await PhotoManager.getAssetPathList( + hasAll: true, + ).then((value) { + if (value?.isNotEmpty == true) { + return value.singleWhere((element) => element.isAll); + } + }); + + if (assetList == null) { + return; + } + + final media = await assetList.getAssetListPaged(_currentPage, 50); + + if (media.isNotEmpty) { + setState(() { + _media.addAll(media); + }); + } + ++_currentPage; + } +} + +class MediaThumbnailProvider extends ImageProvider { + const MediaThumbnailProvider({ + @required this.media, + }) : assert(media != null); + + final AssetEntity media; + + @override + ImageStreamCompleter load(key, decode) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: 1.0, + informationCollector: () sync* { + yield ErrorDescription('Id: ${media?.id}'); + }, + ); + } + + Future _loadAsync( + MediaThumbnailProvider key, DecoderCallback decode) async { + assert(key == this); + final bytes = await media.thumbData; + if (bytes?.isNotEmpty != true) return null; + + return await decode(bytes); + } + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + + @override + bool operator ==(dynamic other) { + if (other.runtimeType != runtimeType) return false; + final MediaThumbnailProvider typedOther = other; + return media?.id == typedOther.media?.id; + } + + @override + int get hashCode => media?.id?.hashCode ?? 0; + + @override + String toString() => '$runtimeType("${media?.id}")'; +} diff --git a/packages/stream_chat_flutter/lib/src/media_utils.dart b/packages/stream_chat_flutter/lib/src/media_utils.dart new file mode 100644 index 00000000..e55e02df --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_utils.dart @@ -0,0 +1,17 @@ +import 'package:http_parser/http_parser.dart' as http_parser; +import 'package:mime/mime.dart'; + +class MediaUtils { + static http_parser.MediaType getMimeType(String filename) { + http_parser.MediaType mimeType; + if (filename != null) { + if (filename.toLowerCase().endsWith('heic')) { + mimeType = http_parser.MediaType.parse('image/heic'); + } else { + mimeType = http_parser.MediaType.parse(lookupMimeType(filename)); + } + } + + return mimeType; + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart new file mode 100644 index 00000000..9551ff71 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -0,0 +1,690 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/src/reaction_picker.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'extension.dart'; +import 'message_input.dart'; +import 'message_widget.dart'; +import 'stream_chat.dart'; +import 'stream_chat_theme.dart'; + +class MessageActionsModal extends StatefulWidget { + final Widget Function(BuildContext, Message) editMessageInputBuilder; + final void Function(Message) onThreadReplyTap; + final void Function(Message) onReplyTap; + final Message message; + final MessageTheme messageTheme; + final bool showReactions; + final bool showDeleteMessage; + final bool showCopyMessage; + final bool showEditMessage; + final bool showResendMessage; + final bool showReplyMessage; + final bool showThreadReplyMessage; + final bool showFlagButton; + final bool reverse; + final ShapeBorder messageShape; + final ShapeBorder attachmentShape; + final DisplayWidget showUserAvatar; + + const MessageActionsModal({ + Key key, + @required this.message, + @required this.messageTheme, + this.showReactions = true, + this.showDeleteMessage = true, + this.showEditMessage = true, + this.onReplyTap, + this.onThreadReplyTap, + this.showCopyMessage = true, + this.showReplyMessage = true, + this.showResendMessage = true, + this.showThreadReplyMessage = true, + this.showFlagButton = true, + this.showUserAvatar = DisplayWidget.show, + this.editMessageInputBuilder, + this.messageShape, + this.attachmentShape, + this.reverse = false, + }) : super(key: key); + + @override + _MessageActionsModalState createState() => _MessageActionsModalState(); +} + +class _MessageActionsModalState extends State { + bool _showActions = true; + + @override + Widget build(BuildContext context) { + return _showMessageOptionsModal(); + } + + Widget _showMessageOptionsModal() { + final size = MediaQuery.of(context).size; + final user = StreamChat.of(context).user; + + final roughMaxSize = 2 * size.width / 3; + var messageTextLength = widget.message.text.length; + if (widget.message.quotedMessage != null) { + var quotedMessageLength = widget.message.quotedMessage.text.length + 40; + if (widget.message.quotedMessage.attachments?.isNotEmpty == true) { + quotedMessageLength += 40; + } + if (quotedMessageLength > messageTextLength) { + messageTextLength = quotedMessageLength; + } + } + final roughSentenceSize = + messageTextLength * widget.messageTheme.messageText.fontSize * 1.2; + final divFactor = widget.message.attachments?.isNotEmpty == true + ? 1 + : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); + + final hasFileAttachment = + widget.message.attachments?.any((it) => it.type == 'file') == true; + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), + child: Container( + color: StreamChatTheme.of(context).colorTheme.overlay, + ), + ), + ), + if (_showActions) + TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: Duration(milliseconds: 300), + curve: Curves.easeInOutBack, + builder: (context, val, snapshot) { + return Transform.scale( + scale: val, + child: Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: widget.reverse + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (widget.showReactions && + (widget.message.status == + MessageSendingStatus.sent || + widget.message.status == null)) + Align( + alignment: Alignment( + user.id == widget.message.user.id + ? (divFactor > 1.0 + ? 0.0 + : (1.0 - divFactor)) + : (divFactor > 1.0 + ? 0.0 + : -(1.0 - divFactor)), + 0.0), + child: ReactionPicker( + message: widget.message, + messageTheme: widget.messageTheme, + ), + ), + SizedBox(height: 8), + IgnorePointer( + child: MessageWidget( + key: Key('MessageWidget'), + reverse: widget.reverse, + message: widget.message.copyWith( + text: widget.message.text.length > 200 + ? '${widget.message.text.substring(0, 200)}...' + : widget.message.text, + ), + messageTheme: widget.messageTheme, + showReactions: false, + showUsername: false, + showThreadReplyIndicator: false, + showReplyMessage: false, + showUserAvatar: widget.showUserAvatar, + attachmentPadding: EdgeInsets.all( + hasFileAttachment ? 4 : 2, + ), + showTimestamp: false, + translateUserAvatar: false, + padding: const EdgeInsets.all(0), + textPadding: EdgeInsets.symmetric( + vertical: 8.0, + horizontal: widget.message.text.isOnlyEmoji + ? 0 + : 16.0, + ), + showReactionPickerIndicator: + widget.showReactions && + (widget.message.status == + MessageSendingStatus.sent || + widget.message.status == null), + showInChannelIndicator: false, + showSendingIndicator: false, + shape: widget.messageShape, + attachmentShape: widget.attachmentShape, + ), + ), + SizedBox(height: 8), + Padding( + padding: EdgeInsets.only( + left: widget.reverse ? 0 : 40, + ), + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.75, + child: Material( + color: StreamChatTheme.of(context) + .colorTheme + .whiteSnow, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.stretch, + children: [ + if (widget.showReplyMessage && + (widget.message.status == + MessageSendingStatus.sent || + widget.message.status == null) && + widget.message.parentId == null) + _buildReplyButton(context), + if (widget.showThreadReplyMessage && + (widget.message.status == + MessageSendingStatus.sent || + widget.message.status == null) && + widget.message.parentId == null) + _buildThreadReplyButton(context), + if (widget.showResendMessage) + _buildResendMessage(context), + if (widget.showEditMessage) + _buildEditMessage(context), + if (widget.showCopyMessage) + _buildCopyButton(context), + if (widget.showFlagButton) + _buildFlagButton(context), + if (widget.showDeleteMessage) + _buildDeleteButton(context), + ].insertBetween( + Container( + height: 1, + color: StreamChatTheme.of(context) + .colorTheme + .greyWhisper, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ], + ), + ); + } + + void _showFlagDialog() async { + final client = StreamChat.of(context).client; + + var answer = await showConfirmationDialog(context, + title: 'Flag Message', + icon: StreamSvgIcon.flag( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + question: + 'Do you want to send a copy of this message to a\nmoderator for further investigation?', + okText: 'FLAG', + cancelText: 'CANCEL'); + + if (answer) { + try { + await client.flagMessage(widget.message.id); + _showDismissAlert(); + } catch (err) { + if (json.decode(err?.body ?? {})['code'] == 4) { + _showDismissAlert(); + } else { + _showErrorAlert(); + } + } + } + } + + void _showDeleteDialog() async { + setState(() { + _showActions = false; + }); + var answer = await showConfirmationDialog( + context, + title: 'Delete message', + icon: StreamSvgIcon.flag( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + question: 'Are you sure you want to permanently delete this\nmessage?', + okText: 'DELETE', + cancelText: 'CANCEL', + ); + + if (answer) { + try { + Navigator.pop(context); + await StreamChat.of(context).client.deleteMessage( + widget.message, + StreamChannel.of(context).channel.cid, + ); + } catch (err) { + _showErrorAlert(); + } + } else { + setState(() { + _showActions = true; + }); + } + } + + void _showDismissAlert() { + showModalBottomSheet( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + StreamSvgIcon.flag( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + SizedBox( + height: 26.0, + ), + Text( + 'Message flagged', + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Text('The message has been reported to a moderator.'), + SizedBox( + height: 36.0, + ), + Container( + color: + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlatButton( + child: Text( + 'OK', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + }, + ); + } + + void _showErrorAlert() { + showModalBottomSheet( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + StreamSvgIcon.error( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + SizedBox( + height: 26.0, + ), + Text( + 'Something went wrong', + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Text('The operation couldn\'t be completed.'), + SizedBox( + height: 36.0, + ), + Container( + color: + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlatButton( + child: Text( + 'OK', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + }, + ); + } + + Widget _buildReplyButton(BuildContext context) { + return InkWell( + onTap: () { + Navigator.pop(context); + if (widget.onReplyTap != null) { + widget.onReplyTap(widget.message); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + StreamSvgIcon.reply( + color: StreamChatTheme.of(context).primaryIconTheme.color, + ), + const SizedBox(width: 16), + Text( + 'Reply', + style: StreamChatTheme.of(context).textTheme.body, + ), + ], + ), + ), + ); + } + + Widget _buildFlagButton(BuildContext context) { + return InkWell( + onTap: () => _showFlagDialog(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + StreamSvgIcon.iconFlag( + color: StreamChatTheme.of(context).primaryIconTheme.color, + ), + const SizedBox(width: 16), + Text( + 'Flag Message', + style: StreamChatTheme.of(context).textTheme.body, + ), + ], + ), + ), + ); + } + + Widget _buildDeleteButton(BuildContext context) { + final isDeleteFailed = + widget.message.status == MessageSendingStatus.failed_delete; + return InkWell( + onTap: () => _showDeleteDialog(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + StreamSvgIcon.delete( + color: Colors.red, + ), + const SizedBox(width: 16), + Text( + isDeleteFailed ? 'Retry Deleting Message' : 'Delete Message', + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith(color: Colors.red), + ), + ], + ), + ), + ); + } + + Widget _buildCopyButton(BuildContext context) { + return InkWell( + onTap: () async { + await Clipboard.setData(ClipboardData(text: widget.message.text)); + Navigator.pop(context); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + StreamSvgIcon.copy( + size: 24, + color: StreamChatTheme.of(context).primaryIconTheme.color, + ), + const SizedBox(width: 16), + Text( + 'Copy Message', + style: StreamChatTheme.of(context).textTheme.body, + ), + ], + ), + ), + ); + } + + Widget _buildEditMessage(BuildContext context) { + return InkWell( + onTap: () async { + Navigator.pop(context); + _showEditBottomSheet(context); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + StreamSvgIcon.edit( + color: StreamChatTheme.of(context).primaryIconTheme.color, + ), + const SizedBox(width: 16), + Text( + 'Edit Message', + style: StreamChatTheme.of(context).textTheme.body, + ), + ], + ), + ), + ); + } + + Widget _buildResendMessage(BuildContext context) { + final isUpdateFailed = + widget.message.status == MessageSendingStatus.failed_update; + return InkWell( + onTap: () { + Navigator.pop(context); + final client = StreamChat.of(context).client; + final channel = StreamChannel.of(context).channel; + if (isUpdateFailed) { + client.updateMessage(widget.message, channel.cid); + } else { + channel.sendMessage(widget.message); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + StreamSvgIcon.circleUp( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + const SizedBox(width: 16), + Text( + isUpdateFailed ? 'Resend Edited Message' : 'Resend', + style: StreamChatTheme.of(context).textTheme.body, + ), + ], + ), + ), + ); + } + + void _showEditBottomSheet(BuildContext context) { + final channel = StreamChannel.of(context).channel; + showModalBottomSheet( + context: context, + elevation: 2, + clipBehavior: Clip.hardEdge, + isScrollControlled: true, + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (context) { + return StreamChannel( + channel: channel, + child: Flex( + direction: Axis.vertical, + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: StreamSvgIcon.edit( + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + ), + ), + Text( + 'Edit Message', + style: TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.closeSmall(), + onPressed: Navigator.of(context).pop, + ), + ], + ), + ), + Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: widget.editMessageInputBuilder != null + ? widget.editMessageInputBuilder(context, widget.message) + : MessageInput( + editMessage: widget.message, + preMessageSending: (m) { + FocusScope.of(context).unfocus(); + Navigator.pop(context); + return m; + }, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildThreadReplyButton(BuildContext context) { + return InkWell( + onTap: () { + Navigator.pop(context); + if (widget.onThreadReplyTap != null) { + widget.onThreadReplyTap(widget.message); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 11.0, horizontal: 16.0), + child: Row( + children: [ + StreamSvgIcon.thread( + color: StreamChatTheme.of(context).primaryIconTheme.color, + ), + const SizedBox(width: 16), + Text( + 'Thread Reply', + style: StreamChatTheme.of(context).textTheme.body, + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart new file mode 100644 index 00000000..425bf5ea --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -0,0 +1,2435 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:emojis/emoji.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:http_parser/http_parser.dart' as http_parser; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:stream_chat_flutter/src/compress_video_service.dart'; +import 'package:stream_chat_flutter/src/media_list_view.dart'; +import 'package:stream_chat_flutter/src/message_list_view.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/user_avatar.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:substring_highlight/substring_highlight.dart'; +import 'package:video_compress/video_compress.dart'; + +import '../stream_chat_flutter.dart'; +import 'extension.dart'; +import 'quoted_message_widget.dart'; + +typedef FileUploader = Future Function(PlatformFile, Channel); +typedef AttachmentThumbnailBuilder = Widget Function( + BuildContext, + _SendingAttachment, +); + +enum ActionsLocation { + left, + right, +} + +enum DefaultAttachmentTypes { + image, + video, + file, +} + +const _kMinMediaPickerSize = 360.0; + +const _kMaxAttachmentSize = 20480; //20MB + +/// Inactive state +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input_paint.png) +/// Focused state +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2_paint.png) +/// +/// Widget used to enter the message and add attachments +/// +/// ```dart +/// class ChannelPage extends StatelessWidget { +/// const ChannelPage({ +/// Key key, +/// }) : super(key: key); +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// appBar: ChannelHeader(), +/// body: Column( +/// children: [ +/// Expanded( +/// child: MessageListView( +/// threadBuilder: (_, parentMessage) { +/// return ThreadPage( +/// parent: parentMessage, +/// ); +/// }, +/// ), +/// ), +/// MessageInput(), +/// ], +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// You usually put this widget in the same page of a [MessageListView] as the bottom widget. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageInput extends StatefulWidget { + /// Instantiate a new MessageInput + MessageInput({ + Key key, + this.onMessageSent, + this.preMessageSending, + this.parentMessage, + this.editMessage, + this.maxHeight = 150, + this.keyboardType = TextInputType.multiline, + this.disableAttachments = false, + this.doImageUploadRequest, + this.doFileUploadRequest, + this.initialMessage, + this.textEditingController, + this.actions, + this.actionsLocation = ActionsLocation.left, + this.attachmentThumbnailBuilders, + this.focusNode, + this.quotedMessage, + this.onQuotedMessageCleared, + }) : super(key: key); + + /// Message to edit + final Message editMessage; + + /// Message to start with + final Message initialMessage; + + /// Function called after sending the message + final void Function(Message) onMessageSent; + + /// Function called right before sending the message + /// Use this to transform the message + final FutureOr Function(Message) preMessageSending; + + /// Parent message in case of a thread + final Message parentMessage; + + /// Maximum Height for the TextField to grow before it starts scrolling + final double maxHeight; + + /// The keyboard type assigned to the TextField + final TextInputType keyboardType; + + /// If true the attachments button will not be displayed + final bool disableAttachments; + + /// Override image upload request + final FileUploader doImageUploadRequest; + + /// Override file upload request + final FileUploader doFileUploadRequest; + + /// The text controller of the TextField + final TextEditingController textEditingController; + + /// List of action widgets + final List actions; + + /// The location of the custom actions + final ActionsLocation actionsLocation; + + /// Map that defines a thumbnail builder for an attachment type + final Map attachmentThumbnailBuilders; + + /// The focus node associated to the TextField + final FocusNode focusNode; + + /// + final Message quotedMessage; + + /// + final VoidCallback onQuotedMessageCleared; + + @override + MessageInputState createState() => MessageInputState(); + + /// Use this method to get the current [StreamChatState] instance + static MessageInputState of(BuildContext context) { + MessageInputState messageInputState; + + messageInputState = context.findAncestorStateOfType(); + + if (messageInputState == null) { + throw Exception( + 'You must have a MessageInput widget as ancestor of your widget tree'); + } + + return messageInputState; + } +} + +class MessageInputState extends State { + final List<_SendingAttachment> _attachments = []; + final List _mentionedUsers = []; + + final _imagePicker = ImagePicker(); + FocusNode _focusNode; + bool _inputEnabled = true; + bool _messageIsPresent = false; + bool _animateContainer = true; + bool _commandEnabled = false; + OverlayEntry _commandsOverlay, _mentionsOverlay, _emojiOverlay; + Iterable _emojiNames; + + Command _chosenCommand; + bool _actionsShrunk = false; + bool _sendAsDm = false; + bool _openFilePickerSection = false; + int _filePickerIndex = 0; + double _filePickerSize = _kMinMediaPickerSize; + final KeyboardVisibilityController _keyboardVisibilityController = + KeyboardVisibilityController(); + + /// The editing controller passed to the input TextField + TextEditingController textEditingController; + + bool get _hasQuotedMessage => widget.quotedMessage != null; + + @override + Widget build(BuildContext context) { + Widget child = SafeArea( + child: GestureDetector( + onPanUpdate: (details) { + if (details.delta.dy > 0) { + _focusNode.unfocus(); + if (_openFilePickerSection) { + setState(() { + _openFilePickerSection = false; + }); + } + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_hasQuotedMessage) + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: StreamSvgIcon.reply( + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + ), + ), + Text( + 'Reply to Message', + style: TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.closeSmall(), + onPressed: widget.onQuotedMessageCleared, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: _buildTextField(context), + ), + if (widget.parentMessage != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: _buildDmCheckbox(), + ), + _buildFilePickerSection(), + ], + ), + ), + ); + if (widget.editMessage == null) { + child = Material( + color: StreamChatTheme.of(context).colorTheme.white, + elevation: 8, + child: child, + ); + } + return child; + } + + Flex _buildTextField(BuildContext context) { + return Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!_commandEnabled) _buildExpandActionsButton(), + if (widget.actionsLocation == ActionsLocation.left) + ...widget.actions ?? [], + _buildTextInput(context), + _animateSendButton(context), + if (widget.actionsLocation == ActionsLocation.right) + ...widget.actions ?? [], + ], + ); + } + + Widget _buildDmCheckbox() { + return Container( + height: 36, + padding: const EdgeInsets.only( + left: 12, + bottom: 12, + top: 8, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 16, + width: 16, + foregroundDecoration: BoxDecoration( + border: _sendAsDm + ? null + : Border.all( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + width: 2, + ), + borderRadius: BorderRadius.circular(3), + ), + child: Center( + child: Material( + borderRadius: BorderRadius.circular(3), + color: _sendAsDm + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context).colorTheme.white, + child: InkWell( + onTap: () { + setState(() { + _sendAsDm = !_sendAsDm; + }); + }, + child: AnimatedCrossFade( + duration: Duration(milliseconds: 300), + reverseDuration: Duration(milliseconds: 300), + crossFadeState: _sendAsDm + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: StreamSvgIcon.check( + size: 16.0, + color: StreamChatTheme.of(context).colorTheme.white, + ), + secondChild: SizedBox( + height: 16, + width: 16, + ), + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text('Also send as direct message'), + ), + ], + ), + ); + } + + Widget _animateSendButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedCrossFade( + crossFadeState: ((_messageIsPresent || _attachments.isNotEmpty) && + _attachments.every((a) => a.uploaded == true)) + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: _buildSendButton(context), + secondChild: _buildIdleSendButton(context), + duration: Duration(milliseconds: 300), + alignment: Alignment.center, + ), + ); + } + + Widget _buildExpandActionsButton() { + return Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedCrossFade( + crossFadeState: _actionsShrunk + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: IconButton( + onPressed: () => setState(() => _actionsShrunk = false), + icon: StreamSvgIcon.emptyCircleLeft( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + ), + secondChild: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (!widget.disableAttachments) _buildAttachmentButton(), + if (widget.editMessage == null && + StreamChannel.of(context) + .channel + ?.config + ?.commands + ?.isNotEmpty == + true) + _buildCommandButton(), + ].insertBetween(const SizedBox(width: 8)), + ), + duration: Duration(milliseconds: 300), + alignment: Alignment.center, + ), + ); + } + + Expanded _buildTextInput(BuildContext context) { + final theme = StreamChatTheme.of(context); + return Expanded( + child: Center( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20.0), + border: Border.all(color: theme.colorTheme.greyGainsboro), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildReplyToMessage(), + _buildAttachments(), + LimitedBox( + maxHeight: widget.maxHeight, + child: TextField( + key: Key('messageInputText'), + enabled: _inputEnabled, + minLines: null, + maxLines: null, + onSubmitted: (_) => sendMessage(), + keyboardType: widget.keyboardType, + controller: textEditingController, + focusNode: _focusNode, + style: theme.textTheme.body, + autofocus: false, + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + isDense: true, + hintText: _getHint(), + hintStyle: theme.textTheme.body.copyWith( + color: theme.colorTheme.grey, + ), + border: OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent)), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent)), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent)), + errorBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent)), + disabledBorder: OutlineInputBorder( + borderSide: BorderSide(color: Colors.transparent)), + contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11), + prefixIconConstraints: BoxConstraints.tight(Size(78, 24)), + suffixIconConstraints: BoxConstraints.tight(Size(40, 40)), + prefixIcon: _commandEnabled + ? Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorTheme.accentBlue, + ), + margin: const EdgeInsets.only(right: 4, left: 8), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon.lightning( + color: Colors.white, + size: 16.0, + ), + Text( + _chosenCommand?.name?.toUpperCase() ?? '', + style: StreamChatTheme.of(context) + .textTheme + .footnoteBold + .copyWith( + color: Colors.white, + ), + ), + ], + ), + ) + : null, + suffixIcon: _commandEnabled + ? IconButton( + icon: StreamSvgIcon.closeSmall(), + splashRadius: 24, + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + onPressed: () { + setState(() => _commandEnabled = false); + }, + ) + : null, + ), + textCapitalization: TextCapitalization.sentences, + ), + ) + ], + ), + ), + ), + ); + } + + Timer _debounce; + + void _onChanged(BuildContext context, String s) { + if (_debounce?.isActive == true) _debounce.cancel(); + _debounce = Timer( + const Duration(milliseconds: 350), + () { + if (!mounted) { + return; + } + StreamChannel.of(context).channel.keyStroke().catchError((e) {}); + + setState(() { + _messageIsPresent = s.trim().isNotEmpty; + _actionsShrunk = s.trim().isNotEmpty; + }); + + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + _emojiOverlay?.remove(); + _emojiOverlay = null; + + _checkCommands(s.trim(), context); + + _checkMentions(s, context); + + _checkEmoji(s, context); + }, + ); + } + + String _getHint() { + if (_commandEnabled && _chosenCommand.name == 'giphy') { + return 'Search GIFs'; + } + if (_attachments.isNotEmpty) { + return 'Add a comment or send'; + } + return 'Write a message'; + } + + void _checkEmoji(String s, BuildContext context) { + if (s.isNotEmpty && + textEditingController.selection.baseOffset > 0 && + textEditingController.text + .substring( + 0, + textEditingController.selection.baseOffset, + ) + .contains(':')) { + final textToSelection = textEditingController.text + .substring(0, textEditingController.value.selection.start); + final splits = textToSelection.split(':'); + final query = splits[splits.length - 2]?.toLowerCase(); + final emoji = Emoji.byName(query); + + if (textToSelection.endsWith(':') && emoji != null) { + _chooseEmoji(splits.sublist(0, splits.length - 1), emoji); + } else { + _emojiOverlay = _buildEmojiOverlay(); + + if (_emojiOverlay != null) { + Overlay.of(context).insert(_emojiOverlay); + } + } + } + } + + void _checkMentions(String s, BuildContext context) { + if (s.isNotEmpty && + textEditingController.selection.baseOffset > 0 && + textEditingController.text + .substring(0, textEditingController.selection.baseOffset) + .split(' ') + .last + .contains('@')) { + _mentionsOverlay = _buildMentionsOverlayEntry(); + Overlay.of(context).insert(_mentionsOverlay); + } + } + + void _checkCommands(String s, BuildContext context) { + if (s.startsWith('/')) { + var matchedCommandsList = StreamChannel.of(context) + .channel + .config + ?.commands + ?.where((element) => element.name == s.substring(1)) + ?.toList() ?? + []; + + if (matchedCommandsList.length == 1) { + _chosenCommand = matchedCommandsList[0]; + textEditingController.clear(); + _messageIsPresent = false; + setState(() { + _commandEnabled = true; + }); + _commandsOverlay.remove(); + _commandsOverlay = null; + } else { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + } + } + } + + OverlayEntry _buildCommandsOverlayEntry() { + final text = textEditingController.text.trimLeft(); + final commands = StreamChannel.of(context) + .channel + .config + ?.commands + ?.where((c) => c.name.contains(text.replaceFirst('/', ''))) + ?.toList() ?? + []; + + RenderBox renderBox = context.findRenderObject(); + final size = renderBox.size; + + return OverlayEntry(builder: (context) { + return Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: Duration(milliseconds: 300), + curve: Curves.easeInOutExpo, + builder: (context, val, wid) { + return Transform.scale( + alignment: Alignment.center, + scale: val, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + elevation: 2.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + color: StreamChatTheme.of(context).colorTheme.white, + clipBehavior: Clip.antiAlias, + child: Container( + constraints: BoxConstraints.loose(Size.fromHeight(400)), + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(8.0)), + child: ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + if (commands.isNotEmpty) + Padding( + padding: + const EdgeInsets.only(left: 0.0, top: 8.0), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: StreamSvgIcon.lightning( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + ), + ), + Text( + 'Instant Commands', + style: TextStyle( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + ), + ) + ], + ), + ), + SizedBox( + height: 10.0, + ), + ...commands + .map( + (c) => InkWell( + onTap: () { + _setCommand(c); + }, + child: Container( + height: 40.0, + child: Row( + children: [ + SizedBox( + width: 16.0, + ), + _buildCommandIcon(c.name), + SizedBox( + width: 8.0, + ), + Text.rich( + TextSpan( + text: '${c.name.capitalize()}', + style: TextStyle( + fontWeight: FontWeight.bold), + children: [ + TextSpan( + text: ' /${c.name} ${c.args}', + style: + StreamChatTheme.of(context) + .textTheme + .body + .copyWith( + color: StreamChatTheme + .of(context) + .colorTheme + .grey, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ) + .toList(), + ], + ), + ), + ), + ), + ); + }), + ); + }); + } + + Widget _buildFilePickerSection() { + var _attachmentContainsFile = + _attachments.any((element) => element?.attachment?.type == 'file'); + + Color _getIconColor(int index) { + switch (index) { + case 0: + return _attachments.isEmpty + ? StreamChatTheme.of(context).colorTheme.accentBlue + : (!_attachmentContainsFile + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.2)); + break; + case 1: + return _attachmentContainsFile + ? StreamChatTheme.of(context).colorTheme.accentBlue + : (_attachments.isEmpty + ? StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5) + : StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.2)); + break; + case 2: + return _attachmentContainsFile && _attachments.isNotEmpty + ? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2) + : StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5); + break; + case 3: + return _attachmentContainsFile && _attachments.isNotEmpty + ? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2) + : StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5); + break; + default: + return Colors.black; + } + } + + return AnimatedContainer( + duration: _animateContainer ? Duration(milliseconds: 300) : Duration.zero, + height: _openFilePickerSection ? _filePickerSize : 0, + child: Material( + color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + iconSize: 24, + icon: StreamSvgIcon.pictures( + color: _getIconColor(0), + ), + onPressed: _attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + setState(() { + _filePickerIndex = 0; + }); + }, + ), + IconButton( + iconSize: 32, + icon: StreamSvgIcon.files( + color: _getIconColor(1), + ), + onPressed: !_attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + pickFile(DefaultAttachmentTypes.file, false); + }, + ), + IconButton( + iconSize: 24, + icon: StreamSvgIcon.camera( + color: _getIconColor(2), + ), + onPressed: _attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + pickFile(DefaultAttachmentTypes.image, true); + }, + ), + IconButton( + padding: const EdgeInsets.all(0), + iconSize: 24, + icon: StreamSvgIcon.record( + color: _getIconColor(3), + ), + onPressed: _attachmentContainsFile && _attachments.isNotEmpty + ? null + : () { + pickFile(DefaultAttachmentTypes.video, true); + }, + ), + ], + ), + GestureDetector( + onVerticalDragUpdate: (update) { + setState(() { + _animateContainer = false; + _filePickerSize = (_filePickerSize - update.delta.dy).clamp( + _kMinMediaPickerSize, + MediaQuery.of(context).size.height / 1.7, + ); + }); + }, + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + child: Container( + width: double.infinity, + child: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: 40.0, + height: 4.0, + decoration: BoxDecoration( + color: + StreamChatTheme.of(context).colorTheme.whiteSmoke, + borderRadius: BorderRadius.circular(4.0), + ), + ), + ), + ), + ), + ), + ), + if (_openFilePickerSection) + Expanded( + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(8.0), + ), + child: _buildPickerSection(), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPickerSection() { + var _attachmentContainsFile = + _attachments.any((element) => element.attachment?.type == 'file'); + + switch (_filePickerIndex) { + case 0: + return FutureBuilder( + future: PhotoManager.requestPermission(), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + + if (snapshot.data) { + if (_attachmentContainsFile) { + return GestureDetector( + onTap: () { + pickFile(DefaultAttachmentTypes.file); + }, + child: Container( + constraints: BoxConstraints.expand(), + color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: Text( + 'Add more files', + style: TextStyle( + color: + StreamChatTheme.of(context).colorTheme.accentBlue, + fontWeight: FontWeight.bold, + ), + ), + alignment: Alignment.center, + ), + ); + } + return MediaListView( + selectedIds: _attachments.map((e) => e.id).toList(), + onSelect: (media) async { + if (!_attachments + .any((element) => element.id == media.id)) { + _addAttachment(media); + } else { + setState(() { + _attachments + .removeWhere((element) => element.id == media.id); + }); + } + }, + ); + } + + return InkWell( + onTap: () async { + PhotoManager.openSetting(); + }, + child: Container( + color: StreamChatTheme.of(context).colorTheme.whiteSmoke, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + 'svgs/icon_picture_empty_state.svg', + package: 'stream_chat_flutter', + height: 140, + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + ), + Text( + 'Please enable access to your photos \nand videos so you can share them with friends.', + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .grey), + textAlign: TextAlign.center, + ), + SizedBox(height: 6.0), + Center( + child: Text( + 'Allow access to your gallery', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + ), + ), + ), + ], + ), + ), + ); + }); + break; + default: + return SizedBox(); + } + } + + void _addAttachment(AssetEntity medium) async { + final attachment = _SendingAttachment( + id: medium.id, + ); + try { + setState(() { + _attachments.add(attachment); + }); + + final mediaFile = await medium.originFile.timeout( + Duration(seconds: 5), + onTimeout: () => medium.originFile, + ); + + var file = PlatformFile( + path: mediaFile.path, + size: ((await mediaFile.length()) / 1024).ceil(), + bytes: mediaFile.readAsBytesSync(), + ); + + if (file.size > _kMaxAttachmentSize) { + if (medium?.type == AssetType.video) { + final mediaInfo = await compressVideoService.compressVideo(file.path); + + if (mediaInfo.filesize / (1024 * 1024) > _kMaxAttachmentSize) { + _showErrorAlert( + 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', + ); + setState(() { + _attachments.remove(attachment); + }); + return; + } + file = PlatformFile( + name: file.name, + size: (mediaInfo.filesize / 1024).ceil(), + bytes: await mediaInfo.file.readAsBytes(), + path: mediaInfo.path, + ); + } else { + _showErrorAlert( + 'The file is too large to upload. The file size limit is 20MB.', + ); + } + } + + final channel = StreamChannel.of(context).channel; + setState(() { + attachment + ..file = file + ..attachment = Attachment( + localUri: file.path != null ? Uri.parse(file.path) : null, + type: medium?.type == AssetType.image ? 'image' : 'video', + ); + }); + + final url = await _uploadAttachment( + file, + medium.type == AssetType.image + ? DefaultAttachmentTypes.image + : DefaultAttachmentTypes.video, + channel); + + final fileType = medium.type == AssetType.image + ? DefaultAttachmentTypes.image + : DefaultAttachmentTypes.video; + + if (fileType == DefaultAttachmentTypes.image) { + attachment.attachment = attachment.attachment.copyWith( + imageUrl: url, + ); + } else { + attachment.attachment = attachment.attachment.copyWith( + assetUrl: url, + ); + } + + if (mounted) { + setState(() { + attachment.uploaded = true; + }); + } + } catch (e, s) { + setState(() { + _attachments.remove(attachment); + }); + print(e); + print(s); + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar( + content: Text('Error adding the attachment: $e'), + ), + ); + } + } + + Widget _buildCommandIcon(String iconType) { + switch (iconType) { + case 'giphy': + return CircleAvatar( + child: StreamSvgIcon.giphyIcon( + size: 24.0, + ), + radius: 12, + ); + break; + case 'ban': + return CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + child: StreamSvgIcon.iconUserDelete( + size: 16.0, + color: Colors.white, + ), + radius: 12, + ); + break; + case 'flag': + return CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + child: StreamSvgIcon.flag( + size: 14.0, + color: Colors.white, + ), + radius: 12, + ); + break; + case 'imgur': + return CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + child: ClipOval( + child: StreamSvgIcon.imgur( + size: 24.0, + ), + ), + radius: 12, + ); + break; + case 'mute': + return CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + child: StreamSvgIcon.mute( + size: 16.0, + color: Colors.white, + ), + radius: 12, + ); + break; + case 'unban': + return CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + child: StreamSvgIcon.userAdd( + size: 16.0, + color: Colors.white, + ), + radius: 12, + ); + break; + case 'unmute': + return CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + child: StreamSvgIcon.volumeUp( + size: 16.0, + color: Colors.white, + ), + radius: 12, + ); + break; + default: + return CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, + child: StreamSvgIcon.lightning( + size: 16.0, + color: Colors.white, + ), + radius: 12, + ); + break; + } + } + + OverlayEntry _buildMentionsOverlayEntry() { + final splits = textEditingController.text + .substring(0, textEditingController.value.selection.start) + .split('@'); + final query = splits.last.toLowerCase(); + + Future> queryMembers; + + if (query.isNotEmpty) { + queryMembers = StreamChannel.of(context).channel.queryMembers(filter: { + 'name': { + '\$autocomplete': query, + }, + }).then((res) => res.members); + } + + final members = StreamChannel.of(context).channel.state.members?.where((m) { + return m.user.name.toLowerCase().contains(query); + })?.toList() ?? + []; + + RenderBox renderBox = context.findRenderObject(); + final size = renderBox.size; + + return OverlayEntry( + builder: (context) { + return Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: Duration(milliseconds: 300), + curve: Curves.easeInOutExpo, + builder: (context, val, wid) { + return Transform.scale( + scale: val, + child: Card( + margin: EdgeInsets.all(8.0), + elevation: 2.0, + color: StreamChatTheme.of(context).colorTheme.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + clipBehavior: Clip.antiAlias, + child: Container( + constraints: BoxConstraints.loose(Size.fromHeight(240)), + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + ), + child: FutureBuilder>( + future: queryMembers ?? Future.value(members), + initialData: members, + builder: (context, snapshot) { + return ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: [ + SizedBox( + height: 8.0, + ), + ...snapshot.data.map( + (m) { + return Material( + color: StreamChatTheme.of(context) + .colorTheme + .white, + child: InkWell( + onTap: () { + _mentionedUsers.add(m.user); + + splits[splits.length - 1] = m.user.name; + final rejoin = splits.join('@'); + + textEditingController.value = + TextEditingValue( + text: rejoin + + textEditingController.text + .substring(textEditingController + .selection.start), + selection: TextSelection.collapsed( + offset: rejoin.length, + ), + ); + _debounce.cancel(); + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + }, + child: Container( + height: 56.0, + child: Row( + crossAxisAlignment: + CrossAxisAlignment.center, + children: [ + SizedBox( + width: 16.0, + ), + UserAvatar( + constraints: BoxConstraints.tight( + Size( + 40, + 40, + ), + ), + user: m.user, + ), + SizedBox( + width: 8.0, + ), + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '${m.user.name}', + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: StreamChatTheme.of( + context) + .textTheme + .bodyBold, + ), + SizedBox( + height: 2.0, + ), + Text( + '@${m.userId}', + maxLines: 1, + overflow: + TextOverflow.ellipsis, + style: StreamChatTheme.of( + context) + .textTheme + .footnoteBold + .copyWith( + color: StreamChatTheme + .of(context) + .colorTheme + .grey), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only( + right: 18.0, left: 8.0), + child: StreamSvgIcon.mentions( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + ), + ), + ], + ), + ), + ), + ); + }, + ).toList(), + SizedBox( + height: 8.0, + ), + ], + ); + }, + ), + ), + ), + ); + }, + ), + ); + }, + ); + } + + OverlayEntry _buildEmojiOverlay() { + final splits = textEditingController.text + .substring(0, textEditingController.value.selection.start) + .split(':'); + final query = splits.last.toLowerCase(); + + if (query.isEmpty) { + return null; + } + + final emojis = _emojiNames + .where((e) => e.contains(query)) + .map((e) => Emoji.byName(e)) + .where((e) => e != null); + + if (emojis.isEmpty) { + return null; + } + + RenderBox renderBox = context.findRenderObject(); + final size = renderBox.size; + + return OverlayEntry(builder: (context) { + return Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: Card( + margin: EdgeInsets.all(8.0), + elevation: 2.0, + color: StreamChatTheme.of(context).colorTheme.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + clipBehavior: Clip.antiAlias, + child: Container( + constraints: BoxConstraints.loose(Size.fromHeight(200)), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + spreadRadius: -8, + blurRadius: 5.0, + offset: Offset(0, -4), + ), + ], + color: StreamChatTheme.of(context).colorTheme.white, + ), + child: ListView.builder( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + itemCount: emojis.length + 1, + itemBuilder: (context, i) { + if (i == 0) { + return Padding( + padding: const EdgeInsets.only(left: 8.0, top: 8.0), + child: Row( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0), + child: StreamSvgIcon.smile( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + ), + ), + Flexible( + child: Text( + 'Emoji matching "$query"', + style: TextStyle( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + ), + ), + ) + ], + ), + ); + } + + final emoji = emojis.elementAt(i - 1); + return ListTile( + title: SubstringHighlight( + text: "${emoji.char} ${emoji.name.replaceAll('_', ' ')}", + term: query, + textStyleHighlight: + Theme.of(context).textTheme.headline6.copyWith( + fontSize: 14.5, + fontWeight: FontWeight.bold, + ), + textStyle: Theme.of(context).textTheme.headline6.copyWith( + fontSize: 14.5, + ), + ), + onTap: () { + _chooseEmoji(splits, emoji); + }, + ); + }), + ), + ), + ); + }); + } + + void _chooseEmoji(List splits, Emoji emoji) { + final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char; + + textEditingController.value = TextEditingValue( + text: rejoin + + textEditingController.text + .substring(textEditingController.selection.start), + selection: TextSelection.collapsed( + offset: rejoin.length, + ), + ); + + _emojiOverlay?.remove(); + _emojiOverlay = null; + } + + void _setCommand(Command c) { + textEditingController.clear(); + setState(() { + _chosenCommand = c; + _commandEnabled = true; + _messageIsPresent = false; + }); + _commandsOverlay?.remove(); + _commandsOverlay = null; + } + + Widget _buildReplyToMessage() { + if (!_hasQuotedMessage) return Offstage(); + final containsUrl = widget.quotedMessage.attachments + ?.any((element) => element.ogScrapeUrl != null) == + true; + return Transform( + transform: Matrix4.rotationY(pi), + alignment: Alignment.center, + child: QuotedMessageWidget( + reverse: true, + showBorder: !containsUrl, + message: widget.quotedMessage, + messageTheme: StreamChatTheme.of(context).otherMessageTheme, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + ), + ); + } + + Widget _buildAttachments() { + if (_attachments.isEmpty) return Offstage(); + return Column( + children: [ + if (_attachments.any((e) => e.attachment?.type == 'file')) + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: LimitedBox( + maxHeight: 136.0, + child: ListView( + reverse: true, + shrinkWrap: true, + children: _attachments.reversed + .where((e) => e.attachment?.type == 'file') + .map( + (e) => ClipRRect( + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: FileAttachment( + attachment: e.attachment, + attachmentType: FileAttachmentType.local, + file: e.file, + size: Size( + MediaQuery.of(context).size.width * 0.65, + 56.0, + ), + trailing: Padding( + padding: const EdgeInsets.all(8.0), + child: InkWell( + child: CircleAvatar( + backgroundColor: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.6), + maxRadius: 12.0, + child: StreamSvgIcon.close( + color: StreamChatTheme.of(context) + .colorTheme + .white, + ), + ), + onTap: () => + setState(() => _attachments.remove(e)), + ), + ), + ), + ), + ) + .insertBetween(const SizedBox(width: 8)), + ), + ), + ), + if (_attachments.any((e) => e.attachment?.type != 'file')) + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: LimitedBox( + maxHeight: 104.0, + child: ListView( + scrollDirection: Axis.horizontal, + children: _attachments + .where((e) => e.attachment?.type != 'file') + .map( + (attachment) => ClipRRect( + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: Stack( + children: [ + AspectRatio( + aspectRatio: 1.0, + child: Container( + height: 104, + width: 104, + child: _buildAttachment(attachment), + ), + ), + _buildRemoveButton(attachment), + attachment.uploaded + ? SizedBox() + : Positioned.fill( + child: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ), + ), + ], + ), + ), + ) + .insertBetween(const SizedBox(width: 8)), + ), + ), + ), + ], + ); + } + + Positioned _buildRemoveButton(_SendingAttachment attachment) { + return Positioned( + height: 24, + width: 24, + top: 4, + right: 4, + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + disabledElevation: 0, + hoverElevation: 0, + onPressed: () { + setState(() { + _attachments.remove(attachment); + }); + }, + fillColor: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5), + child: Center( + child: StreamSvgIcon.close( + size: 24, + color: StreamChatTheme.of(context).colorTheme.white, + ), + ), + ), + ); + } + + Widget _buildAttachment(_SendingAttachment attachment) { + if (widget.attachmentThumbnailBuilders + ?.containsKey(attachment.attachment?.type) == + true) { + return widget.attachmentThumbnailBuilders[attachment.attachment?.type]( + context, + attachment, + ); + } + + if (attachment.attachment == null) { + return SizedBox(); + } + + switch (attachment.attachment?.type) { + case 'image': + case 'giphy': + return attachment.file != null + ? Image.memory( + attachment.file.bytes, + fit: BoxFit.cover, + errorBuilder: (context, _, __) { + return Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ); + }, + ) + : Image.network( + attachment.attachment.imageUrl, + fit: BoxFit.cover, + ); + break; + case 'video': + return Stack( + children: [ + Positioned.fill( + child: Container( + child: FutureBuilder( + future: VideoCompress.getFileThumbnail(attachment.file.path), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ); + } + + return Image.file( + snapshot.data, + fit: BoxFit.cover, + ); + }, + ), + ), + ), + Positioned( + left: 8, + bottom: 10, + child: SvgPicture.asset( + 'svgs/video_call_icon.svg', + package: 'stream_chat_flutter', + ), + ), + ], + ); + break; + default: + return Container( + child: Icon(Icons.insert_drive_file), + color: Colors.black26, + ); + } + } + + Widget _buildCommandButton() { + return IconButton( + icon: StreamSvgIcon.lightning( + color: _commandsOverlay != null + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context).colorTheme.grey, + ), + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () async { + if (_openFilePickerSection) { + setState(() { + _animateContainer = false; + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + await Future.delayed(Duration(milliseconds: 300)); + } + + if (_commandsOverlay == null) { + setState(() { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + }); + } else { + setState(() { + _commandsOverlay?.remove(); + _commandsOverlay = null; + }); + } + }, + ); + } + + Widget _buildAttachmentButton() { + return IconButton( + icon: StreamSvgIcon.attach( + color: _openFilePickerSection + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context).colorTheme.grey, + ), + padding: const EdgeInsets.all(0), + constraints: 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(() { + _animateContainer = true; + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + } else { + showAttachmentModal(); + } + }, + ); + } + + /// Show the attachment modal, making the user choose where to pick a media from + void showAttachmentModal() { + if (_focusNode.hasFocus) { + _focusNode.unfocus(); + } + + if (!kIsWeb) { + setState(() { + _openFilePickerSection = true; + }); + } else { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + isScrollControlled: true, + builder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + 'Add a file', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + ListTile( + leading: Icon(Icons.image), + title: Text('Upload a photo'), + onTap: () { + pickFile(DefaultAttachmentTypes.image, false); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.video_library), + title: Text('Upload a video'), + onTap: () { + pickFile(DefaultAttachmentTypes.video, false); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: Icon(Icons.camera_alt), + title: Text('Photo from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.image, true); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: Icon(Icons.videocam), + title: Text('Video from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.video, true); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.insert_drive_file), + title: Text('Upload a file'), + onTap: () { + pickFile(DefaultAttachmentTypes.file, false); + Navigator.pop(context); + }, + ), + ], + ); + }); + } + } + + /// Add an attachment to the sending message + /// Use this to add custom type attachments + void addAttachment(Attachment attachment) { + setState(() { + _attachments.add(_SendingAttachment( + attachment: attachment, + uploaded: true, + )); + }); + } + + /// Pick a file from the device + /// If [camera] is true then the camera will open + void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async { + setState(() { + _inputEnabled = false; + }); + + PlatformFile file; + String attachmentType; + + if (fileType == DefaultAttachmentTypes.image) { + attachmentType = 'image'; + } else if (fileType == DefaultAttachmentTypes.video) { + attachmentType = 'video'; + } else if (fileType == DefaultAttachmentTypes.file) { + attachmentType = 'file'; + } + + if (camera) { + PickedFile pickedFile; + if (fileType == DefaultAttachmentTypes.image) { + pickedFile = await _imagePicker.getImage(source: ImageSource.camera); + } else if (fileType == DefaultAttachmentTypes.video) { + pickedFile = await _imagePicker.getVideo(source: ImageSource.camera); + } + if (pickedFile == null) { + return; + } + final bytes = await pickedFile.readAsBytes(); + file = PlatformFile( + size: (bytes.length / 1024).ceil(), + path: pickedFile.path, + bytes: bytes, + ); + } else { + FileType type; + if (fileType == DefaultAttachmentTypes.image) { + type = FileType.image; + } else if (fileType == DefaultAttachmentTypes.video) { + type = FileType.video; + } else if (fileType == DefaultAttachmentTypes.file) { + type = FileType.any; + } + final res = await FilePicker.platform.pickFiles( + type: type, + withData: true, + ); + if (res?.files?.isNotEmpty == true) { + file = res.files.single; + } + } + + setState(() { + _inputEnabled = true; + }); + + if (file == null) { + return; + } + + final mimeType = _getMimeType(file.path.split('/').last); + + var extraDataMap = {}; + + if (camera) { + if (mimeType.type == 'video' || mimeType.type == 'image') { + attachmentType = mimeType.type; + } + } else { + attachmentType = 'file'; + } + + if (mimeType?.subtype != null) { + extraDataMap['mime_type'] = mimeType.subtype.toLowerCase(); + } + + if (file.size != null) { + extraDataMap['file_size'] = file.size; + } + + final channel = StreamChannel.of(context).channel; + final attachment = _SendingAttachment( + file: file, + attachment: Attachment( + localUri: file.path != null ? Uri.parse(file.path) : null, + type: attachmentType, + extraData: extraDataMap.isNotEmpty ? extraDataMap : null, + title: file.name, + ), + ); + + setState(() { + _attachments.add(attachment); + }); + + if (file.size / 1024 > _kMaxAttachmentSize) { + if (attachmentType == 'video') { + final mediaInfo = await compressVideoService.compressVideo(file.path); + file = PlatformFile( + name: mediaInfo.title, + size: (mediaInfo.filesize / 1024).ceil(), + bytes: await mediaInfo.file.readAsBytes(), + path: mediaInfo.path, + ); + setState(() { + attachment.file = file; + }); + } else { + // ignore: deprecated_member_use + _showErrorAlert( + 'The file is too large to upload. The file size limit is 20MB.', + ); + setState(() { + _attachments.remove(attachment); + }); + return; + } + } + + final url = await _uploadAttachment(file, fileType, channel); + + if (fileType == DefaultAttachmentTypes.image) { + attachment.attachment = attachment.attachment.copyWith( + imageUrl: url, + ); + } else { + attachment.attachment = attachment.attachment.copyWith( + assetUrl: url, + ); + } + + setState(() { + attachment.uploaded = true; + }); + } + + Future _uploadAttachment( + PlatformFile file, + DefaultAttachmentTypes type, + Channel channel, + ) async { + String url; + if (type == DefaultAttachmentTypes.image) { + if (widget.doImageUploadRequest != null) { + url = await widget.doImageUploadRequest(file, channel); + } else { + url = await _uploadImage(file, channel); + } + } else { + if (widget.doFileUploadRequest != null) { + url = await widget.doFileUploadRequest(file, channel); + } else { + url = await _uploadFile(file, channel); + } + } + return url; + } + + Future _uploadImage(PlatformFile file, Channel channel) async { + final filename = file.path?.split('/')?.last; + final mimeType = _getMimeType(filename); + final bytes = file.bytes; + final res = await channel.sendImage( + MultipartFile.fromBytes( + bytes, + filename: filename, + contentType: mimeType, + ), + ); + return res.file; + } + + http_parser.MediaType _getMimeType(String filename) { + http_parser.MediaType mimeType; + if (filename != null) { + if (filename.toLowerCase().endsWith('heic')) { + mimeType = http_parser.MediaType.parse('image/heic'); + } else { + mimeType = http_parser.MediaType.parse(lookupMimeType(filename)); + } + } + + return mimeType; + } + + Future _uploadFile(PlatformFile file, Channel channel) async { + final filename = file.path?.split('/')?.last; + final mimeType = _getMimeType(filename); + final bytes = file.bytes; + final res = await channel.sendFile( + MultipartFile.fromBytes( + bytes, + filename: filename, + contentType: mimeType, + ), + ); + return res.file; + } + + Widget _buildIdleSendButton(BuildContext context) { + return StreamSvgIcon( + assetName: _getIdleSendIcon(), + color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + ); + } + + Widget _buildSendButton(BuildContext context) { + return IconButton( + onPressed: sendMessage, + padding: const EdgeInsets.all(0), + splashRadius: 24, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + icon: StreamSvgIcon( + assetName: _getSendIcon(), + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + ); + } + + String _getIdleSendIcon() { + if (_commandEnabled) { + return 'Icon_search.svg'; + } else { + return 'Icon_circle_right.svg'; + } + } + + String _getSendIcon() { + if (widget.editMessage != null) { + return 'Icon_circle_up.svg'; + } else if (_commandEnabled) { + return 'Icon_search.svg'; + } else { + return 'Icon_circle_up.svg'; + } + } + + /// Sends the current message + void sendMessage() async { + var text = textEditingController.text.trim(); + if (text.isEmpty && _attachments.isEmpty) { + return; + } + + if (_commandEnabled) { + text = '/${_chosenCommand.name} ' + text; + } + + final attachments = List<_SendingAttachment>.from(_attachments); + + textEditingController.clear(); + _attachments.clear(); + if (widget.onQuotedMessageCleared != null) { + widget.onQuotedMessageCleared(); + } + + setState(() { + _messageIsPresent = false; + _commandEnabled = false; + }); + + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + + Future sendingFuture; + Message message; + if (widget.editMessage != null) { + message = widget.editMessage.copyWith( + text: text, + attachments: _getAttachments(attachments).toList(), + mentionedUsers: + _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), + ); + } else { + message = (widget.initialMessage ?? Message()).copyWith( + parentId: widget.parentMessage?.id, + text: text, + attachments: _getAttachments(attachments).toList(), + mentionedUsers: + _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), + showInChannel: widget.parentMessage != null ? _sendAsDm : null, + ); + } + + if (widget.quotedMessage != null) { + message = message.copyWith( + quotedMessageId: widget.quotedMessage.id, + ); + } + + if (widget.preMessageSending != null) { + message = await widget.preMessageSending(message); + } + + final streamChannel = StreamChannel.of(context); + final channel = streamChannel.channel; + if (!channel.state.isUpToDate) { + await streamChannel.reloadChannel(); + } + + _mentionedUsers.clear(); + + if (widget.editMessage == null || + widget.editMessage.status == MessageSendingStatus.failed) { + sendingFuture = channel.sendMessage(message); + } else { + sendingFuture = StreamChat.of(context).client.updateMessage( + message, + channel.cid, + ); + } + + return sendingFuture.then((resp) { + if (widget.onMessageSent != null) { + widget.onMessageSent(resp.message); + } + }); + } + + Iterable _getAttachments(List<_SendingAttachment> attachments) { + return attachments.map((attachment) { + return attachment.attachment; + }); + } + + StreamSubscription _keyboardListener; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + + _emojiNames = Emoji.all().map((e) => e.name); + + if (!kIsWeb) { + _keyboardListener = + _keyboardVisibilityController.onChange.listen((visible) { + if (_focusNode.hasFocus) { + _onChanged(context, textEditingController.text); + } + }); + } + + textEditingController = + widget.textEditingController ?? TextEditingController(); + if (widget.editMessage != null || widget.initialMessage != null) { + _parseExistingMessage(widget.editMessage ?? widget.initialMessage); + } + + textEditingController.addListener(() { + _onChanged(context, textEditingController.text); + }); + + _focusNode.addListener(() { + if (_focusNode.hasFocus) { + _openFilePickerSection = false; + } + }); + } + + void _showErrorAlert(String description) { + showModalBottomSheet( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + StreamSvgIcon.error( + color: StreamChatTheme.of(context).colorTheme.accentRed, + size: 24.0, + ), + SizedBox( + height: 26.0, + ), + Text( + 'Something went wrong', + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Text( + description, + textAlign: TextAlign.center, + ), + ), + SizedBox( + height: 36.0, + ), + Container( + color: + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FlatButton( + child: Text( + 'OK', + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + }, + ); + } + + void _parseExistingMessage(Message message) { + textEditingController.text = message.text; + + _messageIsPresent = true; + + message.attachments?.forEach((attachment) { + _attachments.add(_SendingAttachment( + attachment: attachment, + uploaded: true, + )); + }); + } + + @override + void dispose() { + _commandsOverlay?.remove(); + _emojiOverlay?.remove(); + _mentionsOverlay?.remove(); + _keyboardListener?.cancel(); + super.dispose(); + } + + bool _initialized = false; + + @override + void didChangeDependencies() { + if (widget.editMessage != null && !_initialized) { + FocusScope.of(context).requestFocus(_focusNode); + _initialized = true; + } + super.didChangeDependencies(); + } +} + +class _SendingAttachment { + PlatformFile file; + Attachment attachment; + bool uploaded; + String id; + + _SendingAttachment({ + this.file, + this.attachment, + this.uploaded = false, + this.id, + }); +} + +/// Represents a 2-tuple, or pair. +class Tuple2 { + /// Returns the first item of the tuple + final T1 item1; + + /// Returns the second item of the tuple + final T2 item2; + + /// Creates a new tuple value with the specified items. + const Tuple2(this.item1, this.item2); + + /// Create a new tuple value with the specified list [items]. + factory Tuple2.fromList(List items) { + if (items.length != 2) { + throw ArgumentError('items must have length 2'); + } + + return Tuple2(items[0] as T1, items[1] as T2); + } + + /// Returns a tuple with the first item set to the specified value. + Tuple2 withItem1(T1 v) => Tuple2(v, item2); + + /// Returns a tuple with the second item set to the specified value. + Tuple2 withItem2(T2 v) => Tuple2(item1, v); + + /// Creates a [List] containing the items of this [Tuple2]. + /// + /// The elements are in item order. The list is variable-length + /// if [growable] is true. + List toList({bool growable = false}) => + List.from([item1, item2], growable: growable); + + @override + String toString() => '[$item1, $item2]'; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Tuple2 && + runtimeType == other.runtimeType && + item1 == other.item1 && + item2 == other.item2; + + @override + int get hashCode => item1.hashCode ^ item2.hashCode; +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart new file mode 100644 index 00000000..3bb6b716 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -0,0 +1,1057 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; +import 'package:stream_chat_flutter/src/info_tile.dart'; +import 'package:stream_chat_flutter/src/message_widget.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/system_message.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:visibility_detector/visibility_detector.dart'; + +import '../stream_chat_flutter.dart'; +import 'connection_status_builder.dart'; +import 'date_divider.dart'; +import 'extension.dart'; +import 'swipeable.dart'; + +typedef MessageBuilder = Widget Function( + BuildContext, + MessageDetails, + List, +); +typedef ParentMessageBuilder = Widget Function( + BuildContext, + Message, +); +typedef ThreadBuilder = Widget Function(BuildContext context, Message parent); +typedef ThreadTapCallback = void Function(Message, Widget); + +typedef OnMessageSwiped = void Function(Message); +typedef ReplyTapCallback = void Function(Message); + +class MessageDetails { + /// True if the message belongs to the current user + bool isMyMessage; + + /// True if the user message is the same of the previous message + bool isLastUser; + + /// True if the user message is the same of the next message + bool isNextUser; + + /// The message + Message message; + + /// The index of the message + int index; + + MessageDetails( + BuildContext context, + this.message, + List messages, + this.index, + ) { + isMyMessage = message.user.id == StreamChat.of(context).user.id; + isLastUser = index + 1 < messages.length && + message.user.id == messages[index + 1]?.user?.id; + isNextUser = + index - 1 >= 0 && message.user.id == messages[index - 1]?.user?.id; + } +} + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_listview.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_listview_paint.png) +/// +/// It shows the list of messages of the current channel. +/// +/// ```dart +/// class ChannelPage extends StatelessWidget { +/// const ChannelPage({ +/// Key key, +/// }) : super(key: key); +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// appBar: ChannelHeader(), +/// body: Column( +/// children: [ +/// Expanded( +/// child: MessageListView( +/// threadBuilder: (_, parentMessage) { +/// return ThreadPage( +/// parent: parentMessage, +/// ); +/// }, +/// ), +/// ), +/// MessageInput(), +/// ], +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channels. +/// The widget uses a [ListView.custom] to render the list of channels. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageListView extends StatefulWidget { + /// Instantiate a new MessageListView + MessageListView({ + Key key, + this.showScrollToBottom = true, + this.messageBuilder, + this.parentMessageBuilder, + this.parentMessage, + this.threadBuilder, + this.onThreadTap, + this.onReplyTap, + this.dateDividerBuilder, + this.scrollPhysics = const ClampingScrollPhysics(), + this.initialScrollIndex, + this.initialAlignment, + this.scrollController, + this.itemPositionListener, + this.onMessageSwiped, + this.highlightInitialMessage = false, + this.messageHighlightColor, + this.onShowMessage, + this.showConnectionStateTile = false, + }) : super(key: key); + + /// Function used to build a custom message widget + final MessageBuilder messageBuilder; + + /// Function used to build a custom parent message widget + final ParentMessageBuilder parentMessageBuilder; + + /// Function used to build a custom thread widget + final ThreadBuilder threadBuilder; + + /// Function called when tapping on a thread + /// By default it calls [Navigator.push] using the widget built using [threadBuilder] + final ThreadTapCallback onThreadTap; + + /// If true will show a scroll to bottom message when there are new messages and the scroll offset is not zero + final bool showScrollToBottom; + + /// Parent message in case of a thread + final Message parentMessage; + + /// Builder used to render date dividers + final Widget Function(DateTime) dateDividerBuilder; + + /// Index of an item to initially align within the viewport. + final int initialScrollIndex; + + /// Determines where the leading edge of the item at [initialScrollIndex] + /// should be placed. + final double initialAlignment; + + /// Controller for jumping or scrolling to an item. + final ItemScrollController scrollController; + + /// Provides a listenable iterable of [itemPositions] of items that are on + /// screen and their locations. + final ItemPositionsListener itemPositionListener; + + /// The ScrollPhysics used by the ListView + final ScrollPhysics scrollPhysics; + + /// Called when message item gets swiped + final OnMessageSwiped onMessageSwiped; + + /// + final ReplyTapCallback onReplyTap; + + /// If true the list will highlight the initialMessage if there is any. + /// + /// Also See [StreamChannel] + final bool highlightInitialMessage; + + /// Color used while highlighting initial message + final Color messageHighlightColor; + + final ShowMessageCallback onShowMessage; + + final bool showConnectionStateTile; + + @override + _MessageListViewState createState() => _MessageListViewState(); +} + +class _MessageListViewState extends State { + ItemScrollController _scrollController; + Function _onThreadTap; + bool _showScrollToBottom = false; + ItemPositionsListener _itemPositionListener; + int _messageListLength; + StreamChannelState streamChannel; + + int get _initialIndex { + if (widget.initialScrollIndex != null) return widget.initialScrollIndex; + if (streamChannel.initialMessageId != null) { + final messages = streamChannel.channel.state.messages; + final totalMessages = messages.length; + final messageIndex = messages.indexWhere((e) { + return e.id == streamChannel.initialMessageId; + }); + final index = totalMessages - messageIndex; + if (index != 0) return index - 1; + return index; + } + return 0; + } + + double get _initialAlignment { + if (widget.initialAlignment != null) return widget.initialAlignment; + return 0; + } + + bool _isInitialMessage(String id) { + return streamChannel.initialMessageId == id; + } + + bool get _upToDate => streamChannel.channel.state.isUpToDate; + + bool get _isThreadConversation => widget.parentMessage != null; + + bool _topPaginationActive = false; + bool _bottomPaginationActive = false; + + int initialIndex; + double initialAlignment; + + List messages = []; + + bool initialMessageHighlightComplete = false; + + bool _inBetweenList = false; + + final MessageListController _messageListController = MessageListController(); + + @override + Widget build(BuildContext context) { + return MessageListCore( + loadingBuilder: (context) { + return Center( + child: const CircularProgressIndicator(), + ); + }, + emptyBuilder: (context) { + return Center( + child: Text( + 'No chats here yet...', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5)), + ), + ); + }, + messageListBuilder: (context, list) { + return _buildListView(list); + }, + messageListController: _messageListController, + parentMessage: widget.parentMessage, + showScrollToBottom: widget.showScrollToBottom, + errorWidgetBuilder: (BuildContext context, Object error) { + return Center( + child: Text( + 'Something went wrong', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5)), + ), + ); + }, + ); + } + + Widget _buildListView(List data) { + messages = data; + final newMessagesListLength = messages.length; + + if (_messageListLength != null) { + if (_bottomPaginationActive || (_inBetweenList && _upToDate)) { + if (_itemPositionListener.itemPositions.value?.isNotEmpty == true) { + final first = _itemPositionListener.itemPositions.value.first; + final diff = newMessagesListLength - _messageListLength; + if (diff > 0) { + initialIndex = first.index + diff; + initialAlignment = first.itemLeadingEdge; + } + } + } else if (!_topPaginationActive && _upToDate) { + // Reset the index in-case we send any new message + initialIndex = 0; + initialAlignment = 0; + } + } + + _messageListLength = newMessagesListLength; + + return Stack( + alignment: Alignment.center, + children: [ + ConnectionStatusBuilder( + statusBuilder: (context, status) { + var statusString = ''; + var showStatus = true; + switch (status) { + case ConnectionStatus.connected: + statusString = 'Connected'; + showStatus = false; + break; + case ConnectionStatus.connecting: + statusString = 'Reconnecting...'; + break; + case ConnectionStatus.disconnected: + statusString = 'Disconnected'; + break; + } + + return InfoTile( + showMessage: widget.showConnectionStateTile ? showStatus : false, + tileAnchor: Alignment.topCenter, + childAnchor: Alignment.topCenter, + message: statusString, + child: LazyLoadScrollView( + onStartOfPage: () async { + _inBetweenList = false; + if (!_upToDate) { + _topPaginationActive = false; + _bottomPaginationActive = true; + return _paginateData( + streamChannel, + QueryDirection.bottom, + ); + } + }, + onEndOfPage: () async { + _inBetweenList = false; + _topPaginationActive = true; + _bottomPaginationActive = false; + return _paginateData( + streamChannel, + QueryDirection.top, + ); + }, + onInBetweenOfPage: () { + _inBetweenList = true; + }, + child: ScrollablePositionedList.separated( + key: ValueKey(initialIndex + initialAlignment), + itemPositionsListener: _itemPositionListener, + addAutomaticKeepAlives: true, + initialScrollIndex: initialIndex ?? 0, + initialAlignment: initialAlignment ?? 0, + physics: widget.scrollPhysics, + itemScrollController: _scrollController, + reverse: true, + itemCount: + messages.length + 2 + (_isThreadConversation ? 1 : 0), + separatorBuilder: (context, i) { + if (i == messages.length) return Offstage(); + if (i == 0) return SizedBox(height: 30); + if (i == messages.length + 1) { + final replyCount = widget.parentMessage.replyCount; + return Container( + decoration: BoxDecoration( + gradient: + StreamChatTheme.of(context).colorTheme.bgGradient, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + '$replyCount ${replyCount == 1 ? 'Reply' : 'Replies'}', + textAlign: TextAlign.center, + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .lastMessageAt, + ), + ), + ); + } + + final message = messages[i]; + final nextMessage = messages[i - 1]; + if (!Jiffy(message.createdAt.toLocal()).isSame( + nextMessage.createdAt.toLocal(), + Units.DAY, + )) { + final divider = widget.dateDividerBuilder != null + ? widget.dateDividerBuilder( + nextMessage.createdAt.toLocal(), + ) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: divider, + ); + } + final timeDiff = + Jiffy(nextMessage.createdAt.toLocal()).diff( + message.createdAt.toLocal(), + Units.MINUTE, + ); + + final isNextUserSame = + message.user.id == nextMessage.user?.id; + final isThread = message.replyCount > 0; + final isDeleted = message.isDeleted; + if (timeDiff >= 1 || + !isNextUserSame || + isThread || + isDeleted) { + return SizedBox(height: 8); + } + return SizedBox(height: 2); + }, + itemBuilder: (context, i) { + if (i == messages.length + 2) { + if (widget.parentMessageBuilder != null) { + return widget.parentMessageBuilder( + context, + widget.parentMessage, + ); + } else { + return buildParentMessage(widget.parentMessage); + } + } + if (i == messages.length + 1) { + return _buildLoadingIndicator( + streamChannel, + QueryDirection.top, + ); + } + if (i == 0) { + return _buildLoadingIndicator( + streamChannel, + QueryDirection.bottom, + ); + } + final message = messages[i - 1]; + + Widget messageWidget; + + if (i == 1) { + messageWidget = _buildBottomMessage( + context, + message, + messages, + streamChannel, + ); + } else if (i == messages.length - 1) { + messageWidget = _buildTopMessage( + context, + message, + messages, + streamChannel, + ); + } else { + if (widget.messageBuilder != null) { + messageWidget = Builder( + key: ValueKey('MESSAGE-${message.id}'), + builder: (context) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + messages, + i, + ), + messages), + ); + } else { + messageWidget = buildMessage(message, messages, i); + } + } + return messageWidget; + }, + ), + ), + ); + }, + ), + if (widget.showScrollToBottom) _buildScrollToBottom(), + Positioned( + top: 20.0, + child: ValueListenableBuilder>( + valueListenable: _itemPositionListener.itemPositions, + builder: (context, values, _) { + final items = _itemPositionListener.itemPositions?.value; + if (items.isEmpty || messages.isEmpty) { + return SizedBox(); + } + + var index = _getTopElement(values).index; + + if (index > messages.length) { + return SizedBox(); + } + + if (index == messages.length) { + index = max(index - 1, 0); + } + + return widget.dateDividerBuilder != null + ? widget.dateDividerBuilder( + messages[index].createdAt.toLocal(), + ) + : DateDivider( + dateTime: messages[index].createdAt.toLocal(), + ); + }, + ), + ), + ], + ); + } + + Future _paginateData( + StreamChannelState channel, QueryDirection direction) { + return _messageListController.paginateData(direction: direction); + } + + ItemPosition _getTopElement(Iterable values) { + return values + .where((ItemPosition position) => position.itemLeadingEdge < 0.9) + .reduce((ItemPosition max, ItemPosition position) => + position.itemLeadingEdge > max.itemLeadingEdge ? position : max); + } + + Widget _buildScrollToBottom() { + return StreamBuilder>( + stream: Rx.combineLatest2( + streamChannel.channel.state.isUpToDateStream, + streamChannel.channel.state.unreadCountStream, + (bool isUpToDate, int unreadCount) => Tuple2(isUpToDate, unreadCount), + ), + builder: (_, snapshot) { + if (snapshot.hasError) { + return Offstage(); + } else if (!snapshot.hasData) { + return Offstage(); + } + final isUpToDate = snapshot.data.item1; + final showScrollToBottom = !isUpToDate || _showScrollToBottom; + if (!showScrollToBottom) { + return Offstage(); + } + final unreadCount = snapshot.data.item2; + final showUnreadCount = unreadCount > 0 && + streamChannel.channel.state.members.any( + (e) => e.userId == streamChannel.channel.client.state.user.id); + return Positioned( + bottom: 8, + right: 8, + width: 40, + height: 40, + child: Stack( + clipBehavior: Clip.none, + children: [ + FloatingActionButton( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + child: StreamSvgIcon.down( + color: StreamChatTheme.of(context).colorTheme.black, + ), + onPressed: () { + if (unreadCount > 0) { + streamChannel.channel.markRead(); + } + if (!_upToDate) { + _bottomPaginationActive = false; + _topPaginationActive = false; + streamChannel.reloadChannel(); + } else { + setState(() => _showScrollToBottom = false); + _scrollController.scrollTo( + index: 0, + duration: Duration(seconds: 1), + curve: Curves.easeInOut, + ); + } + }, + ), + if (showUnreadCount) + Positioned( + width: 20, + height: 20, + left: 10, + top: -10, + child: CircleAvatar( + child: Padding( + padding: const EdgeInsets.all(3.0), + child: Text( + '$unreadCount', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildLoadingIndicator( + StreamChannelState streamChannel, + QueryDirection direction, + ) { + final stream = direction == QueryDirection.top + ? streamChannel.queryTopMessages + : streamChannel.queryBottomMessages; + return StreamBuilder( + key: Key('LOADING-INDICATOR'), + stream: stream, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: StreamChatTheme.of(context) + .colorTheme + .accentRed + .withOpacity(.2), + child: Center( + child: Text('Error loading messages'), + ), + ); + } + if (!snapshot.data) { + if (!_isThreadConversation && direction == QueryDirection.top) { + return Container( + height: 52, + width: double.infinity, + ); + } + return Offstage(); + } + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: const CircularProgressIndicator(), + ), + ); + }, + ); + } + + Widget _buildTopMessage( + BuildContext context, + Message message, + List messages, + StreamChannelState streamChannel, + ) { + Widget messageWidget; + if (widget.messageBuilder != null) { + messageWidget = Builder( + key: ValueKey('TOP-MESSAGE'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + messages, + messages.length - 1, + ), + messages, + ), + ); + } else { + messageWidget = buildMessage(message, messages, messages.length - 1); + } + return messageWidget; + } + + Widget _buildBottomMessage( + BuildContext context, + Message message, + List messages, + StreamChannelState streamChannel, + ) { + Widget messageWidget; + if (widget.messageBuilder != null) { + messageWidget = Builder( + key: ValueKey('BOTTOM-MESSAGE'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + messages, + 0, + ), + messages, + ), + ); + } else { + messageWidget = buildMessage(message, messages, 0); + } + + return VisibilityDetector( + key: ValueKey('BOTTOM-MESSAGE'), + onVisibilityChanged: (visibility) { + final isVisible = visibility.visibleBounds != Rect.zero; + if (isVisible) { + final channel = streamChannel.channel; + if (_upToDate && + channel.config?.readEvents == true && + channel.state.unreadCount > 0) { + streamChannel.channel.markRead(); + } + } + if (mounted) { + setState(() => _showScrollToBottom = !isVisible); + } + }, + child: messageWidget, + ); + } + + Widget buildParentMessage( + Message message, + ) { + final isMyMessage = message.user.id == StreamChat.of(context).user.id; + final isOnlyEmoji = message.text.isOnlyEmoji; + + return MessageWidget( + showThreadReplyIndicator: false, + showInChannelIndicator: false, + showReplyMessage: false, + showResendMessage: false, + showThreadReplyMessage: false, + showCopyMessage: false, + showDeleteMessage: false, + showEditMessage: false, + message: message, + reverse: isMyMessage, + showUsername: !isMyMessage, + padding: const EdgeInsets.all(8.0), + showSendingIndicator: false, + onThreadTap: _onThreadTap, + borderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(2), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + textPadding: EdgeInsets.symmetric( + vertical: 8.0, + horizontal: isOnlyEmoji ? 0 : 16.0, + ), + borderSide: isMyMessage || isOnlyEmoji ? BorderSide.none : null, + showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, + messageTheme: isMyMessage + ? StreamChatTheme.of(context).ownMessageTheme + : StreamChatTheme.of(context).otherMessageTheme, + onShowMessage: widget.onShowMessage, + onReturnAction: (action) { + switch (action) { + case ReturnActionType.none: + break; + case ReturnActionType.reply: + FocusScope.of(context).unfocus(); + widget.onMessageSwiped(message); + break; + } + }, + ); + } + + Widget buildMessage( + Message message, + List messages, + int index, + ) { + if (message.type == 'system' && message.text?.isNotEmpty == true) { + return SystemMessage( + key: ValueKey('MESSAGE-${message.id}'), + message: message, + ); + } + + final userId = StreamChat.of(context).user.id; + final isMyMessage = message.user.id == userId; + final nextMessage = index - 2 >= 0 ? messages[index - 2] : null; + final isNextUserSame = + nextMessage != null && message.user.id == nextMessage.user.id; + + num timeDiff = 0; + if (nextMessage != null) { + timeDiff = Jiffy(nextMessage.createdAt.toLocal()).diff( + message.createdAt.toLocal(), + Units.MINUTE, + ); + } + + final channel = streamChannel.channel; + 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)); + })?.toList() ?? + []; + + final allRead = readList.length >= (channel.memberCount ?? 0) - 1; + final hasFileAttachment = + message.attachments?.any((it) => it.type == 'file') == true; + + final isThreadMessage = + message?.parentId != null && message?.showInChannel == true; + + final hasReplies = message.replyCount > 0; + + final attachmentBorderRadius = hasFileAttachment ? 12.0 : 14.0; + + final showTimeStamp = message.createdAt != null && + (!isThreadMessage || _isThreadConversation) && + !hasReplies && + (timeDiff >= 1 || !isNextUserSame); + + final showUsername = !isMyMessage && + (!isThreadMessage || _isThreadConversation) && + !hasReplies && + (timeDiff >= 1 || !isNextUserSame); + + final showUserAvatar = isMyMessage + ? DisplayWidget.gone + : (timeDiff >= 1 || !isNextUserSame) + ? DisplayWidget.show + : DisplayWidget.hide; + + final showSendingIndicator = + isMyMessage && (index == 0 || timeDiff >= 1 || !isNextUserSame); + + final showInChannelIndicator = !_isThreadConversation && isThreadMessage; + final showThreadReplyIndicator = !_isThreadConversation && hasReplies; + final isOnlyEmoji = message.text.isOnlyEmoji; + + final hasUrlAttachment = + message.attachments?.any((it) => it.ogScrapeUrl != null) == true; + + final borderSide = + isOnlyEmoji || hasUrlAttachment || (isMyMessage && !hasFileAttachment) + ? BorderSide.none + : null; + + Widget child = MessageWidget( + key: ValueKey('MESSAGE-${message.id}'), + message: message, + reverse: isMyMessage, + showReactions: !message.isDeleted, + padding: const EdgeInsets.symmetric(horizontal: 8.0), + showInChannelIndicator: showInChannelIndicator, + showThreadReplyIndicator: showThreadReplyIndicator, + showUsername: showUsername, + showTimestamp: showTimeStamp, + showSendingIndicator: showSendingIndicator, + showUserAvatar: showUserAvatar, + onQuotedMessageTap: (quotedMessageId) async { + final scrollToIndex = () { + final index = messages.indexWhere((m) => m.id == quotedMessageId); + _scrollController?.scrollTo( + index: index, + duration: const Duration(milliseconds: 350), + ); + }; + if (messages.map((e) => e.id).contains(quotedMessageId)) { + scrollToIndex(); + } else { + await streamChannel.loadChannelAtMessage(quotedMessageId).then((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (messages.map((e) => e.id).contains(quotedMessageId)) { + scrollToIndex(); + } + }); + }); + } + }, + showEditMessage: isMyMessage, + showDeleteMessage: isMyMessage, + showThreadReplyMessage: !isThreadMessage, + showFlagButton: !isMyMessage, + borderSide: borderSide, + onThreadTap: _onThreadTap, + onReplyTap: widget.onReplyTap, + attachmentBorderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(attachmentBorderRadius), + bottomLeft: Radius.circular( + (timeDiff >= 1 || !isNextUserSame) && + !(hasReplies || isThreadMessage || hasFileAttachment) + ? 0 + : attachmentBorderRadius, + ), + topRight: Radius.circular(attachmentBorderRadius), + bottomRight: Radius.circular(attachmentBorderRadius), + ), + attachmentPadding: EdgeInsets.all(hasFileAttachment ? 4 : 2), + borderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular( + (timeDiff >= 1 || !isNextUserSame) && !(hasReplies || isThreadMessage) + ? 0 + : 16, + ), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + textPadding: EdgeInsets.symmetric( + vertical: 8.0, + horizontal: isOnlyEmoji ? 0 : 16.0, + ), + messageTheme: isMyMessage + ? StreamChatTheme.of(context).ownMessageTheme + : StreamChatTheme.of(context).otherMessageTheme, + readList: readList, + allRead: allRead, + onShowMessage: widget.onShowMessage, + onReturnAction: (action) { + switch (action) { + case ReturnActionType.none: + break; + case ReturnActionType.reply: + FocusScope.of(context).unfocus(); + widget.onMessageSwiped(message); + break; + } + }, + ); + + if (!message.isDeleted && !message.isSystem && !message.isEphemeral) { + child = Swipeable( + onSwipeEnd: () { + FocusScope.of(context).unfocus(); + widget.onMessageSwiped(message); + }, + backgroundIcon: StreamSvgIcon.reply( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + child: child, + ); + } + + if (!initialMessageHighlightComplete && + widget.highlightInitialMessage && + _isInitialMessage(message.id)) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + final highlightColor = + widget.messageHighlightColor ?? colorTheme.highlight; + child = TweenAnimationBuilder( + tween: ColorTween( + begin: highlightColor, + end: colorTheme.white.withOpacity(0), + ), + duration: const Duration(seconds: 3), + child: Padding( + padding: const EdgeInsets.only(top: 4.0), + child: child, + ), + onEnd: () => initialMessageHighlightComplete = true, + builder: (_, color, child) { + return Container( + color: color, + child: child, + ); + }, + ); + } + return child; + } + + StreamSubscription _messageNewListener; + + @override + void initState() { + _scrollController = widget.scrollController ?? ItemScrollController(); + _itemPositionListener = + widget.itemPositionListener ?? ItemPositionsListener.create(); + + streamChannel = StreamChannel.of(context); + + initialIndex = _initialIndex; + initialAlignment = _initialAlignment; + + _messageNewListener = + streamChannel.channel.on(EventType.messageNew).listen((event) { + if (_upToDate) { + _bottomPaginationActive = false; + _topPaginationActive = false; + } + if (event.message.user.id == streamChannel.channel.client.state.user.id) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollController?.jumpTo( + index: 0, + ); + }); + } + }); + + if (_isThreadConversation) { + streamChannel.getReplies(widget.parentMessage.id); + } + + _getOnThreadTap(); + super.initState(); + } + + void _getOnThreadTap() { + if (widget.onThreadTap != null) { + _onThreadTap = (Message message) { + widget.onThreadTap( + message, + widget.threadBuilder != null + ? widget.threadBuilder(context, message) + : null); + }; + } else if (widget.threadBuilder != null) { + _onThreadTap = (Message message) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) { + return StreamBuilder( + stream: streamChannel.channel.state.messagesStream.map( + (messages) => + messages.firstWhere((m) => m.id == message.id)), + initialData: message, + builder: (_, snapshot) { + return StreamChannel( + channel: streamChannel.channel, + child: widget.threadBuilder(context, snapshot.data), + ); + }); + }), + ); + }; + } + } + + @override + void dispose() { + if (!_upToDate) { + streamChannel.reloadChannel(); + } + _messageNewListener?.cancel(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart new file mode 100644 index 00000000..e3d4f201 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -0,0 +1,268 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/src/reaction_bubble.dart'; +import 'package:stream_chat_flutter/src/reaction_picker.dart'; +import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/user_avatar.dart'; + +import 'message_widget.dart'; +import 'stream_chat_theme.dart'; +import 'extension.dart'; + +class MessageReactionsModal extends StatelessWidget { + final Widget Function(BuildContext, Message) editMessageInputBuilder; + final void Function(Message) onThreadTap; + final Message message; + final MessageTheme messageTheme; + final bool reverse; + final bool showReactions; + final DisplayWidget showUserAvatar; + final ShapeBorder messageShape; + final ShapeBorder attachmentShape; + final void Function(User) onUserAvatarTap; + + const MessageReactionsModal({ + Key key, + @required this.message, + @required this.messageTheme, + this.showReactions = true, + this.onThreadTap, + this.editMessageInputBuilder, + this.messageShape, + this.attachmentShape, + this.reverse = false, + this.showUserAvatar = DisplayWidget.show, + this.onUserAvatarTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final size = MediaQuery.of(context).size; + final user = StreamChat.of(context).user; + + final roughMaxSize = 2 * size.width / 3; + var messageTextLength = message.text.length; + if (message.quotedMessage != null) { + var quotedMessageLength = message.quotedMessage.text.length + 40; + if (message.quotedMessage.attachments?.isNotEmpty == true) { + quotedMessageLength += 40; + } + if (quotedMessageLength > messageTextLength) { + messageTextLength = quotedMessageLength; + } + } + final roughSentenceSize = + messageTextLength * messageTheme.messageText.fontSize * 1.2; + final divFactor = message.attachments?.isNotEmpty == true + ? 1 + : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); + + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: Duration(milliseconds: 300), + curve: Curves.easeInOutBack, + builder: (context, val, snapshot) { + final hasFileAttachment = + message.attachments?.any((it) => it.type == 'file') == true; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => Navigator.maybePop(context), + child: Stack( + children: [ + Positioned.fill( + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 10, + sigmaY: 10, + ), + child: Container( + color: StreamChatTheme.of(context).colorTheme.overlay, + ), + ), + ), + Transform.scale( + scale: val, + child: Center( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showReactions && + (message.status == MessageSendingStatus.sent || + message.status == null)) + Align( + alignment: Alignment( + user.id == message.user.id + ? (divFactor > 1.0 + ? 0.0 + : (1.0 - divFactor)) + : (divFactor > 1.0 + ? 0.0 + : -(1.0 - divFactor)), + 0.0), + child: ReactionPicker( + message: message, + messageTheme: messageTheme, + ), + ), + const SizedBox(height: 8), + IgnorePointer( + child: MessageWidget( + key: Key('MessageWidget'), + reverse: reverse, + message: message.copyWith( + text: message.text.length > 200 + ? '${message.text.substring(0, 200)}...' + : message.text, + ), + messageTheme: messageTheme, + showReactions: false, + showUsername: false, + showUserAvatar: showUserAvatar, + showThreadReplyIndicator: false, + showTimestamp: false, + translateUserAvatar: false, + showSendingIndicator: false, + shape: messageShape, + attachmentShape: attachmentShape, + padding: const EdgeInsets.all(0), + attachmentPadding: EdgeInsets.all( + hasFileAttachment ? 4 : 2, + ), + showInChannelIndicator: false, + textPadding: EdgeInsets.symmetric( + vertical: 8.0, + horizontal: message.text.isOnlyEmoji ? 0 : 16.0, + ), + showReactionPickerIndicator: showReactions && + (message.status == + MessageSendingStatus.sent || + message.status == null), + ), + ), + if (message.latestReactions?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + _buildReactionCard(context), + ] + ], + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildReactionCard(BuildContext context) { + final currentUser = StreamChat.of(context).user; + return Card( + color: StreamChatTheme.of(context).colorTheme.white, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Message Reactions', + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + const SizedBox(height: 16), + Flexible( + child: SingleChildScrollView( + child: Wrap( + spacing: 16, + runSpacing: 16, + alignment: WrapAlignment.start, + children: message.latestReactions + .map((e) => _buildReaction( + e, + currentUser, + context, + )) + .toList(), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildReaction( + Reaction reaction, + User currentUser, + BuildContext context, + ) { + final isCurrentUser = reaction.user.id == currentUser.id; + return ConstrainedBox( + constraints: BoxConstraints.loose(Size( + 64, + 98, + )), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + UserAvatar( + onTap: onUserAvatarTap, + user: reaction.user, + constraints: BoxConstraints.tightFor( + height: 64, + width: 64, + ), + onlineIndicatorConstraints: BoxConstraints.tightFor( + height: 12, + width: 12, + ), + borderRadius: BorderRadius.circular(32), + ), + Positioned( + child: Align( + alignment: + reverse ? Alignment.centerRight : Alignment.centerLeft, + child: ReactionBubble( + reactions: [reaction], + flipTail: !reverse, + borderColor: messageTheme.reactionsBorderColor, + backgroundColor: messageTheme.reactionsBackgroundColor, + maskColor: StreamChatTheme.of(context).colorTheme.white, + tailCirclesSpacing: 1, + highlightOwnReactions: false, + ), + ), + bottom: 6, + left: isCurrentUser ? -3 : null, + right: isCurrentUser ? -3 : null, + ), + ], + ), + const SizedBox(height: 8), + Text( + reaction.user.name, + style: StreamChatTheme.of(context).textTheme.footnoteBold, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_search_item.dart b/packages/stream_chat_flutter/lib/src/message_search_item.dart new file mode 100644 index 00000000..335f7ff4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_search_item.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// It shows the current [Message] preview. +/// +/// Usually you don't use this widget as it's the default item used by [MessageSearchListView]. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageSearchItem extends StatelessWidget { + /// Instantiate a new MessageSearchItem + const MessageSearchItem({ + Key key, + @required this.getMessageResponse, + this.onTap, + this.showOnlineStatus = true, + }) : super(key: key); + + /// [Message] displayed + final GetMessageResponse getMessageResponse; + + /// Function called when tapping this widget + final VoidCallback onTap; + + /// If true the [MessageSearchItem] will show the current online Status + final bool showOnlineStatus; + + @override + Widget build(BuildContext context) { + final message = getMessageResponse.message; + final channel = getMessageResponse.channel; + final channelName = channel.extraData['name']; + final user = message.user; + return ListTile( + onTap: onTap, + leading: UserAvatar( + user: user, + showOnlineStatus: showOnlineStatus, + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + title: Row( + children: [ + Text( + user.id == StreamChat.of(context).user.id ? 'You' : user.name, + style: StreamChatTheme.of(context).channelPreviewTheme.title, + ), + if (channelName != null) ...[ + Text( + ' in ', + style: StreamChatTheme.of(context) + .channelPreviewTheme + .title + .copyWith( + fontWeight: FontWeight.normal, + ), + ), + Text( + channelName, + style: StreamChatTheme.of(context).channelPreviewTheme.title, + ), + ], + ], + ), + subtitle: Row( + children: [ + Expanded(child: _buildSubtitle(context, message)), + SizedBox(width: 16), + _buildDate(context, message), + ], + ), + ); + } + + Widget _buildDate(BuildContext context, Message message) { + final createdAt = message.createdAt; + String stringDate; + final now = DateTime.now(); + + if (now.year != createdAt.year || + now.month != createdAt.month || + now.day != createdAt.day) { + stringDate = Jiffy(createdAt.toLocal()).format('dd/MM/yyyy'); + } else { + stringDate = Jiffy(createdAt.toLocal()).format('HH:mm'); + } + + return Text( + stringDate, + style: StreamChatTheme.of(context).channelPreviewTheme.lastMessageAt, + ); + } + + Widget _buildSubtitle(BuildContext context, Message message) { + if (message == null) { + return SizedBox(); + } + + var text = message.text; + if (message.isDeleted) { + text = 'This message was deleted.'; + } else if (message.attachments != null) { + final parts = [ + ...message.attachments.map((e) { + if (e.type == 'image') { + return '📷'; + } else if (e.type == 'video') { + return '🎬'; + } else if (e.type == 'giphy') { + return '[GIF]'; + } + return e == message.attachments.last + ? (e.title ?? 'File') + : '${e.title ?? 'File'} , '; + }).where((e) => e != null), + message.text ?? '', + ]; + + text = parts.join(' '); + } + + return Text.rich( + _getDisplayText( + text, + message.mentionedUsers, + message.attachments, + StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + fontStyle: (message.isSystem || message.isDeleted) + ? FontStyle.italic + : FontStyle.normal, + ), + StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + fontStyle: (message.isSystem || message.isDeleted) + ? FontStyle.italic + : FontStyle.normal, + fontWeight: FontWeight.bold, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + TextSpan _getDisplayText( + String text, + List mentions, + List attachments, + TextStyle normalTextStyle, + TextStyle mentionsTextStyle) { + var textList = text.split(' '); + var resList = []; + for (var e in textList) { + if (mentions != null && + mentions.isNotEmpty && + mentions.any((element) => '@${element.name}' == e)) { + resList.add(TextSpan( + text: '$e ', + style: mentionsTextStyle, + )); + } else if (attachments != null && + attachments.isNotEmpty && + attachments + .where((e) => e.title != null) + .any((element) => element.title == e)) { + resList.add(TextSpan( + text: '$e ', + style: normalTextStyle.copyWith(fontStyle: FontStyle.italic), + )); + } else { + resList.add(TextSpan( + text: e == textList.last ? '$e' : '$e ', + style: normalTextStyle, + )); + } + } + + return TextSpan(children: resList); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart new file mode 100644 index 00000000..490b3581 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/info_tile.dart'; +import 'package:stream_chat_flutter/src/message_search_item.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import '../stream_chat_flutter.dart'; + +/// Callback called when tapping on a user +typedef MessageSearchItemTapCallback = void Function(GetMessageResponse); + +/// Builder used to create a custom [ListUserItem] from a [User] +typedef MessageSearchItemBuilder = Widget Function( + BuildContext, GetMessageResponse); + +/// Builder used when [MessageSearchListView] is empty +typedef EmptyMessageSearchBuilder = Widget Function( + BuildContext context, String searchQuery); + +/// +/// It shows the list of searched messages. +/// +/// ```dart +/// class MessageSearchPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: MessageSearchListView( +/// messageQuery: _channelQuery, +/// filters: { +/// 'members': { +/// r'$in': [user.id] +/// } +/// }, +/// paginationParams: PaginationParams(limit: 20), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [MessageSearchBloc] ancestor in order to provide the information about the messages. +/// The widget uses a [ListView.separated] to render the list of messages. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageSearchListView extends StatefulWidget { + /// Instantiate a new MessageSearchListView + const MessageSearchListView({ + Key key, + this.messageQuery, + this.filters, + this.sortOptions, + this.paginationParams, + this.messageFilters, + this.emptyBuilder, + this.errorBuilder, + this.separatorBuilder, + this.itemBuilder, + this.onItemTap, + this.showResultCount = true, + this.pullToRefresh = true, + this.showErrorTile = false, + }) : super(key: key); + + /// Message String to search on + final String messageQuery; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filters; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sortOptions; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams paginationParams; + + /// The message query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map messageFilters; + + /// Builder used to create a custom item preview + final MessageSearchItemBuilder itemBuilder; + + /// Function called when tapping on a [MessageSearchItem] + final MessageSearchItemTapCallback onItemTap; + + /// The builder used when the channel list is empty. + final EmptyMessageSearchBuilder emptyBuilder; + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + /// Builder used to create a custom item separator + final IndexedWidgetBuilder separatorBuilder; + + /// Set it to false to hide total results text + final bool showResultCount; + + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + + final bool showErrorTile; + + @override + _MessageSearchListViewState createState() => _MessageSearchListViewState(); +} + +class _MessageSearchListViewState extends State { + final MessageSearchListController _messageSearchListController = + MessageSearchListController(); + + @override + Widget build(BuildContext context) { + return MessageSearchListCore( + filters: widget.filters, + sortOptions: widget.sortOptions, + messageQuery: widget.messageQuery, + paginationParams: widget.paginationParams, + messageFilters: widget.messageFilters, + messageSearchListController: _messageSearchListController, + emptyBuilder: (context) { + if (widget.emptyBuilder != null) { + return widget.emptyBuilder(context, widget.messageQuery); + } + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Text('There are no messages currently'), + ), + ), + ); + }, + ); + }, + errorBuilder: (BuildContext context, dynamic error) { + if (error is Error) { + print((error).stackTrace); + } + + if (widget.errorBuilder != null) { + return widget.errorBuilder(error); + } + + var message = error.toString(); + if (error is DioError) { + if (error.type == DioErrorType.RESPONSE) { + message = error.message; + } else { + message = 'Check your connection and retry'; + } + } + return InfoTile( + showMessage: widget.showErrorTile, + tileAnchor: Alignment.topCenter, + childAnchor: Alignment.topCenter, + message: 'An error occurred.', + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only(right: 2.0), + child: Icon(Icons.error_outline), + ), + ), + TextSpan(text: 'Error loading messages'), + ], + ), + style: Theme.of(context).textTheme.headline6, + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Text(message), + ), + RaisedButton( + onPressed: () { + _messageSearchListController.loadData(); + }, + child: Text('Retry'), + ), + ], + ), + ), + ); + }, + loadingBuilder: (context) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + }, + ); + }, + childBuilder: (list) { + return _buildListView(list); + }, + ); + } + + Widget _separatorBuilder(BuildContext context, int index) { + return Container( + height: 1, + color: StreamChatTheme.of(context).colorTheme.greyWhisper, + ); + } + + Widget _listItemBuilder( + BuildContext context, GetMessageResponse getMessageResponse) { + if (widget.itemBuilder != null) { + return widget.itemBuilder(context, getMessageResponse); + } + return MessageSearchItem( + getMessageResponse: getMessageResponse, + onTap: () => widget.onItemTap(getMessageResponse), + ); + } + + Widget _buildQueryProgressIndicator(context) { + final messageSearchBloc = MessageSearchBloc.of(context); + + return StreamBuilder( + stream: messageSearchBloc.queryMessagesLoading, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: StreamChatTheme.of(context) + .colorTheme + .accentRed + .withOpacity(.2), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text('Error loading messages'), + ), + ), + ); + } + return Container( + height: 100, + padding: EdgeInsets.all(32), + child: Center( + child: snapshot.data ? CircularProgressIndicator() : Container(), + ), + ); + }); + } + + Widget _buildListView(List data) { + final items = data; + + Widget child = ListView.separated( + physics: AlwaysScrollableScrollPhysics(), + itemCount: items.isNotEmpty ? items.length + 1 : items.length, + separatorBuilder: (_, index) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, index); + } + return _separatorBuilder(context, index); + }, + itemBuilder: (context, index) { + if (index < items.length) { + return _listItemBuilder(context, items[index]); + } + return _buildQueryProgressIndicator(context); + }, + ); + if (widget.pullToRefresh) { + child = RefreshIndicator( + onRefresh: () async { + _messageSearchListController.loadData(); + }, + child: child, + ); + } + + child = LazyLoadScrollView( + onEndOfPage: () async { + return _messageSearchListController.paginateData(); + }, + child: child, + ); + + if (widget.showResultCount) { + child = Column( + children: [ + Container( + width: double.maxFinite, + decoration: BoxDecoration( + gradient: StreamChatTheme.of(context).colorTheme.bgGradient, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Text( + '${items.length} results', + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.grey, + ), + ), + ), + ), + Expanded(child: child), + ], + ); + } + return child; + } +} diff --git a/lib/src/message_text.dart b/packages/stream_chat_flutter/lib/src/message_text.dart similarity index 89% rename from lib/src/message_text.dart rename to packages/stream_chat_flutter/lib/src/message_text.dart index 64fc855d..7557a877 100644 --- a/lib/src/message_text.dart +++ b/packages/stream_chat_flutter/lib/src/message_text.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'stream_chat_theme.dart'; import 'utils.dart'; @@ -66,9 +66,9 @@ class MessageText extends StatelessWidget { } String _replaceMentions(String text) { - message.mentionedUsers?.forEach((u) { + message.mentionedUsers?.map((u) => u.name)?.toSet()?.forEach((userName) { text = text.replaceAll( - '@${u.name}', '[@${u.name}](@${u.name.replaceAll(' ', '')})'); + '@$userName', '[@$userName](@${userName.replaceAll(' ', '')})'); }); return text; } diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart new file mode 100644 index 00000000..c2cbdc35 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -0,0 +1,1039 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/message_actions_modal.dart'; +import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; +import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; +import 'package:stream_chat_flutter/src/reaction_bubble.dart'; +import 'package:stream_chat_flutter/src/url_attachment.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'extension.dart'; +import 'image_group.dart'; +import 'message_text.dart'; + +typedef AttachmentBuilder = Widget Function(BuildContext, Message, Attachment); +typedef OnQuotedMessageTap = void Function(String); + +/// The display behaviour of a widget +enum DisplayWidget { + /// Hides the widget replacing its space with a spacer + hide, + + /// Hides the widget not replacing its space + gone, + + /// Shows the widget normally + show, +} + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_widget.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_widget_paint.png) +/// +/// It shows a message with reactions, replies and user avatar. +/// +/// Usually you don't use this widget as it's the default message widget used by [MessageListView]. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageWidget extends StatefulWidget { + /// Function called on mention tap + final void Function(User) onMentionTap; + + /// The function called when tapping on replies + final void Function(Message) onThreadTap; + final void Function(Message) onReplyTap; + final Widget Function(BuildContext, Message) editMessageInputBuilder; + final Widget Function(BuildContext, Message) textBuilder; + + /// Function called on long press + final void Function(BuildContext, Message) onMessageActions; + + /// The message + final Message message; + + /// The message theme + final MessageTheme messageTheme; + + /// If true the widget will be mirrored + final bool reverse; + + /// The shape of the message text + final ShapeBorder shape; + + /// The shape of an attachment + final ShapeBorder attachmentShape; + + /// The borderside of the message text + final BorderSide borderSide; + + /// The borderside of an attachment + final BorderSide attachmentBorderSide; + + /// The border radius of the message text + final BorderRadiusGeometry borderRadiusGeometry; + + /// The border radius of an attachment + final BorderRadiusGeometry attachmentBorderRadiusGeometry; + + /// The padding of the widget + final EdgeInsetsGeometry padding; + + /// The internal padding of the message text + final EdgeInsetsGeometry textPadding; + + /// The internal padding of an attachment + final EdgeInsetsGeometry attachmentPadding; + + /// It controls the display behaviour of the user avatar + final DisplayWidget showUserAvatar; + + /// It controls the display behaviour of the sending indicator + final bool showSendingIndicator; + + /// If true the widget will show the reactions + final bool showReactions; + + final bool allRead; + + /// If true the widget will show the thread reply indicator + final bool showThreadReplyIndicator; + + /// If true the widget will show the show in channel indicator + final bool showInChannelIndicator; + + /// The function called when tapping on UserAvatar + final void Function(User) onUserAvatarTap; + + /// The function called when tapping on a link + final void Function(String) onLinkTap; + + /// Used in [MessageReactionsModal] and [MessageActionsModal] + final bool showReactionPickerIndicator; + + final List readList; + + final ShowMessageCallback onShowMessage; + final ValueChanged onReturnAction; + + /// If true show the users username next to the timestamp of the message + final bool showUsername; + final bool showTimestamp; + + final bool showReplyMessage; + final bool showThreadReplyMessage; + final bool showEditMessage; + final bool showCopyMessage; + final bool showDeleteMessage; + final bool showResendMessage; + + final bool showFlagButton; + final Map attachmentBuilders; + + /// Center user avatar with bottom of the message + final bool translateUserAvatar; + + /// Function called when quotedMessage is tapped + final OnQuotedMessageTap onQuotedMessageTap; + + /// + MessageWidget({ + Key key, + @required this.message, + @required this.messageTheme, + this.reverse = false, + this.translateUserAvatar = true, + this.shape, + this.attachmentShape, + this.borderSide, + this.attachmentBorderSide, + this.borderRadiusGeometry, + this.attachmentBorderRadiusGeometry, + this.onMentionTap, + this.showReactionPickerIndicator = false, + this.showUserAvatar = DisplayWidget.show, + this.showSendingIndicator = true, + this.showThreadReplyIndicator = false, + this.showInChannelIndicator = false, + this.onReplyTap, + this.onThreadTap, + this.showUsername = true, + this.showTimestamp = true, + this.showReactions = true, + this.showDeleteMessage = true, + this.showEditMessage = true, + this.showReplyMessage = true, + this.showThreadReplyMessage = true, + this.showResendMessage = true, + this.showCopyMessage = true, + this.showFlagButton = true, + this.onUserAvatarTap, + this.onLinkTap, + this.onMessageActions, + this.onShowMessage, + this.editMessageInputBuilder, + this.textBuilder, + this.onReturnAction, + Map customAttachmentBuilders, + this.readList, + this.padding, + this.textPadding = const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + this.attachmentPadding = EdgeInsets.zero, + this.allRead = false, + this.onQuotedMessageTap, + }) : attachmentBuilders = { + 'image': (context, message, attachment) { + return ImageAttachment( + attachment: attachment, + message: message, + messageTheme: messageTheme, + size: Size( + MediaQuery.of(context).size.width * 0.8, + MediaQuery.of(context).size.height * 0.3, + ), + onShowMessage: onShowMessage, + onReturnAction: onReturnAction, + ); + }, + 'video': (context, message, attachment) { + return VideoAttachment( + attachment: attachment, + messageTheme: messageTheme, + size: Size( + MediaQuery.of(context).size.width * 0.8, + MediaQuery.of(context).size.height * 0.3, + ), + message: message, + onShowMessage: onShowMessage, + onReturnAction: onReturnAction, + ); + }, + 'giphy': (context, message, attachment) { + return GiphyAttachment( + attachment: attachment, + messageTheme: messageTheme, + message: message, + size: Size( + MediaQuery.of(context).size.width * 0.8, + MediaQuery.of(context).size.height * 0.3, + ), + onShowMessage: onShowMessage, + onReturnAction: onReturnAction, + ); + }, + 'file': (context, message, attachment) { + return FileAttachment( + attachment: attachment, + size: Size( + MediaQuery.of(context).size.width * 0.8, + MediaQuery.of(context).size.height * 0.3, + ), + ); + }, + }..addAll(customAttachmentBuilders ?? {}), + super(key: key); + + @override + _MessageWidgetState createState() => _MessageWidgetState(); +} + +class _MessageWidgetState extends State { + bool get showThreadReplyIndicator => widget.showThreadReplyIndicator; + + bool get showSendingIndicator => widget.showSendingIndicator; + + bool get isDeleted => widget.message.isDeleted; + + bool get showUsername => widget.showUsername; + + bool get showTimeStamp => widget.showTimestamp; + + bool get isMessageRead => widget.readList?.isNotEmpty == true; + + bool get showInChannel => widget.showInChannelIndicator; + + bool get hasQuotedMessage => widget.message?.quotedMessage != null; + + bool get isSendFailed => widget.message.status == MessageSendingStatus.failed; + + bool get isUpdateFailed => + widget.message.status == MessageSendingStatus.failed_update; + + bool get isDeleteFailed => + widget.message.status == MessageSendingStatus.failed_delete; + + bool get isFailedState => isSendFailed || isUpdateFailed || isDeleteFailed; + + bool get isGiphy => + widget.message.attachments?.any((element) => element.type == 'giphy') == + true; + + bool get hasNonUrlAttachments => + widget.message.attachments + ?.where((it) => it.ogScrapeUrl == null) + ?.isNotEmpty == + true; + + bool get hasUrlAttachments => + widget.message.attachments?.any((it) => it.ogScrapeUrl != null) == true; + + bool get showBottomRow => + showThreadReplyIndicator || + showUsername || + showTimeStamp || + showInChannel || + showSendingIndicator || + isDeleted; + + @override + Widget build(BuildContext context) { + final avatarWidth = widget.messageTheme.avatarTheme.constraints.maxWidth; + var leftPadding = + widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; + + return Material( + type: MaterialType.transparency, + child: Portal( + child: InkWell( + onLongPress: widget.message.isDeleted && !isFailedState + ? null + : () => onLongPress(context), + child: Padding( + padding: widget.padding ?? EdgeInsets.all(8), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: 0.75, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + clipBehavior: Clip.none, + alignment: AlignmentDirectional.bottomStart, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showUserAvatar == + DisplayWidget.show) ...[ + _buildUserAvatar(), + SizedBox(width: 4), + ], + if (widget.showUserAvatar == DisplayWidget.hide) + SizedBox(width: avatarWidth + 4), + Flexible( + child: PortalEntry( + portal: Container( + transform: + Matrix4.translationValues(-12, 0, 0), + child: _buildReactionIndicator(context), + constraints: + BoxConstraints(maxWidth: 22 * 6.0), + ), + portalAnchor: Alignment(-1.0, -1.0), + childAnchor: Alignment(1, -1.0), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: widget.showReactions + ? EdgeInsets.only( + top: widget + .message + .reactionCounts + ?.isNotEmpty == + true + ? 18 + : 0, + ) + : EdgeInsets.zero, + child: (widget.message.isDeleted && + !isFailedState) + ? Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY( + widget.reverse ? pi : 0), + child: Container( + margin: EdgeInsets.symmetric( + horizontal: + widget.showUserAvatar == + DisplayWidget + .gone + ? 0 + : 4.0), + child: DeletedMessage( + reverse: widget.reverse, + borderRadiusGeometry: widget + .borderRadiusGeometry, + borderSide: + widget.borderSide, + shape: widget.shape, + messageTheme: + widget.messageTheme, + ), + ), + ) + : Card( + clipBehavior: Clip.antiAlias, + elevation: 0.0, + margin: EdgeInsets.symmetric( + horizontal: (isFailedState + ? 15.0 + : 0.0) + + (widget.showUserAvatar == + DisplayWidget + .gone + ? 0 + : 4.0), + ), + shape: widget.shape ?? + RoundedRectangleBorder( + side: + widget.borderSide ?? + BorderSide( + color: widget + .messageTheme + .messageBorderColor, + ), + borderRadius: widget + .borderRadiusGeometry ?? + BorderRadius.zero, + ), + color: _getBackgroundColor(), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.end, + mainAxisSize: + MainAxisSize.min, + children: [ + if (hasQuotedMessage) + _buildQuotedMessage(), + if (hasNonUrlAttachments) + _parseAttachments(), + if (!isGiphy) + _buildTextBubble(), + ], + ), + ), + ), + if (widget.showReactionPickerIndicator) + Positioned( + right: 4, + top: -8, + child: Transform( + transform: Matrix4.rotationY( + widget.reverse ? pi : 0), + child: CustomPaint( + painter: ReactionBubblePainter( + StreamChatTheme.of(context) + .colorTheme + .white, + Colors.transparent, + Colors.transparent, + tailCirclesSpace: 1, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + if (showBottomRow) SizedBox(height: 20.0), + ], + ), + if (showBottomRow) + Padding( + padding: EdgeInsets.only(left: leftPadding), + child: _bottomRow, + ), + if (isFailedState) + Positioned( + left: widget.reverse ? 0 : null, + right: widget.reverse ? null : 0, + bottom: showBottomRow ? 18 : -2, + child: StreamSvgIcon.error(size: 20), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget _buildQuotedMessage() { + final isMyMessage = + widget.message.user.id == StreamChat.of(context).user.id; + final onTap = widget.message?.quotedMessage?.isDeleted != true && + widget.onQuotedMessageTap != null + ? () => widget.onQuotedMessageTap(widget.message.quotedMessageId) + : null; + return QuotedMessageWidget( + onTap: onTap, + message: widget.message.quotedMessage, + messageTheme: isMyMessage + ? StreamChatTheme.of(context).otherMessageTheme + : StreamChatTheme.of(context).ownMessageTheme, + reverse: widget.reverse, + padding: EdgeInsets.only( + right: 8, left: 8, top: 8, bottom: hasNonUrlAttachments ? 8 : 0), + ); + } + + Widget get _bottomRow { + if (isDeleted) { + return Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSvgIcon.eye( + color: StreamChatTheme.of(context).colorTheme.grey, + size: 16.0, + ), + SizedBox(width: 8.0), + Text( + 'Only visible to you', + style: StreamChatTheme.of(context) + .textTheme + .footnote + .copyWith(color: StreamChatTheme.of(context).colorTheme.grey), + ), + ], + ), + ); + } + + var children = []; + + final threadParticipants = widget.message?.threadParticipants?.take(2); + final showThreadParticipants = threadParticipants?.isNotEmpty == true; + final replyCount = widget.message.replyCount; + + var msg = 'Thread Reply'; + if (showThreadReplyIndicator && replyCount > 1) { + msg = '$replyCount Thread Replies'; + } + + final onThreadTap = () async { + try { + var message = widget.message; + if (showInChannel) { + final channel = StreamChannel.of(context); + message = await channel.getMessage(widget.message.parentId); + } + return widget.onThreadTap(message); + } catch (e, stk) { + print(e); + print(stk); + return null; + } + }; + + children.addAll([ + if (showInChannel || showThreadReplyIndicator) ...[ + if (showThreadParticipants) + SizedBox.fromSize( + size: Size((threadParticipants.length * 8.0) + 8, 16), + child: _buildThreadParticipantsIndicator(threadParticipants), + ), + InkWell( + onTap: widget.onThreadTap != null ? onThreadTap : null, + child: Text(msg, style: widget.messageTheme?.replies), + ), + ], + if (showUsername) + Text( + widget.message.user.name, + style: widget.messageTheme.replies.copyWith( + color: widget.messageTheme.createdAt.color, + ), + ), + if (showTimeStamp) + Text( + Jiffy(widget.message.createdAt.toLocal()).jm, + style: widget.messageTheme.createdAt, + ), + if (showSendingIndicator) _buildSendingIndicator(), + ]); + + final showThreadTail = !(hasUrlAttachments || isGiphy || isOnlyEmoji) && + (showThreadReplyIndicator || showInChannel); + + return Flex( + direction: Axis.horizontal, + clipBehavior: Clip.none, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (showThreadTail) + Container( + margin: EdgeInsets.only( + bottom: widget.messageTheme.replies.fontSize / 2, + ), + child: CustomPaint( + size: const Size(16, 32), + painter: _ThreadReplyPainter( + context: context, + color: widget.messageTheme.messageBorderColor, + ), + ), + ), + ...children.map( + (child) => Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Container( + height: 16, + child: Center( + child: child, + ), + ), + ), + ), + ].insertBetween(const SizedBox(width: 8.0)), + ); + } + + Widget _buildUrlAttachment() { + var urlAttachment = widget.message.attachments + .firstWhere((element) => element.ogScrapeUrl != null); + + var host = Uri.parse(urlAttachment.ogScrapeUrl).host; + var splitList = host.split('.'); + var hostName = splitList.length == 3 ? splitList[1] : splitList[0]; + var hostDisplayName = urlAttachment.authorName?.capitalize() ?? + getWebsiteName(hostName.toLowerCase()) ?? + hostName.capitalize(); + + return UrlAttachment( + urlAttachment: urlAttachment, + hostDisplayName: hostDisplayName, + textPadding: widget.textPadding, + ); + } + + Widget _buildThreadParticipantsIndicator(Iterable threadParticipants) { + var padding = 0.0; + return Stack( + children: threadParticipants.map((user) { + padding += 8.0; + return Positioned( + right: padding - 8, + bottom: 0, + top: 0, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: StreamChatTheme.of(context).colorTheme.white, + ), + padding: const EdgeInsets.all(1), + child: UserAvatar( + user: user, + constraints: BoxConstraints.loose(Size.fromRadius(7)), + showOnlineStatus: false, + ), + ), + ); + }).toList(), + ); + } + + Widget _buildReactionIndicator( + BuildContext context, + ) { + final ownId = StreamChat.of(context).user.id; + final reactionsMap = {}; + widget.message.latestReactions?.forEach((element) { + if (!reactionsMap.containsKey(element.type) || element.user.id == ownId) { + reactionsMap[element.type] = element; + } + }); + final reactionsList = reactionsMap.values.toList() + ..sort((a, b) => a.user.id == ownId ? 1 : -1); + + return AnimatedSwitcher( + duration: Duration(milliseconds: 300), + child: (widget.showReactions && + (widget.message.reactionCounts?.isNotEmpty == true) && + !widget.message.isDeleted) + ? GestureDetector( + onTap: () => _showMessageReactionsModalBottomSheet(context), + child: ReactionBubble( + key: ValueKey('${widget.message.id}.reactions'), + reverse: widget.reverse, + flipTail: widget.reverse, + backgroundColor: widget.messageTheme.reactionsBackgroundColor, + borderColor: widget.messageTheme.reactionsBorderColor, + maskColor: widget.messageTheme.reactionsMaskColor, + reactions: reactionsList, + ), + ) + : SizedBox(), + ); + } + + void _showMessageActionModalBottomSheet(BuildContext context) { + final channel = StreamChannel.of(context).channel; + + showDialog( + context: context, + barrierColor: StreamChatTheme.of(context).colorTheme.overlay, + builder: (context) { + return StreamChannel( + channel: channel, + child: MessageActionsModal( + showUserAvatar: + widget.message.user.id == channel.client.state.user.id + ? DisplayWidget.gone + : DisplayWidget.show, + messageTheme: widget.messageTheme, + messageShape: widget.shape ?? _getDefaultShape(context), + attachmentShape: + widget.attachmentShape ?? _getDefaultAttachmentShape(context), + reverse: widget.reverse, + showDeleteMessage: widget.showDeleteMessage || isDeleteFailed, + message: widget.message, + editMessageInputBuilder: widget.editMessageInputBuilder, + onReplyTap: widget.onReplyTap, + onThreadReplyTap: widget.onThreadTap, + showResendMessage: + widget.showResendMessage && (isSendFailed || isUpdateFailed), + showCopyMessage: widget.showCopyMessage && + !isFailedState && + widget.message.text?.trim()?.isNotEmpty == true, + showEditMessage: widget.showEditMessage && + !isDeleteFailed && + widget.message.attachments + ?.any((element) => element.type == 'giphy') != + true, + showReactions: widget.showReactions, + showReplyMessage: widget.showReplyMessage && + !isFailedState && + widget.onReplyTap != null, + showThreadReplyMessage: widget.showThreadReplyMessage && + !isFailedState && + widget.onThreadTap != null, + showFlagButton: widget.showFlagButton, + ), + ); + }); + } + + void _showMessageReactionsModalBottomSheet(BuildContext context) { + final channel = StreamChannel.of(context).channel; + showDialog( + context: context, + barrierColor: StreamChatTheme.of(context).colorTheme.overlay, + builder: (context) { + return StreamChannel( + channel: channel, + child: MessageReactionsModal( + showUserAvatar: + widget.message.user.id == channel.client.state.user.id + ? DisplayWidget.gone + : DisplayWidget.show, + onUserAvatarTap: widget.onUserAvatarTap, + messageTheme: widget.messageTheme, + messageShape: widget.shape ?? _getDefaultShape(context), + attachmentShape: + widget.attachmentShape ?? _getDefaultAttachmentShape(context), + reverse: widget.reverse, + message: widget.message, + editMessageInputBuilder: widget.editMessageInputBuilder, + onThreadTap: widget.onThreadTap, + showReactions: widget.showReactions, + ), + ); + }); + } + + ShapeBorder _getDefaultAttachmentShape(BuildContext context) { + final hasFiles = + widget.message.attachments?.any((it) => it.type == 'file') == true; + return RoundedRectangleBorder( + side: hasFiles + ? widget.attachmentBorderSide ?? + BorderSide( + color: StreamChatTheme.of(context).colorTheme.greyWhisper, + ) + : BorderSide.none, + borderRadius: widget.attachmentBorderRadiusGeometry ?? BorderRadius.zero, + ); + } + + ShapeBorder _getDefaultShape(BuildContext context) { + return RoundedRectangleBorder( + side: widget.borderSide ?? + BorderSide( + color: StreamChatTheme.of(context).colorTheme.greyWhisper, + ), + borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, + ); + } + + Widget _parseAttachments() { + final images = widget.message.attachments + ?.where((element) => + element.type == 'image' && element.ogScrapeUrl == null) + ?.toList() ?? + []; + + if (images.length > 1) { + return Padding( + padding: widget.attachmentPadding, + child: wrapAttachmentWidget( + context, + Material( + color: widget.messageTheme.messageBackgroundColor, + child: ImageGroup( + size: Size( + MediaQuery.of(context).size.width * 0.8, + MediaQuery.of(context).size.height * 0.3, + ), + images: images, + message: widget.message, + onShowMessage: widget.onShowMessage, + ), + ), + ), + ); + } + + return Padding( + padding: widget.attachmentPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + children: widget.message.attachments + ?.where((element) => element.ogScrapeUrl == null) + ?.map((attachment) { + final attachmentBuilder = + widget.attachmentBuilders[attachment.type]; + + if (attachmentBuilder == null) return SizedBox(); + final attachmentWidget = attachmentBuilder( + context, + widget.message, + attachment, + ); + return wrapAttachmentWidget( + context, + attachmentWidget, + attachment: attachment, + ); + })?.insertBetween(SizedBox( + height: widget.attachmentPadding.vertical / 2, + )) ?? + [], + ), + ); + } + + Widget wrapAttachmentWidget( + BuildContext context, + Widget attachmentWidget, { + Attachment attachment, + }) { + final attachmentShape = + widget.attachmentShape ?? _getDefaultAttachmentShape(context); + return Material( + clipBehavior: Clip.antiAlias, + shape: attachmentShape, + type: MaterialType.transparency, + child: Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: attachmentWidget, + ), + ); + } + + void onLongPress(BuildContext context) { + if (widget.message.isEphemeral || + widget.message.status == MessageSendingStatus.sending) { + return; + } + + if (widget.onMessageActions != null) { + widget.onMessageActions(context, widget.message); + } else { + _showMessageActionModalBottomSheet(context); + } + return; + } + + Widget _buildSendingIndicator() { + final style = widget.messageTheme.createdAt; + Widget child = SendingIndicator( + message: widget.message, + isMessageRead: isMessageRead, + size: style.fontSize, + ); + if (isMessageRead) { + child = Row( + children: [ + if (StreamChannel.of(context).channel.memberCount > 2) + Text( + widget.readList.length.toString(), + style: style.copyWith( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + ), + SizedBox(width: 2), + child, + ], + ); + } + return child; + } + + Widget _buildUserAvatar() => Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Transform.translate( + offset: Offset( + 0, + widget.translateUserAvatar + ? widget.messageTheme.avatarTheme.constraints.maxHeight / 2 + : 0, + ), + child: UserAvatar( + user: widget.message.user, + onTap: widget.onUserAvatarTap, + constraints: widget.messageTheme.avatarTheme.constraints, + showOnlineStatus: false, + ), + ), + ); + + Widget _buildTextBubble() { + if (widget.message.text.trim().isEmpty) return Offstage(); + return Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: isOnlyEmoji ? EdgeInsets.zero : widget.textPadding, + child: widget.textBuilder != null + ? widget.textBuilder(context, widget.message) + : MessageText( + onLinkTap: widget.onLinkTap, + message: widget.message, + onMentionTap: widget.onMentionTap, + messageTheme: isOnlyEmoji + ? widget.messageTheme.copyWith( + messageText: + widget.messageTheme.messageText.copyWith( + fontSize: 42, + )) + : widget.messageTheme, + ), + ), + if (hasUrlAttachments && !hasQuotedMessage) _buildUrlAttachment(), + ], + ), + ); + } + + bool get isOnlyEmoji => widget.message.text.isOnlyEmoji; + + Color _getBackgroundColor() { + if (hasQuotedMessage) { + return widget.messageTheme.messageBackgroundColor; + } + + if (hasUrlAttachments) { + return StreamChatTheme.of(context).colorTheme.blueAlice; + } + + if (isOnlyEmoji) { + return Colors.transparent; + } + + if (isGiphy) { + return Colors.transparent; + } + + return widget.messageTheme.messageBackgroundColor; + } + + void retryMessage(BuildContext context) { + final channel = StreamChannel.of(context).channel; + if (widget.message.status == MessageSendingStatus.failed) { + channel.sendMessage(widget.message); + return; + } + if (widget.message.status == MessageSendingStatus.failed_update) { + StreamChat.of(context).client.updateMessage( + widget.message, + channel.cid, + ); + return; + } + + if (widget.message.status == MessageSendingStatus.failed_delete) { + StreamChat.of(context).client.deleteMessage( + widget.message, + channel.cid, + ); + return; + } + } +} + +class _ThreadReplyPainter extends CustomPainter { + final Color color; + final BuildContext context; + + const _ThreadReplyPainter({this.context, @required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color ?? StreamChatTheme.of(context).colorTheme.greyGainsboro + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..strokeCap = StrokeCap.round; + + final path = Path() + ..moveTo(0, 0) + ..quadraticBezierTo(0, size.height * 0.38, 0, size.height * 0.50) + ..quadraticBezierTo( + 0, + size.height, + size.width, + size.height, + ); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/packages/stream_chat_flutter/lib/src/option_list_tile.dart b/packages/stream_chat_flutter/lib/src/option_list_tile.dart new file mode 100644 index 00000000..61ce939e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/option_list_tile.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; + +class OptionListTile extends StatelessWidget { + final String title; + final Widget leading; + final Widget trailing; + final VoidCallback onTap; + final Color titleColor; + final Color tileColor; + final Color separatorColor; + final TextStyle titleTextStyle; + + OptionListTile({ + this.title, + this.leading, + this.trailing, + this.onTap, + this.titleColor, + this.tileColor, + this.separatorColor, + this.titleTextStyle, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + color: separatorColor ?? + StreamChatTheme.of(context).colorTheme.greyGainsboro, + height: 1.0, + ), + Material( + color: tileColor ?? StreamChatTheme.of(context).colorTheme.white, + child: Container( + height: 63.0, + child: InkWell( + onTap: onTap, + child: Row( + children: [ + if (leading != null) Center(child: leading), + if (leading == null) + SizedBox( + width: 16.0, + ), + Expanded( + flex: 4, + child: Text( + title, + style: titleTextStyle ?? + (titleColor == null + ? StreamChatTheme.of(context).textTheme.bodyBold + : StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: titleColor, + )), + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: Align( + alignment: Alignment.centerRight, + child: trailing ?? Container(), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart new file mode 100644 index 00000000..8b0912eb --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart @@ -0,0 +1,320 @@ +import 'dart:math'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:video_player/video_player.dart'; + +import 'attachment_error.dart'; +import 'extension.dart'; +import 'image_attachment.dart'; +import 'message_text.dart'; +import 'stream_chat_theme.dart'; +import 'user_avatar.dart'; +import 'utils.dart'; + +typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function( + BuildContext, + Attachment, +); + +class _VideoAttachmentThumbnail extends StatefulWidget { + final Size size; + final Attachment attachment; + + const _VideoAttachmentThumbnail({ + Key key, + @required this.attachment, + this.size = const Size(32, 32), + }) : super(key: key); + + @override + _VideoAttachmentThumbnailState createState() => + _VideoAttachmentThumbnailState(); +} + +class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> { + VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.network(widget.attachment.assetUrl) + ..initialize().then((_) { + setState(() {}); //when your thumbnail will show. + }); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: widget.size.height, + width: widget.size.width, + child: _controller.value.initialized + ? VideoPlayer(_controller) + : CircularProgressIndicator()); + } +} + +/// +class QuotedMessageWidget extends StatelessWidget { + /// The message + final Message message; + + /// The message theme + final MessageTheme messageTheme; + + /// If true the widget will be mirrored + final bool reverse; + + /// If true the message will show a grey border + final bool showBorder; + + /// limit of the text message shown + final int textLimit; + + /// Map that defines a thumbnail builder for an attachment type + final Map + attachmentThumbnailBuilders; + + final EdgeInsetsGeometry padding; + + final GestureTapCallback onTap; + + /// + QuotedMessageWidget({ + Key key, + @required this.message, + @required this.messageTheme, + this.reverse = false, + this.showBorder = false, + this.textLimit = 170, + this.attachmentThumbnailBuilders, + this.padding = const EdgeInsets.all(8), + this.onTap, + }) : super(key: key); + + bool get _hasAttachments => message.attachments?.isNotEmpty == true; + + bool get _containsScrapeUrl => + message.attachments?.any((element) => element.ogScrapeUrl != null) == + true; + + bool get _containsText => message?.text?.isNotEmpty == true; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: InkWell( + onTap: onTap, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: _buildMessage(context)), + SizedBox(width: 8), + _buildUserAvatar(), + ], + ), + ), + ); + } + + Widget _buildMessage(BuildContext context) { + final isOnlyEmoji = message.text.isOnlyEmoji; + var msg = _hasAttachments && !_containsText + ? message.copyWith(text: message.attachments.last?.title ?? '') + : message; + if (msg.text.length > textLimit) { + msg = msg.copyWith(text: '${msg.text.substring(0, textLimit - 3)}...'); + } + + final children = [ + if (_hasAttachments) _parseAttachments(context), + if (msg.text.isNotEmpty) + Flexible( + child: Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: MessageText( + message: msg, + messageTheme: isOnlyEmoji && _containsText + ? messageTheme.copyWith( + messageText: messageTheme.messageText.copyWith( + fontSize: 32, + )) + : messageTheme.copyWith( + messageText: messageTheme.messageText.copyWith( + fontSize: 12, + )), + ), + ), + ), + ].insertBetween(const SizedBox(width: 8)); + + return Container( + decoration: BoxDecoration( + color: _getBackgroundColor(context), + border: showBorder + ? Border.all( + color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + ) + : null, + borderRadius: BorderRadius.only( + topRight: Radius.circular(12), + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + padding: const EdgeInsets.all(8), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + reverse ? MainAxisAlignment.end : MainAxisAlignment.start, + children: reverse ? children.reversed.toList() : children, + ), + ); + } + + Widget _buildUrlAttachment(Attachment attachment) { + final size = Size(32, 32); + if (attachment.thumbUrl != null) { + return Container( + height: size.height, + width: size.width, + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.cover, + image: CachedNetworkImageProvider( + attachment.imageUrl, + ), + ), + ), + ); + } + return AttachmentError( + attachment: attachment, + size: size, + ); + } + + Widget _parseAttachments(BuildContext context) { + Widget child; + Attachment attachment; + if (_containsScrapeUrl) { + attachment = message.attachments.firstWhere( + (element) => element.ogScrapeUrl != null, + ); + child = _buildUrlAttachment(attachment); + } else { + QuotedMessageAttachmentThumbnailBuilder attachmentBuilder; + attachment = message.attachments.last; + if (attachmentThumbnailBuilders?.containsKey(attachment?.type) == true) { + attachmentBuilder = attachmentThumbnailBuilders[attachment?.type]; + } + attachmentBuilder = _defaultAttachmentBuilder[attachment?.type]; + if (attachmentBuilder == null) { + child = Offstage(); + } + child = attachmentBuilder(context, attachment); + } + child = AbsorbPointer(child: child); + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: Material( + clipBehavior: Clip.hardEdge, + color: Colors.transparent, + shape: attachment.type == 'file' ? null : _getDefaultShape(context), + child: child, + ), + ); + } + + ShapeBorder _getDefaultShape(BuildContext context) { + return RoundedRectangleBorder( + side: BorderSide(width: 0.0, color: Colors.transparent), + borderRadius: BorderRadius.circular(8), + ); + } + + Widget _buildUserAvatar() { + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: UserAvatar( + user: message.user, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + showOnlineStatus: false, + ), + ); + } + + Map + get _defaultAttachmentBuilder { + return { + 'image': (_, attachment) { + return ImageAttachment( + attachment: attachment, + message: message, + messageTheme: messageTheme, + size: Size(32, 32), + ); + }, + 'video': (_, attachment) { + return _VideoAttachmentThumbnail( + key: ValueKey(attachment.assetUrl), + attachment: attachment, + ); + }, + 'giphy': (_, attachment) { + final size = Size(32, 32); + return CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, + imageUrl: + attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, + ); + }, + 'file': (_, attachment) { + return Container( + height: 32, + width: 32, + child: getFileTypeImage(attachment.extraData['mime_type']), + ); + }, + }; + } + + Color _getBackgroundColor(BuildContext context) { + if (_containsScrapeUrl) { + return StreamChatTheme.of(context).colorTheme.blueAlice; + } + return messageTheme.messageBackgroundColor; + } +} diff --git a/packages/stream_chat_flutter/lib/src/reaction_bubble.dart b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart new file mode 100644 index 00000000..4ddcca93 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reaction_bubble.dart @@ -0,0 +1,300 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:stream_chat_flutter/src/reaction_icon.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class ReactionBubble extends StatelessWidget { + const ReactionBubble({ + Key key, + @required this.reactions, + @required this.borderColor, + @required this.backgroundColor, + @required this.maskColor, + this.reverse = false, + this.flipTail = false, + this.highlightOwnReactions = true, + this.tailCirclesSpacing = 0, + }) : super(key: key); + + final List reactions; + final Color borderColor; + final Color backgroundColor; + final Color maskColor; + final bool reverse; + final bool flipTail; + final bool highlightOwnReactions; + final double tailCirclesSpacing; + + @override + Widget build(BuildContext context) { + final reactionIcons = StreamChatTheme.of(context).reactionIcons; + final totalReactions = reactions.length; + final offset = totalReactions > 1 ? 16.0 : 2.0; + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: Stack( + alignment: Alignment.center, + children: [ + Transform.translate( + offset: Offset(reverse ? offset : -offset, 0), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: maskColor, + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + child: Container( + padding: EdgeInsets.symmetric( + vertical: 4, + horizontal: totalReactions > 1 ? 4 : 0, + ), + decoration: BoxDecoration( + border: Border.all( + color: borderColor, + ), + color: backgroundColor, + borderRadius: BorderRadius.all(Radius.circular(14)), + ), + child: LayoutBuilder( + builder: (context, constraints) { + return Flex( + direction: Axis.horizontal, + mainAxisSize: MainAxisSize.min, + children: [ + if (constraints.maxWidth < double.infinity) + ...reactions + .take((constraints.maxWidth) ~/ 24) + .map((reaction) { + return _buildReaction( + reactionIcons, + reaction, + context, + ); + }).toList(), + if (constraints.maxWidth == double.infinity) + ...reactions.map((reaction) { + return _buildReaction( + reactionIcons, + reaction, + context, + ); + }).toList(), + ], + ); + }, + ), + ), + ), + ), + Positioned( + bottom: 2, + left: reverse ? null : 13, + right: !reverse ? null : 13, + child: _buildReactionsTail(context), + ), + ], + ), + ); + } + + Widget _buildReaction( + List reactionIcons, + Reaction reaction, + BuildContext context, + ) { + final reactionIcon = reactionIcons.firstWhere( + (r) => r.type == reaction.type, + orElse: () => null, + ); + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 4.0, + ), + child: reactionIcon != null + ? StreamSvgIcon( + assetName: reactionIcon.assetName, + width: 16, + height: 16, + color: (!highlightOwnReactions || + reaction.user.id == StreamChat.of(context).user.id) + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + ) + : Icon( + Icons.help_outline_rounded, + size: 16, + color: (!highlightOwnReactions || + reaction.user.id == StreamChat.of(context).user.id) + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5), + ), + ); + } + + Widget _buildReactionsTail(BuildContext context) { + final tail = CustomPaint( + painter: ReactionBubblePainter( + backgroundColor, + borderColor, + maskColor, + tailCirclesSpace: tailCirclesSpacing, + ), + ); + return Transform( + transform: Matrix4.rotationY(flipTail ? 0 : pi), + alignment: Alignment.center, + child: tail, + ); + } +} + +class ReactionBubblePainter extends CustomPainter { + final Color color; + final Color borderColor; + final Color maskColor; + final double tailCirclesSpace; + + ReactionBubblePainter( + this.color, + this.borderColor, + this.maskColor, { + this.tailCirclesSpace = 0, + }); + + @override + void paint(Canvas canvas, Size size) { + _drawOvalMask(size, canvas); + + _drawMask(size, canvas); + + _drawOval(size, canvas); + + _drawOvalBorder(size, canvas); + + _drawArc(size, canvas); + + _drawBorder(size, canvas); + } + + void _drawOvalMask(Size size, Canvas canvas) { + final paint = Paint() + ..color = maskColor + ..style = PaintingStyle.fill; + + final path = Path(); + path.addOval( + Rect.fromCircle( + center: Offset(4, 3) + Offset(tailCirclesSpace, tailCirclesSpace), + radius: 4, + ), + ); + canvas.drawPath(path, paint); + } + + void _drawOvalBorder(Size size, Canvas canvas) { + final paint = Paint() + ..color = borderColor + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + + final path = Path(); + path.addOval( + Rect.fromCircle( + center: Offset(4, 3) + Offset(tailCirclesSpace, tailCirclesSpace), + radius: 2, + ), + ); + canvas.drawPath(path, paint); + } + + void _drawOval(Size size, Canvas canvas) { + final paint = Paint() + ..color = color + ..strokeWidth = 1; + + final path = Path(); + path.addOval(Rect.fromCircle( + center: Offset(4, 3) + Offset(tailCirclesSpace, tailCirclesSpace), + radius: 2, + )); + canvas.drawPath(path, paint); + } + + void _drawBorder(Size size, Canvas canvas) { + final paint = Paint() + ..color = borderColor + ..strokeWidth = 1 + ..style = PaintingStyle.stroke; + + final dy = -2.2; + final startAngle = 1.1; + final sweepAngle = 1.2; + final path = Path(); + path.addArc( + Rect.fromCircle( + center: Offset(1, dy), + radius: 4, + ), + -pi * startAngle, + -pi / sweepAngle, + ); + canvas.drawPath(path, paint); + } + + void _drawArc(Size size, Canvas canvas) { + final paint = Paint() + ..color = color + ..strokeWidth = 1; + + final dy = -2.2; + final startAngle = 1; + final sweepAngle = 1.3; + final path = Path(); + path.addArc( + Rect.fromCircle( + center: Offset(1, dy), + radius: 4, + ), + -pi * startAngle, + -pi * sweepAngle, + ); + canvas.drawPath(path, paint); + } + + void _drawMask(Size size, Canvas canvas) { + final paint = Paint() + ..color = maskColor + ..strokeWidth = 1 + ..style = PaintingStyle.fill; + + final dy = -2.2; + final startAngle = 1.1; + final sweepAngle = 1.2; + final path = Path(); + path.addArc( + Rect.fromCircle( + center: Offset(1, dy), + radius: 6, + ), + -pi * startAngle, + -pi / sweepAngle, + ); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/packages/stream_chat_flutter/lib/src/reaction_icon.dart b/packages/stream_chat_flutter/lib/src/reaction_icon.dart new file mode 100644 index 00000000..99b93328 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reaction_icon.dart @@ -0,0 +1,9 @@ +class ReactionIcon { + final String type; + final String assetName; + + ReactionIcon({ + this.type, + this.assetName, + }); +} diff --git a/packages/stream_chat_flutter/lib/src/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reaction_picker.dart new file mode 100644 index 00000000..b0aa51ce --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reaction_picker.dart @@ -0,0 +1,188 @@ +import 'dart:math'; + +import 'package:ezanimation/ezanimation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; + +import '../stream_chat_flutter.dart'; +import 'extension.dart'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker_paint.png) +/// +/// It shows a reaction picker +/// +/// Usually you don't use this widget as it's one of the default widgets used by [MessageWidget.onMessageActions]. + +class ReactionPicker extends StatefulWidget { + const ReactionPicker({ + Key key, + @required this.message, + @required this.messageTheme, + }) : super(key: key); + + final Message message; + final MessageTheme messageTheme; + + @override + _ReactionPickerState createState() => _ReactionPickerState(); +} + +class _ReactionPickerState extends State + with TickerProviderStateMixin { + List animations = []; + + @override + Widget build(BuildContext context) { + final reactionIcons = StreamChatTheme.of(context).reactionIcons; + + if (animations.isEmpty && reactionIcons.isNotEmpty) { + reactionIcons.forEach((element) { + animations.add( + EzAnimation.tween( + Tween(begin: 0.0, end: 1.0), + Duration(milliseconds: 500), + curve: Curves.easeInOutBack, + ), + ); + }); + + triggerAnimations(); + } + + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + curve: Curves.easeInOutBack, + duration: Duration(milliseconds: 500), + builder: (context, val, wid) { + return Transform.scale( + scale: val, + child: Material( + borderRadius: BorderRadius.circular(24), + color: StreamChatTheme.of(context).colorTheme.white, + clipBehavior: Clip.hardEdge, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + vertical: 8.0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: reactionIcons + .map((reactionIcon) { + final ownReactionIndex = widget.message.ownReactions + ?.indexWhere((reaction) => + reaction.type == reactionIcon.type) ?? + -1; + var index = reactionIcons.indexOf(reactionIcon); + + return ConstrainedBox( + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + child: RawMaterialButton( + elevation: 0, + padding: const EdgeInsets.all(0), + clipBehavior: Clip.none, + shape: ContinuousRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + child: AnimatedBuilder( + animation: animations[index], + builder: (context, val) { + return Transform.scale( + alignment: Alignment.center, + scale: animations[index].value, + child: StreamSvgIcon( + assetName: reactionIcon.assetName, + height: max( + 0, + animations[index].value * 24.0, + ), + width: max( + 0, + animations[index].value * 24.0, + ), + color: ownReactionIndex != -1 + ? StreamChatTheme.of(context) + .colorTheme + .accentBlue + : Theme.of(context) + .iconTheme + .color + .withOpacity(.5), + ), + ); + }), + onPressed: () { + if (ownReactionIndex != -1) { + removeReaction( + context, + widget.message.ownReactions[ownReactionIndex], + ); + } else { + sendReaction( + context, + reactionIcon.type, + ); + } + }, + ), + ); + }) + .insertBetween(SizedBox( + width: 16, + )) + .toList(), + ), + ), + ), + ); + }); + } + + void triggerAnimations() async { + for (var a in animations) { + a.start(); + await Future.delayed(Duration(milliseconds: 100)); + } + } + + void pop() async { + for (var a in animations) { + a.stop(); + } + Navigator.of(context).pop(); + } + + /// Add a reaction to the message + void sendReaction(BuildContext context, String reactionType) { + StreamChannel.of(context).channel.sendReaction( + widget.message, + reactionType, + enforceUnique: true, + ); + pop(); + } + + /// Remove a reaction from the message + void removeReaction(BuildContext context, Reaction reaction) { + StreamChannel.of(context).channel.deleteReaction(widget.message, reaction); + pop(); + } + + @override + void dispose() { + for (var a in animations) { + a?.dispose(); + } + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/sending_indicator.dart new file mode 100644 index 00000000..3fca2fa9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/sending_indicator.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Used to show the sending status of the message +class SendingIndicator extends StatelessWidget { + final Message message; + final bool isMessageRead; + final double size; + + const SendingIndicator({ + Key key, + this.message, + this.isMessageRead = false, + this.size = 12, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (isMessageRead) { + return StreamSvgIcon.checkAll( + size: size, + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ); + } + if (message.status == MessageSendingStatus.sent || message.status == null) { + return StreamSvgIcon.check( + size: size, + color: IconTheme.of(context).color.withOpacity(0.5), + ); + } + if (message.status == MessageSendingStatus.sending || + message.status == MessageSendingStatus.updating) { + return Icon( + Icons.access_time, + size: size, + ); + } + return SizedBox(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart new file mode 100644 index 00000000..f093a81a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_app_badger/flutter_app_badger.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Widget used to provide information about the chat to the widget tree +/// +/// class MyApp extends StatelessWidget { +/// final StreamChatClient client; +/// +/// MyApp(this.client); +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// home: Container( +/// child: StreamChat( +/// client: client, +/// child: ChannelListPage(), +/// ), +/// ), +/// ); +/// } +/// } +/// +/// Use [StreamChat.of] to get the current [StreamChatState] instance. +class StreamChat extends StatefulWidget { + final StreamChatClient client; + final Widget child; + final StreamChatThemeData streamChatThemeData; + + /// The amount of time that will pass before disconnecting the client in the background + final Duration backgroundKeepAlive; + + /// Handler called whenever the [client] receives a new [Event] while the app + /// is in background. Can be used to display various notifications depending + /// upon the [Event.type] + final EventHandler onBackgroundEventReceived; + + StreamChat({ + Key key, + @required this.client, + @required this.child, + this.streamChatThemeData, + this.onBackgroundEventReceived, + this.backgroundKeepAlive = const Duration(minutes: 1), + }) : super( + key: key, + ); + + @override + StreamChatState createState() => StreamChatState(); + + /// Use this method to get the current [StreamChatState] instance + static StreamChatState of(BuildContext context) { + StreamChatState streamChatState; + + streamChatState = context.findAncestorStateOfType(); + + if (streamChatState == null) { + throw Exception( + 'You must have a StreamChat widget at the top of your widget tree'); + } + + return streamChatState; + } +} + +/// The current state of the StreamChat widget +class StreamChatState extends State { + StreamChatClient get client => widget.client; + + @override + Widget build(BuildContext context) { + final theme = _getTheme(context, widget.streamChatThemeData); + return Portal( + child: StreamChatTheme( + data: theme, + child: Builder( + builder: (context) { + final materialTheme = Theme.of(context); + final streamTheme = StreamChatTheme.of(context); + return Theme( + data: materialTheme.copyWith( + primaryIconTheme: streamTheme.primaryIconTheme, + accentColor: streamTheme.colorTheme.accentBlue, + scaffoldBackgroundColor: streamTheme.colorTheme.white, + ), + child: StreamChatCore( + client: client, + child: widget.child, + onBackgroundEventReceived: widget.onBackgroundEventReceived, + backgroundKeepAlive: widget.backgroundKeepAlive, + ), + ); + }, + ), + ), + ); + } + + StreamChatThemeData _getTheme( + BuildContext context, + StreamChatThemeData themeData, + ) { + final defaultTheme = StreamChatThemeData.getDefaultTheme(Theme.of(context)); + return defaultTheme.merge(themeData) ?? themeData; + } + + /// The current user + User get user => widget.client.state.user; + + /// The current user as a stream + Stream get userStream => widget.client.state.userStream; + + @override + void initState() { + super.initState(); + client.state?.totalUnreadCountStream?.listen((count) { + if (count > 0) { + FlutterAppBadger.updateBadgeCount(count); + } else { + FlutterAppBadger.removeBadge(); + } + }); + } +} diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart new file mode 100644 index 00000000..be69e366 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -0,0 +1,891 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/channel_header.dart'; +import 'package:stream_chat_flutter/src/channel_preview.dart'; +import 'package:stream_chat_flutter/src/message_input.dart'; +import 'package:stream_chat_flutter/src/reaction_icon.dart'; +import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Inherited widget providing the [StreamChatThemeData] to the widget tree +class StreamChatTheme extends InheritedWidget { + final StreamChatThemeData data; + + StreamChatTheme({ + Key key, + @required this.data, + Widget child, + }) : super( + key: key, + child: child, + ); + + @override + bool updateShouldNotify(StreamChatTheme old) { + return data != old.data; + } + + /// Use this method to get the current [StreamChatThemeData] instance + static StreamChatThemeData of(BuildContext context) { + final streamChatTheme = + context.dependOnInheritedWidgetOfExactType(); + + if (streamChatTheme == null) { + throw Exception( + 'You must have a StreamChatTheme widget at the top of your widget tree', + ); + } + + return streamChatTheme.data; + } +} + +/// Theme data +class StreamChatThemeData { + /// The text themes used in the widgets + final TextTheme textTheme; + + /// The text themes used in the widgets + final ColorTheme colorTheme; + + /// Theme of the [ChannelPreview] + final ChannelPreviewTheme channelPreviewTheme; + + /// Theme of the chat widgets dedicated to a channel + final ChannelTheme channelTheme; + + /// Theme of the current user messages + final MessageTheme ownMessageTheme; + + /// Theme of other users messages + final MessageTheme otherMessageTheme; + + /// The widget that will be built when the channel image is unavailable + final Widget Function(BuildContext, Channel) defaultChannelImage; + + /// The widget that will be built when the user image is unavailable + final Widget Function(BuildContext, User) defaultUserImage; + + /// Primary icon theme + final IconThemeData primaryIconTheme; + + /// Assets used for rendering reactions + final List reactionIcons; + + /// Create a theme from scratch + const StreamChatThemeData({ + this.textTheme, + this.colorTheme, + this.channelPreviewTheme, + this.channelTheme, + this.otherMessageTheme, + this.ownMessageTheme, + this.defaultChannelImage, + this.defaultUserImage, + this.primaryIconTheme, + this.reactionIcons, + }); + + /// Create a theme from a Material [Theme] + factory StreamChatThemeData.fromTheme(ThemeData theme) { + final defaultTheme = getDefaultTheme(theme); + final customizedTheme = StreamChatThemeData.fromColorAndTextTheme( + defaultTheme.colorTheme.copyWith( + accentBlue: theme.accentColor, + ), + defaultTheme.textTheme, + ).copyWith( + // primaryIconTheme: theme.primaryIconTheme, + ); + return defaultTheme.merge(customizedTheme) ?? customizedTheme; + } + + /// Creates a copy of [StreamChatThemeData] with specified attributes overridden. + StreamChatThemeData copyWith({ + TextTheme textTheme, + ColorTheme colorTheme, + ChannelPreviewTheme channelPreviewTheme, + ChannelTheme channelTheme, + MessageTheme ownMessageTheme, + MessageTheme otherMessageTheme, + Widget Function(BuildContext, Channel) defaultChannelImage, + Widget Function(BuildContext, User) defaultUserImage, + IconThemeData primaryIconTheme, + List reactionIcons, + }) => + StreamChatThemeData( + textTheme: textTheme ?? this.textTheme, + colorTheme: colorTheme ?? this.colorTheme, + primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme, + defaultChannelImage: defaultChannelImage ?? this.defaultChannelImage, + defaultUserImage: defaultUserImage ?? this.defaultUserImage, + channelPreviewTheme: channelPreviewTheme ?? this.channelPreviewTheme, + channelTheme: channelTheme ?? this.channelTheme, + ownMessageTheme: ownMessageTheme ?? this.ownMessageTheme, + otherMessageTheme: otherMessageTheme ?? this.otherMessageTheme, + reactionIcons: reactionIcons ?? this.reactionIcons, + ); + + StreamChatThemeData merge(StreamChatThemeData other) { + if (other == null) return this; + return copyWith( + textTheme: textTheme?.merge(other.textTheme) ?? other.textTheme, + colorTheme: colorTheme?.merge(other.colorTheme) ?? other.colorTheme, + primaryIconTheme: other.primaryIconTheme, + defaultChannelImage: other.defaultChannelImage, + defaultUserImage: other.defaultUserImage, + channelPreviewTheme: + channelPreviewTheme?.merge(other.channelPreviewTheme) ?? + other.channelPreviewTheme, + channelTheme: + channelTheme?.merge(other.channelTheme) ?? other.channelTheme, + ownMessageTheme: ownMessageTheme?.merge(other.ownMessageTheme) ?? + other.ownMessageTheme, + otherMessageTheme: otherMessageTheme?.merge(other.otherMessageTheme) ?? + other.otherMessageTheme, + reactionIcons: other.reactionIcons, + ); + } + + static StreamChatThemeData fromColorAndTextTheme( + ColorTheme colorTheme, + TextTheme textTheme, + ) { + final accentColor = colorTheme.accentBlue; + return StreamChatThemeData( + textTheme: textTheme, + colorTheme: colorTheme, + primaryIconTheme: IconThemeData(color: colorTheme.black.withOpacity(.5)), + defaultChannelImage: (context, channel) => SizedBox(), + defaultUserImage: (context, user) => Center( + child: CachedNetworkImage( + filterQuality: FilterQuality.high, + imageUrl: getRandomPicUrl(user), + fit: BoxFit.cover, + ), + ), + channelPreviewTheme: ChannelPreviewTheme( + unreadCounterColor: colorTheme.accentRed, + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + title: textTheme.bodyBold, + subtitle: textTheme.footnote.copyWith( + color: Color(0xff7A7A7A), + ), + lastMessageAt: textTheme.footnote.copyWith( + color: colorTheme.black.withOpacity(.5), + ), + indicatorIconSize: 16.0), + channelTheme: ChannelTheme( + messageInputButtonIconTheme: IconThemeData( + color: accentColor, + ), + channelHeaderTheme: ChannelHeaderTheme( + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + color: colorTheme.white, + title: TextStyle( + fontSize: 14, + color: colorTheme.black, + ), + lastMessageAt: TextStyle( + fontSize: 11, + color: colorTheme.black.withOpacity(.5), + ), + ), + inputBackground: colorTheme.white.withAlpha(12), + ), + ownMessageTheme: MessageTheme( + messageText: textTheme.body, + createdAt: textTheme.footnote.copyWith(color: colorTheme.grey), + replies: textTheme.footnoteBold.copyWith(color: accentColor), + messageBackgroundColor: colorTheme.greyGainsboro, + reactionsBackgroundColor: colorTheme.white, + reactionsBorderColor: colorTheme.greyWhisper, + reactionsMaskColor: colorTheme.whiteSnow, + messageBorderColor: colorTheme.greyGainsboro, + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 32, + width: 32, + ), + ), + messageLinks: TextStyle( + color: accentColor, + ), + ), + otherMessageTheme: MessageTheme( + reactionsBackgroundColor: colorTheme.greyGainsboro, + reactionsBorderColor: colorTheme.white, + reactionsMaskColor: colorTheme.whiteSnow, + messageText: textTheme.body, + createdAt: textTheme.footnote.copyWith(color: colorTheme.grey), + replies: textTheme.footnoteBold.copyWith(color: accentColor), + messageLinks: TextStyle( + color: accentColor, + ), + messageBackgroundColor: colorTheme.white, + messageBorderColor: colorTheme.greyWhisper, + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 32, + width: 32, + ), + ), + ), + reactionIcons: [ + ReactionIcon( + type: 'love', + assetName: 'Icon_love_reaction.svg', + ), + ReactionIcon( + type: 'like', + assetName: 'Icon_thumbs_up_reaction.svg', + ), + ReactionIcon( + type: 'sad', + assetName: 'Icon_thumbs_down_reaction.svg', + ), + ReactionIcon( + type: 'haha', + assetName: 'Icon_LOL_reaction.svg', + ), + ReactionIcon( + type: 'wow', + assetName: 'Icon_wut_reaction.svg', + ), + ], + ); + } + + /// Get the default Stream Chat theme + static StreamChatThemeData getDefaultTheme(ThemeData theme) { + final isDark = theme.brightness == Brightness.dark; + final textTheme = isDark ? TextTheme.dark() : TextTheme.light(); + final colorTheme = isDark ? ColorTheme.dark() : ColorTheme.light(); + return fromColorAndTextTheme( + colorTheme, + textTheme, + ); + } +} + +enum TextThemeType { + light, + dark, +} + +class TextTheme { + final TextStyle title; + final TextStyle headlineBold; + final TextStyle headline; + final TextStyle bodyBold; + final TextStyle body; + final TextStyle footnoteBold; + final TextStyle footnote; + final TextStyle captionBold; + + TextTheme.light({ + this.title = const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + this.headlineBold = const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + this.headline = const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + this.bodyBold = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + this.body = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + this.footnoteBold = const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + this.footnote = const TextStyle( + fontSize: 12, + color: Colors.black, + ), + this.captionBold = const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.black, + ), + }); + + TextTheme.dark({ + this.title = const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + this.headlineBold = const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + this.headline = const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + this.bodyBold = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + this.body = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + this.footnoteBold = const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + this.footnote = const TextStyle( + fontSize: 12, + color: Colors.white, + ), + this.captionBold = const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + }); + + TextTheme copyWith({ + TextThemeType type = TextThemeType.light, + TextStyle body, + TextStyle title, + TextStyle headlineBold, + TextStyle headline, + TextStyle bodyBold, + TextStyle footnoteBold, + TextStyle footnote, + TextStyle captionBold, + }) { + return type == TextThemeType.light + ? TextTheme.light( + body: body ?? this.body, + title: title ?? this.title, + headlineBold: headlineBold ?? this.headlineBold, + headline: headline ?? this.headline, + bodyBold: bodyBold ?? this.bodyBold, + footnoteBold: footnoteBold ?? this.footnoteBold, + footnote: footnote ?? this.footnote, + captionBold: captionBold ?? this.captionBold, + ) + : TextTheme.dark( + body: body ?? this.body, + title: title ?? this.title, + headlineBold: headlineBold ?? this.headlineBold, + headline: headline ?? this.headline, + bodyBold: bodyBold ?? this.bodyBold, + footnoteBold: footnoteBold ?? this.footnoteBold, + footnote: footnote ?? this.footnote, + captionBold: captionBold ?? this.captionBold, + ); + } + + TextTheme merge(TextTheme other) { + if (other == null) return this; + return copyWith( + body: body?.merge(other.body) ?? other.body, + title: title?.merge(other.title) ?? other.title, + headlineBold: + headlineBold?.merge(other.headlineBold) ?? other.headlineBold, + headline: headline?.merge(other.headline) ?? other.headline, + bodyBold: bodyBold?.merge(other.bodyBold) ?? other.bodyBold, + footnoteBold: + footnoteBold?.merge(other.footnoteBold) ?? other.footnoteBold, + footnote: footnote?.merge(other.footnote) ?? other.footnote, + captionBold: captionBold?.merge(other.captionBold) ?? other.captionBold, + ); + } +} + +enum ColorThemeType { + light, + dark, +} + +class ColorTheme { + final Color black; + final Color grey; + final Color greyGainsboro; + final Color greyWhisper; + final Color whiteSmoke; + final Color whiteSnow; + final Color white; + final Color blueAlice; + final Color accentBlue; + final Color accentRed; + final Color accentGreen; + final Effect borderTop; + final Effect borderBottom; + final Effect shadowIconButton; + final Effect modalShadow; + final Color highlight; + final Color overlay; + final Color overlayDark; + final Gradient bgGradient; + + ColorTheme.light({ + this.black = const Color(0xff000000), + this.grey = const Color(0xff7a7a7a), + this.greyGainsboro = const Color(0xffdbdbdb), + this.greyWhisper = const Color(0xffecebeb), + this.whiteSmoke = const Color(0xfff2f2f2), + this.whiteSnow = const Color(0xfffcfcfc), + this.white = const Color(0xffffffff), + this.blueAlice = const Color(0xffe9f2ff), + this.accentBlue = const Color(0xff005FFF), + this.accentRed = const Color(0xffFF3842), + this.accentGreen = const Color(0xff20E070), + this.highlight = const Color(0xfffbf4dd), + this.overlay = const Color.fromRGBO(0, 0, 0, 0.2), + this.overlayDark = const Color.fromRGBO(0, 0, 0, 0.6), + this.bgGradient = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xfff7f7f7), Color(0xfffcfcfc)], + stops: [0, 1], + ), + this.borderTop = const Effect( + sigmaX: 0, + sigmaY: -1, + color: Color(0xff000000), + blur: 0.0, + alpha: 0.08), + this.borderBottom = const Effect( + sigmaX: 0, sigmaY: 1, color: Color(0xff000000), blur: 0.0, alpha: 0.08), + this.shadowIconButton = const Effect( + sigmaX: 0, sigmaY: 2, color: Color(0xff000000), alpha: 0.5, blur: 4.0), + this.modalShadow = const Effect( + sigmaX: 0, sigmaY: 0, color: Color(0xff000000), alpha: 1, blur: 8.0), + }); + + ColorTheme.dark({ + this.black = const Color(0xffffffff), + this.grey = const Color(0xff7a7a7a), + this.greyGainsboro = const Color(0xff2d2f2f), + this.greyWhisper = const Color(0xff1c1e22), + this.whiteSmoke = const Color(0xff13151b), + this.whiteSnow = const Color(0xff070A0D), + this.white = const Color(0xff101418), + this.blueAlice = const Color(0xff00193D), + this.accentBlue = const Color(0xff005FFF), + this.accentRed = const Color(0xffFF3742), + this.accentGreen = const Color(0xff20E070), + this.borderTop = const Effect( + sigmaX: 0, sigmaY: -1, color: Color(0xff141924), blur: 0.0), + this.borderBottom = const Effect( + sigmaX: 0, sigmaY: 1, color: Color(0xff141924), blur: 0.0, alpha: 1.0), + this.shadowIconButton = const Effect( + sigmaX: 0, sigmaY: 2, color: Color(0xff000000), alpha: 0.5, blur: 4.0), + this.modalShadow = const Effect( + sigmaX: 0, sigmaY: 0, color: Color(0xff000000), alpha: 1, blur: 8.0), + this.highlight = const Color(0xff302d22), + this.overlay = const Color.fromRGBO(0, 0, 0, 0.4), + this.overlayDark = const Color.fromRGBO(255, 255, 255, 0.6), + this.bgGradient = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xff101214), + Color(0xff070a0d), + ], + stops: [0, 1], + ), + }); + + ColorTheme copyWith({ + ColorThemeType type = ColorThemeType.light, + Color black, + Color grey, + Color greyGainsboro, + Color greyWhisper, + Color whiteSmoke, + Color whiteSnow, + Color white, + Color blueAlice, + Color accentBlue, + Color accentRed, + Color accentGreen, + Effect borderTop, + Effect borderBottom, + Effect shadowIconButton, + Effect modalShadow, + Color highlight, + Color overlay, + Color overlayDark, + Gradient bgGradient, + }) { + return type == ColorThemeType.light + ? ColorTheme.light( + black: black ?? this.black, + grey: grey ?? this.grey, + greyGainsboro: greyGainsboro ?? this.greyGainsboro, + greyWhisper: greyWhisper ?? this.greyWhisper, + whiteSmoke: whiteSmoke ?? this.whiteSmoke, + whiteSnow: whiteSnow ?? this.whiteSnow, + white: white ?? this.white, + blueAlice: blueAlice ?? this.blueAlice, + accentBlue: accentBlue ?? this.accentBlue, + accentRed: accentRed ?? this.accentRed, + accentGreen: accentGreen ?? this.accentGreen, + borderTop: borderTop ?? this.borderTop, + borderBottom: borderBottom ?? this.borderBottom, + shadowIconButton: shadowIconButton ?? this.shadowIconButton, + modalShadow: modalShadow ?? this.modalShadow, + highlight: highlight ?? this.highlight, + overlay: overlay ?? this.overlay, + overlayDark: overlayDark ?? this.overlayDark, + bgGradient: bgGradient ?? this.bgGradient, + ) + : ColorTheme.dark( + black: black ?? this.black, + grey: grey ?? this.grey, + greyGainsboro: greyGainsboro ?? this.greyGainsboro, + greyWhisper: greyWhisper ?? this.greyWhisper, + whiteSmoke: whiteSmoke ?? this.whiteSmoke, + whiteSnow: whiteSnow ?? this.whiteSnow, + white: white ?? this.white, + blueAlice: blueAlice ?? this.blueAlice, + accentBlue: accentBlue ?? this.accentBlue, + accentRed: accentRed ?? this.accentRed, + accentGreen: accentGreen ?? this.accentGreen, + borderTop: borderTop ?? this.borderTop, + borderBottom: borderBottom ?? this.borderBottom, + shadowIconButton: shadowIconButton ?? this.shadowIconButton, + modalShadow: modalShadow ?? this.modalShadow, + highlight: highlight ?? this.highlight, + overlay: overlay ?? this.overlay, + overlayDark: overlayDark ?? this.overlayDark, + bgGradient: bgGradient ?? this.bgGradient, + ); + } + + ColorTheme merge(ColorTheme other) { + if (other == null) return this; + return copyWith( + black: other.black, + grey: other.grey, + greyGainsboro: other.greyGainsboro, + greyWhisper: other.greyWhisper, + whiteSmoke: other.whiteSmoke, + whiteSnow: other.whiteSnow, + white: other.white, + blueAlice: other.blueAlice, + accentBlue: other.accentBlue, + accentRed: other.accentRed, + accentGreen: other.accentGreen, + highlight: other.highlight, + overlay: other.overlay, + overlayDark: other.overlayDark, + bgGradient: other.bgGradient, + borderTop: other.borderTop, + borderBottom: other.borderBottom, + shadowIconButton: other.shadowIconButton, + modalShadow: other.modalShadow, + ); + } +} + +/// Channel theme data +class ChannelTheme { + /// Theme of the [ChannelHeader] widget + final ChannelHeaderTheme channelHeaderTheme; + + /// IconTheme of the send button in [MessageInput] + final IconThemeData messageInputButtonIconTheme; + + /// Theme of the send button in [MessageInput] + final ButtonThemeData messageInputButtonTheme; + + /// Background color of [MessageInput] + final Color inputBackground; + + ChannelTheme({ + this.channelHeaderTheme, + this.messageInputButtonIconTheme, + this.messageInputButtonTheme, + this.inputBackground, + }); + + /// Creates a copy of [ChannelTheme] with specified attributes overridden. + ChannelTheme copyWith({ + ChannelHeaderTheme channelHeaderTheme, + IconThemeData messageInputButtonIconTheme, + ButtonThemeData messageInputButtonTheme, + Color inputBackground, + }) => + ChannelTheme( + channelHeaderTheme: channelHeaderTheme ?? this.channelHeaderTheme, + messageInputButtonIconTheme: + messageInputButtonIconTheme ?? this.messageInputButtonIconTheme, + messageInputButtonTheme: + messageInputButtonTheme ?? this.messageInputButtonTheme, + inputBackground: inputBackground ?? this.inputBackground, + ); + + ChannelTheme merge(ChannelTheme other) { + if (other == null) return this; + return copyWith( + channelHeaderTheme: channelHeaderTheme?.merge(other.channelHeaderTheme) ?? + other.channelHeaderTheme, + messageInputButtonIconTheme: messageInputButtonIconTheme + ?.merge(other.messageInputButtonIconTheme) ?? + other.messageInputButtonIconTheme, + messageInputButtonTheme: other.messageInputButtonTheme, + inputBackground: other.inputBackground, + ); + } +} + +class AvatarTheme { + final BoxConstraints constraints; + final BorderRadius borderRadius; + + AvatarTheme({ + this.constraints, + this.borderRadius, + }); + + AvatarTheme copyWith({ + BoxConstraints constraints, + BorderRadius borderRadius, + }) => + AvatarTheme( + constraints: constraints ?? this.constraints, + borderRadius: borderRadius ?? this.borderRadius, + ); + + AvatarTheme merge(AvatarTheme other) { + if (other == null) return this; + return copyWith( + constraints: other.constraints, + borderRadius: other.borderRadius, + ); + } +} + +class MessageTheme { + final TextStyle messageText; + final TextStyle messageAuthor; + final TextStyle messageLinks; + final TextStyle createdAt; + final TextStyle replies; + final Color messageBackgroundColor; + final Color messageBorderColor; + final Color reactionsBackgroundColor; + final Color reactionsBorderColor; + final Color reactionsMaskColor; + final AvatarTheme avatarTheme; + + const MessageTheme({ + this.replies, + this.messageText, + this.messageAuthor, + this.messageLinks, + this.messageBackgroundColor, + this.messageBorderColor, + this.reactionsBackgroundColor, + this.reactionsBorderColor, + this.reactionsMaskColor, + this.avatarTheme, + this.createdAt, + }); + + MessageTheme copyWith({ + TextStyle messageText, + TextStyle messageAuthor, + TextStyle messageLinks, + TextStyle createdAt, + TextStyle replies, + Color messageBackgroundColor, + Color messageBorderColor, + AvatarTheme avatarTheme, + Color reactionsBackgroundColor, + Color reactionsBorderColor, + Color reactionsMaskColor, + }) => + MessageTheme( + messageText: messageText ?? this.messageText, + messageAuthor: messageAuthor ?? this.messageAuthor, + messageLinks: messageLinks ?? this.messageLinks, + createdAt: createdAt ?? this.createdAt, + messageBackgroundColor: + messageBackgroundColor ?? this.messageBackgroundColor, + messageBorderColor: messageBorderColor ?? this.messageBorderColor, + avatarTheme: avatarTheme ?? this.avatarTheme, + replies: replies ?? this.replies, + reactionsBackgroundColor: + reactionsBackgroundColor ?? this.reactionsBackgroundColor, + reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor, + reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor, + ); + + MessageTheme merge(MessageTheme other) { + if (other == null) return this; + return copyWith( + messageText: messageText?.merge(other.messageText) ?? other.messageText, + messageAuthor: + messageAuthor?.merge(other.messageAuthor) ?? other.messageAuthor, + messageLinks: + messageLinks?.merge(other.messageLinks) ?? other.messageLinks, + createdAt: createdAt?.merge(other.createdAt) ?? other.createdAt, + replies: replies?.merge(other.replies) ?? other.replies, + messageBackgroundColor: other.messageBackgroundColor, + messageBorderColor: other.messageBorderColor, + avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, + reactionsBackgroundColor: other.reactionsBackgroundColor, + reactionsBorderColor: other.reactionsBorderColor, + reactionsMaskColor: other.reactionsMaskColor, + ); + } +} + +class ChannelPreviewTheme { + final TextStyle title; + final TextStyle subtitle; + final TextStyle lastMessageAt; + final AvatarTheme avatarTheme; + final Color unreadCounterColor; + final double indicatorIconSize; + + const ChannelPreviewTheme({ + this.title, + this.subtitle, + this.lastMessageAt, + this.avatarTheme, + this.unreadCounterColor, + this.indicatorIconSize, + }); + + ChannelPreviewTheme copyWith({ + TextStyle title, + TextStyle subtitle, + TextStyle lastMessageAt, + AvatarTheme avatarTheme, + Color unreadCounterColor, + double indicatorIconSize, + }) => + ChannelPreviewTheme( + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + avatarTheme: avatarTheme ?? this.avatarTheme, + unreadCounterColor: unreadCounterColor ?? this.unreadCounterColor, + indicatorIconSize: indicatorIconSize ?? this.indicatorIconSize, + ); + + ChannelPreviewTheme merge(ChannelPreviewTheme other) { + if (other == null) return this; + return copyWith( + title: title?.merge(other.title) ?? other.title, + subtitle: subtitle?.merge(other.subtitle) ?? other.subtitle, + lastMessageAt: + lastMessageAt?.merge(other.lastMessageAt) ?? other.lastMessageAt, + avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, + unreadCounterColor: other.unreadCounterColor, + ); + } +} + +class ChannelHeaderTheme { + final TextStyle title; + final TextStyle lastMessageAt; + final AvatarTheme avatarTheme; + final Color color; + + const ChannelHeaderTheme({ + this.title, + this.lastMessageAt, + this.avatarTheme, + this.color, + }); + + ChannelHeaderTheme copyWith({ + TextStyle title, + TextStyle lastMessageAt, + AvatarTheme avatarTheme, + Color color, + }) => + ChannelHeaderTheme( + title: title ?? this.title, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + avatarTheme: avatarTheme ?? this.avatarTheme, + color: color ?? this.color, + ); + + ChannelHeaderTheme merge(ChannelHeaderTheme other) { + if (other == null) return this; + return copyWith( + title: title?.merge(other.title) ?? other.title, + lastMessageAt: + lastMessageAt?.merge(other.lastMessageAt) ?? other.lastMessageAt, + avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, + color: other.color, + ); + } +} + +class Effect { + final double sigmaX; + final double sigmaY; + final Color color; + final double alpha; + final double blur; + + const Effect({ + this.sigmaX, + this.sigmaY, + this.color, + this.alpha, + this.blur, + }); + + Effect copyWith({ + double sigmaX, + double sigmaY, + Color color, + double alpha, + double blur, + }) => + Effect( + sigmaX: sigmaX ?? this.sigmaX, + sigmaY: sigmaY ?? this.sigmaY, + color: color ?? this.color, + alpha: color ?? this.alpha, + blur: blur ?? this.blur, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart b/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart new file mode 100644 index 00000000..bf3e8e97 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/stream_neumorphic_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class StreamNeumorphicButton extends StatelessWidget { + final Widget child; + final Color backgroundColor; + + const StreamNeumorphicButton({ + Key key, + @required this.child, + this.backgroundColor = Colors.white, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + child: child, + margin: EdgeInsets.all(8.0), + height: 40, + width: 40, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.grey[700], + offset: Offset(0, 1.0), + blurRadius: 0.5, + spreadRadius: 0, + ), + BoxShadow( + color: Colors.white, + offset: Offset.zero, + blurRadius: 0.5, + spreadRadius: 0, + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart new file mode 100644 index 00000000..b0b3d0bb --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -0,0 +1,904 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class StreamSvgIcon extends StatelessWidget { + final String assetName; + final double width; + final double height; + final Color color; + + const StreamSvgIcon({ + this.assetName, + this.color, + this.width = 24, + this.height = 24, + }); + + @override + Widget build(BuildContext context) { + final key = Key('StreamSvgIcon-$assetName'); + return kIsWeb + ? Image.network( + 'packages/stream_chat_flutter/svgs/$assetName', + width: width, + height: height, + key: key, + color: color, + alignment: Alignment.center, + ) + : SvgPicture.asset( + 'lib/svgs/$assetName', + package: 'stream_chat_flutter', + key: key, + width: width, + height: height, + color: color, + alignment: Alignment.center, + ); + } + + factory StreamSvgIcon.settings({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'settings.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.down({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_down.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.attach({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_attach.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.smile({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_smile.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.mentions({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'mentions.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.record({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_record.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.camera({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_camera.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.files({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'files.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.pictures({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'pictures.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.left({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_left.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.user({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_user.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.userAdd({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_User_add.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.check({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_check.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.checkAll({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_check_all.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.checkSend({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_check_send.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.penWrite({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_pen-write.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.contacts({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_contacts.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.close({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_close.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.search({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_search.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.right({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_right.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.mute({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_mute.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.userRemove({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_User_deselect.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.lightning({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_lightning-command runner.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.emptyCircleLeft({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_empty_circle_left.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.message({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_message.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.thread({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_Thread_Reply.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.reply({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_curve_line_left_up_big.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.edit({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_edit.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.download({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_download.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.cloudDownload({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_cloud_download.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.copy({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_copy.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.delete({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_delete.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.eye({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_eye-off.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.arrowRight({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_arrow_right.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.closeSmall({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_close_sml.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconCurveLineLeftUp({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_curve_line_left_up.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconMoon({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'icon_moon.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconShare({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'icon_SHARE.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconGrid({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_grid.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconSendMessage({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_send_message.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconMenuPoint({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_menu_point_v.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconSave({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_save.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.shareArrow({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'share_arrow.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetype7z({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_7z.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeCsv({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_CSV.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeDoc({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_DOC.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeDocx({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_DOCX.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeGeneric({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_Generic.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeHtml({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_html.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeMd({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_MD.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeOdt({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_ODT.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypePdf({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_PDF.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypePpt({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_PPT.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypePptx({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_PPTX.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeRar({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_RAR.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeRtf({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_RTF.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeTar({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_TAR.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeTxt({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_TXT.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeXls({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_XLS.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeXlsx({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_XLSX.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.filetypeZip({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'filetype_ZIP.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconGroup({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_group.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconNotification({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_notification.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconUserDelete({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_user_delete.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.error({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_error.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.circleUp({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_circle_up.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconUserSettings({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_user_settings.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.giphyIcon({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'giphy_icon.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.imgur({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'imgur.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.volumeUp({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'volume-up.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.flag({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'flag.svg', + color: color, + width: size, + height: size, + ); + } + + factory StreamSvgIcon.iconFlag({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'icon_flag.svg', + color: color, + width: size, + height: size, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/swipeable.dart b/packages/stream_chat_flutter/lib/src/swipeable.dart new file mode 100644 index 00000000..8280bf24 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/swipeable.dart @@ -0,0 +1,163 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'stream_chat_theme.dart'; + +/// +class Swipeable extends StatefulWidget { + final Widget child; + final Widget backgroundIcon; + final VoidCallback onSwipeStart; + final VoidCallback onSwipeCancel; + final VoidCallback onSwipeEnd; + final double threshold; + + /// + const Swipeable({ + @required this.child, + @required this.backgroundIcon, + this.onSwipeStart, + this.onSwipeCancel, + this.onSwipeEnd, + this.threshold = 82.0, + }); + + @override + State createState() => _SwipeableState(); +} + +class _SwipeableState extends State with TickerProviderStateMixin { + double _dragExtent = 0.0; + AnimationController _moveController; + AnimationController _iconMoveController; + Animation _moveAnimation; + Animation _iconTransitionAnimation; + Animation _iconFadeAnimation; + bool _pastThreshold = false; + + final _animationDuration = const Duration(milliseconds: 200); + + @override + void initState() { + super.initState(); + _moveController = + AnimationController(duration: _animationDuration, vsync: this); + _iconMoveController = + AnimationController(duration: _animationDuration, vsync: this); + _moveAnimation = Tween(begin: Offset.zero, end: Offset(1.0, 0.0)) + .animate(_moveController); + _iconTransitionAnimation = + Tween(begin: Offset(-0.1, 0.0), end: Offset(0.4, 0.0)) + .animate(_moveController); + _iconFadeAnimation = + Tween(begin: 0.7, end: 1.0).animate(_iconMoveController); + + final controllerValue = 0.0; + _moveController.animateTo(controllerValue); + _iconMoveController.animateTo(controllerValue); + } + + @override + void dispose() { + _moveController.dispose(); + _iconMoveController.dispose(); + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) { + if (widget.onSwipeStart != null) { + widget.onSwipeStart(); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + final delta = details.primaryDelta; + _dragExtent += delta; + + if (_dragExtent.isNegative) return; + + var movePastThresholdPixels = widget.threshold; + var newPos = _dragExtent.abs() / context.size.width; + + if (_dragExtent.abs() > movePastThresholdPixels) { + // how many "thresholds" past the threshold we are. 1 = the threshold 2 + // = two thresholds. + var n = _dragExtent.abs() / movePastThresholdPixels; + + // Take the number of thresholds past the threshold, and reduce this + // number + var reducedThreshold = math.pow(n, 0.3); + + var adjustedPixelPos = movePastThresholdPixels * reducedThreshold; + newPos = adjustedPixelPos / context.size.width; + + if (_dragExtent > 0 && !_pastThreshold) { + _iconMoveController.value = 1; + _pastThreshold = true; + } + } else { + // Send a cancel event if the user has swiped back underneath the + // threshold + if (_pastThreshold && widget.onSwipeCancel != null) { + widget.onSwipeCancel(); + } + _pastThreshold = false; + } + if (!_pastThreshold || newPos < _moveController.value) { + _iconMoveController.value = newPos; + } + _moveController.value = newPos; + } + + void _handleDragEnd(DragEndDetails details) { + _moveController.animateTo(0.0, duration: _animationDuration); + _iconMoveController.animateTo(0.0, duration: _animationDuration); + _dragExtent = 0.0; + if (_pastThreshold && widget.onSwipeEnd != null) { + widget.onSwipeEnd(); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + behavior: HitTestBehavior.opaque, + child: Stack( + alignment: Alignment.center, + fit: StackFit.passthrough, + children: [ + SlideTransition( + position: _iconTransitionAnimation, + child: Row( + children: [ + FadeTransition( + opacity: _iconFadeAnimation, + child: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + ), + ), + child: widget.backgroundIcon, + ), + ), + ], + ), + ), + SlideTransition( + position: _moveAnimation, + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/system_message.dart b/packages/stream_chat_flutter/lib/src/system_message.dart new file mode 100644 index 00000000..7b4dc243 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/system_message.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// It shows a date divider depending on the date difference +class SystemMessage extends StatelessWidget { + /// This message + final Message message; + + /// The function called when tapping on the message when the message is not failed + final void Function(Message) onMessageTap; + + const SystemMessage({ + Key key, + @required this.message, + this.onMessageTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final divider = Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Divider(), + ), + ); + + final createdAt = Jiffy(message.createdAt.toLocal()); + final now = DateTime.now(); + final hourInfo = createdAt.format('h:mm a'); + + String dayInfo; + if (Jiffy(createdAt).isSame(now, Units.DAY)) { + dayInfo = 'TODAY'; + } else if (Jiffy(createdAt) + .isSame(now.subtract(Duration(days: 1)), Units.DAY)) { + dayInfo = 'YESTERDAY'; + } else if (Jiffy(createdAt).isAfter( + now.subtract(Duration(days: 7)), + Units.DAY, + )) { + dayInfo = createdAt.format('EEEE').toUpperCase(); + } else if (Jiffy(createdAt).isAfter( + Jiffy(now).subtract(years: 1), + Units.DAY, + )) { + dayInfo = createdAt.format('dd/MM').toUpperCase(); + } else { + dayInfo = createdAt.format('dd/MM/yyyy').toUpperCase(); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (onMessageTap != null) { + onMessageTap(message); + } + }, + child: Container( + width: double.infinity, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + divider, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + message.text, + style: TextStyle( + fontSize: 10, + color: Theme.of(context) + .textTheme + .headline6 + .color + .withOpacity(.5), + fontWeight: FontWeight.bold, + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: dayInfo, + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: ' AT'), + TextSpan(text: ' $hourInfo'), + ], + style: TextStyle( + fontWeight: FontWeight.normal, + ), + ), + style: TextStyle( + fontSize: 10, + color: Theme.of(context) + .textTheme + .headline6 + .color + .withOpacity(.5), + ), + ), + ], + ), + ), + divider, + ], + ), + ), + ); + } +} diff --git a/lib/src/thread_header.dart b/packages/stream_chat_flutter/lib/src/thread_header.dart similarity index 62% rename from lib/src/thread_header.dart rename to packages/stream_chat_flutter/lib/src/thread_header.dart index 993a3047..b33b8bc1 100644 --- a/lib/src/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/thread_header.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/back_button.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'back_button.dart'; +import 'channel_name.dart'; /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/thread_header.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/thread_header_paint.png) @@ -76,39 +79,53 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return AppBar( automaticallyImplyLeading: false, + brightness: Theme.of(context).brightness, elevation: 1, + leading: showBackButton + ? StreamBackButton( + cid: StreamChannel.of(context).channel.cid, + onPressed: onBackPressed, + showUnreads: true, + ) + : SizedBox(), backgroundColor: StreamChatTheme.of(context).channelTheme.channelHeaderTheme.color, - actions: [ - Container( - child: showBackButton - ? AspectRatio( - aspectRatio: 1, - child: StreamBackButton( - onPressed: onBackPressed, - icon: Icons.close, - ), - ) - : SizedBox(), - ), - ], - centerTitle: false, - title: Text.rich( - TextSpan( - text: 'Thread', - children: [ - TextSpan( - text: - ' ${parent.replyCount} ${parent.replyCount == 1 ? 'reply' : 'replies'}', - style: StreamChatTheme.of(context) - .channelTheme - .channelHeaderTheme - .lastMessageAt, - ), - ], - ), - style: - StreamChatTheme.of(context).channelTheme.channelHeaderTheme.title, + centerTitle: true, + title: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Thread Reply', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .title, + ), + SizedBox(height: 2), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'with ', + style: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .lastMessageAt, + ), + Flexible( + child: ChannelName( + textStyle: StreamChatTheme.of(context) + .channelTheme + .channelHeaderTheme + .lastMessageAt, + ), + ), + ], + ), + ], ), ); } diff --git a/lib/src/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/typing_indicator.dart similarity index 51% rename from lib/src/typing_indicator.dart rename to packages/stream_chat_flutter/lib/src/typing_indicator.dart index 69a16b98..8a53c3e8 100644 --- a/lib/src/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/typing_indicator.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/stream_channel.dart'; +import 'package:lottie/lottie.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// Widget to show the current list of typing users class TypingIndicator extends StatelessWidget { @@ -8,9 +8,10 @@ class TypingIndicator extends StatelessWidget { const TypingIndicator({ Key key, this.channel, - this.alternativeWidget = const SizedBox(), + this.alternativeWidget, this.style, this.alignment = Alignment.centerLeft, + this.padding = const EdgeInsets.all(0), }) : super(key: key); /// Style of the text widget @@ -22,6 +23,9 @@ class TypingIndicator extends StatelessWidget { /// Widget built when no typings is happening final Widget alternativeWidget; + /// The padding of this widget + final EdgeInsets padding; + final Alignment alignment; @override @@ -35,20 +39,33 @@ class TypingIndicator extends StatelessWidget { return AnimatedSwitcher( duration: Duration(milliseconds: 300), child: snapshot.data?.isNotEmpty == true - ? Align( - key: Key('typings'), - alignment: alignment, - child: Text( - '${snapshot.data.map((u) => u.name).join(',')} ${snapshot.data.length == 1 ? 'is' : 'are'} typing...', - maxLines: 1, - style: style, + ? Padding( + padding: padding, + child: Align( + key: Key('typings'), + alignment: alignment, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Lottie.asset( + 'animations/typing_dots.json', + package: 'stream_chat_flutter', + height: 4, + ), + Text( + ' ${snapshot.data[0].name}${snapshot.data.length == 1 ? '' : ' and ${snapshot.data.length - 1} more'} ${snapshot.data.length == 1 ? 'is' : 'are'} typing', + maxLines: 1, + style: style, + ), + ], + ), ), ) : Align( key: Key('alternative'), alignment: alignment, child: Container( - child: alternativeWidget, + child: alternativeWidget ?? Offstage(), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/unread_indicator.dart new file mode 100644 index 00000000..cf115f3b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/unread_indicator.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class UnreadIndicator extends StatelessWidget { + const UnreadIndicator({ + Key key, + this.cid, + }) : super(key: key); + + /// Channel cid used to retrieve unread count + final String cid; + + @override + Widget build(BuildContext context) { + final client = StreamChat.of(context).client; + return StreamBuilder( + stream: cid != null + ? client.state.channels[cid].state.unreadCountStream + : client.state.totalUnreadCountStream, + initialData: cid != null + ? client.state.channels[cid].state.unreadCount + : client.state.totalUnreadCount, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == 0) { + return SizedBox(); + } + return Material( + borderRadius: BorderRadius.circular(8), + color: StreamChatTheme.of(context) + .channelPreviewTheme + .unreadCounterColor, + child: Padding( + padding: const EdgeInsets.only( + left: 5.0, + right: 5.0, + top: 2, + bottom: 1, + ), + child: Center( + child: Text( + '${snapshot.data}', + style: TextStyle( + fontSize: 11, + color: Colors.white, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/url_attachment.dart b/packages/stream_chat_flutter/lib/src/url_attachment.dart new file mode 100644 index 00000000..b0584bce --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/url_attachment.dart @@ -0,0 +1,104 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class UrlAttachment extends StatelessWidget { + final Attachment urlAttachment; + final String hostDisplayName; + final EdgeInsets textPadding; + + UrlAttachment({ + @required this.urlAttachment, + @required this.hostDisplayName, + @required this.textPadding, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => launchURL( + context, + urlAttachment.ogScrapeUrl, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (urlAttachment.imageUrl != null) + Container( + clipBehavior: Clip.antiAliasWithSaveLayer, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Stack( + children: [ + CachedNetworkImage( + width: double.infinity, + imageUrl: urlAttachment.imageUrl, + fit: BoxFit.cover, + ), + Positioned( + left: 0.0, + bottom: -1, + child: Container( + child: Padding( + padding: const EdgeInsets.only( + top: 8.0, + left: 8.0, + right: 8.0, + ), + child: Text( + hostDisplayName, + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentBlue, + ), + ), + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + ), + color: StreamChatTheme.of(context).colorTheme.blueAlice, + ), + ), + ), + ], + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + ), + ), + Padding( + padding: textPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (urlAttachment.title != null) + Text( + urlAttachment.title.trim(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith(fontWeight: FontWeight.w700), + ), + if (urlAttachment.text != null) + Text( + urlAttachment.text, + style: StreamChatTheme.of(context) + .textTheme + .body + .copyWith(fontWeight: FontWeight.w400), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/user_avatar.dart b/packages/stream_chat_flutter/lib/src/user_avatar.dart new file mode 100644 index 00000000..f38a9a68 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/user_avatar.dart @@ -0,0 +1,114 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import '../stream_chat_flutter.dart'; + +class UserAvatar extends StatelessWidget { + const UserAvatar({ + Key key, + @required this.user, + this.constraints, + this.onlineIndicatorConstraints, + this.onTap, + this.onLongPress, + this.showOnlineStatus = true, + this.borderRadius, + this.onlineIndicatorAlignment = Alignment.topRight, + this.selected = false, + this.selectionColor, + this.selectionThickness = 4, + }) : super(key: key); + + final User user; + final Alignment onlineIndicatorAlignment; + final BoxConstraints constraints; + final BorderRadius borderRadius; + final BoxConstraints onlineIndicatorConstraints; + final void Function(User) onTap; + final void Function(User) onLongPress; + final bool showOnlineStatus; + final bool selected; + final Color selectionColor; + final double selectionThickness; + + @override + Widget build(BuildContext context) { + final hasImage = user.extraData?.containsKey('image') == true && + user.extraData['image'] != null && + user.extraData['image'] != ''; + final streamChatTheme = StreamChatTheme.of(context); + + Widget avatar = ClipRRect( + clipBehavior: Clip.antiAlias, + borderRadius: borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, + child: Container( + constraints: constraints ?? + streamChatTheme.ownMessageTheme.avatarTheme.constraints, + decoration: BoxDecoration( + color: streamChatTheme.colorTheme.accentBlue, + ), + child: hasImage + ? CachedNetworkImage( + filterQuality: FilterQuality.high, + imageUrl: user.extraData['image'], + errorWidget: (_, __, ___) { + return streamChatTheme.defaultUserImage(context, user); + }, + fit: BoxFit.cover, + ) + : streamChatTheme.defaultUserImage(context, user), + ), + ); + + if (selected) { + avatar = ClipRRect( + borderRadius: (borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + + BorderRadius.circular(selectionThickness), + child: Container( + constraints: constraints ?? + streamChatTheme.ownMessageTheme.avatarTheme.constraints, + color: selectionColor ?? + StreamChatTheme.of(context).colorTheme.accentBlue, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: avatar, + ), + ), + ); + } + return GestureDetector( + onTap: onTap != null ? () => onTap(user) : null, + onLongPress: onLongPress != null ? () => onLongPress(user) : null, + child: Stack( + children: [ + avatar, + if (showOnlineStatus && user.online == true) + Positioned.fill( + child: Align( + alignment: onlineIndicatorAlignment, + child: Material( + type: MaterialType.circle, + child: Container( + margin: const EdgeInsets.all(2.0), + constraints: onlineIndicatorConstraints ?? + BoxConstraints.tightFor( + width: 8, + height: 8, + ), + child: Material( + shape: CircleBorder(), + color: streamChatTheme.colorTheme.accentGreen, + ), + ), + color: streamChatTheme.colorTheme.white, + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/user_item.dart b/packages/stream_chat_flutter/lib/src/user_item.dart new file mode 100644 index 00000000..cb82e311 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/user_item.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/user_list_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'stream_chat_theme.dart'; + +/// +/// It shows the current [User] preview. +/// +/// The widget uses a [StreamBuilder] to render the user information image as soon as it updates. +/// +/// Usually you don't use this widget as it's the default user preview used by [UserListView]. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class UserItem extends StatelessWidget { + /// Instantiate a new UserItem + const UserItem({ + Key key, + @required this.user, + this.onTap, + this.onLongPress, + this.onImageTap, + this.selected = false, + this.showLastOnline = true, + }) : super(key: key); + + /// Function called when tapping this widget + final void Function(User) onTap; + + /// Function called when long pressing this widget + final void Function(User) onLongPress; + + /// User displayed + final User user; + + /// The function called when the image is tapped + final void Function(User) onImageTap; + + /// If true the [UserItem] will show a trailing checkmark + final bool selected; + + /// If true the [UserItem] will show the last seen + final bool showLastOnline; + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () { + if (onTap != null) { + onTap(user); + } + }, + onLongPress: () { + if (onLongPress != null) { + onLongPress(user); + } + }, + leading: UserAvatar( + user: user, + showOnlineStatus: true, + onTap: (user) { + if (onImageTap != null) { + onImageTap(user); + } + }, + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + trailing: selected + ? StreamSvgIcon.checkSend( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ) + : null, + title: Text( + user.name, + style: StreamChatTheme.of(context).textTheme.bodyBold, + ), + subtitle: showLastOnline ? _buildLastActive(context) : null, + ); + } + + Widget _buildLastActive(context) { + return Text( + user.online == true + ? 'Online' + : 'Last online ${Jiffy(user.lastActive).fromNow()}', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5)), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart new file mode 100644 index 00000000..a1819592 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -0,0 +1,441 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'user_item.dart'; + +/// Callback called when tapping on a user +typedef UserTapCallback = void Function(User, Widget); + +/// Builder used to create a custom [ListUserItem] from a [User] +typedef UserItemBuilder = Widget Function(BuildContext, User, bool); + +/// +/// It shows the list of current users. +/// +/// ```dart +/// class UsersListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: UsersListView( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// channelWidget: ChannelPage(), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [UsersBloc] ancestor in order to provide the information about the users. +/// The widget uses a [ListView.separated], [GridView.builder] to render the list, grid of channels. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class UserListView extends StatefulWidget { + /// Instantiate a new UserListView + const UserListView({ + Key key, + this.errorBuilder, + this.emptyBuilder, + this.filter, + this.options, + this.sort, + this.pagination, + this.onUserTap, + this.onUserLongPress, + this.userWidget, + this.userItemBuilder, + this.separatorBuilder, + this.onImageTap, + this.selectedUsers, + this.pullToRefresh = true, + this.groupAlphabetically = false, + this.crossAxisCount = 1, + }) : assert( + crossAxisCount == 1 || groupAlphabetically == false, + 'Cannot group alphabetically when crossAxisCount > 1', + ), + super(key: key); + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filter; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sort; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams pagination; + + /// Function called when tapping on a channel + /// By default it calls [Navigator.push] building a [MaterialPageRoute] + /// with the widget [userWidget] as child. + final UserTapCallback onUserTap; + + /// Function called when long pressing on a channel + final Function(User) onUserLongPress; + + /// Widget used when opening a channel + final Widget userWidget; + + /// Builder used to create a custom user preview + final UserItemBuilder userItemBuilder; + + /// Builder used to create a custom item separator + final Function(BuildContext, int) separatorBuilder; + + /// The function called when the image is tapped + final Function(User) onImageTap; + + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + + /// Sets a blue trailing checkMark in [ListUserItem] for all the [selectedUsers] + final Set selectedUsers; + + /// Set it to true to group users by their first character + /// + /// defaults to false + final bool groupAlphabetically; + + /// The number of children in the cross axis. + final int crossAxisCount; + + @override + _UserListViewState createState() => _UserListViewState(); +} + +class _UserListViewState extends State + with WidgetsBindingObserver { + bool get _isListView => widget.crossAxisCount == 1; + + final UserListController _userListController = UserListController(); + + @override + Widget build(BuildContext context) { + var child = UserListCore( + errorBuilder: (err) { + return _buildError(err); + }, + emptyBuilder: (context) { + return _buildEmpty(); + }, + loadingBuilder: (context) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: CircularProgressIndicator(), + ), + ), + ); + }, + ); + }, + listBuilder: (context, list) { + return _buildListView(list); + }, + pagination: widget.pagination, + options: widget.options, + sort: widget.sort, + filter: widget.filter, + groupAlphabetically: widget.groupAlphabetically, + userListController: _userListController, + ); + + if (!widget.pullToRefresh) { + return child; + } else { + return RefreshIndicator( + onRefresh: () async { + _userListController.loadData(); + }, + child: child, + ); + } + } + + bool get isListAlreadySorted => + widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; + + Widget _buildError(Error error) { + print((error).stackTrace); + + if (widget.errorBuilder != null) { + return widget.errorBuilder(error); + } + + var message = error.toString(); + if (error is DioError) { + final dioError = error as DioError; + if (dioError.type == DioErrorType.RESPONSE) { + message = dioError.message; + } else { + message = 'Check your connection and retry'; + } + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only( + right: 2.0, + ), + child: Icon(Icons.error_outline), + ), + ), + TextSpan(text: 'Error loading channels'), + ], + ), + style: Theme.of(context).textTheme.headline6, + ), + Padding( + padding: const EdgeInsets.only( + top: 16.0, + ), + child: Text(message), + ), + FlatButton( + onPressed: () { + _userListController.loadData(); + }, + child: Text('Retry'), + ), + ], + ), + ); + } + + Widget _buildEmpty() { + if (widget.emptyBuilder != null) { + return widget.emptyBuilder(context); + } + + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Text('There are no users currently'), + ), + ), + ); + }, + ); + } + + Widget _buildListView( + List items, + ) { + final child = _isListView + ? ListView.separated( + physics: AlwaysScrollableScrollPhysics(), + itemCount: items.isNotEmpty ? items.length + 1 : items.length, + separatorBuilder: (_, index) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, index); + } + return _separatorBuilder(context, index); + }, + itemBuilder: (context, index) { + return _listItemBuilder(context, index, items); + }, + ) + : GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + ), + itemCount: items.isNotEmpty ? items.length + 1 : items.length, + physics: AlwaysScrollableScrollPhysics(), + itemBuilder: (context, index) { + return _gridItemBuilder(context, index, items); + }, + ); + + return LazyLoadScrollView( + onEndOfPage: () async { + return _userListController.paginateData(); + }, + child: child, + ); + } + + Widget _listItemBuilder(BuildContext context, int i, List items) { + final usersProvider = UsersBloc.of(context); + if (i < items.length) { + final item = items[i]; + return item.when( + headerItem: (header) { + return Container( + key: ValueKey('HEADER-$header'), + color: + StreamChatTheme.of(context).colorTheme.black.withOpacity(0.05), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6), + child: Text( + header, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14.5, + color: StreamChatTheme.of(context).colorTheme.grey, + ), + ), + ), + ); + }, + userItem: (user) { + final selected = widget.selectedUsers?.contains(user) ?? false; + return Container( + key: ValueKey('USER-${user.id}'), + child: widget.userItemBuilder != null + ? widget.userItemBuilder(context, user, selected) + : UserItem( + user: user, + onTap: (user) => widget.onUserTap(user, widget.userWidget), + onLongPress: widget.onUserLongPress, + onImageTap: widget.onImageTap, + selected: selected, + ), + ); + }, + ); + } else { + return _buildQueryProgressIndicator(context, usersProvider); + } + } + + Widget _gridItemBuilder(BuildContext context, int i, List items) { + final usersProvider = UsersBloc.of(context); + if (i < items.length) { + final item = items[i]; + return item.when( + headerItem: (_) => Offstage(), + userItem: (user) { + final selected = widget.selectedUsers?.contains(user) ?? false; + return Container( + key: ValueKey('USER-${user.id}'), + child: widget.userItemBuilder != null + ? widget.userItemBuilder(context, user, selected) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + UserAvatar( + user: user, + borderRadius: BorderRadius.circular(32), + selected: selected, + constraints: BoxConstraints.tightFor( + height: 64, + width: 64, + ), + onlineIndicatorConstraints: BoxConstraints.tightFor( + height: 12, + width: 12, + ), + onTap: (user) => + widget.onUserTap(user, widget.userWidget), + onLongPress: widget.onUserLongPress, + ), + SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + user.name, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + ); + }, + ); + } else { + return _buildQueryProgressIndicator(context, usersProvider); + } + } + + Widget _buildQueryProgressIndicator(context, UsersBlocState usersProvider) { + return StreamBuilder( + stream: usersProvider.queryUsersLoading, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: StreamChatTheme.of(context) + .colorTheme + .accentRed + .withOpacity(.2), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text('Error loading users'), + ), + ), + ); + } + return Container( + height: 100, + padding: EdgeInsets.all(32), + child: Center( + child: snapshot.data ? CircularProgressIndicator() : Container(), + ), + ); + }); + } + + Widget _separatorBuilder(context, i) { + return Container( + height: 1, + color: StreamChatTheme.of(context).colorTheme.greyWhisper, + ); + } +} diff --git a/lib/src/user_reaction_display.dart b/packages/stream_chat_flutter/lib/src/user_reaction_display.dart similarity index 95% rename from lib/src/user_reaction_display.dart rename to packages/stream_chat_flutter/lib/src/user_reaction_display.dart index 92f66aa2..af2797ca 100644 --- a/lib/src/user_reaction_display.dart +++ b/packages/stream_chat_flutter/lib/src/user_reaction_display.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; class UserReactionDisplay extends StatelessWidget { diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart new file mode 100644 index 00000000..f7eaf385 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -0,0 +1,290 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../stream_chat_flutter.dart'; +import 'stream_svg_icon.dart'; + +Future launchURL(BuildContext context, String url) async { + if (await canLaunch(url)) { + await launch(url); + } else { + // ignore: deprecated_member_use + Scaffold.of(context).showSnackBar( + SnackBar( + content: Text('Cannot launch the url'), + ), + ); + } +} + +Future showConfirmationDialog( + BuildContext context, { + String title, + Widget icon, + String question, + String okText, + String cancelText, +}) { + return showModalBottomSheet( + backgroundColor: StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + final effect = StreamChatTheme.of(context).colorTheme.borderTop; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 26.0), + if (icon != null) icon, + SizedBox(height: 26.0), + Text( + title, + style: StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox(height: 7.0), + Text( + question, + textAlign: TextAlign.center, + ), + SizedBox(height: 36.0), + Container( + color: effect.color.withOpacity(effect.alpha ?? 1), + height: 1, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FlatButton( + child: Text( + cancelText, + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5)), + ), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + FlatButton( + child: Text( + okText, + style: StreamChatTheme.of(context) + .textTheme + .bodyBold + .copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .accentRed), + ), + onPressed: () { + Navigator.pop(context, true); + }, + ), + ], + ), + ], + ); + }); +} + +Future showInfoDialog( + BuildContext context, { + String title, + Widget icon, + String question, + String okText, + StreamChatThemeData theme, +}) { + return showModalBottomSheet( + backgroundColor: + theme.colorTheme.white ?? StreamChatTheme.of(context).colorTheme.white, + context: context, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + )), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 26.0, + ), + if (icon != null) icon, + SizedBox( + height: 26.0, + ), + Text( + title, + style: theme.textTheme.headlineBold ?? + StreamChatTheme.of(context).textTheme.headlineBold, + ), + SizedBox( + height: 7.0, + ), + Text(question), + SizedBox( + height: 36.0, + ), + Container( + color: theme.colorTheme.black.withOpacity(.08) ?? + StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), + height: 1.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FlatButton( + child: Text( + okText, + style: TextStyle( + color: theme.colorTheme.black.withOpacity(0.5) ?? + StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(0.5), + fontWeight: FontWeight.w400), + ), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ], + ), + ], + ); + }, + ); +} + +/// Get random png with initials +String getRandomPicUrl(User user) => + 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; + +/// Get websiteName from [hostName] +String getWebsiteName(String hostName) { + switch (hostName) { + case 'reddit': + return 'Reddit'; + case 'youtube': + return 'Youtube'; + case 'wikipedia': + return 'Wikipedia'; + case 'twitter': + return 'Twitter'; + case 'facebook': + return 'Facebook'; + case 'amazon': + return 'Amazon'; + case 'yelp': + return 'Yelp'; + case 'imdb': + return 'IMDB'; + case 'pinterest': + return 'Pinterest'; + case 'tripadvisor': + return 'TripAdvisor'; + case 'instagram': + return 'Instagram'; + case 'walmart': + return 'Walmart'; + case 'craigslist': + return 'Craigslist'; + case 'ebay': + return 'eBay'; + case 'linkedin': + return 'LinkedIn'; + case 'google': + return 'Google'; + case 'apple': + return 'Apple'; + default: + return null; + } +} + +/// +String getSizeText(int bytes) { + if (bytes == null) { + return 'Size N/A'; + } + + if (bytes <= 1000) { + return '$bytes bytes'; + } else if (bytes <= 100000) { + return '${(bytes / 1000).toStringAsFixed(2)} KB'; + } else { + return '${(bytes / 1000000).toStringAsFixed(2)} MB'; + } +} + +/// +StreamSvgIcon getFileTypeImage(String type) { + switch (type) { + case '7z': + return StreamSvgIcon.filetype7z(); + break; + case 'csv': + return StreamSvgIcon.filetypeCsv(); + break; + case 'doc': + return StreamSvgIcon.filetypeDoc(); + break; + case 'docx': + return StreamSvgIcon.filetypeDocx(); + break; + case 'html': + return StreamSvgIcon.filetypeHtml(); + break; + case 'md': + return StreamSvgIcon.filetypeMd(); + break; + case 'odt': + return StreamSvgIcon.filetypeOdt(); + break; + case 'pdf': + return StreamSvgIcon.filetypePdf(); + break; + case 'ppt': + return StreamSvgIcon.filetypePpt(); + break; + case 'pptx': + return StreamSvgIcon.filetypePptx(); + break; + case 'rar': + return StreamSvgIcon.filetypeRar(); + break; + case 'rtf': + return StreamSvgIcon.filetypeRtf(); + break; + case 'tar': + return StreamSvgIcon.filetypeTar(); + break; + case 'txt': + return StreamSvgIcon.filetypeTxt(); + break; + case 'xls': + return StreamSvgIcon.filetypeXls(); + break; + case 'xlsx': + return StreamSvgIcon.filetypeXlsx(); + break; + case 'zip': + return StreamSvgIcon.filetypeZip(); + break; + default: + return StreamSvgIcon.filetypeGeneric(); + break; + } +} diff --git a/lib/src/video_attachment.dart b/packages/stream_chat_flutter/lib/src/video_attachment.dart similarity index 83% rename from lib/src/video_attachment.dart rename to packages/stream_chat_flutter/lib/src/video_attachment.dart index b0d1f60d..b775109f 100644 --- a/lib/src/video_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/video_attachment.dart @@ -1,7 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/full_screen_video.dart'; +import 'package:stream_chat_flutter/src/full_screen_media.dart'; import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; @@ -13,12 +13,18 @@ class VideoAttachment extends StatefulWidget { final Attachment attachment; final MessageTheme messageTheme; final Size size; + final Message message; + final ShowMessageCallback onShowMessage; + final ValueChanged onReturnAction; VideoAttachment({ Key key, @required this.attachment, @required this.messageTheme, + this.message, this.size, + this.onShowMessage, + this.onReturnAction, }) : super(key: key); @override @@ -80,15 +86,28 @@ class _VideoAttachmentState extends State { }); return GestureDetector( - onTap: () { - Navigator.push( + onTap: () async { + final channel = StreamChannel.of(context).channel; + + var res = await Navigator.push( context, MaterialPageRoute( - builder: (_) => FullScreenVideo( - attachment: widget.attachment, + builder: (_) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [widget.attachment], + userName: widget.message.user.name, + sentAt: widget.message.createdAt, + message: widget.message, + onShowMessage: widget.onShowMessage, + ), ), ), ); + + if (res != null) { + widget.onReturnAction(res); + } }, child: Container( height: widget.size?.height, @@ -100,7 +119,7 @@ class _VideoAttachmentState extends State { children: [ Expanded( child: FittedBox( - fit: BoxFit.cover, + fit: BoxFit.none, child: Stack( children: [ Chewie( diff --git a/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart similarity index 51% rename from lib/stream_chat_flutter.dart rename to packages/stream_chat_flutter/lib/stream_chat_flutter.dart index dffbd154..fa296075 100644 --- a/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -1,16 +1,16 @@ -export 'package:stream_chat/stream_chat.dart'; - export 'src/back_button.dart'; export 'src/channel_header.dart'; export 'src/channel_image.dart'; +export 'src/channel_list_header.dart'; export 'src/channel_list_view.dart'; export 'src/channel_name.dart'; export 'src/channel_preview.dart'; -export 'src/channels_bloc.dart'; export 'src/date_divider.dart'; export 'src/deleted_message.dart'; export 'src/file_attachment.dart'; -export 'src/full_screen_video.dart'; +export 'src/full_screen_media.dart'; +export 'src/image_header.dart'; +export 'src/image_footer.dart'; export 'src/giphy_attachment.dart'; export 'src/image_attachment.dart'; export 'src/message_input.dart'; @@ -18,13 +18,27 @@ export 'src/message_list_view.dart'; export 'src/message_text.dart'; export 'src/message_widget.dart'; export 'src/reaction_picker.dart'; -export 'src/reply_indicator.dart'; export 'src/sending_indicator.dart'; -export 'src/stream_channel.dart'; -export 'src/stream_chat.dart'; export 'src/stream_chat_theme.dart'; +export 'src/stream_neumorphic_button.dart'; +export 'src/stream_svg_icon.dart'; export 'src/system_message.dart'; export 'src/thread_header.dart'; export 'src/typing_indicator.dart'; export 'src/user_avatar.dart'; +export 'src/user_item.dart'; +export 'src/user_item.dart'; +export 'src/user_list_view.dart'; +export 'src/user_list_view.dart'; +export 'src/utils.dart'; export 'src/video_attachment.dart'; +export 'src/message_search_item.dart'; +export 'src/message_search_list_view.dart'; +export 'src/unread_indicator.dart'; +export 'src/option_list_tile.dart'; +export 'src/channel_file_display_screen.dart'; +export 'src/channel_media_display_screen.dart'; +export 'src/info_tile.dart'; +export 'src/stream_chat.dart'; +export 'src/connection_status_builder.dart'; +export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; diff --git a/packages/stream_chat_flutter/lib/svgs/Empty State_Camera.svg b/packages/stream_chat_flutter/lib/svgs/Empty State_Camera.svg new file mode 100644 index 00000000..170ff7d4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Empty State_Camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Empty State_Files.svg b/packages/stream_chat_flutter/lib/svgs/Empty State_Files.svg new file mode 100644 index 00000000..658bcb1d --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Empty State_Files.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Empty State_No channel.svg b/packages/stream_chat_flutter/lib/svgs/Empty State_No channel.svg new file mode 100644 index 00000000..d7979534 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Empty State_No channel.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Empty State_Search.svg b/packages/stream_chat_flutter/lib/svgs/Empty State_Search.svg new file mode 100644 index 00000000..d0026388 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Empty State_Search.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Empty State_picture.svg b/packages/stream_chat_flutter/lib/svgs/Empty State_picture.svg new file mode 100644 index 00000000..fc82db46 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Empty State_picture.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Giphy icon.svg b/packages/stream_chat_flutter/lib/svgs/Giphy icon.svg new file mode 100644 index 00000000..a9419e59 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Giphy icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_LOL_reaction.svg b/packages/stream_chat_flutter/lib/svgs/Icon_LOL_reaction.svg new file mode 100644 index 00000000..c0f1d6fc --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_LOL_reaction.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_Thread_Reply.svg b/packages/stream_chat_flutter/lib/svgs/Icon_Thread_Reply.svg new file mode 100644 index 00000000..7482300d --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_Thread_Reply.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_User_add.svg b/packages/stream_chat_flutter/lib/svgs/Icon_User_add.svg new file mode 100644 index 00000000..c9db7859 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_User_add.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_User_deselect.svg b/packages/stream_chat_flutter/lib/svgs/Icon_User_deselect.svg new file mode 100644 index 00000000..fc5129d7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_User_deselect.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_arrow_right.svg b/packages/stream_chat_flutter/lib/svgs/Icon_arrow_right.svg new file mode 100644 index 00000000..dde29bcb --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_arrow_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_attach.svg b/packages/stream_chat_flutter/lib/svgs/Icon_attach.svg new file mode 100644 index 00000000..e2369d21 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_attach.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_camera.svg b/packages/stream_chat_flutter/lib/svgs/Icon_camera.svg new file mode 100644 index 00000000..a5a7d99d --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_check.svg b/packages/stream_chat_flutter/lib/svgs/Icon_check.svg new file mode 100644 index 00000000..49cf76be --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_check.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_check_all.svg b/packages/stream_chat_flutter/lib/svgs/Icon_check_all.svg new file mode 100644 index 00000000..b477edde --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_check_all.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_check_big.svg b/packages/stream_chat_flutter/lib/svgs/Icon_check_big.svg new file mode 100644 index 00000000..d42503bb --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_check_big.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_check_send.svg b/packages/stream_chat_flutter/lib/svgs/Icon_check_send.svg new file mode 100644 index 00000000..e62c7099 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_check_send.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_circle_right.svg b/packages/stream_chat_flutter/lib/svgs/Icon_circle_right.svg new file mode 100644 index 00000000..1d20c031 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_circle_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_circle_up.svg b/packages/stream_chat_flutter/lib/svgs/Icon_circle_up.svg new file mode 100644 index 00000000..41ed17dd --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_circle_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_close.svg b/packages/stream_chat_flutter/lib/svgs/Icon_close.svg new file mode 100644 index 00000000..0765c5c3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_close.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_close_black.svg b/packages/stream_chat_flutter/lib/svgs/Icon_close_black.svg new file mode 100644 index 00000000..74eb480c --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_close_black.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_close_sml.svg b/packages/stream_chat_flutter/lib/svgs/Icon_close_sml.svg new file mode 100644 index 00000000..63e0d016 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_close_sml.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_cloud_download.svg b/packages/stream_chat_flutter/lib/svgs/Icon_cloud_download.svg new file mode 100644 index 00000000..7b935f29 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_cloud_download.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_contacts.svg b/packages/stream_chat_flutter/lib/svgs/Icon_contacts.svg new file mode 100644 index 00000000..6db40129 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_contacts.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_copy.svg b/packages/stream_chat_flutter/lib/svgs/Icon_copy.svg new file mode 100644 index 00000000..83bd7aa5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_copy.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_curve_line_left_up.svg b/packages/stream_chat_flutter/lib/svgs/Icon_curve_line_left_up.svg new file mode 100644 index 00000000..1c3c718c --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_curve_line_left_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_curve_line_left_up_big.svg b/packages/stream_chat_flutter/lib/svgs/Icon_curve_line_left_up_big.svg new file mode 100644 index 00000000..4b6181b1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_curve_line_left_up_big.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_delete.svg b/packages/stream_chat_flutter/lib/svgs/Icon_delete.svg new file mode 100644 index 00000000..3a63cfec --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_down.svg b/packages/stream_chat_flutter/lib/svgs/Icon_down.svg new file mode 100644 index 00000000..60ea59ee --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_download.svg b/packages/stream_chat_flutter/lib/svgs/Icon_download.svg new file mode 100644 index 00000000..f42047ba --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_download.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_edit.svg b/packages/stream_chat_flutter/lib/svgs/Icon_edit.svg new file mode 100644 index 00000000..7fc784fb --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_edit.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_empty_circle_left.svg b/packages/stream_chat_flutter/lib/svgs/Icon_empty_circle_left.svg new file mode 100644 index 00000000..8f1e8329 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_empty_circle_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_error.svg b/packages/stream_chat_flutter/lib/svgs/Icon_error.svg new file mode 100644 index 00000000..af3df414 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_error.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_eye-off.svg b/packages/stream_chat_flutter/lib/svgs/Icon_eye-off.svg new file mode 100644 index 00000000..8f404ae2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_eye-off.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_grid.svg b/packages/stream_chat_flutter/lib/svgs/Icon_grid.svg new file mode 100644 index 00000000..43bb0489 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_grid.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_group.svg b/packages/stream_chat_flutter/lib/svgs/Icon_group.svg new file mode 100644 index 00000000..6db40129 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_group.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_left.svg b/packages/stream_chat_flutter/lib/svgs/Icon_left.svg new file mode 100644 index 00000000..5a4c5ced --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_lightning-command runner.svg b/packages/stream_chat_flutter/lib/svgs/Icon_lightning-command runner.svg new file mode 100644 index 00000000..83045bbb --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_lightning-command runner.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_love_reaction.svg b/packages/stream_chat_flutter/lib/svgs/Icon_love_reaction.svg new file mode 100644 index 00000000..4d946544 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_love_reaction.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_menu_point_v.svg b/packages/stream_chat_flutter/lib/svgs/Icon_menu_point_v.svg new file mode 100644 index 00000000..f7b61163 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_menu_point_v.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_message.svg b/packages/stream_chat_flutter/lib/svgs/Icon_message.svg new file mode 100644 index 00000000..11eacefa --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_message.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_mute.svg b/packages/stream_chat_flutter/lib/svgs/Icon_mute.svg new file mode 100644 index 00000000..d1d16aa7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_mute.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_notification.svg b/packages/stream_chat_flutter/lib/svgs/Icon_notification.svg new file mode 100644 index 00000000..213f33c0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_notification.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_pen-write.svg b/packages/stream_chat_flutter/lib/svgs/Icon_pen-write.svg new file mode 100644 index 00000000..820d28bd --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_pen-write.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_record.svg b/packages/stream_chat_flutter/lib/svgs/Icon_record.svg new file mode 100644 index 00000000..64d02895 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_record.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_right.svg b/packages/stream_chat_flutter/lib/svgs/Icon_right.svg new file mode 100644 index 00000000..6d42a1db --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_save.svg b/packages/stream_chat_flutter/lib/svgs/Icon_save.svg new file mode 100644 index 00000000..14bedc6f --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_save.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_search.svg b/packages/stream_chat_flutter/lib/svgs/Icon_search.svg new file mode 100644 index 00000000..0865a95e --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_search.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_send_message.svg b/packages/stream_chat_flutter/lib/svgs/Icon_send_message.svg new file mode 100644 index 00000000..0d504a40 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_send_message.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_smile.svg b/packages/stream_chat_flutter/lib/svgs/Icon_smile.svg new file mode 100644 index 00000000..b803cf22 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_smile.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_thumbs_down_reaction.svg b/packages/stream_chat_flutter/lib/svgs/Icon_thumbs_down_reaction.svg new file mode 100644 index 00000000..9036ff72 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_thumbs_down_reaction.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_thumbs_up_reaction.svg b/packages/stream_chat_flutter/lib/svgs/Icon_thumbs_up_reaction.svg new file mode 100644 index 00000000..ff4dcc72 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_thumbs_up_reaction.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_time.svg b/packages/stream_chat_flutter/lib/svgs/Icon_time.svg new file mode 100644 index 00000000..59aa2c5d --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_time.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_user.svg b/packages/stream_chat_flutter/lib/svgs/Icon_user.svg new file mode 100644 index 00000000..f50e33fc --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_user.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_user_delete.svg b/packages/stream_chat_flutter/lib/svgs/Icon_user_delete.svg new file mode 100644 index 00000000..fb47f5e8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_user_delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_user_settings.svg b/packages/stream_chat_flutter/lib/svgs/Icon_user_settings.svg new file mode 100644 index 00000000..36ce6115 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_user_settings.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/Icon_wut_reaction.svg b/packages/stream_chat_flutter/lib/svgs/Icon_wut_reaction.svg new file mode 100644 index 00000000..f445ec29 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/Icon_wut_reaction.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/STREAM MARK 1.svg b/packages/stream_chat_flutter/lib/svgs/STREAM MARK 1.svg new file mode 100644 index 00000000..57ccef0f --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/STREAM MARK 1.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/UI_Reverse Pagination Loading.svg b/packages/stream_chat_flutter/lib/svgs/UI_Reverse Pagination Loading.svg new file mode 100644 index 00000000..dfc3754f --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/UI_Reverse Pagination Loading.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/eye-line_big.svg b/packages/stream_chat_flutter/lib/svgs/eye-line_big.svg new file mode 100644 index 00000000..9eb20a58 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/eye-line_big.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/files.svg b/packages/stream_chat_flutter/lib/svgs/files.svg new file mode 100644 index 00000000..5d72fdd3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/files.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_7z.svg b/packages/stream_chat_flutter/lib/svgs/filetype_7z.svg new file mode 100644 index 00000000..787f5f8c --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_7z.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_CSV.svg b/packages/stream_chat_flutter/lib/svgs/filetype_CSV.svg new file mode 100644 index 00000000..d7395e78 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_CSV.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_DOC.svg b/packages/stream_chat_flutter/lib/svgs/filetype_DOC.svg new file mode 100644 index 00000000..4233055a --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_DOC.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_DOCX.svg b/packages/stream_chat_flutter/lib/svgs/filetype_DOCX.svg new file mode 100644 index 00000000..3077f515 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_DOCX.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_Generic.svg b/packages/stream_chat_flutter/lib/svgs/filetype_Generic.svg new file mode 100644 index 00000000..3ec0f239 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_Generic.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_MD.svg b/packages/stream_chat_flutter/lib/svgs/filetype_MD.svg new file mode 100644 index 00000000..560b7c61 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_MD.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_ODT.svg b/packages/stream_chat_flutter/lib/svgs/filetype_ODT.svg new file mode 100644 index 00000000..6e08875f --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_ODT.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_PDF.svg b/packages/stream_chat_flutter/lib/svgs/filetype_PDF.svg new file mode 100644 index 00000000..c8306ca1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_PDF.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_PPT.svg b/packages/stream_chat_flutter/lib/svgs/filetype_PPT.svg new file mode 100644 index 00000000..0541a496 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_PPT.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_PPTX-1.svg b/packages/stream_chat_flutter/lib/svgs/filetype_PPTX-1.svg new file mode 100644 index 00000000..a50efd64 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_PPTX-1.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_PPTX.svg b/packages/stream_chat_flutter/lib/svgs/filetype_PPTX.svg new file mode 100644 index 00000000..83339acc --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_PPTX.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_RAR.svg b/packages/stream_chat_flutter/lib/svgs/filetype_RAR.svg new file mode 100644 index 00000000..ae0e14d0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_RAR.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_RTF.svg b/packages/stream_chat_flutter/lib/svgs/filetype_RTF.svg new file mode 100644 index 00000000..e9266599 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_RTF.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_TAR.svg b/packages/stream_chat_flutter/lib/svgs/filetype_TAR.svg new file mode 100644 index 00000000..6796e1f7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_TAR.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_TXT.svg b/packages/stream_chat_flutter/lib/svgs/filetype_TXT.svg new file mode 100644 index 00000000..263fdfbe --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_TXT.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_XLS.svg b/packages/stream_chat_flutter/lib/svgs/filetype_XLS.svg new file mode 100644 index 00000000..eec9a05d --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_XLS.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_XLSX.svg b/packages/stream_chat_flutter/lib/svgs/filetype_XLSX.svg new file mode 100644 index 00000000..6203aa27 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_XLSX.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_ZIP.svg b/packages/stream_chat_flutter/lib/svgs/filetype_ZIP.svg new file mode 100644 index 00000000..daa24576 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_ZIP.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/filetype_html.svg b/packages/stream_chat_flutter/lib/svgs/filetype_html.svg new file mode 100644 index 00000000..9e3cb9cf --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/filetype_html.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/flag.svg b/packages/stream_chat_flutter/lib/svgs/flag.svg new file mode 100644 index 00000000..d5903df4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/flag.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/giphy icon blue.svg b/packages/stream_chat_flutter/lib/svgs/giphy icon blue.svg new file mode 100644 index 00000000..746e50a4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/giphy icon blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/giphy_icon.svg b/packages/stream_chat_flutter/lib/svgs/giphy_icon.svg new file mode 100644 index 00000000..e1a1d4bb --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/giphy_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/icon_SHARE.svg b/packages/stream_chat_flutter/lib/svgs/icon_SHARE.svg new file mode 100644 index 00000000..6a35ef8d --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_SHARE.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/icon_delete_grey.svg b/packages/stream_chat_flutter/lib/svgs/icon_delete_grey.svg new file mode 100644 index 00000000..10689df2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_delete_grey.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/icon_flag.svg b/packages/stream_chat_flutter/lib/svgs/icon_flag.svg new file mode 100644 index 00000000..0f382e94 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_flag.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/icon_mentions.svg b/packages/stream_chat_flutter/lib/svgs/icon_mentions.svg new file mode 100644 index 00000000..ce6c6fa1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_mentions.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/icon_moon.svg b/packages/stream_chat_flutter/lib/svgs/icon_moon.svg new file mode 100644 index 00000000..ebc3279c --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/imgur.svg b/packages/stream_chat_flutter/lib/svgs/imgur.svg new file mode 100644 index 00000000..1df5d71f --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/imgur.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter/lib/svgs/mentions.svg b/packages/stream_chat_flutter/lib/svgs/mentions.svg new file mode 100644 index 00000000..05bdee3b --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/mentions.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/pictures.svg b/packages/stream_chat_flutter/lib/svgs/pictures.svg new file mode 100644 index 00000000..64e6ca34 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/pictures.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/settings.svg b/packages/stream_chat_flutter/lib/svgs/settings.svg new file mode 100644 index 00000000..7dfab209 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/share_arrow.svg b/packages/stream_chat_flutter/lib/svgs/share_arrow.svg new file mode 100644 index 00000000..33b99c35 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/share_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/lib/svgs/volume-up.svg b/packages/stream_chat_flutter/lib/svgs/volume-up.svg new file mode 100644 index 00000000..21f64c35 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/volume-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml new file mode 100644 index 00000000..f4fa6f51 --- /dev/null +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -0,0 +1,61 @@ +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: 1.0.0-beta +repository: https://github.com/GetStream/stream-chat-flutter +issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + stream_chat_flutter_core: ^1.0.0-beta + flutter_app_badger: ^1.1.2 + photo_view: ^0.10.3 + rxdart: ^0.25.0 + scrollable_positioned_list: ^0.1.8 + jiffy: ^3.0.1 + flutter_svg: ^0.19.1 + flutter_portal: ^0.3.0 + cached_network_image: ^2.5.0 + shimmer: ^1.1.2 + flutter_markdown: ^0.5.2 + url_launcher: ^5.7.10 + emojis: ^0.9.3 + video_player: ^1.0.1 + chewie: ^0.12.1+1 + file_picker: ^2.1.5 + image_picker: ^0.6.7+17 + flutter_keyboard_visibility: ^4.0.2 + mime: ^0.9.7 + video_compress: ^2.1.1 + visibility_detector: ^0.1.5 + http_parser: ^3.1.4 + lottie: ^0.7.0+1 + substring_highlight: ^0.1.2 + flutter_slidable: ^0.5.7 + clipboard: ^0.1.2+8 + image_gallery_saver: ^1.6.7 + esys_flutter_share: ^1.0.2 + photo_manager: ^0.6.0 + transparent_image: ^1.0.0 + ezanimation: ^0.4.1 + synchronized: ^2.2.0+2 + characters: ^1.0.0 + dio: ^3.0.10 + path_provider: ^1.6.27 + +flutter: + assets: + - images/ + - svgs/ + - lib/svgs/ + - animations/ + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^4.1.3 + pedantic: ^1.9.2 \ No newline at end of file diff --git a/packages/stream_chat_flutter/screenshots/channel_header.png b/packages/stream_chat_flutter/screenshots/channel_header.png new file mode 100644 index 00000000..c0a2e29f Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/channel_header.png differ diff --git a/packages/stream_chat_flutter/screenshots/channel_header_paint.png b/packages/stream_chat_flutter/screenshots/channel_header_paint.png new file mode 100644 index 00000000..8c552b65 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/channel_header_paint.png differ diff --git a/screenshots/channel_image.png b/packages/stream_chat_flutter/screenshots/channel_image.png similarity index 100% rename from screenshots/channel_image.png rename to packages/stream_chat_flutter/screenshots/channel_image.png diff --git a/screenshots/channel_image_paint.png b/packages/stream_chat_flutter/screenshots/channel_image_paint.png similarity index 100% rename from screenshots/channel_image_paint.png rename to packages/stream_chat_flutter/screenshots/channel_image_paint.png diff --git a/packages/stream_chat_flutter/screenshots/channel_list_view.png b/packages/stream_chat_flutter/screenshots/channel_list_view.png new file mode 100644 index 00000000..c3e4e5e2 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/channel_list_view.png differ diff --git a/packages/stream_chat_flutter/screenshots/channel_list_view_paint.png b/packages/stream_chat_flutter/screenshots/channel_list_view_paint.png new file mode 100644 index 00000000..9fb72e65 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/channel_list_view_paint.png differ diff --git a/packages/stream_chat_flutter/screenshots/channel_preview.png b/packages/stream_chat_flutter/screenshots/channel_preview.png new file mode 100644 index 00000000..c98d4c08 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/channel_preview.png differ diff --git a/packages/stream_chat_flutter/screenshots/channel_preview_paint.png b/packages/stream_chat_flutter/screenshots/channel_preview_paint.png new file mode 100644 index 00000000..eda9f14f Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/channel_preview_paint.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_input.png b/packages/stream_chat_flutter/screenshots/message_input.png new file mode 100644 index 00000000..00331e5e Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_input.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_input2.png b/packages/stream_chat_flutter/screenshots/message_input2.png new file mode 100644 index 00000000..dfe7df59 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_input2.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_input2_paint.png b/packages/stream_chat_flutter/screenshots/message_input2_paint.png new file mode 100644 index 00000000..915e64ca Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_input2_paint.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_input_paint.png b/packages/stream_chat_flutter/screenshots/message_input_paint.png new file mode 100644 index 00000000..d6cc4947 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_input_paint.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_listview.png b/packages/stream_chat_flutter/screenshots/message_listview.png new file mode 100644 index 00000000..b4ca5e7e Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_listview.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_listview_paint.png b/packages/stream_chat_flutter/screenshots/message_listview_paint.png new file mode 100644 index 00000000..2ae04856 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_listview_paint.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_widget.png b/packages/stream_chat_flutter/screenshots/message_widget.png new file mode 100644 index 00000000..3d99f056 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_widget.png differ diff --git a/packages/stream_chat_flutter/screenshots/message_widget_paint.png b/packages/stream_chat_flutter/screenshots/message_widget_paint.png new file mode 100644 index 00000000..802deb62 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/message_widget_paint.png differ diff --git a/packages/stream_chat_flutter/screenshots/reaction_picker.png b/packages/stream_chat_flutter/screenshots/reaction_picker.png new file mode 100644 index 00000000..f709c201 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/reaction_picker.png differ diff --git a/packages/stream_chat_flutter/screenshots/reaction_picker_paint.png b/packages/stream_chat_flutter/screenshots/reaction_picker_paint.png new file mode 100644 index 00000000..f70ed795 Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/reaction_picker_paint.png differ diff --git a/packages/stream_chat_flutter/screenshots/thread_header.png b/packages/stream_chat_flutter/screenshots/thread_header.png new file mode 100644 index 00000000..a6325e0f Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/thread_header.png differ diff --git a/packages/stream_chat_flutter/screenshots/thread_header_paint.png b/packages/stream_chat_flutter/screenshots/thread_header_paint.png new file mode 100644 index 00000000..eef1955e Binary files /dev/null and b/packages/stream_chat_flutter/screenshots/thread_header_paint.png differ diff --git a/packages/stream_chat_flutter/svgs/giphy_icon.svg b/packages/stream_chat_flutter/svgs/giphy_icon.svg new file mode 100644 index 00000000..e1a1d4bb --- /dev/null +++ b/packages/stream_chat_flutter/svgs/giphy_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/stream_chat_flutter/svgs/icon_camera.svg b/packages/stream_chat_flutter/svgs/icon_camera.svg new file mode 100644 index 00000000..0bf3122d --- /dev/null +++ b/packages/stream_chat_flutter/svgs/icon_camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/stream_chat_flutter/svgs/icon_pen_write.svg b/packages/stream_chat_flutter/svgs/icon_pen_write.svg new file mode 100644 index 00000000..b2711dad --- /dev/null +++ b/packages/stream_chat_flutter/svgs/icon_pen_write.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/svgs/icon_picture_empty_state.svg b/packages/stream_chat_flutter/svgs/icon_picture_empty_state.svg new file mode 100644 index 00000000..135772d0 --- /dev/null +++ b/packages/stream_chat_flutter/svgs/icon_picture_empty_state.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/stream_chat_flutter/svgs/video_call_icon.svg b/packages/stream_chat_flutter/svgs/video_call_icon.svg new file mode 100644 index 00000000..1c3832d6 --- /dev/null +++ b/packages/stream_chat_flutter/svgs/video_call_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/test/src/channel_preview_test.dart b/packages/stream_chat_flutter/test/src/channel_preview_test.dart similarity index 56% rename from test/src/channel_preview_test.dart rename to packages/stream_chat_flutter/test/src/channel_preview_test.dart index a7173628..34b9bf92 100644 --- a/test/src/channel_preview_test.dart +++ b/packages/stream_chat_flutter/test/src/channel_preview_test.dart @@ -3,35 +3,57 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -class MockClient extends Mock implements Client {} - -class MockChannel extends Mock implements Channel {} - -class MockChannelState extends Mock implements ChannelClientState {} +import 'mocks.dart'; void main() { testWidgets( 'it should show basic channel information', (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.user).thenReturn(OwnUser(id: 'user-id')); 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(channelState.unreadCount).thenReturn(1); + when(channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); + 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.lastMessage).thenReturn(Message( - text: 'hello', - )); + when(channelState.messagesStream).thenAnswer((i) => Stream.value([ + Message( + text: 'hello', + user: User(id: 'other-user'), + ) + ])); await tester.pumpWidget(MaterialApp( home: StreamChat( diff --git a/packages/stream_chat_flutter/test/src/message_action_modal_test.dart b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart new file mode 100644 index 00000000..3cb916d1 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter/src/message_actions_modal.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show the all actions', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: Container( + child: MessageActionsModal( + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.pump(Duration(milliseconds: 1000)); + expect(find.byKey(Key('MessageWidget')), findsOneWidget); + expect(find.text('Thread Reply'), findsOneWidget); + expect(find.text('Edit Message'), findsOneWidget); + expect(find.text('Delete Message'), findsOneWidget); + expect(find.text('Copy Message'), findsOneWidget); + }, + ); + testWidgets( + 'it should show some actions', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: Container( + child: MessageActionsModal( + showEditMessage: false, + showCopyMessage: false, + showDeleteMessage: false, + showReplyMessage: false, + showThreadReplyMessage: false, + message: Message( + text: 'test', + user: User( + id: 'user-id', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ), + ); + await tester.pump(); + + await tester.pump(Duration(milliseconds: 1000)); + expect(find.byKey(Key('MessageWidget')), findsOneWidget); + expect(find.text('Reply'), findsNothing); + expect(find.text('Thread reply'), findsNothing); + expect(find.text('Edit message'), findsNothing); + expect(find.text('Delete message'), findsNothing); + expect(find.text('Copy message'), findsNothing); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_reaction_modal_test.dart b/packages/stream_chat_flutter/test/src/message_reaction_modal_test.dart new file mode 100644 index 00000000..c023b20d --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_reaction_modal_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show one thumbs from the picker', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData(); + + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + client: client, + streamChatThemeData: streamTheme, + child: MessageReactionsModal( + message: Message( + id: 'test', + text: 'test message', + user: User( + id: 'test-user', + ), + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ); + + await tester.pump(Duration(milliseconds: 1000)); + + expect(find.byKey(Key('MessageWidget')), findsOneWidget); + expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), + findsOneWidget); + }, + ); + + testWidgets( + 'it should show two reactions', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + + final themeData = ThemeData(); + final streamTheme = StreamChatThemeData.getDefaultTheme(themeData); + final testUserId = 'test user'; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + streamChatThemeData: streamTheme, + client: client, + child: MessageReactionsModal( + message: Message( + text: 'test message', + user: User( + id: 'test-user', + ), + latestReactions: [ + Reaction( + type: 'like', + user: User(id: testUserId), + ), + Reaction( + type: 'love', + user: User(id: testUserId), + ), + ], + ), + messageTheme: streamTheme.ownMessageTheme, + ), + ), + ), + ); + await tester.pump(); + + await tester.pump(Duration(milliseconds: 1000)); + expect(find.byKey(Key('MessageWidget')), findsOneWidget); + expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), + findsNWidgets(2)); + expect(find.byKey(Key('StreamSvgIcon-Icon_love_reaction.svg')), + findsNWidgets(2)); + expect(find.text(testUserId), findsNWidgets(2)); + }, + ); +} diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart new file mode 100644 index 00000000..0abbf3ca --- /dev/null +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -0,0 +1,10 @@ +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +class MockClient extends Mock implements StreamChatClient {} + +class MockClientState extends Mock implements ClientState {} + +class MockChannel extends Mock implements Channel {} + +class MockChannelState extends Mock implements ChannelClientState {} diff --git a/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart new file mode 100644 index 00000000..0b6c90ef --- /dev/null +++ b/packages/stream_chat_flutter/test/src/reaction_bubble_test.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:stream_chat_flutter/src/reaction_bubble.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +void main() { + testWidgets( + 'it should show no reactions', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData(), + child: Container( + child: ReactionBubble( + reactions: [], + borderColor: Colors.black, + backgroundColor: Colors.white, + maskColor: Colors.white, + ), + ), + ), + ), + ); + + expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), + findsNothing); + }, + ); + + testWidgets( + 'it should show a like', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData(); + + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.getDefaultTheme(themeData), + child: Container( + child: ReactionBubble( + reactions: [ + Reaction( + type: 'like', + user: User(id: 'test'), + ), + ], + borderColor: Colors.black, + backgroundColor: Colors.white, + maskColor: Colors.white, + ), + ), + ), + ), + ); + + expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), + findsOneWidget); + }, + ); + testWidgets( + 'it should show two reactions', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final themeData = ThemeData(); + + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn(OwnUser(id: 'user-id')); + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.getDefaultTheme(themeData), + child: Container( + child: ReactionBubble( + reactions: [ + Reaction( + type: 'like', + user: User(id: 'test'), + ), + Reaction( + type: 'love', + user: User(id: 'test'), + ), + ], + borderColor: Colors.black, + backgroundColor: Colors.white, + maskColor: Colors.white, + ), + ), + ), + ), + ); + + expect(find.byKey(Key('StreamSvgIcon-Icon_thumbs_up_reaction.svg')), + findsOneWidget); + expect(find.byKey(Key('StreamSvgIcon-Icon_love_reaction.svg')), + findsOneWidget); + }, + ); +} diff --git a/packages/stream_chat_flutter_core/.gitignore b/packages/stream_chat_flutter_core/.gitignore new file mode 100644 index 00000000..1985397a --- /dev/null +++ b/packages/stream_chat_flutter_core/.gitignore @@ -0,0 +1,74 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/stream_chat_flutter_core/.metadata b/packages/stream_chat_flutter_core/.metadata new file mode 100644 index 00000000..5eb50347 --- /dev/null +++ b/packages/stream_chat_flutter_core/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 78910062997c3a836feee883712c241a5fd22983 + channel: stable + +project_type: package diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md new file mode 100644 index 00000000..819c0e5a --- /dev/null +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0-beta + +* First release diff --git a/packages/stream_chat_flutter_core/LICENSE b/packages/stream_chat_flutter_core/LICENSE new file mode 100644 index 00000000..49088d47 --- /dev/null +++ b/packages/stream_chat_flutter_core/LICENSE @@ -0,0 +1,219 @@ +SOURCE CODE LICENSE AGREEMENT + +IMPORTANT - READ THIS CAREFULLY BEFORE DOWNLOADING, INSTALLING, USING OR +ELECTRONICALLY ACCESSING THIS PROPRIETARY PRODUCT. + +THIS IS A LEGAL AGREEMENT BETWEEN STREAM.IO, INC. (“STREAM.IO”) AND THE +BUSINESS ENTITY OR PERSON FOR WHOM YOU (“YOU”) ARE ACTING (“CUSTOMER”) AS THE +LICENSEE OF THE PROPRIETARY SOFTWARE INTO WHICH THIS AGREEMENT HAS BEEN +INCLUDED (THE “AGREEMENT”). YOU AGREE THAT YOU ARE THE CUSTOMER, OR YOU ARE AN +EMPLOYEE OR AGENT OF CUSTOMER AND ARE ENTERING INTO THIS AGREEMENT FOR LICENSE +OF THE SOFTWARE BY CUSTOMER FOR CUSTOMER’S BUSINESS PURPOSES AS DESCRIBED IN +AND IN ACCORDANCE WITH THIS AGREEMENT. YOU HEREBY AGREE THAT YOU ENTER INTO +THIS AGREEMENT ON BEHALF OF CUSTOMER AND THAT YOU HAVE THE AUTHORITY TO BIND +CUSTOMER TO THIS AGREEMENT. + +STREAM.IO IS WILLING TO LICENSE THE SOFTWARE TO CUSTOMER ONLY ON THE FOLLOWING +CONDITIONS: (1) YOU ARE A CURRENT CUSTOMER OF STREAM.IO; (2) YOU ARE NOT A +COMPETITOR OF STREAM.IO; AND (3) THAT YOU ACCEPT ALL THE TERMS IN THIS +AGREEMENT. BY DOWNLOADING, INSTALLING, CONFIGURING, ACCESSING OR OTHERWISE +USING THE SOFTWARE, INCLUDING ANY UPDATES, UPGRADES, OR NEWER VERSIONS, YOU +REPRESENT, WARRANT AND ACKNOWLEDGE THAT (A) CUSTOMER IS A CURRENT CUSTOMER OF +STREAM.IO; (B) CUSTOMER IS NOT A COMPETITOR OF STREAM.IO; AND THAT (C) YOU HAVE +READ THIS AGREEMENT, UNDERSTAND THIS AGREEMENT, AND THAT CUSTOMER AGREES TO BE +BOUND BY ALL THE TERMS OF THIS AGREEMENT. + +IF YOU DO NOT AGREE TO ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT, +STREAM.IO IS UNWILLING TO LICENSE THE SOFTWARE TO CUSTOMER, AND THEREFORE, DO +NOT COMPLETE THE DOWNLOAD PROCESS, ACCESS OR OTHERWISE USE THE SOFTWARE, AND +CUSTOMER SHOULD IMMEDIATELY RETURN THE SOFTWARE AND CEASE ANY USE OF THE +SOFTWARE. + +1. SOFTWARE. The Stream.io software accompanying this Agreement, may include +Source Code, Executable Object Code, associated media, printed materials and +documentation (collectively, the “Software”). The Software also includes any +updates or upgrades to or new versions of the original Software, if and when +made available to you by Stream.io. “Source Code” means computer programming +code in human readable form that is not suitable for machine execution without +the intervening steps of interpretation or compilation. “Executable Object +Code" means the computer programming code in any other form than Source Code +that is not readily perceivable by humans and suitable for machine execution +without the intervening steps of interpretation or compilation. “Site” means a +Customer location controlled by Customer. “Authorized User” means any employee +or contractor of Customer working at the Site, who has signed a written +confidentiality agreement with Customer or is otherwise bound in writing by +confidentiality and use obligations at least as restrictive as those imposed +under this Agreement. + +2. LICENSE GRANT. Subject to the terms and conditions of this Agreement, in +consideration for the representations, warranties, and covenants made by +Customer in this Agreement, Stream.io grants to Customer, during the term of +this Agreement, a personal, non-exclusive, non-transferable, non-sublicensable +license to: + +a. install and use Software Source Code on password protected computers at a Site, +restricted to Authorized Users; + +b. create derivative works, improvements (whether or not patentable), extensions +and other modifications to the Software Source Code (“Modifications”) to build +unique scalable newsfeeds, activity streams, and in-app messaging via Stream’s +application program interface (“API”); + +c. compile the Software Source Code to create Executable Object Code versions of +the Software Source Code and Modifications to build such newsfeeds, activity +streams, and in-app messaging via the API; + +d. install, execute and use such Executable Object Code versions solely for +Customer’s internal business use (including development of websites through +which data generated by Stream services will be streamed (“Apps”)); + +e. use and distribute such Executable Object Code as part of Customer’s Apps; and + +f. make electronic copies of the Software and Modifications as required for backup +or archival purposes. + +3. RESTRICTIONS. Customer is responsible for all activities that occur in +connection with the Software. Customer will not, and will not attempt to: (a) +sublicense or transfer the Software or any Source Code related to the Software +or any of Customer’s rights under this Agreement, except as otherwise provided +in this Agreement, (b) use the Software Source Code for the benefit of a third +party or to operate a service; (c) allow any third party to access or use the +Software Source Code; (d) sublicense or distribute the Software Source Code or +any Modifications in Source Code or other derivative works based on any part of +the Software Source Code; (e) use the Software in any manner that competes with +Stream.io or its business; or (e) otherwise use the Software in any manner that +exceeds the scope of use permitted in this Agreement. Customer shall use the +Software in compliance with any accompanying documentation any laws applicable +to Customer. + +4. OPEN SOURCE. Customer and its Authorized Users shall not use any software or +software components that are open source in conjunction with the Software +Source Code or any Modifications in Source Code or in any way that could +subject the Software to any open source licenses. + +5. CONTRACTORS. Under the rights granted to Customer under this Agreement, +Customer may permit its employees, contractors, and agencies of Customer to +become Authorized Users to exercise the rights to the Software granted to +Customer in accordance with this Agreement solely on behalf of Customer to +provide services to Customer; provided that Customer shall be liable for the +acts and omissions of all Authorized Users to the extent any of such acts or +omissions, if performed by Customer, would constitute a breach of, or otherwise +give rise to liability to Customer under, this Agreement. Customer shall not +and shall not permit any Authorized User to use the Software except as +expressly permitted in this Agreement. + +6. COMPETITIVE PRODUCT DEVELOPMENT. Customer shall not use the Software in any way +to engage in the development of products or services which could be reasonably +construed to provide a complete or partial functional or commercial alternative +to Stream.io’s products or services (a “Competitive Product”). Customer shall +ensure that there is no direct or indirect use of, or sharing of, Software +source code, or other information based upon or derived from the Software to +develop such products or services. Without derogating from the generality of +the foregoing, development of Competitive Products shall include having direct +or indirect access to, supervising, consulting or assisting in the development +of, or producing any specifications, documentation, object code or source code +for, all or part of a Competitive Product. + +7. LIMITATION ON MODIFICATIONS. Notwithstanding any provision in this Agreement, +Modifications may only be created and used by Customer as permitted by this +Agreement and Modification Source Code may not be distributed to third parties. +Customer will not assert against Stream.io, its affiliates, or their customers, +direct or indirect, agents and contractors, in any way, any patent rights that +Customer may obtain relating to any Modifications for Stream.io, its +affiliates’, or their customers’, direct or indirect, agents’ and contractors’ +manufacture, use, import, offer for sale or sale of any Stream.io products or +services. + +8. DELIVERY AND ACCEPTANCE. The Software will be delivered electronically pursuant +to Stream.io standard download procedures. The Software is deemed accepted upon +delivery. + +9. IMPLEMENTATION AND SUPPORT. Stream.io has no obligation under this Agreement to +provide any support or consultation concerning the Software. + +10. TERM AND TERMINATION. The term of this Agreement begins when the Software is +downloaded or accessed and shall continue until terminated. Either party may +terminate this Agreement upon written notice. This Agreement shall +automatically terminate if Customer is or becomes a competitor of Stream.io or +makes or sells any Competitive Products. Upon termination of this Agreement for +any reason, (a) all rights granted to Customer in this Agreement immediately +cease to exist, (b) Customer must promptly discontinue all use of the Software +and return to Stream.io or destroy all copies of the Software in Customer’s +possession or control. Any continued use of the Software by Customer or attempt +by Customer to exercise any rights under this Agreement after this Agreement +has terminated shall be considered copyright infringement and subject Customer +to applicable remedies for copyright infringement. Sections 2, 5, 6, 8 and 9 +shall survive expiration or termination of this Agreement for any reason. + +11. OWNERSHIP. As between the parties, the Software and all worldwide intellectual +property rights and proprietary rights relating thereto or embodied therein, +are the exclusive property of Stream.io and its suppliers. Stream.io and its +suppliers reserve all rights in and to the Software not expressly granted to +Customer in this Agreement, and no other licenses or rights are granted by +implication, estoppel or otherwise. + +12. WARRANTY DISCLAIMER. USE OF THIS SOFTWARE IS ENTIRELY AT YOURS AND CUSTOMER’S +OWN RISK. THE SOFTWARE IS PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND +WHATSOEVER. STREAM.IO DOES NOT MAKE, AND HEREBY DISCLAIMS, ANY WARRANTY OF ANY +KIND, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING WITHOUT +LIMITATION, THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, TITLE, NON-INFRINGEMENT OF THIRD-PARTY RIGHTS, RESULTS, EFFORTS, +QUALITY OR QUIET ENJOYMENT. STREAM.IO DOES NOT WARRANT THAT THE SOFTWARE IS +ERROR-FREE, WILL FUNCTION WITHOUT INTERRUPTION, WILL MEET ANY SPECIFIC NEED +THAT CUSTOMER HAS, THAT ALL DEFECTS WILL BE CORRECTED OR THAT IT IS +SUFFICIENTLY DOCUMENTED TO BE USABLE BY CUSTOMER. TO THE EXTENT THAT STREAM.IO +MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW, THE SCOPE AND +DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER SUCH LAW. +CUSTOMER ACKNOWLEDGES THAT IT HAS RELIED ON NO WARRANTIES OTHER THAN THE +EXPRESS WARRANTIES IN THIS AGREEMENT. + +13. LIMITATION OF LIABILITY. TO THE FULLEST EXTENT PERMISSIBLE BY LAW, STREAM.IO’S +TOTAL LIABILITY FOR ALL DAMAGES ARISING OUT OF OR RELATED TO THE SOFTWARE OR +THIS AGREEMENT, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE) OR OTHERWISE, +SHALL NOT EXCEED $100. IN NO EVENT WILL STREAM.IO BE LIABLE FOR ANY INDIRECT, +CONSEQUENTIAL, EXEMPLARY, PUNITIVE, SPECIAL OR INCIDENTAL DAMAGES OF ANY KIND +WHATSOEVER, INCLUDING ANY LOST DATA AND LOST PROFITS, ARISING FROM OR RELATING +TO THE SOFTWARE EVEN IF STREAM.IO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. CUSTOMER ACKNOWLEDGES THAT THIS PROVISION REFLECTS THE AGREED UPON +ALLOCATION OF RISK FOR THIS AGREEMENT AND THAT STREAM.IO WOULD NOT ENTER INTO +THIS AGREEMENT WITHOUT THESE LIMITATIONS ON ITS LIABILITY. + +14. General. Customer may not assign or transfer this Agreement, by operation of +law or otherwise, or any of its rights under this Agreement (including the +license rights granted to Customer) to any third party without Stream.io’s +prior written consent, which consent will not be unreasonably withheld or +delayed. Stream.io may assign this Agreement, without consent, including, but +limited to, affiliate or any successor to all or substantially all its business +or assets to which this Agreement relates, whether by merger, sale of assets, +sale of stock, reorganization or otherwise. Any attempted assignment or +transfer in violation of the foregoing will be null and void. Stream.io shall +not be liable hereunder by reason of any failure or delay in the performance of +its obligations hereunder for any cause which is beyond the reasonable control. +All notices, consents, and approvals under this Agreement must be delivered in +writing by courier, by electronic mail, or by certified or registered mail, +(postage prepaid and return receipt requested) to the other party at the +address set forth in the customer agreement between Stream.io and Customer and +will be effective upon receipt or when delivery is refused. This Agreement will +be governed by and interpreted in accordance with the laws of the State of +Colorado, without reference to its choice of laws rules. The United Nations +Convention on Contracts for the International Sale of Goods does not apply to +this Agreement. Any action or proceeding arising from or relating to this +Agreement shall be brought in a federal or state court in Denver, Colorado, and +each party irrevocably submits to the jurisdiction and venue of any such court +in any such action or proceeding. All waivers must be in writing. Any waiver or +failure to enforce any provision of this Agreement on one occasion will not be +deemed a waiver of any other provision or of such provision on any other +occasion. If any provision of this Agreement is unenforceable, such provision +will be changed and interpreted to accomplish the objectives of such provision +to the greatest extent possible under applicable law and the remaining +provisions will continue in full force and effect. Customer shall not violate +any applicable law, rule or regulation, including those regarding the export of +technical data. The headings of Sections of this Agreement are for convenience +and are not to be used in interpreting this Agreement. As used in this +Agreement, the word “including” means “including but not limited to.” This +Agreement (including all exhibits and attachments) constitutes the entire +agreement between the parties regarding the subject hereof and supersedes all +prior or contemporaneous agreements, understandings and communication, whether +written or oral. This Agreement may be amended only by a written document +signed by both parties. The terms of any purchase order or similar document +submitted by Customer to Stream.io will have no effect. diff --git a/packages/stream_chat_flutter_core/README.md b/packages/stream_chat_flutter_core/README.md new file mode 100644 index 00000000..6fe86329 --- /dev/null +++ b/packages/stream_chat_flutter_core/README.md @@ -0,0 +1,90 @@ +# Official Flutter SDK Core for [Stream Chat](https://getstream.io/chat/) + +

+ Flutter Chat +

+ +> The official Flutter core components for Stream Chat, a service for +> building chat applications. + +[![Pub](https://img.shields.io/pub/v/stream_chat_flutter.svg)](https://pub.dartlang.org/packages/stream_chat_flutter) +![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) +[![Gitter](https://badges.gitter.im/GetStream/stream-chat-flutter.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +![CI](https://github.com/GetStream/stream-chat-flutter/workflows/CI/badge.svg?branch=master) + + +**Quick Links** + +- [Register](https://getstream.io/chat/trial/) to get an API key for Stream Chat +- [Flutter Chat Tutorial](https://getstream.io/chat/flutter/tutorial/) +- [Chat UI Kit](https://getstream.io/chat/ui-kit/) + +## Flutter Chat Tutorial + +The best place to start is the [Flutter Chat Tutorial](https://getstream.io/chat/flutter/tutorial/). +It teaches you how to use this SDK and also shows how to make frequently required changes. + +## 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. + +## 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.svg)](https://pub.dartlang.org/packages/stream_chat_flutter) +```yaml +dependencies: + stream_chat_flutter_core: ^latest_version +``` + +You should then run `flutter packages get` + +This package requires no custom setup on any platform since it does not depend on any platform-specific dependency + +## Docs + +This package provides business logic to fetch common things required for integrating Stream Chat into your application. +The core package allows more customisation and hence provides business logic but no UI components. +Please use the stream_chat_flutter package for the full fledged suite of UI components or stream_chat for the low-level client. + +The package primarily contains three types of classes: + +1) Business Logic Components +2) Core Components +3) Core Controllers + +### Business Logic Components + +These components allow you to have the maximum and lower-level control of the queries being executed. +The BLoCs we provide are: + +1) ChannelsBloc +2) MessageSearchBloc +3) UsersBloc + +### Core Components + +Core components usually are an easy way to fetch data associated with Stream Chat which are decoupled from UI and often expose UI builders. +Data fetching can be controlled with the controllers of the respective core components. + +1) ChannelListCore (Fetch a list of channels) +2) MessageListCore (Fetch a list of messages from a channel) +3) MessageSearchListCore (Fetch a list of search messages) +4) UserListCore (Fetch a list of users) +5) StreamChatCore (This is different from the other core components - it is a version of StreamChat decoupled from theme and initialisations.) + +### Core Controllers + +Core Controllers are supplied to respective CoreList widgets which allows reloading and pagination of data whenever needed. + +1) ChannelListController +2) MessageListController +3) MessageSearchListController +4) ChannelListController + +## Contributing + +We welcome code changes that improve this library or fix a problem, +please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. +We are pleased to merge your code into the official repository. +Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. +See our license file for more details. diff --git a/packages/stream_chat_flutter_core/example/.gitignore b/packages/stream_chat_flutter_core/example/.gitignore new file mode 100644 index 00000000..9d532b18 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/.gitignore @@ -0,0 +1,41 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/stream_chat_flutter_core/example/.metadata b/packages/stream_chat_flutter_core/example/.metadata new file mode 100644 index 00000000..182cccaf --- /dev/null +++ b/packages/stream_chat_flutter_core/example/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 78910062997c3a836feee883712c241a5fd22983 + channel: stable + +project_type: app diff --git a/packages/stream_chat_flutter_core/example/README.md b/packages/stream_chat_flutter_core/example/README.md new file mode 100644 index 00000000..fef640e1 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/README.md @@ -0,0 +1,16 @@ +# example + +Example app for testing stream_chat_flutter_core + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/stream_chat_flutter_core/example/android/.gitignore b/packages/stream_chat_flutter_core/example/android/.gitignore new file mode 100644 index 00000000..0a741cb4 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/packages/stream_chat_flutter_core/example/android/app/build.gradle b/packages/stream_chat_flutter_core/example/android/app/build.gradle new file mode 100644 index 00000000..edcec7b9 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/app/build.gradle @@ -0,0 +1,54 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "example.example" + minSdkVersion 16 + targetSdkVersion 29 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} diff --git a/packages/stream_chat_flutter_core/example/android/app/src/debug/AndroidManifest.xml b/packages/stream_chat_flutter_core/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..397a9492 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/AndroidManifest.xml b/packages/stream_chat_flutter_core/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..d2eb734a --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/java/example/example/MainActivity.java b/packages/stream_chat_flutter_core/example/android/app/src/main/java/example/example/MainActivity.java new file mode 100644 index 00000000..334c0856 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/app/src/main/java/example/example/MainActivity.java @@ -0,0 +1,5 @@ +package example.example; + +import io.flutter.embedding.android.FlutterActivity; + +public class MainActivity extends FlutterActivity {} diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/res/drawable/launch_background.xml b/packages/stream_chat_flutter_core/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/packages/stream_chat_flutter_core/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_flutter_core/example/android/app/src/main/res/values/styles.xml b/packages/stream_chat_flutter_core/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..1f83a33f --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/stream_chat_flutter_core/example/android/app/src/profile/AndroidManifest.xml b/packages/stream_chat_flutter_core/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..397a9492 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/stream_chat_flutter_core/example/android/build.gradle b/packages/stream_chat_flutter_core/example/android/build.gradle new file mode 100644 index 00000000..e0d7ae2c --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/build.gradle @@ -0,0 +1,29 @@ +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.5.0' + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/stream_chat_flutter_core/example/android/gradle.properties b/packages/stream_chat_flutter_core/example/android/gradle.properties new file mode 100644 index 00000000..a6738207 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/packages/stream_chat_flutter_core/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/stream_chat_flutter_core/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..de2ccd60 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip diff --git a/packages/stream_chat_flutter_core/example/android/settings.gradle b/packages/stream_chat_flutter_core/example/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/packages/stream_chat_flutter_core/example/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/packages/stream_chat_flutter_core/example/ios/.gitignore b/packages/stream_chat_flutter_core/example/ios/.gitignore new file mode 100644 index 00000000..e96ef602 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/stream_chat_flutter_core/example/ios/Flutter/AppFrameworkInfo.plist b/packages/stream_chat_flutter_core/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..6b4c0f78 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/packages/stream_chat_flutter_core/example/ios/Flutter/Debug.xcconfig b/packages/stream_chat_flutter_core/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..e8efba11 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/stream_chat_flutter_core/example/ios/Flutter/Release.xcconfig b/packages/stream_chat_flutter_core/example/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..399e9340 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.pbxproj b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 00000000..9943511d --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,495 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1020; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 9.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..a28140cf --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/AppDelegate.swift b/packages/stream_chat_flutter_core/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..70693e4a --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..28c6bf03 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..2ccbfd96 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..f091b6b0 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cde1211 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..d0ef06e7 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..dcdc2306 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..2ccbfd96 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..c8f9ed8f Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..a6d6b860 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..a6d6b860 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..75b2d164 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..c4df70d3 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..6a84f41e Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..d0e1f585 Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/stream_chat_flutter_core/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Base.lproj/Main.storyboard b/packages/stream_chat_flutter_core/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Info.plist b/packages/stream_chat_flutter_core/example/ios/Runner/Info.plist new file mode 100644 index 00000000..a060db61 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/stream_chat_flutter_core/example/ios/Runner/Runner-Bridging-Header.h b/packages/stream_chat_flutter_core/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/stream_chat_flutter_core/example/lib/main.dart b/packages/stream_chat_flutter_core/example/lib/main.dart new file mode 100644 index 00000000..9199ed15 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/lib/main.dart @@ -0,0 +1,306 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +Future main() async { + /// Create a new instance of [StreamChatClient] passing the apikey obtained from your + /// project dashboard. + final client = StreamChatClient('b67pax5b2wdq'); + + /// Set the current user. In a production scenario, this should be done using + /// a backend to generate a user token using our server SDK. + /// Please see the following for more information: + /// https://getstream.io/chat/docs/ios_user_setup_and_tokens/ + await client.setUser( + User( + id: 'cool-shadow-7', + extraData: { + 'image': + 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', + }, + ), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', + ); + + runApp( + StreamExample( + client: client, + ), + ); +} + +/// Example application using Stream Chat core widgets. +/// Stream Chat Core is a set of Flutter wrappers which provide basic functionality +/// for building Flutter applications using Stream. +/// If you'd prefer using pre-made UI widgets for your app, please see our other +/// package, `stream_chat_flutter`. +class StreamExample extends StatelessWidget { + /// Minimal example using Stream's core Flutter package. + /// If you'd prefer using pre-made UI widgets for your app, please see our other + /// package, `stream_chat_flutter`. + const StreamExample({ + Key key, + @required this.client, + }) : super(key: key); + + /// Instance of Stream Client. + /// Stream's [StreamChatClient] can be used to connect to our servers and set the default + /// user for the application. Performing these actions trigger a websocket connection + /// allowing for real-time updates. + final StreamChatClient client; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Stream Chat Core Example', + home: HomeScreen(), + builder: (context, child) => StreamChatCore( + client: client, + child: child, + ), + ); + } +} + +/// Basic layout displaying a list of [Channel]s the user is a part of. +/// This is implemented using [ChannelListCore]. +/// +/// [ChannelListCore] is a `builder` with callbacks for constructing UIs based +/// on different scenarios. +class HomeScreen extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Channels'), + ), + body: ChannelsBloc( + child: ChannelListCore( + emptyBuilder: (BuildContext context) { + return Center( + child: Text('Looks like you are not in any channels'), + ); + }, + loadingBuilder: (BuildContext context) { + return Center( + child: SizedBox( + height: 100.0, + width: 100.0, + child: CircularProgressIndicator(), + ), + ); + }, + errorBuilder: (BuildContext context, dynamic error) { + return Center( + child: Text( + 'Oh no, something went wrong. Please check your config.'), + ); + }, + listBuilder: ( + BuildContext context, + List channels, + ) => + ListView.builder( + itemCount: channels.length, + itemBuilder: (BuildContext context, int index) { + final _item = channels[index]; + return ListTile( + title: Text(_item.name), + subtitle: Text(_item.state.lastMessage.text), + onTap: () { + /// Display a list of messages when the user taps on an item. + /// We can use [StreamChannel] to wrap our [MessageScreen] screen + /// with the selected channel. + /// + /// This allows us to use a built-in inherited widget for accessing + /// our `channel` later on. + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: _item, + child: MessageScreen(), + ), + ), + ); + }, + ); + }, + ), + ), + ), + ); + } +} + +/// A list of messages sent in the current channel. +/// When a user taps on a channel in [HomeScreen], a navigator push [MessageScreen] +/// to display the list of messages in the selected channel. +/// +/// This is implemented using [MessageListCore], a convenience builder with +/// callbacks for building UIs based on different api results. +class MessageScreen extends StatefulWidget { + @override + _MessageScreenState createState() => _MessageScreenState(); +} + +class _MessageScreenState extends State { + TextEditingController _controller; + ScrollController _scrollController; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _updateList() { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + /// To access the current channel, we can use the `.of()` method on [StreamChannel] + /// to fetch the closest instance. + final channel = StreamChannel.of(context).channel; + return Scaffold( + appBar: AppBar( + title: Text(channel.name), + ), + body: SafeArea( + child: Column( + children: [ + Expanded( + child: MessageListCore( + emptyBuilder: (BuildContext context) { + return Center( + child: Text('Nothing here yet'), + ); + }, + loadingBuilder: (BuildContext context) { + return Center( + child: SizedBox( + height: 100.0, + width: 100.0, + child: CircularProgressIndicator(), + ), + ); + }, + messageListBuilder: ( + BuildContext context, + List messages, + ) { + return ListView.builder( + controller: _scrollController, + itemCount: messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = messages[index]; + final client = StreamChatCore.of(context).client; + if (item.user.id == client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(item.text), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(item.text), + ), + ); + } + }, + ); + }, + errorWidgetBuilder: (BuildContext context, error) { + print(error?.toString()); + return Center( + child: SizedBox( + height: 100.0, + width: 100.0, + child: Text('Oh no, an error occured. Please see logs.'), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Enter your message', + ), + ), + ), + Material( + type: MaterialType.circle, + color: Colors.blue, + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () async { + if (_controller.value.text.isNotEmpty) { + await channel.sendMessage( + Message(text: _controller.value.text), + ); + _controller.clear(); + _updateList(); + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Center( + child: Icon( + Icons.send, + color: Colors.white, + ), + ), + ), + ), + ) + ], + ), + ) + ], + ), + ), + ); + } +} + +/// Extensions can be used to add functionality to the SDK. In the examples +/// below, we add two simple extensions to the [StreamChatClient] and [Channel]. +extension on StreamChatClient { + /// Fetches the current user id. + String get uid => state.user.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; + } else { + return cid; + } + } +} diff --git a/packages/stream_chat_flutter_core/example/pubspec.yaml b/packages/stream_chat_flutter_core/example/pubspec.yaml new file mode 100644 index 00000000..b0071176 --- /dev/null +++ b/packages/stream_chat_flutter_core/example/pubspec.yaml @@ -0,0 +1,77 @@ +name: example +description: Example app for testing stream_chat_flutter_core + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + stream_chat_flutter_core: + path: ../ + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart new file mode 100644 index 00000000..b77ef394 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/channel_list_core.dart @@ -0,0 +1,274 @@ +import 'dart:async'; +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/channels_bloc.dart'; +import 'package:stream_chat_flutter_core/src/typedef.dart'; + +import 'stream_chat_core.dart'; + +/// [ChannelListCore] is a simplified class that allows fetching a list of channels while exposing UI builders. +/// A [ChannelListController] is used to reload and paginate data. +/// +/// +/// ```dart +/// class ChannelListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: ChannelListCore( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// errorBuilder: (err) { +/// return Center( +/// child: Text('An error has occured'), +/// ); +/// }, +/// emptyBuilder: (context) { +/// return Center( +/// child: Text('Nothing here...'), +/// ); +/// }, +/// emptyBuilder: (context) { +/// return Center( +/// child: CircularProgressIndicator(), +/// ); +/// }, +/// listBuilder: (context, list) { +/// return ChannelPage(list); +/// } +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// Make sure to have a [StreamChatCore] ancestor in order to provide the information about the channels. +class ChannelListCore extends StatefulWidget { + /// Instantiate a new ChannelListView + ChannelListCore({ + Key key, + @required this.errorBuilder, + @required this.emptyBuilder, + @required this.loadingBuilder, + @required this.listBuilder, + this.filter, + this.options, + this.sort, + this.pagination, + this.channelListController, + }) : assert(errorBuilder != null), + assert(emptyBuilder != null), + assert(loadingBuilder != null), + assert(listBuilder != null), + super(key: key); + + /// A [ChannelListController] allows reloading and pagination. + /// Use [ChannelListController.loadData] and [ChannelListController.paginateData] respectively for reloading and pagination. + final ChannelListController channelListController; + + /// The builder that will be used in case of error + final ErrorBuilder errorBuilder; + + /// The builder that will be used in case of loading + final WidgetBuilder loadingBuilder; + + /// The builder which is used when list of channels loads + final Function(BuildContext, List) listBuilder; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filter; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sort; + + /// Pagination parameters + /// limit: the number of channels to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams pagination; + + @override + _ChannelListCoreState createState() => _ChannelListCoreState(); +} + +class _ChannelListCoreState extends State + with WidgetsBindingObserver { + @override + Widget build(BuildContext context) { + final channelsBloc = ChannelsBloc.of(context); + + return _buildListView(channelsBloc); + } + + StreamBuilder> _buildListView( + ChannelsBlocState channelsBlocState, + ) { + return StreamBuilder>( + stream: channelsBlocState.channelsStream, + builder: (context, snapshot) { + var child; + if (snapshot.hasError) { + child = _buildErrorWidget( + snapshot, + context, + channelsBlocState, + ); + } else if (!snapshot.hasData) { + child = _buildLoadingWidget(); + } else { + final channels = snapshot.data; + + child = widget.emptyBuilder(context); + + if (channels.isNotEmpty) { + return widget.listBuilder(context, channels); + } + } + + return child; + }, + ); + } + + Widget _buildLoadingWidget() { + return widget.loadingBuilder(context); + } + + Widget _buildErrorWidget( + AsyncSnapshot> snapshot, + BuildContext context, + ChannelsBlocState channelsBlocState, + ) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + return widget.errorBuilder(context, snapshot.error); + } + + void loadData() { + final channelsBloc = ChannelsBloc.of(context); + + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + } + + void paginateData() { + final channelsBloc = ChannelsBloc.of(context); + + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination.copyWith( + offset: channelsBloc.channels?.length ?? 0, + ), + options: widget.options, + ); + } + + StreamSubscription _subscription; + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + + final channelsBloc = ChannelsBloc.of(context); + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + + final client = StreamChatCore.of(context).client; + + _subscription = client + .on( + EventType.connectionRecovered, + EventType.notificationAddedToChannel, + EventType.notificationMessageNew, + EventType.channelVisible, + ) + .listen((event) { + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + }); + + if (widget.channelListController != null) { + widget.channelListController.loadData = loadData; + widget.channelListController.paginateData = paginateData; + } + } + + @override + void didUpdateWidget(ChannelListCore oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.filter?.toString() != oldWidget.filter?.toString() || + jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.pagination?.toJson()?.toString() != + oldWidget.pagination?.toJson()?.toString() || + widget.options?.toString() != oldWidget.options?.toString()) { + final channelsBloc = ChannelsBloc.of(context); + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + } + } + + @override + void dispose() { + _subscription.cancel(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } +} + +/// Controller used for loading more data and controlling pagination in [ChannelListCore]. +class ChannelListController { + /// This function calls Stream's servers to load a list of channels. If there is existing data, + /// calling this function causes a reload. + VoidCallback loadData; + + /// This function is used to load another page of data. Note, [loadData] should be + /// used to populate the initial page of data. Calling [paginateData] performs a query + /// to load subsequent pages. + VoidCallback paginateData; +} diff --git a/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart similarity index 86% rename from lib/src/channels_bloc.dart rename to packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 81081051..7009234b 100644 --- a/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -3,11 +3,29 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/stream_chat.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/src/channel_list_core.dart'; +import 'package:stream_chat_flutter_core/src/stream_chat_core.dart'; /// Widget dedicated to the management of a channel list with pagination +/// [ChannelsBloc] is used together with [ChannelListCore] to manage a list of +/// [Channel]s with pagination, re-ordering, querying and other operations +/// associated with [Channel]s. +/// +/// [ChannelsBloc] can be access at anytime by using the static [of] method +/// using Flutter's [BuildContext]. +/// +/// API docs: https://getstream.io/chat/docs/flutter-dart/query_channels/ class ChannelsBloc extends StatefulWidget { + /// Creates a new [ChannelsBloc]. The parameter [child] must be supplied and not null. + const ChannelsBloc({ + Key key, + @required this.child, + this.lockChannelsOrder = false, + this.channelsComparator, + this.shouldAddChannel, + }) : assert(child != null), + super(key: key); + /// The widget child final Widget child; @@ -20,15 +38,6 @@ class ChannelsBloc extends StatefulWidget { /// Function used to evaluate if a channel should be added to the list when a message.new event is received final bool Function(Event) shouldAddChannel; - /// Instantiate a new ChannelsBloc - const ChannelsBloc({ - Key key, - this.child, - this.lockChannelsOrder = false, - this.channelsComparator, - this.shouldAddChannel, - }) : super(key: key); - @override ChannelsBlocState createState() => ChannelsBlocState(); @@ -39,14 +48,14 @@ class ChannelsBloc extends StatefulWidget { streamChatState = context.findAncestorStateOfType(); if (streamChatState == null) { - throw Exception('You must have a ChannelsBloc widget as anchestor'); + throw Exception('You must have a ChannelsBloc widget as ancestor'); } return streamChatState; } } -/// The current state of the [ChannelsBloc] +/// The current state of the [ChannelsBloc]. class ChannelsBlocState extends State with AutomaticKeepAliveClientMixin { @override @@ -80,7 +89,7 @@ class ChannelsBlocState extends State Map options, bool onlyOffline = false, }) async { - final client = StreamChat.of(context).client; + final client = StreamChatCore.of(context).client; if (client.state?.user == null || _queryChannelsLoadingController.value == true) { @@ -121,7 +130,7 @@ class ChannelsBlocState extends State void initState() { super.initState(); - final client = StreamChat.of(context).client; + final client = StreamChatCore.of(context).client; if (!widget.lockChannelsOrder) { _subscriptions.add(client.on(EventType.messageNew).listen((e) { diff --git a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart new file mode 100644 index 00000000..1820827f --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart @@ -0,0 +1,145 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +enum _LoadingStatus { LOADING, STABLE } + +/// Wrapper around a [Scrollable] which triggers [onEndOfPage]/[onStartOfPage] the Scrollable +/// reaches to the start or end of the view extent. +class LazyLoadScrollView extends StatefulWidget { + /// Creates a new instance of [LazyLoadScrollView]. The parameter [child] must be + /// supplied and not null. + const LazyLoadScrollView({ + Key key, + @required this.child, + this.onStartOfPage, + this.onEndOfPage, + this.onPageScrollStart, + this.onPageScrollEnd, + this.onInBetweenOfPage, + this.scrollOffset = 100, + }) : assert(child != null), + super(key: key); + + /// The [Widget] that this widget watches for changes on + final Widget child; + + /// Called when the [child] reaches the start of the list + final AsyncCallback onStartOfPage; + + /// Called when the [child] reaches the end of the list + final AsyncCallback onEndOfPage; + + /// Called when the list scrolling starts + final VoidCallback onPageScrollStart; + + /// Called when the list scrolling ends + final VoidCallback onPageScrollEnd; + + /// Called every time the [child] is in-between the list + final VoidCallback onInBetweenOfPage; + + /// The offset to take into account when triggering [onEndOfPage]/[onStartOfPage] in pixels + final double scrollOffset; + + @override + State createState() => _LazyLoadScrollViewState(); +} + +class _LazyLoadScrollViewState extends State { + _LoadingStatus _loadMoreStatus = _LoadingStatus.STABLE; + double _scrollPosition = 0.0; + + @override + Widget build(BuildContext context) { + return NotificationListener( + child: widget.child, + onNotification: _onNotification, + ); + } + + bool _onNotification(Notification notification) { + if (notification is ScrollStartNotification) { + if (widget.onPageScrollStart != null) { + widget.onPageScrollStart(); + return true; + } + } + if (notification is ScrollEndNotification) { + if (widget.onPageScrollEnd != null) { + widget.onPageScrollEnd(); + return true; + } + } + if (notification is ScrollUpdateNotification) { + final pixels = notification.metrics.pixels; + final maxScrollExtent = notification.metrics.maxScrollExtent; + final minScrollExtent = notification.metrics.minScrollExtent; + final scrollOffset = widget.scrollOffset; + + if (pixels > (minScrollExtent + scrollOffset) && + pixels < (maxScrollExtent - scrollOffset)) { + if (widget.onInBetweenOfPage != null) { + widget.onInBetweenOfPage(); + return true; + } + } + + final extentBefore = notification.metrics.extentBefore; + final extentAfter = notification.metrics.extentAfter; + final scrollingDown = _scrollPosition < pixels; + + if (scrollOffset == null || scrollOffset == 0) { + if (extentAfter == 0) { + _onEndOfPage(); + } + if (extentBefore == 0) { + _onStartOfPage(); + } + } else { + if (scrollingDown) { + if (extentAfter <= scrollOffset) { + _onEndOfPage(); + } + } else { + if (extentBefore <= scrollOffset) { + _onStartOfPage(); + } + } + } + _scrollPosition = pixels; + return true; + } + if (notification is OverscrollNotification) { + if (notification.overscroll > 0) { + _onEndOfPage(); + } + if (notification.overscroll < 0) { + _onStartOfPage(); + } + return true; + } + return false; + } + + void _onEndOfPage() { + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { + _loadMoreStatus = _LoadingStatus.LOADING; + if (widget.onEndOfPage != null) { + widget.onEndOfPage().whenComplete(() { + _loadMoreStatus = _LoadingStatus.STABLE; + }); + } + } + } + + void _onStartOfPage() { + if (_loadMoreStatus != null && _loadMoreStatus == _LoadingStatus.STABLE) { + _loadMoreStatus = _LoadingStatus.LOADING; + if (widget.onStartOfPage != null) { + widget.onStartOfPage().whenComplete(() { + _loadMoreStatus = _LoadingStatus.STABLE; + }); + } + } + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart new file mode 100644 index 00000000..64e8f255 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -0,0 +1,188 @@ +import 'dart:async'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/typedef.dart'; +import 'stream_channel.dart'; + +/// [MessageListCore] is a simplified class that allows fetching a list of messages while exposing UI builders. +/// A [MessageListController] is used to paginate data. +/// +/// ```dart +/// class ChannelPage extends StatelessWidget { +/// const ChannelPage({ +/// Key key, +/// }) : super(key: key); +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: Column( +/// children: [ +/// Expanded( +/// child: MessageListCore( +/// emptyBuilder: (context) { +/// return Center( +/// child: Text('Nothing here...'), +/// ); +/// }, +/// loadingBuilder: (context) { +/// return Center( +/// child: CircularProgressIndicator(), +/// ); +/// }, +/// messageListBuilder: (context, list) { +/// return MessagesPage(list); +/// }, +/// errorWidgetBuilder: (context, err) { +/// return Center( +/// child: Text('Error'), +/// ); +/// }, +/// ), +/// ), +/// ], +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channels. +/// The widget uses a [ListView.custom] to render the list of channels. +/// +class MessageListCore extends StatefulWidget { + /// Instantiate a new [MessageListView]. + MessageListCore({ + Key key, + @required this.loadingBuilder, + @required this.emptyBuilder, + @required this.messageListBuilder, + @required this.errorWidgetBuilder, + this.showScrollToBottom = true, + this.parentMessage, + this.messageListController, + }) : assert(loadingBuilder != null), + assert(emptyBuilder != null), + assert(messageListBuilder != null), + assert(errorWidgetBuilder != null), + super(key: key); + + /// A [MessageListController] allows pagination. + /// Use [ChannelListController.paginateData] pagination. + final MessageListController messageListController; + + /// Function called when messages are fetched + final Widget Function(BuildContext, List) messageListBuilder; + + /// Function used to build a loading widget + final WidgetBuilder loadingBuilder; + + /// Function used to build an empty widget + final WidgetBuilder emptyBuilder; + + /// Callback triggered when an error occurs while performing the given request. + /// This parameter can be used to display an error message to users in the event + /// of a connection failure. + final ErrorBuilder errorWidgetBuilder; + + /// If true will show a scroll to bottom message when there are new messages and the scroll offset is not zero + final bool showScrollToBottom; + + /// If the current message belongs to a `thread`, this property represents the + /// first message or the parent of the conversation. + final Message parentMessage; + + @override + _MessageListCoreState createState() => _MessageListCoreState(); +} + +class _MessageListCoreState extends State { + StreamChannelState streamChannel; + + bool get _upToDate => streamChannel.channel.state.isUpToDate; + + bool get _isThreadConversation => widget.parentMessage != null; + + int initialIndex; + double initialAlignment; + + List messages = []; + + bool initialMessageHighlightComplete = false; + + @override + Widget build(BuildContext context) { + final messagesStream = _isThreadConversation + ? streamChannel.channel.state.threadsStream + .where((threads) => threads.containsKey(widget.parentMessage.id)) + .map((threads) => threads[widget.parentMessage.id]) + : streamChannel.channel.state?.messagesStream; + + return StreamBuilder>( + stream: messagesStream?.map((messages) => messages + ?.where((e) => + (!e.isDeleted && e.shadowed != true) || + (e.isDeleted && + e.user.id == streamChannel.channel.client.state.user.id)) + ?.toList()), + builder: (context, snapshot) { + if (!snapshot.hasData) { + return widget.loadingBuilder(context); + } else if (snapshot.hasError) { + return widget.errorWidgetBuilder(context, snapshot.error); + } else { + final messageList = snapshot.data?.reversed?.toList() ?? []; + if (messageList.isEmpty) { + if (_upToDate) { + return widget.emptyBuilder(context); + } + } else { + messages = messageList; + } + return widget.messageListBuilder(context, messages); + } + }, + ); + } + + Future paginateData( + {QueryDirection direction = QueryDirection.bottom}) { + if (!_isThreadConversation) { + return streamChannel.queryMessages(direction: direction); + } else { + return streamChannel.getReplies(widget.parentMessage.id); + } + } + + @override + void initState() { + streamChannel = StreamChannel.of(context); + + if (_isThreadConversation) { + streamChannel.getReplies(widget.parentMessage.id); + } + + if (widget.messageListController != null) { + widget.messageListController.paginateData = paginateData; + } + + super.initState(); + } + + @override + void dispose() { + if (!_upToDate) { + streamChannel.reloadChannel(); + } + super.dispose(); + } +} + +/// Controller used for paginating data in [ChannelListView] +class MessageListController { + /// Call this function to load further data + Function({QueryDirection direction}) paginateData; +} diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart new file mode 100644 index 00000000..9839994f --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'stream_chat_core.dart'; + +/// [MessageSearchBloc] is used to manage a list of messages with pagination. +/// This class can be used to load messages, perform queries, etc. +/// +/// [MessageSearchBloc] can be access at anytime by using the static [of] method +/// using Flutter's [BuildContext]. +/// +// API docs: https://getstream.io/chat/docs/flutter-dart/send_message/ +class MessageSearchBloc extends StatefulWidget { + /// Instantiate a new MessageSearchBloc + const MessageSearchBloc({ + Key key, + @required this.child, + }) : assert(child != null), + super(key: key); + + /// The widget child + final Widget child; + + @override + MessageSearchBlocState createState() => MessageSearchBlocState(); + + /// Use this method to get the current [MessageSearchBlocState] instance + static MessageSearchBlocState of(BuildContext context) { + MessageSearchBlocState state; + + state = context.findAncestorStateOfType(); + + if (state == null) { + throw Exception('You must have a MessageSearchBloc widget as ancestor'); + } + + return state; + } +} + +/// The current state of the [MessageSearchBloc] +class MessageSearchBlocState extends State + with AutomaticKeepAliveClientMixin { + /// The current messages list + List get messageResponses => _messageResponses.value; + + /// The current messages list as a stream + Stream> get messagesStream => + _messageResponses.stream; + + final BehaviorSubject> _messageResponses = + BehaviorSubject(); + + final BehaviorSubject _queryMessagesLoadingController = + BehaviorSubject.seeded(false); + + /// The stream notifying the state of queryUsers call + Stream get queryMessagesLoading => + _queryMessagesLoadingController.stream; + + /// Calls [StreamChatClient.search] updating [messageResponses] stream + Future search({ + Map filter, + Map messageFilter, + List sort, + String query, + PaginationParams pagination, + }) async { + _messageResponses.add(null); + try { + final messages = await _search( + filter: filter, + messageFilter: messageFilter, + sort: sort, + query: query, + pagination: pagination, + ); + _messageResponses.add(messages.results); + } catch (err, stk) { + _messageResponses.addError(err, stk); + } + } + + /// Calls [StreamChatClient.search] updating [queryMessagesLoading] stream + Future loadMore({ + Map filter, + Map messageFilter, + List sort, + String query, + PaginationParams pagination, + }) async { + if (_queryMessagesLoadingController.value == true) { + return; + } + _queryMessagesLoadingController.add(true); + try { + final clear = pagination == null || + pagination.offset == null || + pagination.offset == 0; + + final oldMessages = List.from(messageResponses ?? []); + + final messages = await _search( + filter: filter, + messageFilter: messageFilter, + sort: sort, + query: query, + pagination: pagination, + ); + + if (clear) { + _messageResponses.add(messages.results); + } else { + final temp = oldMessages + messages.results; + _messageResponses.add(temp); + } + + _queryMessagesLoadingController.add(false); + } catch (err, stackTrace) { + _queryMessagesLoadingController.addError(err, stackTrace); + } + } + + Future _search({ + Map filter, + Map messageFilter, + List sort, + String query, + PaginationParams pagination, + }) { + final client = StreamChatCore.of(context).client; + return client.search( + filter, + sort, + query, + pagination, + messageFilters: messageFilter, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + @override + void dispose() { + _messageResponses.close(); + _queryMessagesLoadingController.close(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart new file mode 100644 index 00000000..e3d98426 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/typedef.dart'; +import 'message_search_bloc.dart'; + +/// +/// [MessageSearchListCore] is a simplified class that allows searching for messages across channels while exposing UI builders. +/// A [MessageSearchListController] is used to load and paginate data. +/// +/// ```dart +/// class MessageSearchPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: MessageSearchListCore( +/// messageQuery: _channelQuery, +/// filters: { +/// 'members': { +/// r'$in': [user.id] +/// } +/// }, +/// paginationParams: PaginationParams(limit: 20), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// Make sure to have a [MessageSearchBloc] ancestor in order to provide the information about the messages. +/// The widget uses a [ListView.separated] to render the list of messages. +/// +class MessageSearchListCore extends StatefulWidget { + /// Instantiate a new [MessageSearchListView]. + /// The following parameters must be supplied and not null: + /// * [emptyBuilder] + /// * [errorBuilder] + /// * [loadingBuilder] + /// * [childBuilder] + const MessageSearchListCore({ + Key key, + @required this.emptyBuilder, + @required this.errorBuilder, + @required this.loadingBuilder, + @required this.childBuilder, + this.messageQuery, + this.filters, + this.sortOptions, + this.paginationParams, + this.messageFilters, + this.messageSearchListController, + }) : assert(emptyBuilder != null), + assert(errorBuilder != null), + assert(loadingBuilder != null), + assert(childBuilder != null), + super(key: key); + + /// A [MessageSearchListController] allows reloading and pagination. + /// Use [MessageSearchListController.loadData] and [MessageSearchListController.paginateData] respectively for reloading and pagination. + final MessageSearchListController messageSearchListController; + + /// Message String to search on + final String messageQuery; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filters; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sortOptions; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams paginationParams; + + /// The message query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map messageFilters; + + /// The builder that is used when the search messages are fetched + final Widget Function(List) childBuilder; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The builder that will be used in case of error + final ErrorBuilder errorBuilder; + + /// The builder that will be used in case of loading + final WidgetBuilder loadingBuilder; + + @override + _MessageSearchListCoreState createState() => _MessageSearchListCoreState(); +} + +class _MessageSearchListCoreState extends State { + @override + void initState() { + super.initState(); + final messageSearchBloc = MessageSearchBloc.of(context); + messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + messageFilter: widget.messageFilters, + ); + + if (widget.messageSearchListController != null) { + widget.messageSearchListController.loadData = loadData; + widget.messageSearchListController.paginateData = paginateData; + } + } + + @override + Widget build(BuildContext context) { + final messageSearchBloc = MessageSearchBloc.of(context); + return _buildListView(messageSearchBloc); + } + + Widget _buildListView(MessageSearchBlocState messageSearchBloc) { + return StreamBuilder>( + stream: messageSearchBloc.messagesStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + return widget.errorBuilder(context, snapshot.error); + } + + if (!snapshot.hasData) { + return widget.loadingBuilder(context); + } + + final items = snapshot.data; + + if (items.isEmpty) { + return widget.emptyBuilder(context); + } + + return widget.childBuilder(snapshot.data); + }, + ); + } + + void loadData() { + final messageSearchBloc = MessageSearchBloc.of(context); + + messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + messageFilter: widget.messageFilters, + ); + } + + void paginateData() { + final messageSearchBloc = MessageSearchBloc.of(context); + + messageSearchBloc.loadMore( + filter: widget.filters, + sort: widget.sortOptions, + pagination: widget.paginationParams.copyWith( + offset: messageSearchBloc.messageResponses?.length ?? 0, + ), + query: widget.messageQuery, + messageFilter: widget.messageFilters, + ); + } + + @override + void didUpdateWidget(MessageSearchListCore oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.filters?.toString() != oldWidget.filters?.toString() || + jsonEncode(widget.sortOptions) != jsonEncode(oldWidget.sortOptions) || + widget.paginationParams?.toJson()?.toString() != + oldWidget.paginationParams?.toJson()?.toString() || + widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() || + widget.messageFilters?.toString() != + oldWidget.messageFilters?.toString()) { + final messageSearchBloc = MessageSearchBloc.of(context); + messageSearchBloc.search( + filter: widget.filters, + sort: widget.sortOptions, + query: widget.messageQuery, + pagination: widget.paginationParams, + messageFilter: widget.messageFilters, + ); + } + } +} + +/// Controller used for paginating data in [ChannelListView] +class MessageSearchListController { + /// Call this function to reload data + VoidCallback loadData; + + /// Call this function to load further data + VoidCallback paginateData; +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart new file mode 100644 index 00000000..af5502ae --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -0,0 +1,371 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// Specifies query direction for pagination +enum QueryDirection { + /// Query earlier messages + top, + + /// Query later messages + bottom, +} + +/// Widget used to provide information about the channel to the widget tree +/// +/// Use [StreamChannel.of] to get the current [StreamChannelState] instance. +class StreamChannel extends StatefulWidget { + /// Creates a new instance of [StreamChannel]. Both [child] and [client] must + /// be supplied and not null. + const StreamChannel({ + Key key, + @required this.child, + @required this.channel, + this.showLoading = true, + this.initialMessageId, + }) : assert(child != null), + assert(channel != null), + super(key: key); + + // ignore: public_member_api_docs + final Widget child; + + /// [channel] specifies the channel with which child should be wrapped + final Channel channel; + + /// Shows a loading indicator + final bool showLoading; + + /// If passed the channel will load from this particular message. + final String initialMessageId; + + /// Use this method to get the current [StreamChannelState] instance + static StreamChannelState of(BuildContext context) { + StreamChannelState streamChannelState; + + streamChannelState = context.findAncestorStateOfType(); + + if (streamChannelState == null) { + throw Exception( + 'You must have a StreamChannel widget at the top of your widget tree'); + } + + return streamChannelState; + } + + @override + StreamChannelState createState() => StreamChannelState(); +} + +// ignore: public_member_api_docs +class StreamChannelState extends State { + /// Current channel + Channel get channel => widget.channel; + + /// InitialMessageId + String get initialMessageId => widget.initialMessageId; + + /// Current channel state stream + Stream get channelStateStream => + widget.channel.state.channelStateStream; + + final _queryTopMessagesController = BehaviorSubject.seeded(false); + final _queryBottomMessagesController = BehaviorSubject.seeded(false); + + /// The stream notifying the state of [_queryTopMessages] call + Stream get queryTopMessages => _queryTopMessagesController.stream; + + /// The stream notifying the state of [_queryBottomMessages] call + Stream get queryBottomMessages => _queryBottomMessagesController.stream; + + bool _topPaginationEnded = false; + bool _bottomPaginationEnded = false; + + Future _queryTopMessages({ + int limit = 20, + bool preferOffline = false, + }) async { + if (_topPaginationEnded || _queryTopMessagesController?.value == true) { + return; + } + _queryTopMessagesController.add(true); + + if (channel.state.messages.isEmpty) { + return _queryTopMessagesController.add(false); + } + + final oldestMessage = channel.state.messages.first; + + try { + final state = await queryBeforeMessage( + oldestMessage.id, + limit: limit, + preferOffline: preferOffline, + ); + if (state.messages.isEmpty || state.messages.length < limit) { + _topPaginationEnded = true; + } + _queryTopMessagesController.add(false); + } catch (e, stk) { + _queryTopMessagesController.addError(e, stk); + } + } + + Future _queryBottomMessages({ + int limit = 20, + bool preferOffline = false, + }) async { + if (_bottomPaginationEnded || + _queryBottomMessagesController?.value == true || + channel?.state?.isUpToDate == true) return; + _queryBottomMessagesController.add(true); + + if (channel.state.messages.isEmpty) { + return _queryBottomMessagesController.add(false); + } + + final recentMessage = channel.state.messages.last; + + try { + final state = await queryAfterMessage( + recentMessage.id, + limit: limit, + preferOffline: preferOffline, + ); + if (state.messages.isEmpty || state.messages.length < limit) { + _bottomPaginationEnded = true; + } + _queryBottomMessagesController.add(false); + } catch (e, stk) { + _queryBottomMessagesController.addError(e, stk); + } + } + + /// Calls [channel.query] updating [queryMessage] stream + Future queryMessages({QueryDirection direction = QueryDirection.top}) { + if (direction == QueryDirection.top) return _queryTopMessages(); + return _queryBottomMessages(); + } + + /// Calls [channel.getReplies] updating [queryMessage] stream + Future getReplies( + String parentId, { + int limit = 50, + bool preferOffline = false, + }) async { + if (_topPaginationEnded || _queryTopMessagesController.value) return; + _queryTopMessagesController.add(true); + + Message message; + if (channel.state.threads.containsKey(parentId)) { + final thread = channel.state.threads[parentId]; + if (thread.isNotEmpty) { + message = thread.first; + } + } + + try { + final response = await channel.getReplies( + parentId, + PaginationParams( + lessThan: message?.id, + limit: limit, + ), + preferOffline: preferOffline, + ); + if (response.messages.isEmpty || response.messages.length < limit) { + _topPaginationEnded = true; + } + _queryTopMessagesController.add(false); + } catch (e, stk) { + _queryTopMessagesController.addError(e, stk); + } + } + + /// Query the channel members and watchers + Future queryMembersAndWatchers() async { + await widget.channel.query( + membersPagination: PaginationParams( + offset: channel.state.members?.length, + limit: 100, + ), + watchersPagination: PaginationParams( + offset: channel.state.watchers?.length, + limit: 100, + ), + ); + } + + /// Loads channel at specific message + Future loadChannelAtMessage( + String messageId, { + int before = 20, + int after = 20, + bool preferOffline = false, + }) { + return queryAtMessage( + messageId: messageId, + before: before, + after: after, + preferOffline: preferOffline, + ); + } + + /// + Future queryAtMessage({ + String messageId, + int before = 20, + int after = 20, + bool preferOffline = false, + }) async { + if (channel.state == null) return; + channel.state.isUpToDate = false; + channel.state.truncate(); + + if (messageId == null) { + await channel.query( + messagesPagination: PaginationParams( + limit: before, + ), + preferOffline: preferOffline, + ); + channel.state.isUpToDate = true; + return; + } + + return Future.wait([ + queryBeforeMessage( + messageId, + limit: before, + preferOffline: preferOffline, + ), + queryAfterMessage( + messageId, + limit: after, + preferOffline: preferOffline, + ), + ]); + } + + /// + Future queryBeforeMessage( + String messageId, { + int limit = 20, + bool preferOffline = false, + }) { + return channel.query( + messagesPagination: PaginationParams( + lessThan: messageId, + limit: limit, + ), + preferOffline: preferOffline, + ); + } + + /// + Future queryAfterMessage( + String messageId, { + int limit = 20, + bool preferOffline = false, + }) async { + final state = await channel.query( + messagesPagination: PaginationParams( + greaterThanOrEqual: messageId, + limit: limit, + ), + preferOffline: preferOffline, + ); + if (state.messages.isEmpty || state.messages.length < limit) { + channel.state.isUpToDate = true; + } + return state; + } + + /// + Future getMessage(String messageId) async { + var message = channel.state.messages.firstWhere( + (it) => it.id == messageId, + orElse: () => null, + ); + if (message == null) { + final response = await channel.getMessagesById([messageId]); + message = response.messages.first; + } + return message; + } + + /// Reloads the channel with latest message + Future reloadChannel() => queryAtMessage(before: 30); + + List> _futures; + + Future get _loadChannelAtMessage async { + try { + await loadChannelAtMessage(initialMessageId); + return true; + } catch (e, stk) { + print('Error: $e\nStack: $stk'); + rethrow; + } + } + + @override + void initState() { + super.initState(); + _futures = [widget.channel.initialized]; + if (initialMessageId != null) { + _futures.add(_loadChannelAtMessage); + } + } + + @override + void dispose() { + _queryTopMessagesController.close(); + _queryBottomMessagesController.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child = FutureBuilder>( + future: Future.wait(_futures), + initialData: [ + channel.state != null, + if (initialMessageId != null) false, + ], + builder: (context, snapshot) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + var message = snapshot.error.toString(); + if (snapshot.error is DioError) { + final dioError = snapshot.error as DioError; + if (dioError.type == DioErrorType.RESPONSE) { + message = dioError.message; + } else { + message = 'Check your connection and retry'; + } + } + return Center( + child: Text(message), + ); + } + final initialized = snapshot.data[0]; + final dataLoaded = initialMessageId == null ? true : snapshot.data[1]; + if (widget.showLoading && (!initialized || !dataLoaded)) { + return Center( + child: CircularProgressIndicator(), + ); + } + return widget.child; + }, + ); + if (initialMessageId != null) { + child = Material(child: child); + } + return child; + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart new file mode 100644 index 00000000..8a8d0185 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -0,0 +1,145 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'typedef.dart'; + +/// Widget used to provide information about the chat to the widget tree. +/// This Widget is used to react to life cycle changes and system updates. +/// When the app goes into the background, the websocket connection is kept alive +/// for two minutes before being terminated. +/// +/// Conversely, when app is resumed or restarted, a new connection is initiated. +/// +/// ```dart +/// class MyApp extends StatelessWidget { +/// final StreamChatClient client; +/// +/// MyApp(this.client); +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// home: Container( +/// child: StreamChatCore( +/// client: client, +/// child: ChannelListPage(), +/// ), +/// ), +/// ); +/// } +/// } +/// ``` +/// +class StreamChatCore extends StatefulWidget { + /// Constructor used for creating a new instance of [StreamChatCore]. + /// + /// [StreamChatCore] is a stateful widget which reacts to system events and updates + /// Stream's connection status accordingly. + StreamChatCore({ + Key key, + @required this.client, + @required this.child, + this.onBackgroundEventReceived, + this.backgroundKeepAlive = const Duration(minutes: 1), + }) : assert(client != null), + assert(child != null), + super(key: key); + + /// Instance of Stream Chat Client containing information about the current + /// application. + final StreamChatClient client; + + /// Widget descendant. + final Widget child; + + /// The amount of time that will pass before disconnecting the client in the background + final Duration backgroundKeepAlive; + + /// Handler called whenever the [client] receives a new [Event] while the app + /// is in background. Can be used to display various notifications depending + /// upon the [Event.type] + final EventHandler onBackgroundEventReceived; + + @override + StreamChatCoreState createState() => StreamChatCoreState(); + + /// Use this method to get the current [StreamChatCoreState] instance + static StreamChatCoreState of(BuildContext context) { + StreamChatCoreState streamChatState; + + streamChatState = context.findAncestorStateOfType(); + + if (streamChatState == null) { + throw Exception( + 'You must have a StreamChat widget at the top of your widget tree'); + } + + return streamChatState; + } +} + +/// State class associated with [StreamChatCore]. +class StreamChatCoreState extends State + with WidgetsBindingObserver { + /// Initialized client used throughout the application. + StreamChatClient get client => widget.client; + + Timer _disconnectTimer; + + @override + Widget build(BuildContext context) { + return widget.child; + } + + /// The current user + User get user => widget.client.state.user; + + /// The current user as a stream + Stream get userStream => widget.client.state.userStream; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + StreamSubscription _eventSubscription; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (client.state?.user != null) { + if (state == AppLifecycleState.paused) { + if (widget.onBackgroundEventReceived != null) { + _eventSubscription = + client.on().listen(widget.onBackgroundEventReceived); + _disconnectTimer = Timer( + widget.backgroundKeepAlive, + client.disconnect, + ); + } else { + client.disconnect(); + } + } else if (state == AppLifecycleState.resumed) { + _eventSubscription?.cancel(); + if (_disconnectTimer?.isActive == true) { + _disconnectTimer.cancel(); + } else { + if (client.wsConnectionStatus == ConnectionStatus.disconnected) { + client.connect(); + } + } + } + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _eventSubscription?.cancel(); + _disconnectTimer?.cancel(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/typedef.dart b/packages/stream_chat_flutter_core/lib/src/typedef.dart new file mode 100644 index 00000000..29fb2d31 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/typedef.dart @@ -0,0 +1,10 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// A signature for a callback which exposes an error and returns a function. +/// This Callback can be used in cases where an API failure occurs and the widget +/// is unable to render data. +typedef ErrorBuilder = Widget Function(BuildContext context, Object error); + +/// A Signature for a handler function which will expose a [event]. +typedef EventHandler = void Function(Event event); diff --git a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart new file mode 100644 index 00000000..23140258 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart @@ -0,0 +1,312 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter_core/src/users_bloc.dart'; + +/// +/// [UserListCore] is a simplified class that allows fetching users while exposing UI builders. +/// A [UserListController] is used to load and paginate data. +/// +/// ```dart +/// class UsersListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: UsersListCore( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// errorBuilder: (err) { +/// return Center( +/// child: Text('An error has occured'), +/// ); +/// }, +/// emptyBuilder: (context) { +/// return Center( +/// child: Text('Nothing here...'), +/// ); +/// }, +/// emptyBuilder: (context) { +/// return Center( +/// child: CircularProgressIndicator(), +/// ); +/// }, +/// listBuilder: (context, list) { +/// return UsersPage(list); +/// } +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// [UsersBloc] must be the ancestor of this widget. This is necessary since +/// [UserListCore] depends on functionality contained within [UsersBloc]. +/// +/// The parameters [listBuilder], [loadingBuilder], [emptyBuilder] and [errorBuilder] must all be supplied +/// and not null. +class UserListCore extends StatefulWidget { + /// Instantiate a new [UserListCore] + const UserListCore({ + Key key, + @required this.errorBuilder, + @required this.emptyBuilder, + @required this.loadingBuilder, + @required this.listBuilder, + this.filter, + this.options, + this.sort, + this.pagination, + this.groupAlphabetically = false, + this.userListController, + }) : assert(errorBuilder != null), + assert(emptyBuilder != null), + assert(loadingBuilder != null), + assert(listBuilder != null), + super(key: key); + + /// A [UserListController] allows reloading and pagination. + /// Use [UserListController.loadData] and [UserListController.paginateData] respectively for reloading and pagination. + final UserListController userListController; + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + /// The builder that will be used to build the list + final Widget Function(BuildContext context, List users) listBuilder; + + /// The builder that will be used for loading + final WidgetBuilder loadingBuilder; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filter; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sort; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams pagination; + + /// Set it to true to group users by their first character + /// + /// defaults to false + final bool groupAlphabetically; + + @override + _UserListCoreState createState() => _UserListCoreState(); +} + +class _UserListCoreState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + final usersBloc = UsersBloc.of(context); + usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + + if (widget.userListController != null) { + widget.userListController.loadData = loadData; + widget.userListController.paginateData = paginateData; + } + } + + @override + Widget build(BuildContext context) { + final _usersBloc = UsersBloc.of(context); + + return _buildListView(_usersBloc); + } + + bool get isListAlreadySorted => + widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; + + Stream> _buildUserStream( + UsersBlocState usersBlocState, + ) { + return usersBlocState.usersStream.map( + (users) { + if (widget.groupAlphabetically) { + var temp = users; + if (!isListAlreadySorted) { + temp = users..sort((curr, next) => curr.name.compareTo(next.name)); + } + final groupedUsers = >{}; + for (final e in temp) { + final alphabet = e.name[0]?.toUpperCase(); + groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; + } + final items = []; + for (final key in groupedUsers.keys) { + items.add(ListHeaderItem(key)); + items.addAll(groupedUsers[key].map((e) => ListUserItem(e))); + } + return items; + } + return users.map((e) => ListUserItem(e)).toList(); + }, + ); + } + + StreamBuilder> _buildListView( + UsersBlocState usersBlocState, + ) { + return StreamBuilder( + stream: _buildUserStream(usersBlocState), + builder: (context, snapshot) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + return widget.errorBuilder(snapshot.error); + } + + if (!snapshot.hasData) { + return widget.loadingBuilder(context); + } + + final items = snapshot.data; + + if (items.isEmpty) { + return widget.emptyBuilder(context); + } + + if (items.isEmpty) { + return widget.emptyBuilder(context); + } + + return widget.listBuilder(context, items); + }, + ); + } + + void loadData() { + final _usersBloc = UsersBloc.of(context); + + _usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + } + + void paginateData() { + final _usersBloc = UsersBloc.of(context); + + _usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination.copyWith( + offset: _usersBloc.users?.length ?? 0, + ), + options: widget.options, + ); + } + + @override + void didUpdateWidget(UserListCore oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.filter?.toString() != oldWidget.filter?.toString() || + jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.pagination?.toJson()?.toString() != + oldWidget.pagination?.toJson()?.toString() || + widget.options?.toString() != oldWidget.options?.toString()) { + final usersBloc = UsersBloc.of(context); + usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + } + } +} + +/// Represents an item in a the user stream list. +/// Header items are prefixed with the key `HEADER` While users are prefixed with +/// `USER`. +abstract class ListItem { + // ignore: public_member_api_docs + String get key { + if (this is ListHeaderItem) { + final header = (this as ListHeaderItem).heading; + return 'HEADER-$header'; + } + if (this is ListUserItem) { + final user = (this as ListUserItem).user; + return 'USER-${user.id}'; + } + return null; + } + + // ignore: public_member_api_docs + Widget when({ + @required Widget Function(String heading) headerItem, + @required Widget Function(User user) userItem, + }) { + if (this is ListHeaderItem) { + return headerItem((this as ListHeaderItem).heading); + } + if (this is ListUserItem) { + return userItem((this as ListUserItem).user); + } + return SizedBox(); + } +} + +// ignore: public_member_api_docs +class ListHeaderItem extends ListItem { + // ignore: public_member_api_docs + final String heading; + + // ignore: public_member_api_docs + ListHeaderItem(this.heading); +} + +// ignore: public_member_api_docs +class ListUserItem extends ListItem { + // ignore: public_member_api_docs + final User user; + + // ignore: public_member_api_docs + ListUserItem(this.user); +} + +/// Controller used for paginating data in [ChannelListView] +class UserListController { + /// Call this function to reload data + VoidCallback loadData; + + /// Call this function to load further data + VoidCallback paginateData; +} diff --git a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart new file mode 100644 index 00000000..19c4a026 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'stream_chat_core.dart'; + +/// Widget dedicated to the management of a users list with pagination. +/// +/// [UsersBloc] can be access at anytime by using the static [of] method +/// using Flutter's [BuildContext]. +/// +/// API docs: https://getstream.io/chat/docs/flutter-dart/init_and_users/ +class UsersBloc extends StatefulWidget { + /// Instantiate a new [UsersBloc]. The parameter [child] must be supplied and + /// not null. + const UsersBloc({ + Key key, + @required this.child, + }) : assert(child != null), + super(key: key); + + /// The widget child + final Widget child; + + @override + UsersBlocState createState() => UsersBlocState(); + + /// Use this method to get the current [UsersBlocState] instance + static UsersBlocState of(BuildContext context) { + UsersBlocState state; + + state = context.findAncestorStateOfType(); + + if (state == null) { + throw Exception('You must have a UsersBloc widget as ancestor'); + } + + return state; + } +} + +/// The current state of the [UsersBloc] +class UsersBlocState extends State + with AutomaticKeepAliveClientMixin { + /// The current users list + List get users => _usersController.value; + + /// The current users list as a stream + Stream> get usersStream => _usersController.stream; + + final BehaviorSubject> _usersController = BehaviorSubject(); + + final BehaviorSubject _queryUsersLoadingController = + BehaviorSubject.seeded(false); + + /// The stream notifying the state of queryUsers call + Stream get queryUsersLoading => _queryUsersLoadingController.stream; + + /// The Query Users method allows you to search for users and see if they are + /// online/offline. + /// [API Reference](https://getstream.io/chat/docs/flutter-dart/query_users/?language=dart) + Future queryUsers({ + Map filter, + List sort, + Map options, + PaginationParams pagination, + }) async { + final client = StreamChatCore.of(context).client; + + if (client.state?.user == null || + _queryUsersLoadingController.value == true) { + return; + } + _queryUsersLoadingController.add(true); + try { + final clear = pagination == null || + pagination.offset == null || + pagination.offset == 0; + + final oldUsers = List.from(users ?? []); + + final usersResponse = await client.queryUsers( + filter: filter, + sort: sort, + options: options, + pagination: pagination, + ); + + if (clear) { + _usersController.add(usersResponse.users); + } else { + final temp = oldUsers + usersResponse.users; + _usersController.add(temp); + } + + _queryUsersLoadingController.add(false); + } catch (err, stackTrace) { + _queryUsersLoadingController.addError(err, stackTrace); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + @override + void dispose() { + _usersController.close(); + _queryUsersLoadingController.close(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart new file mode 100644 index 00000000..48d31b45 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -0,0 +1,14 @@ +library stream_chat_flutter_core; + +export 'src/channel_list_core.dart'; +export 'src/channels_bloc.dart'; +export 'src/lazy_load_scroll_view.dart'; +export 'src/message_list_core.dart'; +export 'src/message_search_bloc.dart'; +export 'src/message_search_list_core.dart'; +export 'src/stream_channel.dart'; +export 'src/stream_chat_core.dart'; +export 'src/user_list_core.dart'; +export 'src/users_bloc.dart'; +export 'src/typedef.dart'; +export 'package:stream_chat/stream_chat.dart'; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml new file mode 100644 index 00000000..85e7d8cf --- /dev/null +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -0,0 +1,24 @@ +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: 1.0.0-beta +repository: https://github.com/GetStream/stream-chat-flutter +issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues + +environment: + sdk: ">=2.7.0 <3.0.0" + flutter: ">=1.17.0" + +dependencies: + stream_chat: ^1.0.1-beta + flutter: + sdk: flutter + rxdart: ^0.25.0 + +dev_dependencies: + mockito: ^4.1.4 + flutter_test: + sdk: flutter + fake_async: ^1.1.0 + pedantic: ^1.9.2 + \ No newline at end of file diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart new file mode 100644 index 00000000..c23a4be4 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -0,0 +1,10 @@ +import 'package:mockito/mockito.dart'; +import 'package:stream_chat/stream_chat.dart'; + +class MockClient extends Mock implements StreamChatClient {} + +class MockClientState extends Mock implements ClientState {} + +class MockChannel extends Mock implements Channel {} + +class MockChannelState extends Mock implements ChannelClientState {} diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart new file mode 100644 index 00000000..a8d7e227 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -0,0 +1,179 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:mockito/mockito.dart'; + +import 'mocks.dart'; + +class MockShowLocalNotifications extends Mock { + void call(Event event); +} + +void main() { + testWidgets( + 'StreamChatCore.of(context) should throw an exception', + (WidgetTester tester) async { + await tester.pumpWidget( + Builder( + builder: (context) { + expect(() => StreamChatCore.of(context), throwsException); + return Container(); + }, + ), + ); + }, + ); + + testWidgets( + 'StreamChatCore.of(context) should return the StreamChatCore ancestor', + (WidgetTester tester) async { + final client = MockClient(); + await tester.pumpWidget( + StreamChatCore( + client: client, + child: Builder( + builder: (context) { + final sc = StreamChatCore.of(context); + expect(sc, isNotNull); + return Container(); + }, + ), + ), + ); + }, + ); + + testWidgets( + 'StreamChatCore.of(context).client should return the client', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn( + OwnUser( + id: 'test', + ), + ); + final userStream = Stream.value( + OwnUser( + id: 'test', + ), + ); + when(clientState.userStream).thenAnswer( + (_) => userStream, + ); + + await tester.pumpWidget( + StreamChatCore( + client: client, + child: Builder( + builder: (context) { + final sc = StreamChatCore.of(context); + expect(sc.client, client); + expect(sc.user, client.state.user); + expect(sc.userStream, userStream); + return Container(); + }, + ), + ), + ); + }, + ); + + testWidgets( + 'StreamChatCore should disconnect on background', + (WidgetTester tester) async { + await fakeAsync((_async) { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn( + OwnUser( + id: 'test', + ), + ); + final showLocalNotificationMock = MockShowLocalNotifications().call; + final eventStreamController = StreamController(); + when(client.on()).thenAnswer((_) => eventStreamController.stream); + + when(client.channel('test', id: 'testid')).thenReturn(channel); + + final scKey = GlobalKey(); + tester.pumpWidget( + StreamChatCore( + key: scKey, + client: client, + onBackgroundEventReceived: showLocalNotificationMock, + backgroundKeepAlive: const Duration(seconds: 4), + child: Builder( + builder: (context) { + return Container(); + }, + ), + ), + ); + + final sc = scKey.currentState; + sc.didChangeAppLifecycleState(AppLifecycleState.paused); + + _async.elapse(Duration(seconds: 5)); + + verify(client.disconnect()).called(1); + }); + }, + ); + + testWidgets( + 'StreamChatCore should handle notifications when on background and connected', + (WidgetTester tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + when(client.state).thenReturn(clientState); + when(clientState.user).thenReturn( + OwnUser( + id: 'test', + ), + ); + final showLocalNotificationMock = MockShowLocalNotifications().call; + final eventStreamController = StreamController(); + when(client.on()).thenAnswer((_) => eventStreamController.stream); + + when(client.channel('test', id: 'testid')).thenReturn(channel); + + final scKey = GlobalKey(); + await tester.pumpWidget( + StreamChatCore( + key: scKey, + client: client, + onBackgroundEventReceived: showLocalNotificationMock, + backgroundKeepAlive: const Duration(seconds: 4), + child: Builder( + builder: (context) { + return Container(); + }, + ), + ), + ); + + final sc = scKey.currentState; + sc.didChangeAppLifecycleState(AppLifecycleState.paused); + final event = Event( + type: EventType.messageNew, + message: Message(text: 'hey'), + channelType: 'test', + channelId: 'testid', + user: User(id: 'other user'), + ); + eventStreamController.add(event); + + await untilCalled(showLocalNotificationMock(event)); + + verify(showLocalNotificationMock(event)).called(1); + }, + ); +} diff --git a/packages/stream_chat_persistence/.gitignore b/packages/stream_chat_persistence/.gitignore new file mode 100644 index 00000000..1985397a --- /dev/null +++ b/packages/stream_chat_persistence/.gitignore @@ -0,0 +1,74 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 diff --git a/packages/stream_chat_persistence/.metadata b/packages/stream_chat_persistence/.metadata new file mode 100644 index 00000000..5eb50347 --- /dev/null +++ b/packages/stream_chat_persistence/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 78910062997c3a836feee883712c241a5fd22983 + channel: stable + +project_type: package diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md new file mode 100644 index 00000000..72eec406 --- /dev/null +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0-beta + +* Initial release \ No newline at end of file diff --git a/packages/stream_chat_persistence/LICENSE b/packages/stream_chat_persistence/LICENSE new file mode 100644 index 00000000..49088d47 --- /dev/null +++ b/packages/stream_chat_persistence/LICENSE @@ -0,0 +1,219 @@ +SOURCE CODE LICENSE AGREEMENT + +IMPORTANT - READ THIS CAREFULLY BEFORE DOWNLOADING, INSTALLING, USING OR +ELECTRONICALLY ACCESSING THIS PROPRIETARY PRODUCT. + +THIS IS A LEGAL AGREEMENT BETWEEN STREAM.IO, INC. (“STREAM.IO”) AND THE +BUSINESS ENTITY OR PERSON FOR WHOM YOU (“YOU”) ARE ACTING (“CUSTOMER”) AS THE +LICENSEE OF THE PROPRIETARY SOFTWARE INTO WHICH THIS AGREEMENT HAS BEEN +INCLUDED (THE “AGREEMENT”). YOU AGREE THAT YOU ARE THE CUSTOMER, OR YOU ARE AN +EMPLOYEE OR AGENT OF CUSTOMER AND ARE ENTERING INTO THIS AGREEMENT FOR LICENSE +OF THE SOFTWARE BY CUSTOMER FOR CUSTOMER’S BUSINESS PURPOSES AS DESCRIBED IN +AND IN ACCORDANCE WITH THIS AGREEMENT. YOU HEREBY AGREE THAT YOU ENTER INTO +THIS AGREEMENT ON BEHALF OF CUSTOMER AND THAT YOU HAVE THE AUTHORITY TO BIND +CUSTOMER TO THIS AGREEMENT. + +STREAM.IO IS WILLING TO LICENSE THE SOFTWARE TO CUSTOMER ONLY ON THE FOLLOWING +CONDITIONS: (1) YOU ARE A CURRENT CUSTOMER OF STREAM.IO; (2) YOU ARE NOT A +COMPETITOR OF STREAM.IO; AND (3) THAT YOU ACCEPT ALL THE TERMS IN THIS +AGREEMENT. BY DOWNLOADING, INSTALLING, CONFIGURING, ACCESSING OR OTHERWISE +USING THE SOFTWARE, INCLUDING ANY UPDATES, UPGRADES, OR NEWER VERSIONS, YOU +REPRESENT, WARRANT AND ACKNOWLEDGE THAT (A) CUSTOMER IS A CURRENT CUSTOMER OF +STREAM.IO; (B) CUSTOMER IS NOT A COMPETITOR OF STREAM.IO; AND THAT (C) YOU HAVE +READ THIS AGREEMENT, UNDERSTAND THIS AGREEMENT, AND THAT CUSTOMER AGREES TO BE +BOUND BY ALL THE TERMS OF THIS AGREEMENT. + +IF YOU DO NOT AGREE TO ALL THE TERMS AND CONDITIONS OF THIS AGREEMENT, +STREAM.IO IS UNWILLING TO LICENSE THE SOFTWARE TO CUSTOMER, AND THEREFORE, DO +NOT COMPLETE THE DOWNLOAD PROCESS, ACCESS OR OTHERWISE USE THE SOFTWARE, AND +CUSTOMER SHOULD IMMEDIATELY RETURN THE SOFTWARE AND CEASE ANY USE OF THE +SOFTWARE. + +1. SOFTWARE. The Stream.io software accompanying this Agreement, may include +Source Code, Executable Object Code, associated media, printed materials and +documentation (collectively, the “Software”). The Software also includes any +updates or upgrades to or new versions of the original Software, if and when +made available to you by Stream.io. “Source Code” means computer programming +code in human readable form that is not suitable for machine execution without +the intervening steps of interpretation or compilation. “Executable Object +Code" means the computer programming code in any other form than Source Code +that is not readily perceivable by humans and suitable for machine execution +without the intervening steps of interpretation or compilation. “Site” means a +Customer location controlled by Customer. “Authorized User” means any employee +or contractor of Customer working at the Site, who has signed a written +confidentiality agreement with Customer or is otherwise bound in writing by +confidentiality and use obligations at least as restrictive as those imposed +under this Agreement. + +2. LICENSE GRANT. Subject to the terms and conditions of this Agreement, in +consideration for the representations, warranties, and covenants made by +Customer in this Agreement, Stream.io grants to Customer, during the term of +this Agreement, a personal, non-exclusive, non-transferable, non-sublicensable +license to: + +a. install and use Software Source Code on password protected computers at a Site, +restricted to Authorized Users; + +b. create derivative works, improvements (whether or not patentable), extensions +and other modifications to the Software Source Code (“Modifications”) to build +unique scalable newsfeeds, activity streams, and in-app messaging via Stream’s +application program interface (“API”); + +c. compile the Software Source Code to create Executable Object Code versions of +the Software Source Code and Modifications to build such newsfeeds, activity +streams, and in-app messaging via the API; + +d. install, execute and use such Executable Object Code versions solely for +Customer’s internal business use (including development of websites through +which data generated by Stream services will be streamed (“Apps”)); + +e. use and distribute such Executable Object Code as part of Customer’s Apps; and + +f. make electronic copies of the Software and Modifications as required for backup +or archival purposes. + +3. RESTRICTIONS. Customer is responsible for all activities that occur in +connection with the Software. Customer will not, and will not attempt to: (a) +sublicense or transfer the Software or any Source Code related to the Software +or any of Customer’s rights under this Agreement, except as otherwise provided +in this Agreement, (b) use the Software Source Code for the benefit of a third +party or to operate a service; (c) allow any third party to access or use the +Software Source Code; (d) sublicense or distribute the Software Source Code or +any Modifications in Source Code or other derivative works based on any part of +the Software Source Code; (e) use the Software in any manner that competes with +Stream.io or its business; or (e) otherwise use the Software in any manner that +exceeds the scope of use permitted in this Agreement. Customer shall use the +Software in compliance with any accompanying documentation any laws applicable +to Customer. + +4. OPEN SOURCE. Customer and its Authorized Users shall not use any software or +software components that are open source in conjunction with the Software +Source Code or any Modifications in Source Code or in any way that could +subject the Software to any open source licenses. + +5. CONTRACTORS. Under the rights granted to Customer under this Agreement, +Customer may permit its employees, contractors, and agencies of Customer to +become Authorized Users to exercise the rights to the Software granted to +Customer in accordance with this Agreement solely on behalf of Customer to +provide services to Customer; provided that Customer shall be liable for the +acts and omissions of all Authorized Users to the extent any of such acts or +omissions, if performed by Customer, would constitute a breach of, or otherwise +give rise to liability to Customer under, this Agreement. Customer shall not +and shall not permit any Authorized User to use the Software except as +expressly permitted in this Agreement. + +6. COMPETITIVE PRODUCT DEVELOPMENT. Customer shall not use the Software in any way +to engage in the development of products or services which could be reasonably +construed to provide a complete or partial functional or commercial alternative +to Stream.io’s products or services (a “Competitive Product”). Customer shall +ensure that there is no direct or indirect use of, or sharing of, Software +source code, or other information based upon or derived from the Software to +develop such products or services. Without derogating from the generality of +the foregoing, development of Competitive Products shall include having direct +or indirect access to, supervising, consulting or assisting in the development +of, or producing any specifications, documentation, object code or source code +for, all or part of a Competitive Product. + +7. LIMITATION ON MODIFICATIONS. Notwithstanding any provision in this Agreement, +Modifications may only be created and used by Customer as permitted by this +Agreement and Modification Source Code may not be distributed to third parties. +Customer will not assert against Stream.io, its affiliates, or their customers, +direct or indirect, agents and contractors, in any way, any patent rights that +Customer may obtain relating to any Modifications for Stream.io, its +affiliates’, or their customers’, direct or indirect, agents’ and contractors’ +manufacture, use, import, offer for sale or sale of any Stream.io products or +services. + +8. DELIVERY AND ACCEPTANCE. The Software will be delivered electronically pursuant +to Stream.io standard download procedures. The Software is deemed accepted upon +delivery. + +9. IMPLEMENTATION AND SUPPORT. Stream.io has no obligation under this Agreement to +provide any support or consultation concerning the Software. + +10. TERM AND TERMINATION. The term of this Agreement begins when the Software is +downloaded or accessed and shall continue until terminated. Either party may +terminate this Agreement upon written notice. This Agreement shall +automatically terminate if Customer is or becomes a competitor of Stream.io or +makes or sells any Competitive Products. Upon termination of this Agreement for +any reason, (a) all rights granted to Customer in this Agreement immediately +cease to exist, (b) Customer must promptly discontinue all use of the Software +and return to Stream.io or destroy all copies of the Software in Customer’s +possession or control. Any continued use of the Software by Customer or attempt +by Customer to exercise any rights under this Agreement after this Agreement +has terminated shall be considered copyright infringement and subject Customer +to applicable remedies for copyright infringement. Sections 2, 5, 6, 8 and 9 +shall survive expiration or termination of this Agreement for any reason. + +11. OWNERSHIP. As between the parties, the Software and all worldwide intellectual +property rights and proprietary rights relating thereto or embodied therein, +are the exclusive property of Stream.io and its suppliers. Stream.io and its +suppliers reserve all rights in and to the Software not expressly granted to +Customer in this Agreement, and no other licenses or rights are granted by +implication, estoppel or otherwise. + +12. WARRANTY DISCLAIMER. USE OF THIS SOFTWARE IS ENTIRELY AT YOURS AND CUSTOMER’S +OWN RISK. THE SOFTWARE IS PROVIDED “AS IS” WITHOUT ANY WARRANTY OF ANY KIND +WHATSOEVER. STREAM.IO DOES NOT MAKE, AND HEREBY DISCLAIMS, ANY WARRANTY OF ANY +KIND, WHETHER EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING WITHOUT +LIMITATION, THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, TITLE, NON-INFRINGEMENT OF THIRD-PARTY RIGHTS, RESULTS, EFFORTS, +QUALITY OR QUIET ENJOYMENT. STREAM.IO DOES NOT WARRANT THAT THE SOFTWARE IS +ERROR-FREE, WILL FUNCTION WITHOUT INTERRUPTION, WILL MEET ANY SPECIFIC NEED +THAT CUSTOMER HAS, THAT ALL DEFECTS WILL BE CORRECTED OR THAT IT IS +SUFFICIENTLY DOCUMENTED TO BE USABLE BY CUSTOMER. TO THE EXTENT THAT STREAM.IO +MAY NOT DISCLAIM ANY WARRANTY AS A MATTER OF APPLICABLE LAW, THE SCOPE AND +DURATION OF SUCH WARRANTY WILL BE THE MINIMUM PERMITTED UNDER SUCH LAW. +CUSTOMER ACKNOWLEDGES THAT IT HAS RELIED ON NO WARRANTIES OTHER THAN THE +EXPRESS WARRANTIES IN THIS AGREEMENT. + +13. LIMITATION OF LIABILITY. TO THE FULLEST EXTENT PERMISSIBLE BY LAW, STREAM.IO’S +TOTAL LIABILITY FOR ALL DAMAGES ARISING OUT OF OR RELATED TO THE SOFTWARE OR +THIS AGREEMENT, WHETHER IN CONTRACT, TORT (INCLUDING NEGLIGENCE) OR OTHERWISE, +SHALL NOT EXCEED $100. IN NO EVENT WILL STREAM.IO BE LIABLE FOR ANY INDIRECT, +CONSEQUENTIAL, EXEMPLARY, PUNITIVE, SPECIAL OR INCIDENTAL DAMAGES OF ANY KIND +WHATSOEVER, INCLUDING ANY LOST DATA AND LOST PROFITS, ARISING FROM OR RELATING +TO THE SOFTWARE EVEN IF STREAM.IO HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH +DAMAGES. CUSTOMER ACKNOWLEDGES THAT THIS PROVISION REFLECTS THE AGREED UPON +ALLOCATION OF RISK FOR THIS AGREEMENT AND THAT STREAM.IO WOULD NOT ENTER INTO +THIS AGREEMENT WITHOUT THESE LIMITATIONS ON ITS LIABILITY. + +14. General. Customer may not assign or transfer this Agreement, by operation of +law or otherwise, or any of its rights under this Agreement (including the +license rights granted to Customer) to any third party without Stream.io’s +prior written consent, which consent will not be unreasonably withheld or +delayed. Stream.io may assign this Agreement, without consent, including, but +limited to, affiliate or any successor to all or substantially all its business +or assets to which this Agreement relates, whether by merger, sale of assets, +sale of stock, reorganization or otherwise. Any attempted assignment or +transfer in violation of the foregoing will be null and void. Stream.io shall +not be liable hereunder by reason of any failure or delay in the performance of +its obligations hereunder for any cause which is beyond the reasonable control. +All notices, consents, and approvals under this Agreement must be delivered in +writing by courier, by electronic mail, or by certified or registered mail, +(postage prepaid and return receipt requested) to the other party at the +address set forth in the customer agreement between Stream.io and Customer and +will be effective upon receipt or when delivery is refused. This Agreement will +be governed by and interpreted in accordance with the laws of the State of +Colorado, without reference to its choice of laws rules. The United Nations +Convention on Contracts for the International Sale of Goods does not apply to +this Agreement. Any action or proceeding arising from or relating to this +Agreement shall be brought in a federal or state court in Denver, Colorado, and +each party irrevocably submits to the jurisdiction and venue of any such court +in any such action or proceeding. All waivers must be in writing. Any waiver or +failure to enforce any provision of this Agreement on one occasion will not be +deemed a waiver of any other provision or of such provision on any other +occasion. If any provision of this Agreement is unenforceable, such provision +will be changed and interpreted to accomplish the objectives of such provision +to the greatest extent possible under applicable law and the remaining +provisions will continue in full force and effect. Customer shall not violate +any applicable law, rule or regulation, including those regarding the export of +technical data. The headings of Sections of this Agreement are for convenience +and are not to be used in interpreting this Agreement. As used in this +Agreement, the word “including” means “including but not limited to.” This +Agreement (including all exhibits and attachments) constitutes the entire +agreement between the parties regarding the subject hereof and supersedes all +prior or contemporaneous agreements, understandings and communication, whether +written or oral. This Agreement may be amended only by a written document +signed by both parties. The terms of any purchase order or similar document +submitted by Customer to Stream.io will have no effect. diff --git a/packages/stream_chat_persistence/README.md b/packages/stream_chat_persistence/README.md new file mode 100644 index 00000000..77b2eea5 --- /dev/null +++ b/packages/stream_chat_persistence/README.md @@ -0,0 +1,71 @@ +# Official Chat Persistence Client for [Stream Chat](https://getstream.io/chat/) + +

+ Flutter Chat +

+ +> The official Chat Persistence Client for Stream Chat, a service for +> building chat applications. + +[![Pub](https://img.shields.io/pub/v/stream_chat_persistence.svg)](https://pub.dartlang.org/packages/stream_chat_persistence) +![](https://img.shields.io/badge/platform-flutter%20%7C%20flutter%20web-ff69b4.svg?style=flat-square) +[![Gitter](https://badges.gitter.im/GetStream/stream_chat_persistence.svg)](https://gitter.im/GetStream/stream-chat-flutter?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +![CI](https://github.com/GetStream/stream-chat-flutter/workflows/CI/badge.svg?branch=master) + + +This package provides a persistence client for fetching and saving chat data locally. +Stream Chat Persistence uses [Moor](https://github.com/simolus3/moor) as a disk cache. + +## Add dependency +Add this to your package's pubspec.yaml file, use the latest version [![Pub](https://img.shields.io/pub/v/stream_chat_persistence.svg)](https://pub.dartlang.org/packages/stream_chat_persistence) +```yaml +dependencies: + stream_chat_persistence: ^latest_version +``` + +You should then run `flutter packages get` + +## Usage +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 Stream chat client. +```dart + final client = StreamChatClient( + apiKey ?? kDefaultStreamApiKey, + logLevel: Level.INFO, + )..chatPersistenceClient = chatPersistentClient; +``` + +And you are ready to go... + +## Flutter Web + +Due to Moor web (for offline storage) you need to include the sql.js library: + +```html + + + + + + + + + +``` + +You can grab the latest version of sql-wasm.js and sql-wasm.wasm [here](https://github.com/sql-js/sql.js/releases) and copy them into your `/web` folder. + +## Contributing + +We welcome code changes that improve this library or fix a problem, +please make sure to follow all best practices and add tests if applicable before submitting a Pull Request on Github. +We are pleased to merge your code into the official repository. +Make sure to sign our [Contributor License Agreement (CLA)](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) first. +See our license file for more details. diff --git a/packages/stream_chat_persistence/build.yaml b/packages/stream_chat_persistence/build.yaml new file mode 100644 index 00000000..373486d0 --- /dev/null +++ b/packages/stream_chat_persistence/build.yaml @@ -0,0 +1,9 @@ +targets: + $default: + builders: + moor_generator: + options: + generate_connect_constructor: true + data_class_to_companions: false + apply_converters_on_variables: true + generate_values_in_copy_with: true diff --git a/packages/stream_chat_persistence/example/.gitignore b/packages/stream_chat_persistence/example/.gitignore new file mode 100644 index 00000000..9d532b18 --- /dev/null +++ b/packages/stream_chat_persistence/example/.gitignore @@ -0,0 +1,41 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/stream_chat_persistence/example/.metadata b/packages/stream_chat_persistence/example/.metadata new file mode 100644 index 00000000..182cccaf --- /dev/null +++ b/packages/stream_chat_persistence/example/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 78910062997c3a836feee883712c241a5fd22983 + channel: stable + +project_type: app diff --git a/packages/stream_chat_persistence/example/README.md b/packages/stream_chat_persistence/example/README.md new file mode 100644 index 00000000..07e5ac18 --- /dev/null +++ b/packages/stream_chat_persistence/example/README.md @@ -0,0 +1,2 @@ +# Stream Chat Persistence Example +Please see `lib/` for example code. \ No newline at end of file diff --git a/packages/stream_chat_persistence/example/android/.gitignore b/packages/stream_chat_persistence/example/android/.gitignore new file mode 100644 index 00000000..0a741cb4 --- /dev/null +++ b/packages/stream_chat_persistence/example/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties diff --git a/packages/stream_chat_persistence/example/android/app/build.gradle b/packages/stream_chat_persistence/example/android/app/build.gradle new file mode 100644 index 00000000..3932aa91 --- /dev/null +++ b/packages/stream_chat_persistence/example/android/app/build.gradle @@ -0,0 +1,63 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + compileSdkVersion 29 + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + lintOptions { + disable 'InvalidPackage' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId "com.example.example" + minSdkVersion 16 + targetSdkVersion 29 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} diff --git a/packages/stream_chat_persistence/example/android/app/src/debug/AndroidManifest.xml b/packages/stream_chat_persistence/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 00000000..c208884f --- /dev/null +++ b/packages/stream_chat_persistence/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/stream_chat_persistence/example/android/app/src/main/AndroidManifest.xml b/packages/stream_chat_persistence/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..55ca830c --- /dev/null +++ b/packages/stream_chat_persistence/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_persistence/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/packages/stream_chat_persistence/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 00000000..e793a000 --- /dev/null +++ b/packages/stream_chat_persistence/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/packages/stream_chat_persistence/example/android/app/src/main/res/drawable/launch_background.xml b/packages/stream_chat_persistence/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 00000000..304732f8 --- /dev/null +++ b/packages/stream_chat_persistence/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..db77bb4b Binary files /dev/null and b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..17987b79 Binary files /dev/null and b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..09d43914 Binary files /dev/null and b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..d5f1c8d3 Binary files /dev/null and b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..4d6372ee Binary files /dev/null and b/packages/stream_chat_persistence/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/stream_chat_persistence/example/android/app/src/main/res/values/styles.xml b/packages/stream_chat_persistence/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 00000000..1f83a33f --- /dev/null +++ b/packages/stream_chat_persistence/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/stream_chat_persistence/example/android/app/src/profile/AndroidManifest.xml b/packages/stream_chat_persistence/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 00000000..c208884f --- /dev/null +++ b/packages/stream_chat_persistence/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/stream_chat_persistence/example/android/build.gradle b/packages/stream_chat_persistence/example/android/build.gradle new file mode 100644 index 00000000..3100ad2d --- /dev/null +++ b/packages/stream_chat_persistence/example/android/build.gradle @@ -0,0 +1,31 @@ +buildscript { + ext.kotlin_version = '1.3.50' + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:3.5.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/packages/stream_chat_persistence/example/android/gradle.properties b/packages/stream_chat_persistence/example/android/gradle.properties new file mode 100644 index 00000000..a6738207 --- /dev/null +++ b/packages/stream_chat_persistence/example/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx1536M +android.useAndroidX=true +android.enableJetifier=true +android.enableR8=true diff --git a/packages/stream_chat_persistence/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/stream_chat_persistence/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..de2ccd60 --- /dev/null +++ b/packages/stream_chat_persistence/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri Jun 23 08:50:38 CEST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-bin.zip diff --git a/packages/stream_chat_persistence/example/android/settings.gradle b/packages/stream_chat_persistence/example/android/settings.gradle new file mode 100644 index 00000000..44e62bcf --- /dev/null +++ b/packages/stream_chat_persistence/example/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/packages/stream_chat_persistence/example/ios/.gitignore b/packages/stream_chat_persistence/example/ios/.gitignore new file mode 100644 index 00000000..e96ef602 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/stream_chat_persistence/example/ios/Flutter/AppFrameworkInfo.plist b/packages/stream_chat_persistence/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 00000000..6b4c0f78 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 8.0 + + diff --git a/packages/stream_chat_persistence/example/ios/Flutter/Debug.xcconfig b/packages/stream_chat_persistence/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 00000000..e8efba11 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/stream_chat_persistence/example/ios/Flutter/Release.xcconfig b/packages/stream_chat_persistence/example/ios/Flutter/Release.xcconfig new file mode 100644 index 00000000..399e9340 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 00000000..a28140cf --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000..1d526a16 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 00000000..18d98100 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 00000000..f9b0d7c5 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner/AppDelegate.swift b/packages/stream_chat_persistence/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..70693e4a --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..d36b1fab --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dc9ada47 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..28c6bf03 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..2ccbfd96 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..f091b6b0 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..4cde1211 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..d0ef06e7 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..dcdc2306 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..2ccbfd96 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..c8f9ed8f Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..a6d6b860 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..a6d6b860 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..75b2d164 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..c4df70d3 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..6a84f41e Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..d0e1f585 Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 00000000..0bedcf2f --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 00000000..9da19eac Binary files /dev/null and b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 00000000..89c2725b --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/stream_chat_persistence/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/stream_chat_persistence/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 00000000..f2e259c7 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner/Base.lproj/Main.storyboard b/packages/stream_chat_persistence/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 00000000..f3c28516 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner/Info.plist b/packages/stream_chat_persistence/example/ios/Runner/Info.plist new file mode 100644 index 00000000..a060db61 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/Info.plist @@ -0,0 +1,45 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/packages/stream_chat_persistence/example/ios/Runner/Runner-Bridging-Header.h b/packages/stream_chat_persistence/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 00000000..308a2a56 --- /dev/null +++ b/packages/stream_chat_persistence/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/stream_chat_persistence/example/lib/main.dart b/packages/stream_chat_persistence/example/lib/main.dart new file mode 100644 index 00000000..82dc1dc9 --- /dev/null +++ b/packages/stream_chat_persistence/example/lib/main.dart @@ -0,0 +1,257 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/stream_chat_persistence.dart'; + +Future main() async { + /// Create a new instance of [StreamChatClient] passing the apikey obtained from your + /// project dashboard. + final client = StreamChatClient('b67pax5b2wdq'); + + WidgetsFlutterBinding.ensureInitialized(); + + /// Set the chatPersistenceClient for offline support + client.chatPersistenceClient = StreamChatPersistenceClient( + logLevel: Level.INFO, + connectionMode: ConnectionMode.background, + ); + + /// Set the current user. In a production scenario, this should be done using + /// a backend to generate a user token using our server SDK. + /// Please see the following for more information: + /// https://getstream.io/chat/docs/ios_user_setup_and_tokens/ + await client.setUser( + User( + id: 'cool-shadow-7', + extraData: { + 'image': + 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', + }, + ), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', + ); + + /// Creates a channel using the type `messaging` and `godevs`. + /// Channels are containers for holding messages between different members. To + /// learn more about channels and some of our predefined types, checkout our + /// our channel docs: https://getstream.io/chat/docs/initialize_channel/?language=dart + final channel = client.channel('messaging', id: 'godevs'); + + /// `.watch()` is used to create and listen to the channel for updates. If the + /// channel already exists, it will simply listen for new events. + await channel.watch(); + + runApp( + StreamExample( + client: client, + channel: channel, + ), + ); +} + +/// Example using Stream's Low Level Dart client. +class StreamExample extends StatelessWidget { + /// To initialize this example, an instance of [client] and [channel] is required. + const StreamExample({ + Key key, + @required this.client, + @required this.channel, + }) : super(key: key); + + /// Instance of [StreamChatClient] we created earlier. This contains information about + /// our application and connection state. + final StreamChatClient client; + + /// The channel we'd like to observe and participate. + final Channel channel; + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Stream Chat Dart Example', + home: HomeScreen(channel: channel), + ); + } +} + +/// Main screen of our application. The layout is comprised of an [AppBar] +/// containing the channel name and a [MessageView] displaying recent messages. +class HomeScreen extends StatelessWidget { + /// [HomeScreen] is constructed using the [Channel] we defined earlier. + const HomeScreen({Key key, @required this.channel}) : super(key: key); + + /// Channel object containing the [Channel.id] we'd like to observe. + final Channel channel; + + @override + Widget build(BuildContext context) { + final messages = channel.state.channelStateStream; + return Scaffold( + appBar: AppBar( + title: Text('Channel: ${channel.id}'), + ), + body: SafeArea( + child: StreamBuilder( + stream: messages, + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.hasData && snapshot.data != null) { + return MessageView( + messages: snapshot.data.messages.reversed.toList(), + channel: channel, + ); + } else if (snapshot.hasError) { + return const Center( + child: Text( + 'There was an error loading messages. Please see logs.', + ), + ); + } + return const Center( + child: SizedBox( + width: 100.0, + height: 100.0, + child: CircularProgressIndicator(), + ), + ); + }, + ), + ), + ); + } +} + +/// UI used to display a list of recent messages and a [TextField] for sending +/// new messages. +class MessageView extends StatefulWidget { + /// Message takes the latest list of messages and the current channel. + const MessageView({ + Key key, + @required this.messages, + @required this.channel, + }) : super(key: key); + + /// List of messages sent in the given channel. + final List messages; + + /// Current channel being observed. + final Channel channel; + + @override + _MessageViewState createState() => _MessageViewState(); +} + +class _MessageViewState extends State { + TextEditingController _controller; + ScrollController _scrollController; + + List get _messages => widget.messages; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _scrollController = ScrollController(); + } + + @override + void dispose() { + _controller.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + /// Convenience method for scrolling the list view when a new message is sent. + void _updateList() { + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = _messages[index]; + if (item.user.id == widget.channel.client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(item.text), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text(item.text), + ), + ); + } + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Enter your message', + ), + ), + ), + Material( + type: MaterialType.circle, + color: Colors.blue, + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () async { + // We can send a new message by calling `sendMessage` on + // the current channel. After sending a message, the + // TextField is cleared and the list view is scrolled + // to show the new item. + if (_controller.value.text.isNotEmpty) { + await widget.channel.sendMessage( + Message(text: _controller.value.text), + ); + _controller.clear(); + _updateList(); + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Center( + child: Icon( + Icons.send, + color: Colors.white, + ), + ), + ), + ), + ) + ], + ), + ) + ], + ); + } +} + +/// Helper extension for quickly retrieving the current user id from a [StreamChatClient]. +extension on StreamChatClient { + String get uid => state.user.id; +} diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml new file mode 100644 index 00000000..5008566b --- /dev/null +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -0,0 +1,23 @@ +name: example +description: A new Flutter project. + +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.0 + stream_chat: + path: ../../stream_chat + stream_chat_persistence: + path: ../ + +dev_dependencies: + flutter_test: + sdk: flutter +flutter: + uses-material-design: true \ No newline at end of file diff --git a/packages/stream_chat_persistence/lib/src/converter/converter.dart b/packages/stream_chat_persistence/lib/src/converter/converter.dart new file mode 100644 index 00000000..a5d7bbdc --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/converter.dart @@ -0,0 +1,3 @@ +export 'list_converter.dart'; +export 'map_converter.dart'; +export 'message_sending_status_converter.dart'; diff --git a/packages/stream_chat_persistence/lib/src/converter/list_converter.dart b/packages/stream_chat_persistence/lib/src/converter/list_converter.dart new file mode 100644 index 00000000..d14642ce --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/list_converter.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +import 'package:moor/moor.dart'; + +/// Maps a [List] of type [T] into a [String] understood +/// by the sqlite backend. +class ListConverter extends TypeConverter, String> { + @override + List mapToDart(fromDb) { + if (fromDb == null) { + return null; + } + return List.from(jsonDecode(fromDb) ?? []); + } + + @override + String mapToSql(value) { + if (value == null) { + return null; + } + return jsonEncode(value); + } +} diff --git a/packages/stream_chat_persistence/lib/src/converter/map_converter.dart b/packages/stream_chat_persistence/lib/src/converter/map_converter.dart new file mode 100644 index 00000000..6e7a648d --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/map_converter.dart @@ -0,0 +1,23 @@ +import 'dart:convert'; + +import 'package:moor/moor.dart'; + +/// Maps a [Map] of type [String], [T] into a [String] understood +/// by the sqlite backend. +class MapConverter extends TypeConverter, String> { + @override + Map mapToDart(fromDb) { + if (fromDb == null) { + return null; + } + return Map.from(jsonDecode(fromDb) ?? {}); + } + + @override + String mapToSql(value) { + if (value == null) { + return null; + } + return jsonEncode(value); + } +} diff --git a/packages/stream_chat_persistence/lib/src/converter/message_sending_status_converter.dart b/packages/stream_chat_persistence/lib/src/converter/message_sending_status_converter.dart new file mode 100644 index 00000000..75b006bb --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/converter/message_sending_status_converter.dart @@ -0,0 +1,51 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// Maps a [MessageSendingStatus] into a [int] understood +/// by the sqlite backend. +class MessageSendingStatusConverter + extends TypeConverter { + @override + MessageSendingStatus mapToDart(int fromDb) { + switch (fromDb) { + case 0: + return MessageSendingStatus.sending; + case 1: + return MessageSendingStatus.sent; + case 2: + return MessageSendingStatus.failed; + case 3: + return MessageSendingStatus.updating; + case 4: + return MessageSendingStatus.failed_update; + case 5: + return MessageSendingStatus.deleting; + case 6: + return MessageSendingStatus.failed_delete; + default: + return null; + } + } + + @override + int mapToSql(MessageSendingStatus value) { + switch (value) { + case MessageSendingStatus.sending: + return 0; + case MessageSendingStatus.sent: + return 1; + case MessageSendingStatus.failed: + return 2; + case MessageSendingStatus.updating: + return 3; + case MessageSendingStatus.failed_update: + return 4; + case MessageSendingStatus.deleting: + return 5; + case MessageSendingStatus.failed_delete: + return 6; + default: + return null; + } + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart new file mode 100644 index 00000000..57ec95d3 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart @@ -0,0 +1,57 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/channels.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; +import '../mapper/mapper.dart'; + +part 'channel_dao.g.dart'; + +/// The Data Access Object for operations in [Channels] table. +@UseDao(tables: [Channels, Users]) +class ChannelDao extends DatabaseAccessor + with _$ChannelDaoMixin { + /// Creates a new channel dao instance + ChannelDao(MoorChatDatabase db) : super(db); + + /// Get channel by cid + Future getChannelByCid(String cid) async { + return (select(channels)..where((c) => c.cid.equals(cid))).join([ + leftOuterJoin(users, channels.createdById.equalsExp(users.id)), + ]).map((rows) { + final channel = rows.readTable(channels); + final createdBy = rows.readTable(users); + return channel.toChannelModel(createdBy: createdBy?.toUser()); + }).getSingle(); + } + + /// Delete all channels by matching cid in [cids] + /// + /// This will automatically delete the following linked records + /// 1. Channel Reads + /// 2. Channel Members + /// 3. Channel Messages -> Messages Reactions + Future deleteChannelByCids(List cids) async { + return (delete(channels)..where((tbl) => tbl.cid.isIn(cids))).go(); + } + + /// Get the channel cids saved in the storage + Future> get cids { + return (select(channels) + ..orderBy([(c) => OrderingTerm.desc(c.lastMessageAt)]) + ..limit(250)) + .map((c) => c.cid) + .get(); + } + + /// Updates all the channels using the new [channelList] data + Future updateChannels(List channelList) { + return batch( + (it) => it.insertAll( + channels, + channelList.map((c) => c.toEntity()).toList(), + mode: InsertMode.insertOrReplace, + ), + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/channel_dao.g.dart new file mode 100644 index 00000000..a3ac1713 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/channel_dao.g.dart @@ -0,0 +1,12 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$ChannelDaoMixin on DatabaseAccessor { + $ChannelsTable get channels => attachedDatabase.channels; + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart new file mode 100644 index 00000000..1492420c --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/channel_queries.dart'; +import 'package:stream_chat_persistence/src/entity/channels.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; +import '../mapper/mapper.dart'; + +part 'channel_query_dao.g.dart'; + +/// The Data Access Object for operations in [ChannelQueries] table. +@UseDao(tables: [ChannelQueries, Channels, Users]) +class ChannelQueryDao extends DatabaseAccessor + with _$ChannelQueryDaoMixin { + /// Creates a new channel query dao instance + ChannelQueryDao(this._db) : super(_db); + + final MoorChatDatabase _db; + + String _computeHash(Map filter) { + if (filter == null) { + return 'allchannels'; + } + final hash = base64Encode(utf8.encode('filter: ${jsonEncode(filter)}')); + return hash; + } + + /// Update list of channel queries + /// If [clearQueryCache] is true before the insert + /// the list of matching rows will be deleted + Future updateChannelQueries( + Map filter, + List cids, + bool clearQueryCache, + ) async { + final hash = _computeHash(filter); + if (clearQueryCache) { + await (delete(channelQueries) + ..where((query) => query.queryHash.equals(hash))) + .go(); + } + + return batch((batch) { + batch.insertAll( + channelQueries, + cids.map((cid) { + return ChannelQueryEntity( + queryHash: hash, + channelCid: cid, + ); + }).toList(), + mode: InsertMode.insertOrReplace, + ); + }); + } + + /// Get list of channels by filter, sort and paginationParams + Future> getChannelStates({ + Map filter, + List sort = const [], + PaginationParams paginationParams, + }) async { + final hash = _computeHash(filter); + final cachedChannels = await Future.wait(await (select(channelQueries) + ..where((c) => c.queryHash.equals(hash))) + .get() + .then((channelQueries) { + final cids = channelQueries.map((c) => c.channelCid).toList(); + final query = select(channels)..where((c) => c.cid.isIn(cids)); + + sort = sort + ?.where((s) => ChannelModel.topLevelFields.contains(s.field)) + ?.toList(); + + if (sort != null && sort.isNotEmpty) { + query.orderBy(sort.map((s) { + final orderExpression = CustomExpression('channels.${s.field}'); + return (c) => OrderingTerm( + expression: orderExpression, + mode: s.direction == 1 ? OrderingMode.asc : OrderingMode.desc, + ); + }).toList()); + } + + if (paginationParams != null) { + query.limit( + paginationParams.limit ?? 10, + offset: paginationParams.offset, + ); + } + + return query.join([ + leftOuterJoin(users, channels.createdById.equalsExp(users.id)), + ]).map((row) async { + final userEntity = row.readTable(users); + final channelEntity = row.readTable(channels); + + final cid = channelEntity.cid; + final members = await _db.memberDao.getMembersByCid(cid); + final reads = await _db.readDao.getReadsByCid(cid); + final messages = await _db.messageDao.getMessagesByCid(cid); + + return channelEntity.toChannelState( + createdBy: userEntity?.toUser(), + members: members, + reads: reads, + messages: messages, + ); + }).get(); + })); + + if (sort?.isEmpty != false && cachedChannels?.isNotEmpty == true) { + cachedChannels + .sort((a, b) => b.channel.updatedAt.compareTo(a.channel.updatedAt)); + cachedChannels.sort((a, b) { + final dateA = a.channel.lastMessageAt ?? a.channel.createdAt; + final dateB = b.channel.lastMessageAt ?? b.channel.createdAt; + return dateB.compareTo(dateA); + }); + } + + return cachedChannels; + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart new file mode 100644 index 00000000..5879d8bb --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'channel_query_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$ChannelQueryDaoMixin on DatabaseAccessor { + $ChannelQueriesTable get channelQueries => attachedDatabase.channelQueries; + $ChannelsTable get channels => attachedDatabase.channels; + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart new file mode 100644 index 00000000..8d2033b2 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart @@ -0,0 +1,54 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/connection_events.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; +import '../mapper/mapper.dart'; + +part 'connection_event_dao.g.dart'; + +/// The Data Access Object for operations in [ConnectionEvents] table. +@UseDao(tables: [ConnectionEvents, Users]) +class ConnectionEventDao extends DatabaseAccessor + with _$ConnectionEventDaoMixin { + /// Creates a new connection event dao instance + ConnectionEventDao(MoorChatDatabase db) : super(db); + + /// Get the latest stored connection event + Future get connectionEvent { + return select(connectionEvents).map((eventEntity) { + return eventEntity.toEvent(); + }).getSingle(); + } + + /// Get the latest stored lastSyncAt + Future get lastSyncAt { + return select(connectionEvents).getSingle().then((r) => r?.lastSyncAt); + } + + /// Update stored connection event with latest data + Future updateConnectionEvent(Event event) async { + final connectionInfo = await select(connectionEvents).getSingle(); + return into(connectionEvents).insert( + ConnectionEventEntity( + id: 1, + lastSyncAt: connectionInfo?.lastSyncAt, + lastEventAt: event.createdAt ?? connectionInfo?.lastEventAt, + totalUnreadCount: + event.totalUnreadCount ?? connectionInfo?.totalUnreadCount, + ownUser: event.me?.toJson() ?? connectionInfo?.ownUser, + unreadChannels: event.unreadChannels ?? connectionInfo?.unreadChannels, + ), + mode: InsertMode.insertOrReplace, + ); + } + + /// Update stored lastSyncAt with latest data + Future updateLastSyncAt(DateTime lastSyncAt) async { + return (update(connectionEvents)..where((tbl) => tbl.id.equals(1))).write( + ConnectionEventsCompanion( + lastSyncAt: Value(lastSyncAt), + ), + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart new file mode 100644 index 00000000..a8245982 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'connection_event_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$ConnectionEventDaoMixin on DatabaseAccessor { + $ConnectionEventsTable get connectionEvents => + attachedDatabase.connectionEvents; + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/dao.dart b/packages/stream_chat_persistence/lib/src/dao/dao.dart new file mode 100644 index 00000000..6f1e8221 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/dao.dart @@ -0,0 +1,8 @@ +export 'user_dao.dart'; +export 'channel_dao.dart'; +export 'message_dao.dart'; +export 'member_dao.dart'; +export 'connection_event_dao.dart'; +export 'reaction_dao.dart'; +export 'read_dao.dart'; +export 'channel_query_dao.dart'; diff --git a/packages/stream_chat_persistence/lib/src/dao/member_dao.dart b/packages/stream_chat_persistence/lib/src/dao/member_dao.dart new file mode 100644 index 00000000..ed30a931 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/member_dao.dart @@ -0,0 +1,53 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +import 'package:stream_chat_persistence/src/entity/members.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; + +import '../mapper/mapper.dart'; + +part 'member_dao.g.dart'; + +/// The Data Access Object for operations in [Members] table. +@UseDao(tables: [Members, Users]) +class MemberDao extends DatabaseAccessor + with _$MemberDaoMixin { + /// Creates a new member dao instance + MemberDao(MoorChatDatabase db) : super(db); + + /// Get all members where [Members.channelCid] matches [cid] + Future> getMembersByCid(String cid) async { + return (select(members).join([ + leftOuterJoin(users, members.userId.equalsExp(users.id)), + ]) + ..where(members.channelCid.equals(cid)) + ..orderBy([OrderingTerm.asc(members.createdAt)])) + .map((row) { + final userEntity = row.readTable(users); + final memberEntity = row.readTable(members); + return memberEntity.toMember(user: userEntity?.toUser()); + }).get(); + } + + /// Updates all the members using the new [memberList] data + Future updateMembers(String cid, List memberList) async { + return batch( + (it) => it.insertAll( + members, + memberList.map((m) => m.toEntity(cid: cid)).toList(), + mode: InsertMode.insertOrReplace, + ), + ); + } + + /// Deletes all the members whose [Members.channelCid] is present in [cids] + Future deleteMemberByCids(List cids) async { + return batch((it) { + it.deleteWhere( + members, + (m) => m.channelCid.isIn(cids), + ); + }); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/member_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/member_dao.g.dart new file mode 100644 index 00000000..dbda1351 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/member_dao.g.dart @@ -0,0 +1,12 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'member_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$MemberDaoMixin on DatabaseAccessor { + $MembersTable get members => attachedDatabase.members; + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart new file mode 100644 index 00000000..c207ae20 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart @@ -0,0 +1,153 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/messages.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; + +import '../mapper/mapper.dart'; + +part 'message_dao.g.dart'; + +/// The Data Access Object for operations in [Messages] table. +@UseDao(tables: [Messages, Users]) +class MessageDao extends DatabaseAccessor + with _$MessageDaoMixin { + /// Creates a new message dao instance + MessageDao(this._db) : super(_db); + + final MoorChatDatabase _db; + + /// Removes all the messages by matching [Messages.id] in [messageIds] + /// + /// This will automatically delete the following linked records + /// 1. Message Reactions + Future deleteMessageByIds(List messageIds) { + return (delete(messages)..where((tbl) => tbl.id.isIn(messageIds))).go(); + } + + /// Removes all the messages by matching [Messages.channelCid] in [cids] + /// + /// This will automatically delete the following linked records + /// 1. Message Reactions + Future deleteMessageByCids(List cids) async { + return (delete(messages)..where((tbl) => tbl.channelCid.isIn(cids))).go(); + } + + Future _messageFromJoinRow(TypedResult rows) async { + final userEntity = rows.readTable(users); + final msgEntity = rows.readTable(messages); + final latestReactions = await _db.reactionDao.getReactions(msgEntity.id); + final ownReactions = await _db.reactionDao.getReactionsByUserId( + msgEntity.id, + _db.userId, + ); + Message quotedMessage; + if (msgEntity.quotedMessageId != null) { + quotedMessage = await getMessageById(msgEntity.quotedMessageId); + } + return msgEntity.toMessage( + user: userEntity?.toUser(), + latestReactions: latestReactions, + ownReactions: ownReactions, + quotedMessage: quotedMessage, + ); + } + + /// Returns a single message by matching the [Messages.id] with [id] + Future getMessageById(String id) async { + return await (select(messages).join([ + leftOuterJoin(users, messages.userId.equalsExp(users.id)), + ]) + ..where(messages.id.equals(id))) + .map(_messageFromJoinRow) + .getSingle(); + } + + /// Returns all the messages of a particular thread by matching + /// [Messages.channelCid] with [cid] + Future> getThreadMessages(String cid) async { + return Future.wait(await (select(messages).join([ + leftOuterJoin(users, messages.userId.equalsExp(users.id)), + ]) + ..where(messages.channelCid.equals(cid)) + ..where(isNotNull(messages.parentId)) + ..orderBy([OrderingTerm.asc(messages.createdAt)])) + .map(_messageFromJoinRow) + .get()); + } + + /// Returns all the messages of a particular thread by matching + /// [Messages.parentId] with [parentId] + Future> getThreadMessagesByParentId( + String parentId, { + PaginationParams options, + }) async { + final msgList = await Future.wait(await (select(messages).join([ + innerJoin(users, messages.userId.equalsExp(users.id)), + ]) + ..where(messages.parentId.equals(parentId)) + ..orderBy([OrderingTerm.asc(messages.createdAt)])) + .map(_messageFromJoinRow) + .get()); + + if (options?.lessThan != null) { + final lessThanIndex = msgList.indexWhere((m) => m.id == options.lessThan); + msgList.removeRange(lessThanIndex, msgList.length); + } + return msgList; + } + + /// Returns all the messages of a channel by matching + /// [Messages.channelCid] with [parentId] + Future> getMessagesByCid( + String cid, { + PaginationParams messagePagination, + }) async { + final msgList = await Future.wait(await (select(messages).join([ + leftOuterJoin(users, messages.userId.equalsExp(users.id)), + ]) + ..where(messages.channelCid.equals(cid)) + ..where( + isNull(messages.parentId) | messages.showInChannel.equals(true)) + ..orderBy([OrderingTerm.asc(messages.createdAt)])) + .map(_messageFromJoinRow) + .get()); + + if (messagePagination?.lessThan != null) { + final lessThanIndex = msgList.indexWhere( + (m) => m.id == messagePagination.lessThan, + ); + if (lessThanIndex != -1) { + msgList.removeRange(lessThanIndex, msgList.length); + } + } + if (messagePagination?.greaterThanOrEqual != null) { + final greaterThanIndex = msgList.indexWhere( + (m) => m.id == messagePagination.greaterThanOrEqual, + ); + if (greaterThanIndex != -1) { + msgList.removeRange(0, greaterThanIndex); + } + } + if (messagePagination?.limit != null) { + return msgList.take(messagePagination.limit).toList(); + } + return msgList; + } + + /// Updates the message data of a particular channel with + /// the new [messageList] data + Future updateMessages(String cid, List messageList) async { + if (messageList == null) { + return; + } + + return batch((batch) { + batch.insertAll( + messages, + messageList.map((it) => it.toEntity(cid: cid)).toList(), + mode: InsertMode.insertOrReplace, + ); + }); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.g.dart new file mode 100644 index 00000000..e27a5d42 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.g.dart @@ -0,0 +1,12 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$MessageDaoMixin on DatabaseAccessor { + $MessagesTable get messages => attachedDatabase.messages; + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart new file mode 100644 index 00000000..99053fca --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart @@ -0,0 +1,64 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/reactions.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; +import '../mapper/mapper.dart'; + +part 'reaction_dao.g.dart'; + +/// The Data Access Object for operations in [Reactions] table. +@UseDao(tables: [Reactions, Users]) +class ReactionDao extends DatabaseAccessor + with _$ReactionDaoMixin { + /// Creates a new reaction dao instance + ReactionDao(MoorChatDatabase db) : super(db); + + /// Returns all the reactions of a particular message by matching + /// [Reactions.messageId] with [messageId] + Future> getReactions(String messageId) { + return (select(reactions).join([ + leftOuterJoin(users, reactions.userId.equalsExp(users.id)), + ]) + ..where(reactions.messageId.equals(messageId)) + ..orderBy([OrderingTerm.asc(reactions.createdAt)])) + .map((rows) { + final userEntity = rows.readTable(users); + final reactionEntity = rows.readTable(reactions); + return reactionEntity.toReaction(user: userEntity?.toUser()); + }).get(); + } + + /// Returns all the reactions of a particular message + /// added by a particular user by matching + /// [Reactions.messageId] with [messageId] and + /// [Reactions.userId] with [userId] + Future> getReactionsByUserId( + String messageId, + String userId, + ) async { + final reactions = await getReactions(messageId); + return reactions.where((it) => it.userId == userId).toList(); + } + + /// Updates the reactions data with the new [reactionList] data + Future updateReactions(List reactionList) { + return batch((it) { + it.insertAll( + reactions, + reactionList.map((r) => r.toEntity()).toList(), + mode: InsertMode.insertOrReplace, + ); + }); + } + + /// Deletes all the reactions whose [Reactions.messageId] is present in [messageIds] + Future deleteReactionsByMessageIds(List messageIds) { + return batch((it) { + it.deleteWhere( + reactions, + (r) => r.messageId.isIn(messageIds), + ); + }); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.g.dart new file mode 100644 index 00000000..b95e2126 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.g.dart @@ -0,0 +1,12 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'reaction_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$ReactionDaoMixin on DatabaseAccessor { + $ReactionsTable get reactions => attachedDatabase.reactions; + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/read_dao.dart b/packages/stream_chat_persistence/lib/src/dao/read_dao.dart new file mode 100644 index 00000000..c78cab07 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/read_dao.dart @@ -0,0 +1,43 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/reads.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; +import '../mapper/mapper.dart'; + +part 'read_dao.g.dart'; + +/// The Data Access Object for operations in [Reads] table. +@UseDao(tables: [Reads, Users]) +class ReadDao extends DatabaseAccessor with _$ReadDaoMixin { + /// Creates a new read dao instance + ReadDao(MoorChatDatabase db) : super(db); + + /// Get all reads where [Reads.channelCid] matches [cid] + Future> getReadsByCid(String cid) async { + return (select(reads).join([ + leftOuterJoin(users, reads.userId.equalsExp(users.id)), + ]) + ..where(reads.channelCid.equals(cid)) + ..orderBy([ + OrderingTerm.asc(reads.lastRead), + ])) + .map((row) { + final userEntity = row.readTable(users); + final readEntity = row.readTable(reads); + return readEntity.toRead(user: userEntity?.toUser()); + }).get(); + } + + /// Updates the read data of a particular channel with + /// the new [readList] data + Future updateReads(String cid, List readList) { + return batch( + (it) => it.insertAll( + reads, + readList.map((r) => r.toEntity(cid: cid)).toList(), + mode: InsertMode.insertOrReplace, + ), + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/read_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/read_dao.g.dart new file mode 100644 index 00000000..b9ac9228 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/read_dao.g.dart @@ -0,0 +1,12 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'read_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$ReadDaoMixin on DatabaseAccessor { + $ReadsTable get reads => attachedDatabase.reads; + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/user_dao.dart b/packages/stream_chat_persistence/lib/src/dao/user_dao.dart new file mode 100644 index 00000000..795c7968 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/user_dao.dart @@ -0,0 +1,25 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/users.dart'; +import '../mapper/user_mapper.dart'; + +part 'user_dao.g.dart'; + +/// The Data Access Object for operations in [Users] table. +@UseDao(tables: [Users]) +class UserDao extends DatabaseAccessor with _$UserDaoMixin { + /// Creates a new user dao instance + UserDao(MoorChatDatabase db) : super(db); + + /// Updates the users data with the new [userList] data + Future updateUsers(List userList) { + return batch( + (it) => it.insertAll( + users, + userList.map((u) => u.toEntity()).toList(), + mode: InsertMode.insertOrReplace, + ), + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/dao/user_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/user_dao.g.dart new file mode 100644 index 00000000..edd9a33b --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/user_dao.g.dart @@ -0,0 +1,11 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_dao.dart'; + +// ************************************************************************** +// DaoGenerator +// ************************************************************************** + +mixin _$UserDaoMixin on DatabaseAccessor { + $UsersTable get users => attachedDatabase.users; +} diff --git a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart new file mode 100644 index 00000000..4230f278 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.dart @@ -0,0 +1,90 @@ +import 'package:moor/isolate.dart'; +import 'package:moor/moor.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import '../entity/entity.dart'; +import '../dao/dao.dart'; +import '../converter/converter.dart'; +import 'shared/shared_db.dart'; + +part 'moor_chat_database.g.dart'; + +LazyDatabase _openConnection( + String userId, { + logStatements = false, +}) { + return LazyDatabase(() async { + return await SharedDB.constructDatabase( + userId, + logStatements: logStatements, + ); + }); +} + +/// A chat database implemented using moor +@UseMoor(tables: [ + Channels, + Messages, + Reactions, + Users, + Members, + Reads, + ChannelQueries, + ConnectionEvents, +], daos: [ + UserDao, + ChannelDao, + MessageDao, + MemberDao, + ReactionDao, + ReadDao, + ChannelQueryDao, + ConnectionEventDao, +]) +class MoorChatDatabase extends _$MoorChatDatabase { + /// Creates a new moor chat database instance + MoorChatDatabase( + this._userId, { + logStatements = false, + }) : super(_openConnection( + _userId, + logStatements: logStatements, + )); + + /// Instantiate a new database instance + MoorChatDatabase.connect( + this._userId, + this._isolate, + DatabaseConnection connection, + ) : super.connect(connection); + + final String _userId; + + /// User id to which the database is connected + String get userId => _userId; + + MoorIsolate _isolate; + + // you should bump this number whenever you change or add a table definition. + @override + int get schemaVersion => 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + onUpgrade: (openingDetails, before, after) async { + if (before != after) { + final m = createMigrator(); + for (final table in allTables) { + await m.deleteTable(table.actualTableName); + await m.createTable(table); + } + } + }, + ); + + /// Closes the database instance + Future disconnect() async { + await _isolate?.shutdownAll(); + await close(); + } +} diff --git a/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart new file mode 100644 index 00000000..0ff6e9f5 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/db/moor_chat_database.g.dart @@ -0,0 +1,4030 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'moor_chat_database.dart'; + +// ************************************************************************** +// MoorGenerator +// ************************************************************************** + +// ignore_for_file: unnecessary_brace_in_string_interps, unnecessary_this +class ChannelEntity extends DataClass implements Insertable { + final String id; + final String type; + final String cid; + final Map config; + final bool frozen; + final DateTime lastMessageAt; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime deletedAt; + final int memberCount; + final String createdById; + final Map extraData; + ChannelEntity( + {@required this.id, + @required this.type, + @required this.cid, + @required this.config, + @required this.frozen, + this.lastMessageAt, + this.createdAt, + this.updatedAt, + this.deletedAt, + this.memberCount, + this.createdById, + this.extraData}); + factory ChannelEntity.fromData( + Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final stringType = db.typeSystem.forDartType(); + final boolType = db.typeSystem.forDartType(); + final dateTimeType = db.typeSystem.forDartType(); + final intType = db.typeSystem.forDartType(); + return ChannelEntity( + id: stringType.mapFromDatabaseResponse(data['${effectivePrefix}id']), + type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), + cid: stringType.mapFromDatabaseResponse(data['${effectivePrefix}cid']), + config: $ChannelsTable.$converter0.mapToDart( + stringType.mapFromDatabaseResponse(data['${effectivePrefix}config'])), + frozen: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}frozen']), + lastMessageAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}last_message_at']), + createdAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), + updatedAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), + deletedAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}deleted_at']), + memberCount: intType + .mapFromDatabaseResponse(data['${effectivePrefix}member_count']), + createdById: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}created_by_id']), + extraData: $ChannelsTable.$converter1.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || id != null) { + map['id'] = Variable(id); + } + if (!nullToAbsent || type != null) { + map['type'] = Variable(type); + } + if (!nullToAbsent || cid != null) { + map['cid'] = Variable(cid); + } + if (!nullToAbsent || config != null) { + final converter = $ChannelsTable.$converter0; + map['config'] = Variable(converter.mapToSql(config)); + } + if (!nullToAbsent || frozen != null) { + map['frozen'] = Variable(frozen); + } + if (!nullToAbsent || lastMessageAt != null) { + map['last_message_at'] = Variable(lastMessageAt); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || updatedAt != null) { + map['updated_at'] = Variable(updatedAt); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || memberCount != null) { + map['member_count'] = Variable(memberCount); + } + if (!nullToAbsent || createdById != null) { + map['created_by_id'] = Variable(createdById); + } + if (!nullToAbsent || extraData != null) { + final converter = $ChannelsTable.$converter1; + map['extra_data'] = Variable(converter.mapToSql(extraData)); + } + return map; + } + + factory ChannelEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return ChannelEntity( + id: serializer.fromJson(json['id']), + type: serializer.fromJson(json['type']), + cid: serializer.fromJson(json['cid']), + config: serializer.fromJson>(json['config']), + frozen: serializer.fromJson(json['frozen']), + lastMessageAt: serializer.fromJson(json['lastMessageAt']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + memberCount: serializer.fromJson(json['memberCount']), + createdById: serializer.fromJson(json['createdById']), + extraData: serializer.fromJson>(json['extraData']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'type': serializer.toJson(type), + 'cid': serializer.toJson(cid), + 'config': serializer.toJson>(config), + 'frozen': serializer.toJson(frozen), + 'lastMessageAt': serializer.toJson(lastMessageAt), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'memberCount': serializer.toJson(memberCount), + 'createdById': serializer.toJson(createdById), + 'extraData': serializer.toJson>(extraData), + }; + } + + ChannelEntity copyWith( + {String id, + String type, + String cid, + Map config, + bool frozen, + Value lastMessageAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value memberCount = const Value.absent(), + Value createdById = const Value.absent(), + Value> extraData = const Value.absent()}) => + ChannelEntity( + id: id ?? this.id, + type: type ?? this.type, + cid: cid ?? this.cid, + config: config ?? this.config, + frozen: frozen ?? this.frozen, + lastMessageAt: + lastMessageAt.present ? lastMessageAt.value : this.lastMessageAt, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + memberCount: memberCount.present ? memberCount.value : this.memberCount, + createdById: createdById.present ? createdById.value : this.createdById, + extraData: extraData.present ? extraData.value : this.extraData, + ); + @override + String toString() { + return (StringBuffer('ChannelEntity(') + ..write('id: $id, ') + ..write('type: $type, ') + ..write('cid: $cid, ') + ..write('config: $config, ') + ..write('frozen: $frozen, ') + ..write('lastMessageAt: $lastMessageAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('memberCount: $memberCount, ') + ..write('createdById: $createdById, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + id.hashCode, + $mrjc( + type.hashCode, + $mrjc( + cid.hashCode, + $mrjc( + config.hashCode, + $mrjc( + frozen.hashCode, + $mrjc( + lastMessageAt.hashCode, + $mrjc( + createdAt.hashCode, + $mrjc( + updatedAt.hashCode, + $mrjc( + deletedAt.hashCode, + $mrjc( + memberCount.hashCode, + $mrjc(createdById.hashCode, + extraData.hashCode)))))))))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is ChannelEntity && + other.id == this.id && + other.type == this.type && + other.cid == this.cid && + other.config == this.config && + other.frozen == this.frozen && + other.lastMessageAt == this.lastMessageAt && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.memberCount == this.memberCount && + other.createdById == this.createdById && + other.extraData == this.extraData); +} + +class ChannelsCompanion extends UpdateCompanion { + final Value id; + final Value type; + final Value cid; + final Value> config; + final Value frozen; + final Value lastMessageAt; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value memberCount; + final Value createdById; + final Value> extraData; + const ChannelsCompanion({ + this.id = const Value.absent(), + this.type = const Value.absent(), + this.cid = const Value.absent(), + this.config = const Value.absent(), + this.frozen = const Value.absent(), + this.lastMessageAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.memberCount = const Value.absent(), + this.createdById = const Value.absent(), + this.extraData = const Value.absent(), + }); + ChannelsCompanion.insert({ + @required String id, + @required String type, + @required String cid, + @required Map config, + this.frozen = const Value.absent(), + this.lastMessageAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.memberCount = const Value.absent(), + this.createdById = const Value.absent(), + this.extraData = const Value.absent(), + }) : id = Value(id), + type = Value(type), + cid = Value(cid), + config = Value(config); + static Insertable custom({ + Expression id, + Expression type, + Expression cid, + Expression config, + Expression frozen, + Expression lastMessageAt, + Expression createdAt, + Expression updatedAt, + Expression deletedAt, + Expression memberCount, + Expression createdById, + Expression extraData, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (type != null) 'type': type, + if (cid != null) 'cid': cid, + if (config != null) 'config': config, + if (frozen != null) 'frozen': frozen, + if (lastMessageAt != null) 'last_message_at': lastMessageAt, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (memberCount != null) 'member_count': memberCount, + if (createdById != null) 'created_by_id': createdById, + if (extraData != null) 'extra_data': extraData, + }); + } + + ChannelsCompanion copyWith( + {Value id, + Value type, + Value cid, + Value> config, + Value frozen, + Value lastMessageAt, + Value createdAt, + Value updatedAt, + Value deletedAt, + Value memberCount, + Value createdById, + Value> extraData}) { + return ChannelsCompanion( + id: id ?? this.id, + type: type ?? this.type, + cid: cid ?? this.cid, + config: config ?? this.config, + frozen: frozen ?? this.frozen, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + memberCount: memberCount ?? this.memberCount, + createdById: createdById ?? this.createdById, + extraData: extraData ?? this.extraData, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (cid.present) { + map['cid'] = Variable(cid.value); + } + if (config.present) { + final converter = $ChannelsTable.$converter0; + map['config'] = Variable(converter.mapToSql(config.value)); + } + if (frozen.present) { + map['frozen'] = Variable(frozen.value); + } + if (lastMessageAt.present) { + map['last_message_at'] = Variable(lastMessageAt.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (memberCount.present) { + map['member_count'] = Variable(memberCount.value); + } + if (createdById.present) { + map['created_by_id'] = Variable(createdById.value); + } + if (extraData.present) { + final converter = $ChannelsTable.$converter1; + map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ChannelsCompanion(') + ..write('id: $id, ') + ..write('type: $type, ') + ..write('cid: $cid, ') + ..write('config: $config, ') + ..write('frozen: $frozen, ') + ..write('lastMessageAt: $lastMessageAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('memberCount: $memberCount, ') + ..write('createdById: $createdById, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } +} + +class $ChannelsTable extends Channels + with TableInfo<$ChannelsTable, ChannelEntity> { + final GeneratedDatabase _db; + final String _alias; + $ChannelsTable(this._db, [this._alias]); + final VerificationMeta _idMeta = const VerificationMeta('id'); + GeneratedTextColumn _id; + @override + GeneratedTextColumn get id => _id ??= _constructId(); + GeneratedTextColumn _constructId() { + return GeneratedTextColumn( + 'id', + $tableName, + false, + ); + } + + final VerificationMeta _typeMeta = const VerificationMeta('type'); + GeneratedTextColumn _type; + @override + GeneratedTextColumn get type => _type ??= _constructType(); + GeneratedTextColumn _constructType() { + return GeneratedTextColumn( + 'type', + $tableName, + false, + ); + } + + final VerificationMeta _cidMeta = const VerificationMeta('cid'); + GeneratedTextColumn _cid; + @override + GeneratedTextColumn get cid => _cid ??= _constructCid(); + GeneratedTextColumn _constructCid() { + return GeneratedTextColumn( + 'cid', + $tableName, + false, + ); + } + + final VerificationMeta _configMeta = const VerificationMeta('config'); + GeneratedTextColumn _config; + @override + GeneratedTextColumn get config => _config ??= _constructConfig(); + GeneratedTextColumn _constructConfig() { + return GeneratedTextColumn( + 'config', + $tableName, + false, + ); + } + + final VerificationMeta _frozenMeta = const VerificationMeta('frozen'); + GeneratedBoolColumn _frozen; + @override + GeneratedBoolColumn get frozen => _frozen ??= _constructFrozen(); + GeneratedBoolColumn _constructFrozen() { + return GeneratedBoolColumn('frozen', $tableName, false, + defaultValue: Constant(false)); + } + + final VerificationMeta _lastMessageAtMeta = + const VerificationMeta('lastMessageAt'); + GeneratedDateTimeColumn _lastMessageAt; + @override + GeneratedDateTimeColumn get lastMessageAt => + _lastMessageAt ??= _constructLastMessageAt(); + GeneratedDateTimeColumn _constructLastMessageAt() { + return GeneratedDateTimeColumn( + 'last_message_at', + $tableName, + true, + ); + } + + final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + GeneratedDateTimeColumn _createdAt; + @override + GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); + GeneratedDateTimeColumn _constructCreatedAt() { + return GeneratedDateTimeColumn( + 'created_at', + $tableName, + true, + ); + } + + final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + GeneratedDateTimeColumn _updatedAt; + @override + GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); + GeneratedDateTimeColumn _constructUpdatedAt() { + return GeneratedDateTimeColumn( + 'updated_at', + $tableName, + true, + ); + } + + final VerificationMeta _deletedAtMeta = const VerificationMeta('deletedAt'); + GeneratedDateTimeColumn _deletedAt; + @override + GeneratedDateTimeColumn get deletedAt => _deletedAt ??= _constructDeletedAt(); + GeneratedDateTimeColumn _constructDeletedAt() { + return GeneratedDateTimeColumn( + 'deleted_at', + $tableName, + true, + ); + } + + final VerificationMeta _memberCountMeta = + const VerificationMeta('memberCount'); + GeneratedIntColumn _memberCount; + @override + GeneratedIntColumn get memberCount => + _memberCount ??= _constructMemberCount(); + GeneratedIntColumn _constructMemberCount() { + return GeneratedIntColumn( + 'member_count', + $tableName, + true, + ); + } + + final VerificationMeta _createdByIdMeta = + const VerificationMeta('createdById'); + GeneratedTextColumn _createdById; + @override + GeneratedTextColumn get createdById => + _createdById ??= _constructCreatedById(); + GeneratedTextColumn _constructCreatedById() { + return GeneratedTextColumn( + 'created_by_id', + $tableName, + true, + ); + } + + final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); + GeneratedTextColumn _extraData; + @override + GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); + GeneratedTextColumn _constructExtraData() { + return GeneratedTextColumn( + 'extra_data', + $tableName, + true, + ); + } + + @override + List get $columns => [ + id, + type, + cid, + config, + frozen, + lastMessageAt, + createdAt, + updatedAt, + deletedAt, + memberCount, + createdById, + extraData + ]; + @override + $ChannelsTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'channels'; + @override + final String actualTableName = 'channels'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('type')) { + context.handle( + _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + } else if (isInserting) { + context.missing(_typeMeta); + } + if (data.containsKey('cid')) { + context.handle( + _cidMeta, cid.isAcceptableOrUnknown(data['cid'], _cidMeta)); + } else if (isInserting) { + context.missing(_cidMeta); + } + context.handle(_configMeta, const VerificationResult.success()); + if (data.containsKey('frozen')) { + context.handle(_frozenMeta, + frozen.isAcceptableOrUnknown(data['frozen'], _frozenMeta)); + } + if (data.containsKey('last_message_at')) { + context.handle( + _lastMessageAtMeta, + lastMessageAt.isAcceptableOrUnknown( + data['last_message_at'], _lastMessageAtMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + } + if (data.containsKey('deleted_at')) { + context.handle(_deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at'], _deletedAtMeta)); + } + if (data.containsKey('member_count')) { + context.handle( + _memberCountMeta, + memberCount.isAcceptableOrUnknown( + data['member_count'], _memberCountMeta)); + } + if (data.containsKey('created_by_id')) { + context.handle( + _createdByIdMeta, + createdById.isAcceptableOrUnknown( + data['created_by_id'], _createdByIdMeta)); + } + context.handle(_extraDataMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {cid}; + @override + ChannelEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return ChannelEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $ChannelsTable createAlias(String alias) { + return $ChannelsTable(_db, alias); + } + + static TypeConverter, String> $converter0 = + MapConverter(); + static TypeConverter, String> $converter1 = + MapConverter(); +} + +class MessageEntity extends DataClass implements Insertable { + final String id; + final String messageText; + final List attachments; + final MessageSendingStatus status; + final String type; + final List mentionedUsers; + final Map reactionCounts; + final Map reactionScores; + final String parentId; + final String quotedMessageId; + final int replyCount; + final bool showInChannel; + final bool shadowed; + final String command; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime deletedAt; + final String userId; + final String channelCid; + final Map extraData; + MessageEntity( + {@required this.id, + this.messageText, + this.attachments, + this.status, + this.type, + this.mentionedUsers, + this.reactionCounts, + this.reactionScores, + this.parentId, + this.quotedMessageId, + this.replyCount, + this.showInChannel, + this.shadowed, + this.command, + @required this.createdAt, + this.updatedAt, + this.deletedAt, + this.userId, + this.channelCid, + this.extraData}); + factory MessageEntity.fromData( + Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final stringType = db.typeSystem.forDartType(); + final intType = db.typeSystem.forDartType(); + final boolType = db.typeSystem.forDartType(); + final dateTimeType = db.typeSystem.forDartType(); + return MessageEntity( + id: stringType.mapFromDatabaseResponse(data['${effectivePrefix}id']), + messageText: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}message_text']), + attachments: $MessagesTable.$converter0.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}attachments'])), + status: $MessagesTable.$converter1.mapToDart( + intType.mapFromDatabaseResponse(data['${effectivePrefix}status'])), + type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), + mentionedUsers: $MessagesTable.$converter2.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}mentioned_users'])), + reactionCounts: $MessagesTable.$converter3.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}reaction_counts'])), + reactionScores: $MessagesTable.$converter4.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}reaction_scores'])), + parentId: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}parent_id']), + quotedMessageId: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}quoted_message_id']), + replyCount: intType + .mapFromDatabaseResponse(data['${effectivePrefix}reply_count']), + showInChannel: boolType + .mapFromDatabaseResponse(data['${effectivePrefix}show_in_channel']), + shadowed: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}shadowed']), + command: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}command']), + createdAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), + updatedAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), + deletedAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}deleted_at']), + userId: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), + channelCid: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), + extraData: $MessagesTable.$converter5.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || id != null) { + map['id'] = Variable(id); + } + if (!nullToAbsent || messageText != null) { + map['message_text'] = Variable(messageText); + } + if (!nullToAbsent || attachments != null) { + final converter = $MessagesTable.$converter0; + map['attachments'] = Variable(converter.mapToSql(attachments)); + } + if (!nullToAbsent || status != null) { + final converter = $MessagesTable.$converter1; + map['status'] = Variable(converter.mapToSql(status)); + } + if (!nullToAbsent || type != null) { + map['type'] = Variable(type); + } + if (!nullToAbsent || mentionedUsers != null) { + final converter = $MessagesTable.$converter2; + map['mentioned_users'] = + Variable(converter.mapToSql(mentionedUsers)); + } + if (!nullToAbsent || reactionCounts != null) { + final converter = $MessagesTable.$converter3; + map['reaction_counts'] = + Variable(converter.mapToSql(reactionCounts)); + } + if (!nullToAbsent || reactionScores != null) { + final converter = $MessagesTable.$converter4; + map['reaction_scores'] = + Variable(converter.mapToSql(reactionScores)); + } + if (!nullToAbsent || parentId != null) { + map['parent_id'] = Variable(parentId); + } + if (!nullToAbsent || quotedMessageId != null) { + map['quoted_message_id'] = Variable(quotedMessageId); + } + if (!nullToAbsent || replyCount != null) { + map['reply_count'] = Variable(replyCount); + } + if (!nullToAbsent || showInChannel != null) { + map['show_in_channel'] = Variable(showInChannel); + } + if (!nullToAbsent || shadowed != null) { + map['shadowed'] = Variable(shadowed); + } + if (!nullToAbsent || command != null) { + map['command'] = Variable(command); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || updatedAt != null) { + map['updated_at'] = Variable(updatedAt); + } + if (!nullToAbsent || deletedAt != null) { + map['deleted_at'] = Variable(deletedAt); + } + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + if (!nullToAbsent || channelCid != null) { + map['channel_cid'] = Variable(channelCid); + } + if (!nullToAbsent || extraData != null) { + final converter = $MessagesTable.$converter5; + map['extra_data'] = Variable(converter.mapToSql(extraData)); + } + return map; + } + + factory MessageEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return MessageEntity( + id: serializer.fromJson(json['id']), + messageText: serializer.fromJson(json['messageText']), + attachments: serializer.fromJson>(json['attachments']), + status: serializer.fromJson(json['status']), + type: serializer.fromJson(json['type']), + mentionedUsers: serializer.fromJson>(json['mentionedUsers']), + reactionCounts: + serializer.fromJson>(json['reactionCounts']), + reactionScores: + serializer.fromJson>(json['reactionScores']), + parentId: serializer.fromJson(json['parentId']), + quotedMessageId: serializer.fromJson(json['quotedMessageId']), + replyCount: serializer.fromJson(json['replyCount']), + showInChannel: serializer.fromJson(json['showInChannel']), + shadowed: serializer.fromJson(json['shadowed']), + command: serializer.fromJson(json['command']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + deletedAt: serializer.fromJson(json['deletedAt']), + userId: serializer.fromJson(json['userId']), + channelCid: serializer.fromJson(json['channelCid']), + extraData: serializer.fromJson>(json['extraData']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'messageText': serializer.toJson(messageText), + 'attachments': serializer.toJson>(attachments), + 'status': serializer.toJson(status), + 'type': serializer.toJson(type), + 'mentionedUsers': serializer.toJson>(mentionedUsers), + 'reactionCounts': serializer.toJson>(reactionCounts), + 'reactionScores': serializer.toJson>(reactionScores), + 'parentId': serializer.toJson(parentId), + 'quotedMessageId': serializer.toJson(quotedMessageId), + 'replyCount': serializer.toJson(replyCount), + 'showInChannel': serializer.toJson(showInChannel), + 'shadowed': serializer.toJson(shadowed), + 'command': serializer.toJson(command), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'deletedAt': serializer.toJson(deletedAt), + 'userId': serializer.toJson(userId), + 'channelCid': serializer.toJson(channelCid), + 'extraData': serializer.toJson>(extraData), + }; + } + + MessageEntity copyWith( + {String id, + Value messageText = const Value.absent(), + Value> attachments = const Value.absent(), + Value status = const Value.absent(), + Value type = const Value.absent(), + Value> mentionedUsers = const Value.absent(), + Value> reactionCounts = const Value.absent(), + Value> reactionScores = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + Value shadowed = const Value.absent(), + Value command = const Value.absent(), + DateTime createdAt, + Value updatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value userId = const Value.absent(), + Value channelCid = const Value.absent(), + Value> extraData = const Value.absent()}) => + MessageEntity( + id: id ?? this.id, + messageText: messageText.present ? messageText.value : this.messageText, + attachments: attachments.present ? attachments.value : this.attachments, + status: status.present ? status.value : this.status, + type: type.present ? type.value : this.type, + mentionedUsers: + mentionedUsers.present ? mentionedUsers.value : this.mentionedUsers, + reactionCounts: + reactionCounts.present ? reactionCounts.value : this.reactionCounts, + reactionScores: + reactionScores.present ? reactionScores.value : this.reactionScores, + parentId: parentId.present ? parentId.value : this.parentId, + quotedMessageId: quotedMessageId.present + ? quotedMessageId.value + : this.quotedMessageId, + replyCount: replyCount.present ? replyCount.value : this.replyCount, + showInChannel: + showInChannel.present ? showInChannel.value : this.showInChannel, + shadowed: shadowed.present ? shadowed.value : this.shadowed, + command: command.present ? command.value : this.command, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + userId: userId.present ? userId.value : this.userId, + channelCid: channelCid.present ? channelCid.value : this.channelCid, + extraData: extraData.present ? extraData.value : this.extraData, + ); + @override + String toString() { + return (StringBuffer('MessageEntity(') + ..write('id: $id, ') + ..write('messageText: $messageText, ') + ..write('attachments: $attachments, ') + ..write('status: $status, ') + ..write('type: $type, ') + ..write('mentionedUsers: $mentionedUsers, ') + ..write('reactionCounts: $reactionCounts, ') + ..write('reactionScores: $reactionScores, ') + ..write('parentId: $parentId, ') + ..write('quotedMessageId: $quotedMessageId, ') + ..write('replyCount: $replyCount, ') + ..write('showInChannel: $showInChannel, ') + ..write('shadowed: $shadowed, ') + ..write('command: $command, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('userId: $userId, ') + ..write('channelCid: $channelCid, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + id.hashCode, + $mrjc( + messageText.hashCode, + $mrjc( + attachments.hashCode, + $mrjc( + status.hashCode, + $mrjc( + type.hashCode, + $mrjc( + mentionedUsers.hashCode, + $mrjc( + reactionCounts.hashCode, + $mrjc( + reactionScores.hashCode, + $mrjc( + parentId.hashCode, + $mrjc( + quotedMessageId.hashCode, + $mrjc( + replyCount.hashCode, + $mrjc( + showInChannel.hashCode, + $mrjc( + shadowed.hashCode, + $mrjc( + command.hashCode, + $mrjc( + createdAt + .hashCode, + $mrjc( + updatedAt + .hashCode, + $mrjc( + deletedAt + .hashCode, + $mrjc( + userId + .hashCode, + $mrjc( + channelCid.hashCode, + extraData.hashCode)))))))))))))))))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is MessageEntity && + other.id == this.id && + other.messageText == this.messageText && + other.attachments == this.attachments && + other.status == this.status && + other.type == this.type && + other.mentionedUsers == this.mentionedUsers && + other.reactionCounts == this.reactionCounts && + other.reactionScores == this.reactionScores && + other.parentId == this.parentId && + other.quotedMessageId == this.quotedMessageId && + other.replyCount == this.replyCount && + other.showInChannel == this.showInChannel && + other.shadowed == this.shadowed && + other.command == this.command && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.deletedAt == this.deletedAt && + other.userId == this.userId && + other.channelCid == this.channelCid && + other.extraData == this.extraData); +} + +class MessagesCompanion extends UpdateCompanion { + final Value id; + final Value messageText; + final Value> attachments; + final Value status; + final Value type; + final Value> mentionedUsers; + final Value> reactionCounts; + final Value> reactionScores; + final Value parentId; + final Value quotedMessageId; + final Value replyCount; + final Value showInChannel; + final Value shadowed; + final Value command; + final Value createdAt; + final Value updatedAt; + final Value deletedAt; + final Value userId; + final Value channelCid; + final Value> extraData; + const MessagesCompanion({ + this.id = const Value.absent(), + this.messageText = const Value.absent(), + this.attachments = const Value.absent(), + this.status = const Value.absent(), + this.type = const Value.absent(), + this.mentionedUsers = const Value.absent(), + this.reactionCounts = const Value.absent(), + this.reactionScores = const Value.absent(), + this.parentId = const Value.absent(), + this.quotedMessageId = const Value.absent(), + this.replyCount = const Value.absent(), + this.showInChannel = const Value.absent(), + this.shadowed = const Value.absent(), + this.command = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.userId = const Value.absent(), + this.channelCid = const Value.absent(), + this.extraData = const Value.absent(), + }); + MessagesCompanion.insert({ + @required String id, + this.messageText = const Value.absent(), + this.attachments = const Value.absent(), + this.status = const Value.absent(), + this.type = const Value.absent(), + this.mentionedUsers = const Value.absent(), + this.reactionCounts = const Value.absent(), + this.reactionScores = const Value.absent(), + this.parentId = const Value.absent(), + this.quotedMessageId = const Value.absent(), + this.replyCount = const Value.absent(), + this.showInChannel = const Value.absent(), + this.shadowed = const Value.absent(), + this.command = const Value.absent(), + @required DateTime createdAt, + this.updatedAt = const Value.absent(), + this.deletedAt = const Value.absent(), + this.userId = const Value.absent(), + this.channelCid = const Value.absent(), + this.extraData = const Value.absent(), + }) : id = Value(id), + createdAt = Value(createdAt); + static Insertable custom({ + Expression id, + Expression messageText, + Expression attachments, + Expression status, + Expression type, + Expression mentionedUsers, + Expression reactionCounts, + Expression reactionScores, + Expression parentId, + Expression quotedMessageId, + Expression replyCount, + Expression showInChannel, + Expression shadowed, + Expression command, + Expression createdAt, + Expression updatedAt, + Expression deletedAt, + Expression userId, + Expression channelCid, + Expression extraData, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (messageText != null) 'message_text': messageText, + if (attachments != null) 'attachments': attachments, + if (status != null) 'status': status, + if (type != null) 'type': type, + if (mentionedUsers != null) 'mentioned_users': mentionedUsers, + if (reactionCounts != null) 'reaction_counts': reactionCounts, + if (reactionScores != null) 'reaction_scores': reactionScores, + if (parentId != null) 'parent_id': parentId, + if (quotedMessageId != null) 'quoted_message_id': quotedMessageId, + if (replyCount != null) 'reply_count': replyCount, + if (showInChannel != null) 'show_in_channel': showInChannel, + if (shadowed != null) 'shadowed': shadowed, + if (command != null) 'command': command, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (deletedAt != null) 'deleted_at': deletedAt, + if (userId != null) 'user_id': userId, + if (channelCid != null) 'channel_cid': channelCid, + if (extraData != null) 'extra_data': extraData, + }); + } + + MessagesCompanion copyWith( + {Value id, + Value messageText, + Value> attachments, + Value status, + Value type, + Value> mentionedUsers, + Value> reactionCounts, + Value> reactionScores, + Value parentId, + Value quotedMessageId, + Value replyCount, + Value showInChannel, + Value shadowed, + Value command, + Value createdAt, + Value updatedAt, + Value deletedAt, + Value userId, + Value channelCid, + Value> extraData}) { + return MessagesCompanion( + id: id ?? this.id, + messageText: messageText ?? this.messageText, + attachments: attachments ?? this.attachments, + status: status ?? this.status, + type: type ?? this.type, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, + reactionCounts: reactionCounts ?? this.reactionCounts, + reactionScores: reactionScores ?? this.reactionScores, + parentId: parentId ?? this.parentId, + quotedMessageId: quotedMessageId ?? this.quotedMessageId, + replyCount: replyCount ?? this.replyCount, + showInChannel: showInChannel ?? this.showInChannel, + shadowed: shadowed ?? this.shadowed, + command: command ?? this.command, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + userId: userId ?? this.userId, + channelCid: channelCid ?? this.channelCid, + extraData: extraData ?? this.extraData, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (messageText.present) { + map['message_text'] = Variable(messageText.value); + } + if (attachments.present) { + final converter = $MessagesTable.$converter0; + map['attachments'] = + Variable(converter.mapToSql(attachments.value)); + } + if (status.present) { + final converter = $MessagesTable.$converter1; + map['status'] = Variable(converter.mapToSql(status.value)); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (mentionedUsers.present) { + final converter = $MessagesTable.$converter2; + map['mentioned_users'] = + Variable(converter.mapToSql(mentionedUsers.value)); + } + if (reactionCounts.present) { + final converter = $MessagesTable.$converter3; + map['reaction_counts'] = + Variable(converter.mapToSql(reactionCounts.value)); + } + if (reactionScores.present) { + final converter = $MessagesTable.$converter4; + map['reaction_scores'] = + Variable(converter.mapToSql(reactionScores.value)); + } + if (parentId.present) { + map['parent_id'] = Variable(parentId.value); + } + if (quotedMessageId.present) { + map['quoted_message_id'] = Variable(quotedMessageId.value); + } + if (replyCount.present) { + map['reply_count'] = Variable(replyCount.value); + } + if (showInChannel.present) { + map['show_in_channel'] = Variable(showInChannel.value); + } + if (shadowed.present) { + map['shadowed'] = Variable(shadowed.value); + } + if (command.present) { + map['command'] = Variable(command.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (deletedAt.present) { + map['deleted_at'] = Variable(deletedAt.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (channelCid.present) { + map['channel_cid'] = Variable(channelCid.value); + } + if (extraData.present) { + final converter = $MessagesTable.$converter5; + map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MessagesCompanion(') + ..write('id: $id, ') + ..write('messageText: $messageText, ') + ..write('attachments: $attachments, ') + ..write('status: $status, ') + ..write('type: $type, ') + ..write('mentionedUsers: $mentionedUsers, ') + ..write('reactionCounts: $reactionCounts, ') + ..write('reactionScores: $reactionScores, ') + ..write('parentId: $parentId, ') + ..write('quotedMessageId: $quotedMessageId, ') + ..write('replyCount: $replyCount, ') + ..write('showInChannel: $showInChannel, ') + ..write('shadowed: $shadowed, ') + ..write('command: $command, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('deletedAt: $deletedAt, ') + ..write('userId: $userId, ') + ..write('channelCid: $channelCid, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } +} + +class $MessagesTable extends Messages + with TableInfo<$MessagesTable, MessageEntity> { + final GeneratedDatabase _db; + final String _alias; + $MessagesTable(this._db, [this._alias]); + final VerificationMeta _idMeta = const VerificationMeta('id'); + GeneratedTextColumn _id; + @override + GeneratedTextColumn get id => _id ??= _constructId(); + GeneratedTextColumn _constructId() { + return GeneratedTextColumn( + 'id', + $tableName, + false, + ); + } + + final VerificationMeta _messageTextMeta = + const VerificationMeta('messageText'); + GeneratedTextColumn _messageText; + @override + GeneratedTextColumn get messageText => + _messageText ??= _constructMessageText(); + GeneratedTextColumn _constructMessageText() { + return GeneratedTextColumn( + 'message_text', + $tableName, + true, + ); + } + + final VerificationMeta _attachmentsMeta = + const VerificationMeta('attachments'); + GeneratedTextColumn _attachments; + @override + GeneratedTextColumn get attachments => + _attachments ??= _constructAttachments(); + GeneratedTextColumn _constructAttachments() { + return GeneratedTextColumn( + 'attachments', + $tableName, + true, + ); + } + + final VerificationMeta _statusMeta = const VerificationMeta('status'); + GeneratedIntColumn _status; + @override + GeneratedIntColumn get status => _status ??= _constructStatus(); + GeneratedIntColumn _constructStatus() { + return GeneratedIntColumn( + 'status', + $tableName, + true, + ); + } + + final VerificationMeta _typeMeta = const VerificationMeta('type'); + GeneratedTextColumn _type; + @override + GeneratedTextColumn get type => _type ??= _constructType(); + GeneratedTextColumn _constructType() { + return GeneratedTextColumn( + 'type', + $tableName, + true, + ); + } + + final VerificationMeta _mentionedUsersMeta = + const VerificationMeta('mentionedUsers'); + GeneratedTextColumn _mentionedUsers; + @override + GeneratedTextColumn get mentionedUsers => + _mentionedUsers ??= _constructMentionedUsers(); + GeneratedTextColumn _constructMentionedUsers() { + return GeneratedTextColumn( + 'mentioned_users', + $tableName, + true, + ); + } + + final VerificationMeta _reactionCountsMeta = + const VerificationMeta('reactionCounts'); + GeneratedTextColumn _reactionCounts; + @override + GeneratedTextColumn get reactionCounts => + _reactionCounts ??= _constructReactionCounts(); + GeneratedTextColumn _constructReactionCounts() { + return GeneratedTextColumn( + 'reaction_counts', + $tableName, + true, + ); + } + + final VerificationMeta _reactionScoresMeta = + const VerificationMeta('reactionScores'); + GeneratedTextColumn _reactionScores; + @override + GeneratedTextColumn get reactionScores => + _reactionScores ??= _constructReactionScores(); + GeneratedTextColumn _constructReactionScores() { + return GeneratedTextColumn( + 'reaction_scores', + $tableName, + true, + ); + } + + final VerificationMeta _parentIdMeta = const VerificationMeta('parentId'); + GeneratedTextColumn _parentId; + @override + GeneratedTextColumn get parentId => _parentId ??= _constructParentId(); + GeneratedTextColumn _constructParentId() { + return GeneratedTextColumn( + 'parent_id', + $tableName, + true, + ); + } + + final VerificationMeta _quotedMessageIdMeta = + const VerificationMeta('quotedMessageId'); + GeneratedTextColumn _quotedMessageId; + @override + GeneratedTextColumn get quotedMessageId => + _quotedMessageId ??= _constructQuotedMessageId(); + GeneratedTextColumn _constructQuotedMessageId() { + return GeneratedTextColumn( + 'quoted_message_id', + $tableName, + true, + ); + } + + final VerificationMeta _replyCountMeta = const VerificationMeta('replyCount'); + GeneratedIntColumn _replyCount; + @override + GeneratedIntColumn get replyCount => _replyCount ??= _constructReplyCount(); + GeneratedIntColumn _constructReplyCount() { + return GeneratedIntColumn( + 'reply_count', + $tableName, + true, + ); + } + + final VerificationMeta _showInChannelMeta = + const VerificationMeta('showInChannel'); + GeneratedBoolColumn _showInChannel; + @override + GeneratedBoolColumn get showInChannel => + _showInChannel ??= _constructShowInChannel(); + GeneratedBoolColumn _constructShowInChannel() { + return GeneratedBoolColumn( + 'show_in_channel', + $tableName, + true, + ); + } + + final VerificationMeta _shadowedMeta = const VerificationMeta('shadowed'); + GeneratedBoolColumn _shadowed; + @override + GeneratedBoolColumn get shadowed => _shadowed ??= _constructShadowed(); + GeneratedBoolColumn _constructShadowed() { + return GeneratedBoolColumn( + 'shadowed', + $tableName, + true, + ); + } + + final VerificationMeta _commandMeta = const VerificationMeta('command'); + GeneratedTextColumn _command; + @override + GeneratedTextColumn get command => _command ??= _constructCommand(); + GeneratedTextColumn _constructCommand() { + return GeneratedTextColumn( + 'command', + $tableName, + true, + ); + } + + final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + GeneratedDateTimeColumn _createdAt; + @override + GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); + GeneratedDateTimeColumn _constructCreatedAt() { + return GeneratedDateTimeColumn( + 'created_at', + $tableName, + false, + ); + } + + final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + GeneratedDateTimeColumn _updatedAt; + @override + GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); + GeneratedDateTimeColumn _constructUpdatedAt() { + return GeneratedDateTimeColumn( + 'updated_at', + $tableName, + true, + ); + } + + final VerificationMeta _deletedAtMeta = const VerificationMeta('deletedAt'); + GeneratedDateTimeColumn _deletedAt; + @override + GeneratedDateTimeColumn get deletedAt => _deletedAt ??= _constructDeletedAt(); + GeneratedDateTimeColumn _constructDeletedAt() { + return GeneratedDateTimeColumn( + 'deleted_at', + $tableName, + true, + ); + } + + final VerificationMeta _userIdMeta = const VerificationMeta('userId'); + GeneratedTextColumn _userId; + @override + GeneratedTextColumn get userId => _userId ??= _constructUserId(); + GeneratedTextColumn _constructUserId() { + return GeneratedTextColumn( + 'user_id', + $tableName, + true, + ); + } + + final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); + GeneratedTextColumn _channelCid; + @override + GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); + GeneratedTextColumn _constructChannelCid() { + return GeneratedTextColumn('channel_cid', $tableName, true, + $customConstraints: + 'NULLABLE REFERENCES channels(cid) ON DELETE CASCADE'); + } + + final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); + GeneratedTextColumn _extraData; + @override + GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); + GeneratedTextColumn _constructExtraData() { + return GeneratedTextColumn( + 'extra_data', + $tableName, + true, + ); + } + + @override + List get $columns => [ + id, + messageText, + attachments, + status, + type, + mentionedUsers, + reactionCounts, + reactionScores, + parentId, + quotedMessageId, + replyCount, + showInChannel, + shadowed, + command, + createdAt, + updatedAt, + deletedAt, + userId, + channelCid, + extraData + ]; + @override + $MessagesTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'messages'; + @override + final String actualTableName = 'messages'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('message_text')) { + context.handle( + _messageTextMeta, + messageText.isAcceptableOrUnknown( + data['message_text'], _messageTextMeta)); + } + context.handle(_attachmentsMeta, const VerificationResult.success()); + context.handle(_statusMeta, const VerificationResult.success()); + if (data.containsKey('type')) { + context.handle( + _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + } + context.handle(_mentionedUsersMeta, const VerificationResult.success()); + context.handle(_reactionCountsMeta, const VerificationResult.success()); + context.handle(_reactionScoresMeta, const VerificationResult.success()); + if (data.containsKey('parent_id')) { + context.handle(_parentIdMeta, + parentId.isAcceptableOrUnknown(data['parent_id'], _parentIdMeta)); + } + if (data.containsKey('quoted_message_id')) { + context.handle( + _quotedMessageIdMeta, + quotedMessageId.isAcceptableOrUnknown( + data['quoted_message_id'], _quotedMessageIdMeta)); + } + if (data.containsKey('reply_count')) { + context.handle( + _replyCountMeta, + replyCount.isAcceptableOrUnknown( + data['reply_count'], _replyCountMeta)); + } + if (data.containsKey('show_in_channel')) { + context.handle( + _showInChannelMeta, + showInChannel.isAcceptableOrUnknown( + data['show_in_channel'], _showInChannelMeta)); + } + if (data.containsKey('shadowed')) { + context.handle(_shadowedMeta, + shadowed.isAcceptableOrUnknown(data['shadowed'], _shadowedMeta)); + } + if (data.containsKey('command')) { + context.handle(_commandMeta, + command.isAcceptableOrUnknown(data['command'], _commandMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + } + if (data.containsKey('deleted_at')) { + context.handle(_deletedAtMeta, + deletedAt.isAcceptableOrUnknown(data['deleted_at'], _deletedAtMeta)); + } + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, + userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + } + if (data.containsKey('channel_cid')) { + context.handle( + _channelCidMeta, + channelCid.isAcceptableOrUnknown( + data['channel_cid'], _channelCidMeta)); + } + context.handle(_extraDataMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {id}; + @override + MessageEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return MessageEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $MessagesTable createAlias(String alias) { + return $MessagesTable(_db, alias); + } + + static TypeConverter, String> $converter0 = + ListConverter(); + static TypeConverter $converter1 = + MessageSendingStatusConverter(); + static TypeConverter, String> $converter2 = + ListConverter(); + static TypeConverter, String> $converter3 = + MapConverter(); + static TypeConverter, String> $converter4 = + MapConverter(); + static TypeConverter, String> $converter5 = + MapConverter(); +} + +class ReactionEntity extends DataClass implements Insertable { + final String userId; + final String messageId; + final String type; + final DateTime createdAt; + final int score; + final Map extraData; + ReactionEntity( + {@required this.userId, + @required this.messageId, + @required this.type, + @required this.createdAt, + this.score, + this.extraData}); + factory ReactionEntity.fromData( + Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final stringType = db.typeSystem.forDartType(); + final dateTimeType = db.typeSystem.forDartType(); + final intType = db.typeSystem.forDartType(); + return ReactionEntity( + userId: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), + messageId: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}message_id']), + type: stringType.mapFromDatabaseResponse(data['${effectivePrefix}type']), + createdAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), + score: intType.mapFromDatabaseResponse(data['${effectivePrefix}score']), + extraData: $ReactionsTable.$converter0.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + if (!nullToAbsent || messageId != null) { + map['message_id'] = Variable(messageId); + } + if (!nullToAbsent || type != null) { + map['type'] = Variable(type); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || score != null) { + map['score'] = Variable(score); + } + if (!nullToAbsent || extraData != null) { + final converter = $ReactionsTable.$converter0; + map['extra_data'] = Variable(converter.mapToSql(extraData)); + } + return map; + } + + factory ReactionEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return ReactionEntity( + userId: serializer.fromJson(json['userId']), + messageId: serializer.fromJson(json['messageId']), + type: serializer.fromJson(json['type']), + createdAt: serializer.fromJson(json['createdAt']), + score: serializer.fromJson(json['score']), + extraData: serializer.fromJson>(json['extraData']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'messageId': serializer.toJson(messageId), + 'type': serializer.toJson(type), + 'createdAt': serializer.toJson(createdAt), + 'score': serializer.toJson(score), + 'extraData': serializer.toJson>(extraData), + }; + } + + ReactionEntity copyWith( + {String userId, + String messageId, + String type, + DateTime createdAt, + Value score = const Value.absent(), + Value> extraData = const Value.absent()}) => + ReactionEntity( + userId: userId ?? this.userId, + messageId: messageId ?? this.messageId, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + score: score.present ? score.value : this.score, + extraData: extraData.present ? extraData.value : this.extraData, + ); + @override + String toString() { + return (StringBuffer('ReactionEntity(') + ..write('userId: $userId, ') + ..write('messageId: $messageId, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('score: $score, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + userId.hashCode, + $mrjc( + messageId.hashCode, + $mrjc( + type.hashCode, + $mrjc(createdAt.hashCode, + $mrjc(score.hashCode, extraData.hashCode)))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is ReactionEntity && + other.userId == this.userId && + other.messageId == this.messageId && + other.type == this.type && + other.createdAt == this.createdAt && + other.score == this.score && + other.extraData == this.extraData); +} + +class ReactionsCompanion extends UpdateCompanion { + final Value userId; + final Value messageId; + final Value type; + final Value createdAt; + final Value score; + final Value> extraData; + const ReactionsCompanion({ + this.userId = const Value.absent(), + this.messageId = const Value.absent(), + this.type = const Value.absent(), + this.createdAt = const Value.absent(), + this.score = const Value.absent(), + this.extraData = const Value.absent(), + }); + ReactionsCompanion.insert({ + @required String userId, + @required String messageId, + @required String type, + @required DateTime createdAt, + this.score = const Value.absent(), + this.extraData = const Value.absent(), + }) : userId = Value(userId), + messageId = Value(messageId), + type = Value(type), + createdAt = Value(createdAt); + static Insertable custom({ + Expression userId, + Expression messageId, + Expression type, + Expression createdAt, + Expression score, + Expression extraData, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (messageId != null) 'message_id': messageId, + if (type != null) 'type': type, + if (createdAt != null) 'created_at': createdAt, + if (score != null) 'score': score, + if (extraData != null) 'extra_data': extraData, + }); + } + + ReactionsCompanion copyWith( + {Value userId, + Value messageId, + Value type, + Value createdAt, + Value score, + Value> extraData}) { + return ReactionsCompanion( + userId: userId ?? this.userId, + messageId: messageId ?? this.messageId, + type: type ?? this.type, + createdAt: createdAt ?? this.createdAt, + score: score ?? this.score, + extraData: extraData ?? this.extraData, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (messageId.present) { + map['message_id'] = Variable(messageId.value); + } + if (type.present) { + map['type'] = Variable(type.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (score.present) { + map['score'] = Variable(score.value); + } + if (extraData.present) { + final converter = $ReactionsTable.$converter0; + map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ReactionsCompanion(') + ..write('userId: $userId, ') + ..write('messageId: $messageId, ') + ..write('type: $type, ') + ..write('createdAt: $createdAt, ') + ..write('score: $score, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } +} + +class $ReactionsTable extends Reactions + with TableInfo<$ReactionsTable, ReactionEntity> { + final GeneratedDatabase _db; + final String _alias; + $ReactionsTable(this._db, [this._alias]); + final VerificationMeta _userIdMeta = const VerificationMeta('userId'); + GeneratedTextColumn _userId; + @override + GeneratedTextColumn get userId => _userId ??= _constructUserId(); + GeneratedTextColumn _constructUserId() { + return GeneratedTextColumn( + 'user_id', + $tableName, + false, + ); + } + + final VerificationMeta _messageIdMeta = const VerificationMeta('messageId'); + GeneratedTextColumn _messageId; + @override + GeneratedTextColumn get messageId => _messageId ??= _constructMessageId(); + GeneratedTextColumn _constructMessageId() { + return GeneratedTextColumn('message_id', $tableName, false, + $customConstraints: 'REFERENCES messages(id) ON DELETE CASCADE'); + } + + final VerificationMeta _typeMeta = const VerificationMeta('type'); + GeneratedTextColumn _type; + @override + GeneratedTextColumn get type => _type ??= _constructType(); + GeneratedTextColumn _constructType() { + return GeneratedTextColumn( + 'type', + $tableName, + false, + ); + } + + final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + GeneratedDateTimeColumn _createdAt; + @override + GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); + GeneratedDateTimeColumn _constructCreatedAt() { + return GeneratedDateTimeColumn( + 'created_at', + $tableName, + false, + ); + } + + final VerificationMeta _scoreMeta = const VerificationMeta('score'); + GeneratedIntColumn _score; + @override + GeneratedIntColumn get score => _score ??= _constructScore(); + GeneratedIntColumn _constructScore() { + return GeneratedIntColumn( + 'score', + $tableName, + true, + ); + } + + final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); + GeneratedTextColumn _extraData; + @override + GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); + GeneratedTextColumn _constructExtraData() { + return GeneratedTextColumn( + 'extra_data', + $tableName, + true, + ); + } + + @override + List get $columns => + [userId, messageId, type, createdAt, score, extraData]; + @override + $ReactionsTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'reactions'; + @override + final String actualTableName = 'reactions'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, + userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + } else if (isInserting) { + context.missing(_userIdMeta); + } + if (data.containsKey('message_id')) { + context.handle(_messageIdMeta, + messageId.isAcceptableOrUnknown(data['message_id'], _messageIdMeta)); + } else if (isInserting) { + context.missing(_messageIdMeta); + } + if (data.containsKey('type')) { + context.handle( + _typeMeta, type.isAcceptableOrUnknown(data['type'], _typeMeta)); + } else if (isInserting) { + context.missing(_typeMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('score')) { + context.handle( + _scoreMeta, score.isAcceptableOrUnknown(data['score'], _scoreMeta)); + } + context.handle(_extraDataMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {messageId, type, userId}; + @override + ReactionEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return ReactionEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $ReactionsTable createAlias(String alias) { + return $ReactionsTable(_db, alias); + } + + static TypeConverter, String> $converter0 = + MapConverter(); +} + +class UserEntity extends DataClass implements Insertable { + final String id; + final String role; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime lastActive; + final bool online; + final bool banned; + final Map extraData; + UserEntity( + {@required this.id, + this.role, + this.createdAt, + this.updatedAt, + this.lastActive, + this.online, + this.banned, + this.extraData}); + factory UserEntity.fromData(Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final stringType = db.typeSystem.forDartType(); + final dateTimeType = db.typeSystem.forDartType(); + final boolType = db.typeSystem.forDartType(); + return UserEntity( + id: stringType.mapFromDatabaseResponse(data['${effectivePrefix}id']), + role: stringType.mapFromDatabaseResponse(data['${effectivePrefix}role']), + createdAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), + updatedAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), + lastActive: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}last_active']), + online: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}online']), + banned: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}banned']), + extraData: $UsersTable.$converter0.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}extra_data'])), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || id != null) { + map['id'] = Variable(id); + } + if (!nullToAbsent || role != null) { + map['role'] = Variable(role); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || updatedAt != null) { + map['updated_at'] = Variable(updatedAt); + } + if (!nullToAbsent || lastActive != null) { + map['last_active'] = Variable(lastActive); + } + if (!nullToAbsent || online != null) { + map['online'] = Variable(online); + } + if (!nullToAbsent || banned != null) { + map['banned'] = Variable(banned); + } + if (!nullToAbsent || extraData != null) { + final converter = $UsersTable.$converter0; + map['extra_data'] = Variable(converter.mapToSql(extraData)); + } + return map; + } + + factory UserEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return UserEntity( + id: serializer.fromJson(json['id']), + role: serializer.fromJson(json['role']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + lastActive: serializer.fromJson(json['lastActive']), + online: serializer.fromJson(json['online']), + banned: serializer.fromJson(json['banned']), + extraData: serializer.fromJson>(json['extraData']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'role': serializer.toJson(role), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + 'lastActive': serializer.toJson(lastActive), + 'online': serializer.toJson(online), + 'banned': serializer.toJson(banned), + 'extraData': serializer.toJson>(extraData), + }; + } + + UserEntity copyWith( + {String id, + Value role = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastActive = const Value.absent(), + Value online = const Value.absent(), + Value banned = const Value.absent(), + Value> extraData = const Value.absent()}) => + UserEntity( + id: id ?? this.id, + role: role.present ? role.value : this.role, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + lastActive: lastActive.present ? lastActive.value : this.lastActive, + online: online.present ? online.value : this.online, + banned: banned.present ? banned.value : this.banned, + extraData: extraData.present ? extraData.value : this.extraData, + ); + @override + String toString() { + return (StringBuffer('UserEntity(') + ..write('id: $id, ') + ..write('role: $role, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastActive: $lastActive, ') + ..write('online: $online, ') + ..write('banned: $banned, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + id.hashCode, + $mrjc( + role.hashCode, + $mrjc( + createdAt.hashCode, + $mrjc( + updatedAt.hashCode, + $mrjc( + lastActive.hashCode, + $mrjc(online.hashCode, + $mrjc(banned.hashCode, extraData.hashCode)))))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is UserEntity && + other.id == this.id && + other.role == this.role && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && + other.lastActive == this.lastActive && + other.online == this.online && + other.banned == this.banned && + other.extraData == this.extraData); +} + +class UsersCompanion extends UpdateCompanion { + final Value id; + final Value role; + final Value createdAt; + final Value updatedAt; + final Value lastActive; + final Value online; + final Value banned; + final Value> extraData; + const UsersCompanion({ + this.id = const Value.absent(), + this.role = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.lastActive = const Value.absent(), + this.online = const Value.absent(), + this.banned = const Value.absent(), + this.extraData = const Value.absent(), + }); + UsersCompanion.insert({ + @required String id, + this.role = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.lastActive = const Value.absent(), + this.online = const Value.absent(), + this.banned = const Value.absent(), + this.extraData = const Value.absent(), + }) : id = Value(id); + static Insertable custom({ + Expression id, + Expression role, + Expression createdAt, + Expression updatedAt, + Expression lastActive, + Expression online, + Expression banned, + Expression extraData, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (role != null) 'role': role, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (lastActive != null) 'last_active': lastActive, + if (online != null) 'online': online, + if (banned != null) 'banned': banned, + if (extraData != null) 'extra_data': extraData, + }); + } + + UsersCompanion copyWith( + {Value id, + Value role, + Value createdAt, + Value updatedAt, + Value lastActive, + Value online, + Value banned, + Value> extraData}) { + return UsersCompanion( + id: id ?? this.id, + role: role ?? this.role, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastActive: lastActive ?? this.lastActive, + online: online ?? this.online, + banned: banned ?? this.banned, + extraData: extraData ?? this.extraData, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (lastActive.present) { + map['last_active'] = Variable(lastActive.value); + } + if (online.present) { + map['online'] = Variable(online.value); + } + if (banned.present) { + map['banned'] = Variable(banned.value); + } + if (extraData.present) { + final converter = $UsersTable.$converter0; + map['extra_data'] = Variable(converter.mapToSql(extraData.value)); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UsersCompanion(') + ..write('id: $id, ') + ..write('role: $role, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('lastActive: $lastActive, ') + ..write('online: $online, ') + ..write('banned: $banned, ') + ..write('extraData: $extraData') + ..write(')')) + .toString(); + } +} + +class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { + final GeneratedDatabase _db; + final String _alias; + $UsersTable(this._db, [this._alias]); + final VerificationMeta _idMeta = const VerificationMeta('id'); + GeneratedTextColumn _id; + @override + GeneratedTextColumn get id => _id ??= _constructId(); + GeneratedTextColumn _constructId() { + return GeneratedTextColumn( + 'id', + $tableName, + false, + ); + } + + final VerificationMeta _roleMeta = const VerificationMeta('role'); + GeneratedTextColumn _role; + @override + GeneratedTextColumn get role => _role ??= _constructRole(); + GeneratedTextColumn _constructRole() { + return GeneratedTextColumn( + 'role', + $tableName, + true, + ); + } + + final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + GeneratedDateTimeColumn _createdAt; + @override + GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); + GeneratedDateTimeColumn _constructCreatedAt() { + return GeneratedDateTimeColumn( + 'created_at', + $tableName, + true, + ); + } + + final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + GeneratedDateTimeColumn _updatedAt; + @override + GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); + GeneratedDateTimeColumn _constructUpdatedAt() { + return GeneratedDateTimeColumn( + 'updated_at', + $tableName, + true, + ); + } + + final VerificationMeta _lastActiveMeta = const VerificationMeta('lastActive'); + GeneratedDateTimeColumn _lastActive; + @override + GeneratedDateTimeColumn get lastActive => + _lastActive ??= _constructLastActive(); + GeneratedDateTimeColumn _constructLastActive() { + return GeneratedDateTimeColumn( + 'last_active', + $tableName, + true, + ); + } + + final VerificationMeta _onlineMeta = const VerificationMeta('online'); + GeneratedBoolColumn _online; + @override + GeneratedBoolColumn get online => _online ??= _constructOnline(); + GeneratedBoolColumn _constructOnline() { + return GeneratedBoolColumn( + 'online', + $tableName, + true, + ); + } + + final VerificationMeta _bannedMeta = const VerificationMeta('banned'); + GeneratedBoolColumn _banned; + @override + GeneratedBoolColumn get banned => _banned ??= _constructBanned(); + GeneratedBoolColumn _constructBanned() { + return GeneratedBoolColumn( + 'banned', + $tableName, + true, + ); + } + + final VerificationMeta _extraDataMeta = const VerificationMeta('extraData'); + GeneratedTextColumn _extraData; + @override + GeneratedTextColumn get extraData => _extraData ??= _constructExtraData(); + GeneratedTextColumn _constructExtraData() { + return GeneratedTextColumn( + 'extra_data', + $tableName, + true, + ); + } + + @override + List get $columns => + [id, role, createdAt, updatedAt, lastActive, online, banned, extraData]; + @override + $UsersTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'users'; + @override + final String actualTableName = 'users'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('role')) { + context.handle( + _roleMeta, role.isAcceptableOrUnknown(data['role'], _roleMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + } + if (data.containsKey('last_active')) { + context.handle( + _lastActiveMeta, + lastActive.isAcceptableOrUnknown( + data['last_active'], _lastActiveMeta)); + } + if (data.containsKey('online')) { + context.handle(_onlineMeta, + online.isAcceptableOrUnknown(data['online'], _onlineMeta)); + } + if (data.containsKey('banned')) { + context.handle(_bannedMeta, + banned.isAcceptableOrUnknown(data['banned'], _bannedMeta)); + } + context.handle(_extraDataMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => {id}; + @override + UserEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return UserEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $UsersTable createAlias(String alias) { + return $UsersTable(_db, alias); + } + + static TypeConverter, String> $converter0 = + MapConverter(); +} + +class MemberEntity extends DataClass implements Insertable { + final String userId; + final String channelCid; + final String role; + final DateTime inviteAcceptedAt; + final DateTime inviteRejectedAt; + final bool invited; + final bool banned; + final bool shadowBanned; + final bool isModerator; + final DateTime createdAt; + final DateTime updatedAt; + MemberEntity( + {@required this.userId, + @required this.channelCid, + this.role, + this.inviteAcceptedAt, + this.inviteRejectedAt, + this.invited, + this.banned, + this.shadowBanned, + this.isModerator, + @required this.createdAt, + this.updatedAt}); + factory MemberEntity.fromData(Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final stringType = db.typeSystem.forDartType(); + final dateTimeType = db.typeSystem.forDartType(); + final boolType = db.typeSystem.forDartType(); + return MemberEntity( + userId: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), + channelCid: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), + role: stringType.mapFromDatabaseResponse(data['${effectivePrefix}role']), + inviteAcceptedAt: dateTimeType.mapFromDatabaseResponse( + data['${effectivePrefix}invite_accepted_at']), + inviteRejectedAt: dateTimeType.mapFromDatabaseResponse( + data['${effectivePrefix}invite_rejected_at']), + invited: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}invited']), + banned: + boolType.mapFromDatabaseResponse(data['${effectivePrefix}banned']), + shadowBanned: boolType + .mapFromDatabaseResponse(data['${effectivePrefix}shadow_banned']), + isModerator: boolType + .mapFromDatabaseResponse(data['${effectivePrefix}is_moderator']), + createdAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}created_at']), + updatedAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}updated_at']), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + if (!nullToAbsent || channelCid != null) { + map['channel_cid'] = Variable(channelCid); + } + if (!nullToAbsent || role != null) { + map['role'] = Variable(role); + } + if (!nullToAbsent || inviteAcceptedAt != null) { + map['invite_accepted_at'] = Variable(inviteAcceptedAt); + } + if (!nullToAbsent || inviteRejectedAt != null) { + map['invite_rejected_at'] = Variable(inviteRejectedAt); + } + if (!nullToAbsent || invited != null) { + map['invited'] = Variable(invited); + } + if (!nullToAbsent || banned != null) { + map['banned'] = Variable(banned); + } + if (!nullToAbsent || shadowBanned != null) { + map['shadow_banned'] = Variable(shadowBanned); + } + if (!nullToAbsent || isModerator != null) { + map['is_moderator'] = Variable(isModerator); + } + if (!nullToAbsent || createdAt != null) { + map['created_at'] = Variable(createdAt); + } + if (!nullToAbsent || updatedAt != null) { + map['updated_at'] = Variable(updatedAt); + } + return map; + } + + factory MemberEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return MemberEntity( + userId: serializer.fromJson(json['userId']), + channelCid: serializer.fromJson(json['channelCid']), + role: serializer.fromJson(json['role']), + inviteAcceptedAt: serializer.fromJson(json['inviteAcceptedAt']), + inviteRejectedAt: serializer.fromJson(json['inviteRejectedAt']), + invited: serializer.fromJson(json['invited']), + banned: serializer.fromJson(json['banned']), + shadowBanned: serializer.fromJson(json['shadowBanned']), + isModerator: serializer.fromJson(json['isModerator']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'userId': serializer.toJson(userId), + 'channelCid': serializer.toJson(channelCid), + 'role': serializer.toJson(role), + 'inviteAcceptedAt': serializer.toJson(inviteAcceptedAt), + 'inviteRejectedAt': serializer.toJson(inviteRejectedAt), + 'invited': serializer.toJson(invited), + 'banned': serializer.toJson(banned), + 'shadowBanned': serializer.toJson(shadowBanned), + 'isModerator': serializer.toJson(isModerator), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + MemberEntity copyWith( + {String userId, + String channelCid, + Value role = const Value.absent(), + Value inviteAcceptedAt = const Value.absent(), + Value inviteRejectedAt = const Value.absent(), + Value invited = const Value.absent(), + Value banned = const Value.absent(), + Value shadowBanned = const Value.absent(), + Value isModerator = const Value.absent(), + DateTime createdAt, + Value updatedAt = const Value.absent()}) => + MemberEntity( + userId: userId ?? this.userId, + channelCid: channelCid ?? this.channelCid, + role: role.present ? role.value : this.role, + inviteAcceptedAt: inviteAcceptedAt.present + ? inviteAcceptedAt.value + : this.inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt.present + ? inviteRejectedAt.value + : this.inviteRejectedAt, + invited: invited.present ? invited.value : this.invited, + banned: banned.present ? banned.value : this.banned, + shadowBanned: + shadowBanned.present ? shadowBanned.value : this.shadowBanned, + isModerator: isModerator.present ? isModerator.value : this.isModerator, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + ); + @override + String toString() { + return (StringBuffer('MemberEntity(') + ..write('userId: $userId, ') + ..write('channelCid: $channelCid, ') + ..write('role: $role, ') + ..write('inviteAcceptedAt: $inviteAcceptedAt, ') + ..write('inviteRejectedAt: $inviteRejectedAt, ') + ..write('invited: $invited, ') + ..write('banned: $banned, ') + ..write('shadowBanned: $shadowBanned, ') + ..write('isModerator: $isModerator, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + userId.hashCode, + $mrjc( + channelCid.hashCode, + $mrjc( + role.hashCode, + $mrjc( + inviteAcceptedAt.hashCode, + $mrjc( + inviteRejectedAt.hashCode, + $mrjc( + invited.hashCode, + $mrjc( + banned.hashCode, + $mrjc( + shadowBanned.hashCode, + $mrjc( + isModerator.hashCode, + $mrjc(createdAt.hashCode, + updatedAt.hashCode))))))))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is MemberEntity && + other.userId == this.userId && + other.channelCid == this.channelCid && + other.role == this.role && + other.inviteAcceptedAt == this.inviteAcceptedAt && + other.inviteRejectedAt == this.inviteRejectedAt && + other.invited == this.invited && + other.banned == this.banned && + other.shadowBanned == this.shadowBanned && + other.isModerator == this.isModerator && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class MembersCompanion extends UpdateCompanion { + final Value userId; + final Value channelCid; + final Value role; + final Value inviteAcceptedAt; + final Value inviteRejectedAt; + final Value invited; + final Value banned; + final Value shadowBanned; + final Value isModerator; + final Value createdAt; + final Value updatedAt; + const MembersCompanion({ + this.userId = const Value.absent(), + this.channelCid = const Value.absent(), + this.role = const Value.absent(), + this.inviteAcceptedAt = const Value.absent(), + this.inviteRejectedAt = const Value.absent(), + this.invited = const Value.absent(), + this.banned = const Value.absent(), + this.shadowBanned = const Value.absent(), + this.isModerator = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + }); + MembersCompanion.insert({ + @required String userId, + @required String channelCid, + this.role = const Value.absent(), + this.inviteAcceptedAt = const Value.absent(), + this.inviteRejectedAt = const Value.absent(), + this.invited = const Value.absent(), + this.banned = const Value.absent(), + this.shadowBanned = const Value.absent(), + this.isModerator = const Value.absent(), + @required DateTime createdAt, + this.updatedAt = const Value.absent(), + }) : userId = Value(userId), + channelCid = Value(channelCid), + createdAt = Value(createdAt); + static Insertable custom({ + Expression userId, + Expression channelCid, + Expression role, + Expression inviteAcceptedAt, + Expression inviteRejectedAt, + Expression invited, + Expression banned, + Expression shadowBanned, + Expression isModerator, + Expression createdAt, + Expression updatedAt, + }) { + return RawValuesInsertable({ + if (userId != null) 'user_id': userId, + if (channelCid != null) 'channel_cid': channelCid, + if (role != null) 'role': role, + if (inviteAcceptedAt != null) 'invite_accepted_at': inviteAcceptedAt, + if (inviteRejectedAt != null) 'invite_rejected_at': inviteRejectedAt, + if (invited != null) 'invited': invited, + if (banned != null) 'banned': banned, + if (shadowBanned != null) 'shadow_banned': shadowBanned, + if (isModerator != null) 'is_moderator': isModerator, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + }); + } + + MembersCompanion copyWith( + {Value userId, + Value channelCid, + Value role, + Value inviteAcceptedAt, + Value inviteRejectedAt, + Value invited, + Value banned, + Value shadowBanned, + Value isModerator, + Value createdAt, + Value updatedAt}) { + return MembersCompanion( + userId: userId ?? this.userId, + channelCid: channelCid ?? this.channelCid, + role: role ?? this.role, + inviteAcceptedAt: inviteAcceptedAt ?? this.inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt ?? this.inviteRejectedAt, + invited: invited ?? this.invited, + banned: banned ?? this.banned, + shadowBanned: shadowBanned ?? this.shadowBanned, + isModerator: isModerator ?? this.isModerator, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (channelCid.present) { + map['channel_cid'] = Variable(channelCid.value); + } + if (role.present) { + map['role'] = Variable(role.value); + } + if (inviteAcceptedAt.present) { + map['invite_accepted_at'] = Variable(inviteAcceptedAt.value); + } + if (inviteRejectedAt.present) { + map['invite_rejected_at'] = Variable(inviteRejectedAt.value); + } + if (invited.present) { + map['invited'] = Variable(invited.value); + } + if (banned.present) { + map['banned'] = Variable(banned.value); + } + if (shadowBanned.present) { + map['shadow_banned'] = Variable(shadowBanned.value); + } + if (isModerator.present) { + map['is_moderator'] = Variable(isModerator.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('MembersCompanion(') + ..write('userId: $userId, ') + ..write('channelCid: $channelCid, ') + ..write('role: $role, ') + ..write('inviteAcceptedAt: $inviteAcceptedAt, ') + ..write('inviteRejectedAt: $inviteRejectedAt, ') + ..write('invited: $invited, ') + ..write('banned: $banned, ') + ..write('shadowBanned: $shadowBanned, ') + ..write('isModerator: $isModerator, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } +} + +class $MembersTable extends Members + with TableInfo<$MembersTable, MemberEntity> { + final GeneratedDatabase _db; + final String _alias; + $MembersTable(this._db, [this._alias]); + final VerificationMeta _userIdMeta = const VerificationMeta('userId'); + GeneratedTextColumn _userId; + @override + GeneratedTextColumn get userId => _userId ??= _constructUserId(); + GeneratedTextColumn _constructUserId() { + return GeneratedTextColumn( + 'user_id', + $tableName, + false, + ); + } + + final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); + GeneratedTextColumn _channelCid; + @override + GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); + GeneratedTextColumn _constructChannelCid() { + return GeneratedTextColumn('channel_cid', $tableName, false, + $customConstraints: 'REFERENCES channels(cid) ON DELETE CASCADE'); + } + + final VerificationMeta _roleMeta = const VerificationMeta('role'); + GeneratedTextColumn _role; + @override + GeneratedTextColumn get role => _role ??= _constructRole(); + GeneratedTextColumn _constructRole() { + return GeneratedTextColumn( + 'role', + $tableName, + true, + ); + } + + final VerificationMeta _inviteAcceptedAtMeta = + const VerificationMeta('inviteAcceptedAt'); + GeneratedDateTimeColumn _inviteAcceptedAt; + @override + GeneratedDateTimeColumn get inviteAcceptedAt => + _inviteAcceptedAt ??= _constructInviteAcceptedAt(); + GeneratedDateTimeColumn _constructInviteAcceptedAt() { + return GeneratedDateTimeColumn( + 'invite_accepted_at', + $tableName, + true, + ); + } + + final VerificationMeta _inviteRejectedAtMeta = + const VerificationMeta('inviteRejectedAt'); + GeneratedDateTimeColumn _inviteRejectedAt; + @override + GeneratedDateTimeColumn get inviteRejectedAt => + _inviteRejectedAt ??= _constructInviteRejectedAt(); + GeneratedDateTimeColumn _constructInviteRejectedAt() { + return GeneratedDateTimeColumn( + 'invite_rejected_at', + $tableName, + true, + ); + } + + final VerificationMeta _invitedMeta = const VerificationMeta('invited'); + GeneratedBoolColumn _invited; + @override + GeneratedBoolColumn get invited => _invited ??= _constructInvited(); + GeneratedBoolColumn _constructInvited() { + return GeneratedBoolColumn( + 'invited', + $tableName, + true, + ); + } + + final VerificationMeta _bannedMeta = const VerificationMeta('banned'); + GeneratedBoolColumn _banned; + @override + GeneratedBoolColumn get banned => _banned ??= _constructBanned(); + GeneratedBoolColumn _constructBanned() { + return GeneratedBoolColumn( + 'banned', + $tableName, + true, + ); + } + + final VerificationMeta _shadowBannedMeta = + const VerificationMeta('shadowBanned'); + GeneratedBoolColumn _shadowBanned; + @override + GeneratedBoolColumn get shadowBanned => + _shadowBanned ??= _constructShadowBanned(); + GeneratedBoolColumn _constructShadowBanned() { + return GeneratedBoolColumn( + 'shadow_banned', + $tableName, + true, + ); + } + + final VerificationMeta _isModeratorMeta = + const VerificationMeta('isModerator'); + GeneratedBoolColumn _isModerator; + @override + GeneratedBoolColumn get isModerator => + _isModerator ??= _constructIsModerator(); + GeneratedBoolColumn _constructIsModerator() { + return GeneratedBoolColumn( + 'is_moderator', + $tableName, + true, + ); + } + + final VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); + GeneratedDateTimeColumn _createdAt; + @override + GeneratedDateTimeColumn get createdAt => _createdAt ??= _constructCreatedAt(); + GeneratedDateTimeColumn _constructCreatedAt() { + return GeneratedDateTimeColumn( + 'created_at', + $tableName, + false, + ); + } + + final VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + GeneratedDateTimeColumn _updatedAt; + @override + GeneratedDateTimeColumn get updatedAt => _updatedAt ??= _constructUpdatedAt(); + GeneratedDateTimeColumn _constructUpdatedAt() { + return GeneratedDateTimeColumn( + 'updated_at', + $tableName, + true, + ); + } + + @override + List get $columns => [ + userId, + channelCid, + role, + inviteAcceptedAt, + inviteRejectedAt, + invited, + banned, + shadowBanned, + isModerator, + createdAt, + updatedAt + ]; + @override + $MembersTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'members'; + @override + final String actualTableName = 'members'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, + userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + } else if (isInserting) { + context.missing(_userIdMeta); + } + if (data.containsKey('channel_cid')) { + context.handle( + _channelCidMeta, + channelCid.isAcceptableOrUnknown( + data['channel_cid'], _channelCidMeta)); + } else if (isInserting) { + context.missing(_channelCidMeta); + } + if (data.containsKey('role')) { + context.handle( + _roleMeta, role.isAcceptableOrUnknown(data['role'], _roleMeta)); + } + if (data.containsKey('invite_accepted_at')) { + context.handle( + _inviteAcceptedAtMeta, + inviteAcceptedAt.isAcceptableOrUnknown( + data['invite_accepted_at'], _inviteAcceptedAtMeta)); + } + if (data.containsKey('invite_rejected_at')) { + context.handle( + _inviteRejectedAtMeta, + inviteRejectedAt.isAcceptableOrUnknown( + data['invite_rejected_at'], _inviteRejectedAtMeta)); + } + if (data.containsKey('invited')) { + context.handle(_invitedMeta, + invited.isAcceptableOrUnknown(data['invited'], _invitedMeta)); + } + if (data.containsKey('banned')) { + context.handle(_bannedMeta, + banned.isAcceptableOrUnknown(data['banned'], _bannedMeta)); + } + if (data.containsKey('shadow_banned')) { + context.handle( + _shadowBannedMeta, + shadowBanned.isAcceptableOrUnknown( + data['shadow_banned'], _shadowBannedMeta)); + } + if (data.containsKey('is_moderator')) { + context.handle( + _isModeratorMeta, + isModerator.isAcceptableOrUnknown( + data['is_moderator'], _isModeratorMeta)); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at'], _createdAtMeta)); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at'], _updatedAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {userId, channelCid}; + @override + MemberEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return MemberEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $MembersTable createAlias(String alias) { + return $MembersTable(_db, alias); + } +} + +class ReadEntity extends DataClass implements Insertable { + final DateTime lastRead; + final String userId; + final String channelCid; + final int unreadMessages; + ReadEntity( + {@required this.lastRead, + @required this.userId, + @required this.channelCid, + this.unreadMessages}); + factory ReadEntity.fromData(Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final dateTimeType = db.typeSystem.forDartType(); + final stringType = db.typeSystem.forDartType(); + final intType = db.typeSystem.forDartType(); + return ReadEntity( + lastRead: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}last_read']), + userId: + stringType.mapFromDatabaseResponse(data['${effectivePrefix}user_id']), + channelCid: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), + unreadMessages: intType + .mapFromDatabaseResponse(data['${effectivePrefix}unread_messages']), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || lastRead != null) { + map['last_read'] = Variable(lastRead); + } + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + if (!nullToAbsent || channelCid != null) { + map['channel_cid'] = Variable(channelCid); + } + if (!nullToAbsent || unreadMessages != null) { + map['unread_messages'] = Variable(unreadMessages); + } + return map; + } + + factory ReadEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return ReadEntity( + lastRead: serializer.fromJson(json['lastRead']), + userId: serializer.fromJson(json['userId']), + channelCid: serializer.fromJson(json['channelCid']), + unreadMessages: serializer.fromJson(json['unreadMessages']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'lastRead': serializer.toJson(lastRead), + 'userId': serializer.toJson(userId), + 'channelCid': serializer.toJson(channelCid), + 'unreadMessages': serializer.toJson(unreadMessages), + }; + } + + ReadEntity copyWith( + {DateTime lastRead, + String userId, + String channelCid, + Value unreadMessages = const Value.absent()}) => + ReadEntity( + lastRead: lastRead ?? this.lastRead, + userId: userId ?? this.userId, + channelCid: channelCid ?? this.channelCid, + unreadMessages: + unreadMessages.present ? unreadMessages.value : this.unreadMessages, + ); + @override + String toString() { + return (StringBuffer('ReadEntity(') + ..write('lastRead: $lastRead, ') + ..write('userId: $userId, ') + ..write('channelCid: $channelCid, ') + ..write('unreadMessages: $unreadMessages') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + lastRead.hashCode, + $mrjc(userId.hashCode, + $mrjc(channelCid.hashCode, unreadMessages.hashCode)))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is ReadEntity && + other.lastRead == this.lastRead && + other.userId == this.userId && + other.channelCid == this.channelCid && + other.unreadMessages == this.unreadMessages); +} + +class ReadsCompanion extends UpdateCompanion { + final Value lastRead; + final Value userId; + final Value channelCid; + final Value unreadMessages; + const ReadsCompanion({ + this.lastRead = const Value.absent(), + this.userId = const Value.absent(), + this.channelCid = const Value.absent(), + this.unreadMessages = const Value.absent(), + }); + ReadsCompanion.insert({ + @required DateTime lastRead, + @required String userId, + @required String channelCid, + this.unreadMessages = const Value.absent(), + }) : lastRead = Value(lastRead), + userId = Value(userId), + channelCid = Value(channelCid); + static Insertable custom({ + Expression lastRead, + Expression userId, + Expression channelCid, + Expression unreadMessages, + }) { + return RawValuesInsertable({ + if (lastRead != null) 'last_read': lastRead, + if (userId != null) 'user_id': userId, + if (channelCid != null) 'channel_cid': channelCid, + if (unreadMessages != null) 'unread_messages': unreadMessages, + }); + } + + ReadsCompanion copyWith( + {Value lastRead, + Value userId, + Value channelCid, + Value unreadMessages}) { + return ReadsCompanion( + lastRead: lastRead ?? this.lastRead, + userId: userId ?? this.userId, + channelCid: channelCid ?? this.channelCid, + unreadMessages: unreadMessages ?? this.unreadMessages, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (lastRead.present) { + map['last_read'] = Variable(lastRead.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (channelCid.present) { + map['channel_cid'] = Variable(channelCid.value); + } + if (unreadMessages.present) { + map['unread_messages'] = Variable(unreadMessages.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ReadsCompanion(') + ..write('lastRead: $lastRead, ') + ..write('userId: $userId, ') + ..write('channelCid: $channelCid, ') + ..write('unreadMessages: $unreadMessages') + ..write(')')) + .toString(); + } +} + +class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { + final GeneratedDatabase _db; + final String _alias; + $ReadsTable(this._db, [this._alias]); + final VerificationMeta _lastReadMeta = const VerificationMeta('lastRead'); + GeneratedDateTimeColumn _lastRead; + @override + GeneratedDateTimeColumn get lastRead => _lastRead ??= _constructLastRead(); + GeneratedDateTimeColumn _constructLastRead() { + return GeneratedDateTimeColumn( + 'last_read', + $tableName, + false, + ); + } + + final VerificationMeta _userIdMeta = const VerificationMeta('userId'); + GeneratedTextColumn _userId; + @override + GeneratedTextColumn get userId => _userId ??= _constructUserId(); + GeneratedTextColumn _constructUserId() { + return GeneratedTextColumn( + 'user_id', + $tableName, + false, + ); + } + + final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); + GeneratedTextColumn _channelCid; + @override + GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); + GeneratedTextColumn _constructChannelCid() { + return GeneratedTextColumn('channel_cid', $tableName, false, + $customConstraints: 'REFERENCES channels(cid) ON DELETE CASCADE'); + } + + final VerificationMeta _unreadMessagesMeta = + const VerificationMeta('unreadMessages'); + GeneratedIntColumn _unreadMessages; + @override + GeneratedIntColumn get unreadMessages => + _unreadMessages ??= _constructUnreadMessages(); + GeneratedIntColumn _constructUnreadMessages() { + return GeneratedIntColumn( + 'unread_messages', + $tableName, + true, + ); + } + + @override + List get $columns => + [lastRead, userId, channelCid, unreadMessages]; + @override + $ReadsTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'reads'; + @override + final String actualTableName = 'reads'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('last_read')) { + context.handle(_lastReadMeta, + lastRead.isAcceptableOrUnknown(data['last_read'], _lastReadMeta)); + } else if (isInserting) { + context.missing(_lastReadMeta); + } + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, + userId.isAcceptableOrUnknown(data['user_id'], _userIdMeta)); + } else if (isInserting) { + context.missing(_userIdMeta); + } + if (data.containsKey('channel_cid')) { + context.handle( + _channelCidMeta, + channelCid.isAcceptableOrUnknown( + data['channel_cid'], _channelCidMeta)); + } else if (isInserting) { + context.missing(_channelCidMeta); + } + if (data.containsKey('unread_messages')) { + context.handle( + _unreadMessagesMeta, + unreadMessages.isAcceptableOrUnknown( + data['unread_messages'], _unreadMessagesMeta)); + } + return context; + } + + @override + Set get $primaryKey => {userId, channelCid}; + @override + ReadEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return ReadEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $ReadsTable createAlias(String alias) { + return $ReadsTable(_db, alias); + } +} + +class ChannelQueryEntity extends DataClass + implements Insertable { + final String queryHash; + final String channelCid; + ChannelQueryEntity({@required this.queryHash, @required this.channelCid}); + factory ChannelQueryEntity.fromData( + Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final stringType = db.typeSystem.forDartType(); + return ChannelQueryEntity( + queryHash: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}query_hash']), + channelCid: stringType + .mapFromDatabaseResponse(data['${effectivePrefix}channel_cid']), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || queryHash != null) { + map['query_hash'] = Variable(queryHash); + } + if (!nullToAbsent || channelCid != null) { + map['channel_cid'] = Variable(channelCid); + } + return map; + } + + factory ChannelQueryEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return ChannelQueryEntity( + queryHash: serializer.fromJson(json['queryHash']), + channelCid: serializer.fromJson(json['channelCid']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'queryHash': serializer.toJson(queryHash), + 'channelCid': serializer.toJson(channelCid), + }; + } + + ChannelQueryEntity copyWith({String queryHash, String channelCid}) => + ChannelQueryEntity( + queryHash: queryHash ?? this.queryHash, + channelCid: channelCid ?? this.channelCid, + ); + @override + String toString() { + return (StringBuffer('ChannelQueryEntity(') + ..write('queryHash: $queryHash, ') + ..write('channelCid: $channelCid') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc(queryHash.hashCode, channelCid.hashCode)); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is ChannelQueryEntity && + other.queryHash == this.queryHash && + other.channelCid == this.channelCid); +} + +class ChannelQueriesCompanion extends UpdateCompanion { + final Value queryHash; + final Value channelCid; + const ChannelQueriesCompanion({ + this.queryHash = const Value.absent(), + this.channelCid = const Value.absent(), + }); + ChannelQueriesCompanion.insert({ + @required String queryHash, + @required String channelCid, + }) : queryHash = Value(queryHash), + channelCid = Value(channelCid); + static Insertable custom({ + Expression queryHash, + Expression channelCid, + }) { + return RawValuesInsertable({ + if (queryHash != null) 'query_hash': queryHash, + if (channelCid != null) 'channel_cid': channelCid, + }); + } + + ChannelQueriesCompanion copyWith( + {Value queryHash, Value channelCid}) { + return ChannelQueriesCompanion( + queryHash: queryHash ?? this.queryHash, + channelCid: channelCid ?? this.channelCid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (queryHash.present) { + map['query_hash'] = Variable(queryHash.value); + } + if (channelCid.present) { + map['channel_cid'] = Variable(channelCid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ChannelQueriesCompanion(') + ..write('queryHash: $queryHash, ') + ..write('channelCid: $channelCid') + ..write(')')) + .toString(); + } +} + +class $ChannelQueriesTable extends ChannelQueries + with TableInfo<$ChannelQueriesTable, ChannelQueryEntity> { + final GeneratedDatabase _db; + final String _alias; + $ChannelQueriesTable(this._db, [this._alias]); + final VerificationMeta _queryHashMeta = const VerificationMeta('queryHash'); + GeneratedTextColumn _queryHash; + @override + GeneratedTextColumn get queryHash => _queryHash ??= _constructQueryHash(); + GeneratedTextColumn _constructQueryHash() { + return GeneratedTextColumn( + 'query_hash', + $tableName, + false, + ); + } + + final VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); + GeneratedTextColumn _channelCid; + @override + GeneratedTextColumn get channelCid => _channelCid ??= _constructChannelCid(); + GeneratedTextColumn _constructChannelCid() { + return GeneratedTextColumn( + 'channel_cid', + $tableName, + false, + ); + } + + @override + List get $columns => [queryHash, channelCid]; + @override + $ChannelQueriesTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'channel_queries'; + @override + final String actualTableName = 'channel_queries'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('query_hash')) { + context.handle(_queryHashMeta, + queryHash.isAcceptableOrUnknown(data['query_hash'], _queryHashMeta)); + } else if (isInserting) { + context.missing(_queryHashMeta); + } + if (data.containsKey('channel_cid')) { + context.handle( + _channelCidMeta, + channelCid.isAcceptableOrUnknown( + data['channel_cid'], _channelCidMeta)); + } else if (isInserting) { + context.missing(_channelCidMeta); + } + return context; + } + + @override + Set get $primaryKey => {queryHash, channelCid}; + @override + ChannelQueryEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return ChannelQueryEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $ChannelQueriesTable createAlias(String alias) { + return $ChannelQueriesTable(_db, alias); + } +} + +class ConnectionEventEntity extends DataClass + implements Insertable { + final int id; + final Map ownUser; + final int totalUnreadCount; + final int unreadChannels; + final DateTime lastEventAt; + final DateTime lastSyncAt; + ConnectionEventEntity( + {@required this.id, + this.ownUser, + this.totalUnreadCount, + this.unreadChannels, + this.lastEventAt, + this.lastSyncAt}); + factory ConnectionEventEntity.fromData( + Map data, GeneratedDatabase db, + {String prefix}) { + final effectivePrefix = prefix ?? ''; + final intType = db.typeSystem.forDartType(); + final stringType = db.typeSystem.forDartType(); + final dateTimeType = db.typeSystem.forDartType(); + return ConnectionEventEntity( + id: intType.mapFromDatabaseResponse(data['${effectivePrefix}id']), + ownUser: $ConnectionEventsTable.$converter0.mapToDart(stringType + .mapFromDatabaseResponse(data['${effectivePrefix}own_user'])), + totalUnreadCount: intType.mapFromDatabaseResponse( + data['${effectivePrefix}total_unread_count']), + unreadChannels: intType + .mapFromDatabaseResponse(data['${effectivePrefix}unread_channels']), + lastEventAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}last_event_at']), + lastSyncAt: dateTimeType + .mapFromDatabaseResponse(data['${effectivePrefix}last_sync_at']), + ); + } + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || id != null) { + map['id'] = Variable(id); + } + if (!nullToAbsent || ownUser != null) { + final converter = $ConnectionEventsTable.$converter0; + map['own_user'] = Variable(converter.mapToSql(ownUser)); + } + if (!nullToAbsent || totalUnreadCount != null) { + map['total_unread_count'] = Variable(totalUnreadCount); + } + if (!nullToAbsent || unreadChannels != null) { + map['unread_channels'] = Variable(unreadChannels); + } + if (!nullToAbsent || lastEventAt != null) { + map['last_event_at'] = Variable(lastEventAt); + } + if (!nullToAbsent || lastSyncAt != null) { + map['last_sync_at'] = Variable(lastSyncAt); + } + return map; + } + + factory ConnectionEventEntity.fromJson(Map json, + {ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return ConnectionEventEntity( + id: serializer.fromJson(json['id']), + ownUser: serializer.fromJson>(json['ownUser']), + totalUnreadCount: serializer.fromJson(json['totalUnreadCount']), + unreadChannels: serializer.fromJson(json['unreadChannels']), + lastEventAt: serializer.fromJson(json['lastEventAt']), + lastSyncAt: serializer.fromJson(json['lastSyncAt']), + ); + } + @override + Map toJson({ValueSerializer serializer}) { + serializer ??= moorRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'ownUser': serializer.toJson>(ownUser), + 'totalUnreadCount': serializer.toJson(totalUnreadCount), + 'unreadChannels': serializer.toJson(unreadChannels), + 'lastEventAt': serializer.toJson(lastEventAt), + 'lastSyncAt': serializer.toJson(lastSyncAt), + }; + } + + ConnectionEventEntity copyWith( + {int id, + Value> ownUser = const Value.absent(), + Value totalUnreadCount = const Value.absent(), + Value unreadChannels = const Value.absent(), + Value lastEventAt = const Value.absent(), + Value lastSyncAt = const Value.absent()}) => + ConnectionEventEntity( + id: id ?? this.id, + ownUser: ownUser.present ? ownUser.value : this.ownUser, + totalUnreadCount: totalUnreadCount.present + ? totalUnreadCount.value + : this.totalUnreadCount, + unreadChannels: + unreadChannels.present ? unreadChannels.value : this.unreadChannels, + lastEventAt: lastEventAt.present ? lastEventAt.value : this.lastEventAt, + lastSyncAt: lastSyncAt.present ? lastSyncAt.value : this.lastSyncAt, + ); + @override + String toString() { + return (StringBuffer('ConnectionEventEntity(') + ..write('id: $id, ') + ..write('ownUser: $ownUser, ') + ..write('totalUnreadCount: $totalUnreadCount, ') + ..write('unreadChannels: $unreadChannels, ') + ..write('lastEventAt: $lastEventAt, ') + ..write('lastSyncAt: $lastSyncAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => $mrjf($mrjc( + id.hashCode, + $mrjc( + ownUser.hashCode, + $mrjc( + totalUnreadCount.hashCode, + $mrjc(unreadChannels.hashCode, + $mrjc(lastEventAt.hashCode, lastSyncAt.hashCode)))))); + @override + bool operator ==(dynamic other) => + identical(this, other) || + (other is ConnectionEventEntity && + other.id == this.id && + other.ownUser == this.ownUser && + other.totalUnreadCount == this.totalUnreadCount && + other.unreadChannels == this.unreadChannels && + other.lastEventAt == this.lastEventAt && + other.lastSyncAt == this.lastSyncAt); +} + +class ConnectionEventsCompanion extends UpdateCompanion { + final Value id; + final Value> ownUser; + final Value totalUnreadCount; + final Value unreadChannels; + final Value lastEventAt; + final Value lastSyncAt; + const ConnectionEventsCompanion({ + this.id = const Value.absent(), + this.ownUser = const Value.absent(), + this.totalUnreadCount = const Value.absent(), + this.unreadChannels = const Value.absent(), + this.lastEventAt = const Value.absent(), + this.lastSyncAt = const Value.absent(), + }); + ConnectionEventsCompanion.insert({ + this.id = const Value.absent(), + this.ownUser = const Value.absent(), + this.totalUnreadCount = const Value.absent(), + this.unreadChannels = const Value.absent(), + this.lastEventAt = const Value.absent(), + this.lastSyncAt = const Value.absent(), + }); + static Insertable custom({ + Expression id, + Expression ownUser, + Expression totalUnreadCount, + Expression unreadChannels, + Expression lastEventAt, + Expression lastSyncAt, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (ownUser != null) 'own_user': ownUser, + if (totalUnreadCount != null) 'total_unread_count': totalUnreadCount, + if (unreadChannels != null) 'unread_channels': unreadChannels, + if (lastEventAt != null) 'last_event_at': lastEventAt, + if (lastSyncAt != null) 'last_sync_at': lastSyncAt, + }); + } + + ConnectionEventsCompanion copyWith( + {Value id, + Value> ownUser, + Value totalUnreadCount, + Value unreadChannels, + Value lastEventAt, + Value lastSyncAt}) { + return ConnectionEventsCompanion( + id: id ?? this.id, + ownUser: ownUser ?? this.ownUser, + totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, + unreadChannels: unreadChannels ?? this.unreadChannels, + lastEventAt: lastEventAt ?? this.lastEventAt, + lastSyncAt: lastSyncAt ?? this.lastSyncAt, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (ownUser.present) { + final converter = $ConnectionEventsTable.$converter0; + map['own_user'] = Variable(converter.mapToSql(ownUser.value)); + } + if (totalUnreadCount.present) { + map['total_unread_count'] = Variable(totalUnreadCount.value); + } + if (unreadChannels.present) { + map['unread_channels'] = Variable(unreadChannels.value); + } + if (lastEventAt.present) { + map['last_event_at'] = Variable(lastEventAt.value); + } + if (lastSyncAt.present) { + map['last_sync_at'] = Variable(lastSyncAt.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ConnectionEventsCompanion(') + ..write('id: $id, ') + ..write('ownUser: $ownUser, ') + ..write('totalUnreadCount: $totalUnreadCount, ') + ..write('unreadChannels: $unreadChannels, ') + ..write('lastEventAt: $lastEventAt, ') + ..write('lastSyncAt: $lastSyncAt') + ..write(')')) + .toString(); + } +} + +class $ConnectionEventsTable extends ConnectionEvents + with TableInfo<$ConnectionEventsTable, ConnectionEventEntity> { + final GeneratedDatabase _db; + final String _alias; + $ConnectionEventsTable(this._db, [this._alias]); + final VerificationMeta _idMeta = const VerificationMeta('id'); + GeneratedIntColumn _id; + @override + GeneratedIntColumn get id => _id ??= _constructId(); + GeneratedIntColumn _constructId() { + return GeneratedIntColumn( + 'id', + $tableName, + false, + ); + } + + final VerificationMeta _ownUserMeta = const VerificationMeta('ownUser'); + GeneratedTextColumn _ownUser; + @override + GeneratedTextColumn get ownUser => _ownUser ??= _constructOwnUser(); + GeneratedTextColumn _constructOwnUser() { + return GeneratedTextColumn( + 'own_user', + $tableName, + true, + ); + } + + final VerificationMeta _totalUnreadCountMeta = + const VerificationMeta('totalUnreadCount'); + GeneratedIntColumn _totalUnreadCount; + @override + GeneratedIntColumn get totalUnreadCount => + _totalUnreadCount ??= _constructTotalUnreadCount(); + GeneratedIntColumn _constructTotalUnreadCount() { + return GeneratedIntColumn( + 'total_unread_count', + $tableName, + true, + ); + } + + final VerificationMeta _unreadChannelsMeta = + const VerificationMeta('unreadChannels'); + GeneratedIntColumn _unreadChannels; + @override + GeneratedIntColumn get unreadChannels => + _unreadChannels ??= _constructUnreadChannels(); + GeneratedIntColumn _constructUnreadChannels() { + return GeneratedIntColumn( + 'unread_channels', + $tableName, + true, + ); + } + + final VerificationMeta _lastEventAtMeta = + const VerificationMeta('lastEventAt'); + GeneratedDateTimeColumn _lastEventAt; + @override + GeneratedDateTimeColumn get lastEventAt => + _lastEventAt ??= _constructLastEventAt(); + GeneratedDateTimeColumn _constructLastEventAt() { + return GeneratedDateTimeColumn( + 'last_event_at', + $tableName, + true, + ); + } + + final VerificationMeta _lastSyncAtMeta = const VerificationMeta('lastSyncAt'); + GeneratedDateTimeColumn _lastSyncAt; + @override + GeneratedDateTimeColumn get lastSyncAt => + _lastSyncAt ??= _constructLastSyncAt(); + GeneratedDateTimeColumn _constructLastSyncAt() { + return GeneratedDateTimeColumn( + 'last_sync_at', + $tableName, + true, + ); + } + + @override + List get $columns => + [id, ownUser, totalUnreadCount, unreadChannels, lastEventAt, lastSyncAt]; + @override + $ConnectionEventsTable get asDslTable => this; + @override + String get $tableName => _alias ?? 'connection_events'; + @override + final String actualTableName = 'connection_events'; + @override + VerificationContext validateIntegrity( + Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id'], _idMeta)); + } + context.handle(_ownUserMeta, const VerificationResult.success()); + if (data.containsKey('total_unread_count')) { + context.handle( + _totalUnreadCountMeta, + totalUnreadCount.isAcceptableOrUnknown( + data['total_unread_count'], _totalUnreadCountMeta)); + } + if (data.containsKey('unread_channels')) { + context.handle( + _unreadChannelsMeta, + unreadChannels.isAcceptableOrUnknown( + data['unread_channels'], _unreadChannelsMeta)); + } + if (data.containsKey('last_event_at')) { + context.handle( + _lastEventAtMeta, + lastEventAt.isAcceptableOrUnknown( + data['last_event_at'], _lastEventAtMeta)); + } + if (data.containsKey('last_sync_at')) { + context.handle( + _lastSyncAtMeta, + lastSyncAt.isAcceptableOrUnknown( + data['last_sync_at'], _lastSyncAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + ConnectionEventEntity map(Map data, {String tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : null; + return ConnectionEventEntity.fromData(data, _db, prefix: effectivePrefix); + } + + @override + $ConnectionEventsTable createAlias(String alias) { + return $ConnectionEventsTable(_db, alias); + } + + static TypeConverter, String> $converter0 = + MapConverter(); +} + +abstract class _$MoorChatDatabase extends GeneratedDatabase { + _$MoorChatDatabase(QueryExecutor e) : super(SqlTypeSystem.defaultInstance, e); + _$MoorChatDatabase.connect(DatabaseConnection c) : super.connect(c); + $ChannelsTable _channels; + $ChannelsTable get channels => _channels ??= $ChannelsTable(this); + $MessagesTable _messages; + $MessagesTable get messages => _messages ??= $MessagesTable(this); + $ReactionsTable _reactions; + $ReactionsTable get reactions => _reactions ??= $ReactionsTable(this); + $UsersTable _users; + $UsersTable get users => _users ??= $UsersTable(this); + $MembersTable _members; + $MembersTable get members => _members ??= $MembersTable(this); + $ReadsTable _reads; + $ReadsTable get reads => _reads ??= $ReadsTable(this); + $ChannelQueriesTable _channelQueries; + $ChannelQueriesTable get channelQueries => + _channelQueries ??= $ChannelQueriesTable(this); + $ConnectionEventsTable _connectionEvents; + $ConnectionEventsTable get connectionEvents => + _connectionEvents ??= $ConnectionEventsTable(this); + UserDao _userDao; + UserDao get userDao => _userDao ??= UserDao(this as MoorChatDatabase); + ChannelDao _channelDao; + ChannelDao get channelDao => + _channelDao ??= ChannelDao(this as MoorChatDatabase); + MessageDao _messageDao; + MessageDao get messageDao => + _messageDao ??= MessageDao(this as MoorChatDatabase); + MemberDao _memberDao; + MemberDao get memberDao => _memberDao ??= MemberDao(this as MoorChatDatabase); + ReactionDao _reactionDao; + ReactionDao get reactionDao => + _reactionDao ??= ReactionDao(this as MoorChatDatabase); + ReadDao _readDao; + ReadDao get readDao => _readDao ??= ReadDao(this as MoorChatDatabase); + ChannelQueryDao _channelQueryDao; + ChannelQueryDao get channelQueryDao => + _channelQueryDao ??= ChannelQueryDao(this as MoorChatDatabase); + ConnectionEventDao _connectionEventDao; + ConnectionEventDao get connectionEventDao => + _connectionEventDao ??= ConnectionEventDao(this as MoorChatDatabase); + @override + Iterable get allTables => allSchemaEntities.whereType(); + @override + List get allSchemaEntities => [ + channels, + messages, + reactions, + users, + members, + reads, + channelQueries, + connectionEvents + ]; +} diff --git a/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart new file mode 100644 index 00000000..74cf91f8 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'dart:isolate'; +import 'package:moor/ffi.dart'; +import 'package:moor/isolate.dart'; +import 'package:moor/moor.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:stream_chat_persistence/src/stream_chat_persistence_client.dart'; +import 'package:stream_chat_persistence/stream_chat_persistence.dart'; + +import '../moor_chat_database.dart'; + +/// A Helper class to construct new instances of [MoorChatDatabase] specifically +/// for native platform applications +class SharedDB { + /// Returns a new instance of [VmDatabase] created using [userId] + /// on a regular isolate. + /// + /// Generally used with [ConnectionMode.regular]. + static Future constructDatabase( + String userId, { + bool logStatements = false, + }) async { + final dbName = 'db_$userId'; + if (Platform.isIOS || Platform.isAndroid) { + final dir = await getApplicationDocumentsDirectory(); + final path = join(dir.path, '$dbName.sqlite'); + final file = File(path); + return VmDatabase(file, logStatements: logStatements); + } + if (Platform.isMacOS || Platform.isLinux) { + final file = File('$dbName.sqlite'); + return VmDatabase(file, logStatements: logStatements); + } + return VmDatabase.memory(logStatements: logStatements); + } + + static void _startBackground(_IsolateStartRequest request) { + final executor = LazyDatabase(() async { + return VmDatabase( + File(request.targetPath), + logStatements: request.logStatements, + ); + }); + final moorIsolate = MoorIsolate.inCurrent( + () => DatabaseConnection.fromExecutor(executor), + ); + request.sendMoorIsolate.send(moorIsolate); + } + + static Future _createMoorIsolate( + String dbName, { + bool logStatements = false, + }) async { + final dir = await getApplicationDocumentsDirectory(); + final path = join(dir.path, '$dbName.sqlite'); + + final receivePort = ReceivePort(); + await Isolate.spawn( + _startBackground, + _IsolateStartRequest( + receivePort.sendPort, + path, + logStatements: logStatements, + ), + ); + + return (await receivePort.first as MoorIsolate); + } + + /// Returns a new instance of [MoorChatDatabase] using the factory constructor + /// [MoorChatDatabase.connect] created on a background isolate. + /// + /// Generally used with [ConnectionMode.background]. + static Future constructMoorChatDatabase( + String userId, { + bool logStatements = false, + }) async { + final dbName = 'db_$userId'; + final isolate = await _createMoorIsolate( + dbName, + logStatements: logStatements, + ); + final connection = await isolate.connect(); + return MoorChatDatabase.connect(userId, isolate, connection); + } +} + +class _IsolateStartRequest { + final SendPort sendMoorIsolate; + final String targetPath; + final bool logStatements; + + const _IsolateStartRequest( + this.sendMoorIsolate, + this.targetPath, { + this.logStatements = false, + }); +} diff --git a/packages/stream_chat_persistence/lib/src/db/shared/shared_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/shared_db.dart new file mode 100644 index 00000000..25120fcf --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/db/shared/shared_db.dart @@ -0,0 +1,3 @@ +export 'unsupported_db.dart' + if (dart.library.io) 'native_db.dart' // implementation using dart:io + if (dart.library.html) 'web_db.dart'; diff --git a/packages/stream_chat_persistence/lib/src/db/shared/unsupported_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/unsupported_db.dart new file mode 100644 index 00000000..303d925f --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/db/shared/unsupported_db.dart @@ -0,0 +1,24 @@ +import 'package:stream_chat_persistence/stream_chat_persistence.dart'; + +/// A Helper class to construct new instances of [MoorChatDatabase] +class SharedDB { + /// Returns a new instance of database. + /// + /// Generally used with [ConnectionMode.regular]. + static dynamic constructDatabase( + String userId, { + bool logStatements = false, + }) { + throw 'Unsupported Platform'; + } + + /// Return a new instance of moor chat database. + /// + /// Generally used with [ConnectionMode.background]. + static dynamic constructMoorChatDatabase( + String userId, { + bool logStatements = false, + }) { + throw 'Unsupported Platform'; + } +} diff --git a/packages/stream_chat_persistence/lib/src/db/shared/web_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/web_db.dart new file mode 100644 index 00000000..6f4360c0 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/db/shared/web_db.dart @@ -0,0 +1,31 @@ +import 'package:moor/moor_web.dart'; +import 'package:stream_chat_persistence/src/stream_chat_persistence_client.dart'; + +import '../moor_chat_database.dart'; + +/// A Helper class to construct new instances of [MoorChatDatabase] specifically +/// for Web applications +class SharedDB { + /// Returns a new instance of [WebDatabase] created using [userId]. + /// + /// Generally used with [ConnectionMode.regular]. + static Future constructDatabase( + String userId, { + bool logStatements = false, + }) async { + final dbName = 'db_$userId'; + return WebDatabase(dbName, logStatements: logStatements); + } + + /// Returns a new instance of [MoorChatDatabase] creating using the + /// default constructor. + /// + /// Generally used with [ConnectionMode.background]. + static Future constructMoorChatDatabase( + String userId, { + bool logStatements = false, + }) async { + final dbName = 'db_$userId'; + return MoorChatDatabase(dbName, logStatements: logStatements); + } +} diff --git a/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart b/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart new file mode 100644 index 00000000..301e3592 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart @@ -0,0 +1,17 @@ +import 'package:moor/moor.dart'; + +/// Represents a [ChannelQueries] table in [MoorChatDatabase]. +@DataClassName('ChannelQueryEntity') +class ChannelQueries extends Table { + /// The unique hash of this query + TextColumn get queryHash => text()(); + + /// The channel cid of this query + TextColumn get channelCid => text()(); + + @override + Set get primaryKey => { + queryHash, + channelCid, + }; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/channels.dart b/packages/stream_chat_persistence/lib/src/entity/channels.dart new file mode 100644 index 00000000..38a40081 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/channels.dart @@ -0,0 +1,45 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat_persistence/src/converter/map_converter.dart'; + +/// Represents a [Channels] table in [MoorChatDatabase]. +@DataClassName('ChannelEntity') +class Channels extends Table { + /// The id of this channel + TextColumn get id => text()(); + + /// The type of this channel + TextColumn get type => text()(); + + /// The cid of this channel + TextColumn get cid => text()(); + + /// The channel configuration data + TextColumn get config => text().map(MapConverter())(); + + /// True if this channel entity is frozen + BoolColumn get frozen => boolean().withDefault(Constant(false))(); + + /// The date of the last message + DateTimeColumn get lastMessageAt => dateTime().nullable()(); + + /// The date of channel creation + DateTimeColumn get createdAt => dateTime().nullable()(); + + /// The date of the last channel update + DateTimeColumn get updatedAt => dateTime().nullable()(); + + /// The date of channel deletion + DateTimeColumn get deletedAt => dateTime().nullable()(); + + /// The count of this channel members + IntColumn get memberCount => integer().nullable()(); + + /// The id of the user that created this channel + TextColumn get createdById => text().nullable()(); + + /// Map of custom channel extraData + TextColumn get extraData => text().nullable().map(MapConverter())(); + + @override + Set get primaryKey => {cid}; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/connection_events.dart b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart new file mode 100644 index 00000000..7caa0407 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/connection_events.dart @@ -0,0 +1,27 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat_persistence/src/converter/map_converter.dart'; + +/// Represents a [ConnectionEvents] table in [MoorChatDatabase]. +@DataClassName('ConnectionEventEntity') +class ConnectionEvents extends Table { + /// event id + IntColumn get id => integer()(); + + /// User object of the current user + TextColumn get ownUser => text().nullable().map(MapConverter())(); + + /// The number of unread messages for current user + IntColumn get totalUnreadCount => integer().nullable()(); + + /// User total unread channels for current user + IntColumn get unreadChannels => integer().nullable()(); + + /// DateTime of the last event + DateTimeColumn get lastEventAt => dateTime().nullable()(); + + /// DateTime of the last sync + DateTimeColumn get lastSyncAt => dateTime().nullable()(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/entity.dart b/packages/stream_chat_persistence/lib/src/entity/entity.dart new file mode 100644 index 00000000..8751f538 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/entity.dart @@ -0,0 +1,8 @@ +export 'channels.dart'; +export 'messages.dart'; +export 'reactions.dart'; +export 'users.dart'; +export 'members.dart'; +export 'reads.dart'; +export 'channel_queries.dart'; +export 'connection_events.dart'; diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart new file mode 100644 index 00000000..9eafd4aa --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -0,0 +1,45 @@ +import 'package:moor/moor.dart'; + +/// Represents a [Members] table in [MoorChatDatabase]. +@DataClassName('MemberEntity') +class Members extends Table { + /// The interested user id + TextColumn get userId => text()(); + + /// The channel cid of which this user is part of + TextColumn get channelCid => + text().customConstraint('REFERENCES channels(cid) ON DELETE CASCADE')(); + + /// The role of the user in the channel + TextColumn get role => text().nullable()(); + + /// The date on which the user accepted the invite to the channel + DateTimeColumn get inviteAcceptedAt => dateTime().nullable()(); + + /// The date on which the user rejected the invite to the channel + DateTimeColumn get inviteRejectedAt => dateTime().nullable()(); + + /// True if the user has been invited to the channel + BoolColumn get invited => boolean().nullable()(); + + /// True if the member is banned from the channel + BoolColumn get banned => boolean().nullable()(); + + /// True if the member is shadow banned from the channel + BoolColumn get shadowBanned => boolean().nullable()(); + + /// True if the user is a moderator of the channel + BoolColumn get isModerator => boolean().nullable()(); + + /// The date of creation + DateTimeColumn get createdAt => dateTime()(); + + /// The last date of update + DateTimeColumn get updatedAt => dateTime().nullable()(); + + @override + Set get primaryKey => { + userId, + channelCid, + }; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/messages.dart b/packages/stream_chat_persistence/lib/src/entity/messages.dart new file mode 100644 index 00000000..8a151ae6 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/messages.dart @@ -0,0 +1,76 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat_persistence/src/converter/list_converter.dart'; +import 'package:stream_chat_persistence/src/converter/map_converter.dart'; +import 'package:stream_chat_persistence/src/converter/message_sending_status_converter.dart'; + +/// Represents a [Messages] table in [MoorChatDatabase]. +@DataClassName('MessageEntity') +class Messages extends Table { + /// The message id + TextColumn get id => text()(); + + /// The text of this message + TextColumn get messageText => text().nullable()(); + + /// The list of attachments, either provided by the user + /// or generated from a command or as a result of URL scraping. + TextColumn get attachments => + text().nullable().map(ListConverter())(); + + /// The status of a sending message + IntColumn get status => + integer().nullable().map(MessageSendingStatusConverter())(); + + /// The message type + TextColumn get type => text().nullable()(); + + /// The list of user mentioned in the message + TextColumn get mentionedUsers => + text().nullable().map(ListConverter())(); + + /// A map describing the count of number of every reaction + TextColumn get reactionCounts => text().nullable().map(MapConverter())(); + + /// A map describing the count of score of every reaction + TextColumn get reactionScores => text().nullable().map(MapConverter())(); + + /// The ID of the parent message, if the message is a thread reply. + TextColumn get parentId => text().nullable()(); + + /// The ID of the quoted message, if the message is a quoted reply. + TextColumn get quotedMessageId => text().nullable()(); + + /// Number of replies for this message. + IntColumn get replyCount => integer().nullable()(); + + /// Check if this message needs to show in the channel. + BoolColumn get showInChannel => boolean().nullable()(); + + /// If true the message is shadowed + BoolColumn get shadowed => boolean().nullable()(); + + /// A used command name. + TextColumn get command => text().nullable()(); + + /// The DateTime when the message was created. + DateTimeColumn get createdAt => dateTime()(); + + /// The DateTime when the message was updated last time. + DateTimeColumn get updatedAt => dateTime().nullable()(); + + /// The DateTime when the message was deleted. + DateTimeColumn get deletedAt => dateTime().nullable()(); + + /// Id of the User who sent the message + TextColumn get userId => text().nullable()(); + + /// The channel cid of which this message is part of + TextColumn get channelCid => text().nullable().customConstraint( + 'NULLABLE REFERENCES channels(cid) ON DELETE CASCADE')(); + + /// Message custom extraData + TextColumn get extraData => text().nullable().map(MapConverter())(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/reactions.dart b/packages/stream_chat_persistence/lib/src/entity/reactions.dart new file mode 100644 index 00000000..b7e6342f --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/reactions.dart @@ -0,0 +1,32 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat_persistence/src/converter/map_converter.dart'; + +/// Represents a [Reactions] table in [MoorChatDatabase]. +@DataClassName('ReactionEntity') +class Reactions extends Table { + /// The id of the user that sent the reaction + TextColumn get userId => text()(); + + /// The messageId to which the reaction belongs + TextColumn get messageId => + text().customConstraint('REFERENCES messages(id) ON DELETE CASCADE')(); + + /// The type of the reaction + TextColumn get type => text()(); + + /// The DateTime on which the reaction is created + DateTimeColumn get createdAt => dateTime()(); + + /// The score of the reaction (ie. number of reactions sent) + IntColumn get score => integer().nullable()(); + + /// Reaction custom extraData + TextColumn get extraData => text().nullable().map(MapConverter())(); + + @override + Set get primaryKey => { + messageId, + type, + userId, + }; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/reads.dart b/packages/stream_chat_persistence/lib/src/entity/reads.dart new file mode 100644 index 00000000..4b9ce6fb --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/reads.dart @@ -0,0 +1,24 @@ +import 'package:moor/moor.dart'; + +/// Represents a [Reads] table in [MoorChatDatabase]. +@DataClassName('ReadEntity') +class Reads extends Table { + /// Date of the read event + DateTimeColumn get lastRead => dateTime()(); + + /// Id of the User who sent the event + TextColumn get userId => text()(); + + /// The channel cid of which this read belongs + TextColumn get channelCid => + text().customConstraint('REFERENCES channels(cid) ON DELETE CASCADE')(); + + /// Number of unread messages + IntColumn get unreadMessages => integer().nullable()(); + + @override + Set get primaryKey => { + userId, + channelCid, + }; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/users.dart b/packages/stream_chat_persistence/lib/src/entity/users.dart new file mode 100644 index 00000000..4916ad28 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/users.dart @@ -0,0 +1,33 @@ +import 'package:moor/moor.dart'; +import 'package:stream_chat_persistence/src/converter/map_converter.dart'; + +/// Represents a [Users] table in [MoorChatDatabase]. +@DataClassName('UserEntity') +class Users extends Table { + /// User id + TextColumn get id => text()(); + + /// User role + TextColumn get role => text().nullable()(); + + /// Date of user creation + DateTimeColumn get createdAt => dateTime().nullable()(); + + /// Date of last user update + DateTimeColumn get updatedAt => dateTime().nullable()(); + + /// Date of last user connection + DateTimeColumn get lastActive => dateTime().nullable()(); + + /// True if user is online + BoolColumn get online => boolean().nullable()(); + + /// True if user is banned from the chat + BoolColumn get banned => boolean().nullable()(); + + /// Map of custom user extraData + TextColumn get extraData => text().nullable().map(MapConverter())(); + + @override + Set get primaryKey => {id}; +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart new file mode 100644 index 00000000..7d0e152f --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart @@ -0,0 +1,60 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +/// Useful mapping functions for [ChannelEntity] +extension ChannelEntityX on ChannelEntity { + /// Maps a [ChannelEntity] into [ChannelModel] + ChannelModel toChannelModel({User createdBy}) { + final config = ChannelConfig.fromJson(this.config ?? {}); + return ChannelModel( + id: id, + config: config, + type: type, + frozen: frozen, + createdAt: createdAt, + updatedAt: updatedAt, + memberCount: memberCount, + cid: cid, + lastMessageAt: lastMessageAt, + deletedAt: deletedAt, + extraData: extraData, + createdBy: createdBy, + ); + } + + /// Maps a [ChannelEntity] into [ChannelState] + ChannelState toChannelState({ + User createdBy, + List members, + List reads, + List messages, + }) { + return ChannelState( + members: members, + read: reads, + messages: messages, + channel: toChannelModel(createdBy: createdBy), + ); + } +} + +/// Useful mapping functions for [ChannelModel] +extension ChannelModelX on ChannelModel { + /// Maps a [ChannelModel] into [ChannelEntity] + ChannelEntity toEntity() { + return ChannelEntity( + id: id, + type: type, + cid: cid, + config: config.toJson(), + frozen: frozen, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + memberCount: memberCount, + createdById: createdBy.id, + extraData: extraData, + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart new file mode 100644 index 00000000..7bc1739c --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart @@ -0,0 +1,14 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +/// Useful mapping functions for [ConnectionEventEntity] +extension ConnectionEventX on ConnectionEventEntity { + /// Maps a [ConnectionEventEntity] into [Event] + Event toEvent() { + return Event( + me: ownUser != null ? OwnUser.fromJson(ownUser) : null, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/mapper.dart new file mode 100644 index 00000000..8d19729d --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/mapper.dart @@ -0,0 +1,7 @@ +export 'user_mapper.dart'; +export 'reaction_mapper.dart'; +export 'channel_mapper.dart'; +export 'event_mapper.dart'; +export 'member_mapper.dart'; +export 'read_mapper.dart'; +export 'message_mapper.dart'; diff --git a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart new file mode 100644 index 00000000..2d32c9f1 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart @@ -0,0 +1,42 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +/// Useful mapping functions for [MemberEntity] +extension MemberEntityX on MemberEntity { + /// Maps a [MemberEntity] into [Member] + Member toMember({User user}) { + return Member( + user: user, + userId: userId, + banned: banned, + shadowBanned: shadowBanned, + updatedAt: updatedAt, + createdAt: createdAt, + role: role, + inviteAcceptedAt: inviteAcceptedAt, + invited: invited, + inviteRejectedAt: inviteRejectedAt, + isModerator: isModerator, + ); + } +} + +/// Useful mapping functions for [Member] +extension MemberX on Member { + /// Maps a [Member] into [MemberEntity] + MemberEntity toEntity({String cid}) { + return MemberEntity( + userId: user?.id, + banned: banned, + shadowBanned: shadowBanned, + channelCid: cid, + createdAt: createdAt, + isModerator: isModerator, + inviteRejectedAt: inviteRejectedAt, + invited: invited, + inviteAcceptedAt: inviteAcceptedAt, + role: role, + updatedAt: updatedAt, + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart new file mode 100644 index 00000000..5ad92ddd --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart @@ -0,0 +1,70 @@ +import 'dart:convert'; + +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +/// Useful mapping functions for [MessageEntity] +extension MessageEntityX on MessageEntity { + /// Maps a [MessageEntity] into [Message] + Message toMessage({ + User user, + List latestReactions, + List ownReactions, + Message quotedMessage, + }) { + return Message( + shadowed: shadowed, + latestReactions: latestReactions, + ownReactions: ownReactions, + attachments: attachments?.map((it) { + final json = jsonDecode(it); + return Attachment.fromJson(json); + })?.toList(), + createdAt: createdAt, + extraData: extraData, + updatedAt: updatedAt, + id: id, + type: type, + status: status, + command: command, + parentId: parentId, + quotedMessageId: quotedMessageId, + quotedMessage: quotedMessage, + reactionCounts: reactionCounts, + reactionScores: reactionScores, + replyCount: replyCount, + showInChannel: showInChannel, + text: messageText, + user: user, + deletedAt: deletedAt, + ); + } +} + +/// Useful mapping functions for [Message] +extension MessageX on Message { + /// Maps a [Message] into [MessageEntity] + MessageEntity toEntity({String cid}) { + return MessageEntity( + id: id, + attachments: attachments?.map((it) => jsonEncode(it))?.toList() ?? [], + channelCid: cid, + type: type, + parentId: parentId, + quotedMessageId: quotedMessageId, + command: command, + createdAt: createdAt, + shadowed: shadowed, + showInChannel: showInChannel, + replyCount: replyCount, + reactionScores: reactionScores, + reactionCounts: reactionCounts, + status: status, + updatedAt: updatedAt, + extraData: extraData, + userId: user?.id, + deletedAt: deletedAt, + messageText: text, + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart new file mode 100644 index 00000000..d265fcb7 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart @@ -0,0 +1,33 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +/// Useful mapping functions for [ReactionEntity] +extension ReactionEntityX on ReactionEntity { + /// Maps a [ReactionEntity] into [Reaction] + Reaction toReaction({User user}) { + return Reaction( + extraData: extraData, + type: type, + createdAt: createdAt, + userId: userId, + user: user, + messageId: messageId, + score: score, + ); + } +} + +/// Useful mapping functions for [Reaction] +extension ReactionX on Reaction { + /// Maps a [Reaction] into [ReactionEntity] + ReactionEntity toEntity() { + return ReactionEntity( + extraData: extraData, + type: type, + createdAt: createdAt, + userId: userId, + messageId: messageId, + score: score, + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart new file mode 100644 index 00000000..c79cf8d5 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart @@ -0,0 +1,27 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +/// Useful mapping functions for [ReadEntity] +extension ReadEntityX on ReadEntity { + /// Maps a [ReadEntity] into [Read] + Read toRead({User user}) { + return Read( + user: user, + lastRead: lastRead, + unreadMessages: unreadMessages, + ); + } +} + +/// Useful mapping functions for [Read] +extension ReadX on Read { + /// Maps a [Read] into [ReadEntity] + ReadEntity toEntity({String cid}) { + return ReadEntity( + lastRead: lastRead, + userId: user?.id, + channelCid: cid, + unreadMessages: unreadMessages, + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart new file mode 100644 index 00000000..b1e53786 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart @@ -0,0 +1,36 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/moor_chat_database.dart'; + +/// Useful mapping functions for [UserEntity] +extension UserEntityX on UserEntity { + /// Maps a [UserEntity] into [User] + User toUser() { + return User( + id: id, + updatedAt: updatedAt, + role: role, + online: online, + lastActive: lastActive, + extraData: extraData, + banned: banned, + createdAt: createdAt, + ); + } +} + +/// Useful mapping functions for [User] +extension UserX on User { + /// Maps a [User] into [UserEntity] + UserEntity toEntity() { + return UserEntity( + id: id, + role: role, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + banned: banned, + extraData: extraData, + ); + } +} diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart new file mode 100644 index 00000000..0962c9ac --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -0,0 +1,222 @@ +import 'package:stream_chat/stream_chat.dart'; + +import 'db/moor_chat_database.dart'; +import 'db/shared/shared_db.dart'; + +/// Various connection modes on which [StreamChatPersistenceClient] can work +enum ConnectionMode { + /// Connects the [StreamChatPersistenceClient] on a regular/default isolate + regular, + + /// Connects the [StreamChatPersistenceClient] on a background isolate + background, +} + +/// A [MoorChatDatabase] based implementation of the [ChatPersistenceClient] +class StreamChatPersistenceClient extends ChatPersistenceClient { + /// Creates a new instance of the stream chat persistence client + StreamChatPersistenceClient({ + /// Connection mode on which the client will work + ConnectionMode connectionMode = ConnectionMode.regular, + Level logLevel = Level.WARNING, + }) : assert(connectionMode != null), + assert(logLevel != null), + _connectionMode = connectionMode, + _logger = Logger.detached('💽')..level = logLevel; + + MoorChatDatabase _db; + final Logger _logger; + final ConnectionMode _connectionMode; + + @override + Future connect(String userId) async { + if (_db != null) { + throw Exception( + 'An instance of StreamChatDatabase is already connected.\n' + 'disconnect the previous instance before connecting again.', + ); + } + switch (_connectionMode) { + case ConnectionMode.regular: + _logger.info('Connecting on a regular isolate'); + _db = MoorChatDatabase(userId); + return; + case ConnectionMode.background: + _logger.info('Connecting on background isolate'); + _db = await SharedDB.constructMoorChatDatabase(userId); + return; + } + } + + @override + Future getConnectionInfo() { + return _db.connectionEventDao.connectionEvent; + } + + @override + Future updateConnectionInfo(Event event) { + return _db.connectionEventDao.updateConnectionEvent(event); + } + + @override + Future updateLastSyncAt(DateTime lastSyncAt) { + return _db.connectionEventDao.updateLastSyncAt(lastSyncAt); + } + + @override + Future getLastSyncAt() { + return _db.connectionEventDao.lastSyncAt; + } + + @override + Future deleteChannels(List cids) { + return _db.channelDao.deleteChannelByCids(cids); + } + + @override + Future> getChannelCids() => _db.channelDao.cids; + + @override + Future deleteMessageByIds(List messageIds) { + return _db.messageDao.deleteMessageByIds(messageIds); + } + + @override + Future deleteMessageByCids(List cids) { + return _db.messageDao.deleteMessageByCids(cids); + } + + @override + Future> getMembersByCid(String cid) { + return _db.memberDao.getMembersByCid(cid); + } + + @override + Future getChannelByCid(String cid) { + return _db.channelDao.getChannelByCid(cid); + } + + @override + Future> getMessagesByCid( + String cid, { + PaginationParams messagePagination, + }) { + return _db.messageDao.getMessagesByCid( + cid, + messagePagination: messagePagination, + ); + } + + @override + Future> getReadsByCid(String cid) { + return _db.readDao.getReadsByCid(cid); + } + + @override + Future>> getChannelThreads(String cid) async { + final messages = await _db.messageDao.getThreadMessages(cid); + final messageByParentIdDictionary = >{}; + for (final message in messages) { + final parentId = message.parentId; + messageByParentIdDictionary[parentId] = [ + ...messageByParentIdDictionary[parentId] ?? [], + message + ]; + } + return messageByParentIdDictionary; + } + + @override + Future> getReplies( + String parentId, { + PaginationParams options, + }) { + return _db.messageDao.getThreadMessagesByParentId( + parentId, + options: options, + ); + } + + @override + Future> getChannelStates({ + Map filter, + List sort = const [], + PaginationParams paginationParams, + }) { + return _db.channelQueryDao.getChannelStates( + filter: filter, + sort: sort, + paginationParams: paginationParams, + ); + } + + @override + Future updateChannelQueries( + Map filter, + List cids, + bool clearQueryCache, + ) { + return _db.channelQueryDao.updateChannelQueries( + filter, + cids, + clearQueryCache, + ); + } + + @override + Future updateChannels(List channels) { + return _db.channelDao.updateChannels(channels); + } + + @override + Future updateMembers(String cid, List members) { + return _db.memberDao.updateMembers(cid, members); + } + + @override + Future updateMessages(String cid, List messages) { + return _db.messageDao.updateMessages(cid, messages); + } + + @override + Future updateReactions(List reactions) { + return _db.reactionDao.updateReactions(reactions); + } + + @override + Future updateReads(String cid, List reads) { + return _db.readDao.updateReads(cid, reads); + } + + @override + Future updateUsers(List users) { + return _db.userDao.updateUsers(users); + } + + @override + Future deleteReactionsByMessageId(List messageIds) { + return _db.reactionDao.deleteReactionsByMessageIds(messageIds); + } + + @override + Future deleteMembersByCids(List cids) { + return _db.memberDao.deleteMemberByCids(cids); + } + + @override + Future disconnect({bool flush = false}) async { + if (_db != null) { + _logger.info('Disconnecting'); + if (flush) { + _logger.info('Flushing'); + await _db.batch((batch) { + _db.allTables.forEach((table) { + _db.delete(table).go(); + }); + }); + } + await _db.disconnect(); + _db = null; + } + } +} diff --git a/packages/stream_chat_persistence/lib/stream_chat_persistence.dart b/packages/stream_chat_persistence/lib/stream_chat_persistence.dart new file mode 100644 index 00000000..5f9aff5b --- /dev/null +++ b/packages/stream_chat_persistence/lib/stream_chat_persistence.dart @@ -0,0 +1,3 @@ +library stream_chat_persistence; + +export 'src/stream_chat_persistence_client.dart'; diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml new file mode 100644 index 00000000..158e8adf --- /dev/null +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -0,0 +1,22 @@ +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: 1.0.0-beta +repository: https://github.com/GetStream/stream-chat-flutter +issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues + +environment: + sdk: ">=2.7.0 <3.0.0" + +dependencies: + moor: ^3.4.0 + path: ^1.7.0 + path_provider: ^1.6.27 + sqlite3_flutter_libs: ^0.3.0 + stream_chat: ^1.0.1-beta + +dev_dependencies: + test: ^1.15.7 + build_runner: ^1.11.0 + moor_generator: ^3.4.1 + pedantic: ^1.9.2 diff --git a/screenshots/channel_header.png b/screenshots/channel_header.png deleted file mode 100644 index 41b2b77a..00000000 Binary files a/screenshots/channel_header.png and /dev/null differ diff --git a/screenshots/channel_header_paint.png b/screenshots/channel_header_paint.png deleted file mode 100644 index ba4c6175..00000000 Binary files a/screenshots/channel_header_paint.png and /dev/null differ diff --git a/screenshots/channel_list_view.png b/screenshots/channel_list_view.png deleted file mode 100644 index fd04fa47..00000000 Binary files a/screenshots/channel_list_view.png and /dev/null differ diff --git a/screenshots/channel_list_view_paint.png b/screenshots/channel_list_view_paint.png deleted file mode 100644 index c1d49780..00000000 Binary files a/screenshots/channel_list_view_paint.png and /dev/null differ diff --git a/screenshots/channel_preview.png b/screenshots/channel_preview.png deleted file mode 100644 index 04058387..00000000 Binary files a/screenshots/channel_preview.png and /dev/null differ diff --git a/screenshots/channel_preview_paint.png b/screenshots/channel_preview_paint.png deleted file mode 100644 index c2f822a3..00000000 Binary files a/screenshots/channel_preview_paint.png and /dev/null differ diff --git a/screenshots/message_input.png b/screenshots/message_input.png deleted file mode 100644 index 9dab6cce..00000000 Binary files a/screenshots/message_input.png and /dev/null differ diff --git a/screenshots/message_input2.png b/screenshots/message_input2.png deleted file mode 100644 index 61b75deb..00000000 Binary files a/screenshots/message_input2.png and /dev/null differ diff --git a/screenshots/message_input2_paint.png b/screenshots/message_input2_paint.png deleted file mode 100644 index 9bb5da11..00000000 Binary files a/screenshots/message_input2_paint.png and /dev/null differ diff --git a/screenshots/message_input_paint.png b/screenshots/message_input_paint.png deleted file mode 100644 index 555ce941..00000000 Binary files a/screenshots/message_input_paint.png and /dev/null differ diff --git a/screenshots/message_listview.png b/screenshots/message_listview.png deleted file mode 100644 index adf3f087..00000000 Binary files a/screenshots/message_listview.png and /dev/null differ diff --git a/screenshots/message_listview_paint.png b/screenshots/message_listview_paint.png deleted file mode 100644 index 1618cd40..00000000 Binary files a/screenshots/message_listview_paint.png and /dev/null differ diff --git a/screenshots/message_widget.png b/screenshots/message_widget.png deleted file mode 100644 index d333c03a..00000000 Binary files a/screenshots/message_widget.png and /dev/null differ diff --git a/screenshots/message_widget_paint.png b/screenshots/message_widget_paint.png deleted file mode 100644 index 4eee610b..00000000 Binary files a/screenshots/message_widget_paint.png and /dev/null differ diff --git a/screenshots/reaction_picker.png b/screenshots/reaction_picker.png deleted file mode 100644 index eafefa31..00000000 Binary files a/screenshots/reaction_picker.png and /dev/null differ diff --git a/screenshots/reaction_picker_paint.png b/screenshots/reaction_picker_paint.png deleted file mode 100644 index 20028f4c..00000000 Binary files a/screenshots/reaction_picker_paint.png and /dev/null differ diff --git a/screenshots/thread_header.png b/screenshots/thread_header.png deleted file mode 100644 index 45707fde..00000000 Binary files a/screenshots/thread_header.png and /dev/null differ diff --git a/screenshots/thread_header_paint.png b/screenshots/thread_header_paint.png deleted file mode 100644 index 41e2b0dc..00000000 Binary files a/screenshots/thread_header_paint.png and /dev/null differ