diff --git a/CHANGELOG.md b/CHANGELOG.md index dd92e9a9..d0054b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,60 +1,135 @@ -## 0.1.35 +## 0.2.1-alpha+11 -- Add backgroundColor as part of StreamChatThemeData +- Update llc dependency -## 0.1.34 +## 0.2.1-alpha+10 -- Add `onUserAvatarTap` +- Update llc dependency -## 0.1.33 +## 0.2.1-alpha+9 -- Add default user and channel image to `StreamChatTheme` +- Add read indicators +- Update llc dependency -## 0.1.32 +## 0.2.1-alpha+8 -- Update llc version +- User queryMembers for mentions -## 0.1.31 +## 0.2.1-alpha+7 -- Add `initialMessage` property to `MessageInput` +- Update llc dependency -## 0.1.30 +## 0.2.1-alpha+6 -- Add simple rendering of file attachments +- Update llc dependency +- Minor bugfix -## 0.1.29 +## 0.2.1-alpha+4 -- Add `doImageUploadRequest` and `doFileUploadRequest` to `MessageInput` to let users customize file uploading (eg: custom cdn) +- Update llc dependency -## 0.1.28 +- Add system messages -- Add `onTap` to `ChannelImage` +## 0.2.1-alpha+3 -- Add `onImageTap` and `onTitleTap` to `ChannelHeader` +- Update llc dependency -- Add `onImageTap` to `ChannelListView` +- Fix hero tag generation for attachment -## 0.1.27 +## 0.2.1-alpha+2 -- Show other member user's name and image in one to one channels +- Fixed reactions bubble going below other messages +- Updated llc dependency -## 0.1.25 +## 0.2.1-alpha+1 + +- Removed the additional `Navigator` in `StreamChat` widget. + It was added to make the app have the `StreamChat` widget as ancestor in every route. + Now the recommended way to add `StreamChat` to your app is using the `builder` property of your `MaterialApp` widget. + Otherwise you can use it in the usual way, but you need to add a `StreamChat` widget to every route of your app. + Read [this issue](https://github.com/GetStream/stream-chat-flutter/issues/47) for more information. + +```dart + @override + Widget build(BuildContext context) { + return MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.system, + builder: (context, widget) { + return StreamChat( + child: widget, + client: client, + ); + }, + home: ChannelListPage(), + ); +``` + +- Fix reaction bubble going below previous message on iOS + +- Fix message list view reloading messages even if the pagination is ended + +## 0.2.1-alpha + +- New message widget +- Moved some properties from `MessageListView` to `MessageWidget` +- Added `MessageDetails` property to `MessageBuilder` +- Added example to customize the message using `MessageWidget` (`customize_message_widget.dart`) + +## 0.2.0-alpha+15 + +- Add background color in StreamChatTheme + +## 0.2.0-alpha+13 + +- Handle channel deleted event + +## 0.2.0-alpha+11 + +- Fix message builder and add messageList to it + +## 0.2.0-alpha+10 + +- Add date divider builder + +- Fix reply indicator tap + +## 0.2.0-alpha+9 + +- Add `attachmentBuilders` to `MessageWidget` and `MessageListView` + +## 0.2.0-alpha+7 + +- Update llc dependency + +## 0.2.0-alpha+5 + +- Remove dependencies on notification service + +- Expose some helping method for integrate offline storage with push notifications + +## 0.2.0-alpha+3 - Fix overflow in mentions overlay -## 0.1.23 +## 0.2.0-alpha+2 -- Hotfix +- Add better mime detection -## 0.1.22 - -- Better mime type detection - -## 0.1.21 +## 0.2.0-alpha+1 - Fix video loading and error -## 0.1.20 +## 0.2.0-alpha + +- Offline storage + +- Push notifications + +- Minor bug fixes + +## 0.1.20s - Add message configuration properties to MessageListView diff --git a/README.md b/README.md index dc828ad6..9049b4c0 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,14 @@ The example is available under the [example](https://github.com/GetStream/stream ```yaml dependencies: - stream_chat_flutter: ^0.1.21 + stream_chat_flutter: ^0.1.19 ``` You should then run `flutter packages get` ### Alpha version -Use version `^0.2.0-alpha+1` to use the latest available version. +Use version `^0.2.0-alpha+2` to use the latest available version. Note that this is still an alpha version. There may be some bugs, and the API can change in breaking ways. @@ -65,10 +65,11 @@ Follow [these instructions](https://pub.dev/packages/image_picker#ios) to check ### Business logic components -We provide 2 Widgets dedicated to business logic and state management: +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 @@ -156,6 +157,12 @@ Out of the box, all chat widgets use their default styling, and there are two wa } } ``` + +### 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 diff --git a/analysis_options.yaml b/analysis_options.yaml index fedac90d..3723c0af 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,7 +3,7 @@ include: package:pedantic/analysis_options.yaml analyzer: exclude: - lib/**/*.g.dart - - example/* + - example/** linter: rules: @@ -44,7 +44,7 @@ linter: - package_prefixed_library_names - prefer_is_not_empty # - prefer_mixin # https://github.com/dart-lang/language/issues/32 -# - public_member_api_docs + - public_member_api_docs - slash_for_doc_comments # - sort_constructors_first # - sort_unnamed_constructors_first diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 0f6a5e54..dda667cf 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -64,4 +64,7 @@ dependencies { 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/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 1b2ad157..c5f1e85b 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ FlutterApplication and put your custom class here. --> + + + + 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 new file mode 100644 index 00000000..dd47a03c --- /dev/null +++ b/example/android/app/src/main/kotlin/com/example/example/Application.kt @@ -0,0 +1,27 @@ +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/build.gradle b/example/android/build.gradle index 3100ad2d..70b3637d 100644 --- a/example/android/build.gradle +++ b/example/android/build.gradle @@ -8,6 +8,7 @@ buildscript { dependencies { 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/ios/Notifications/Info.plist b/example/ios/Notifications/Info.plist new file mode 100644 index 00000000..a225b5ca --- /dev/null +++ b/example/ios/Notifications/Info.plist @@ -0,0 +1,31 @@ + + + + + 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 new file mode 100644 index 00000000..7d085536 --- /dev/null +++ b/example/ios/Notifications/NotificationService.swift @@ -0,0 +1,182 @@ +// +// 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/Notifications/Notifications.entitlements b/example/ios/Notifications/Notifications.entitlements new file mode 100644 index 00000000..00390120 --- /dev/null +++ b/example/ios/Notifications/Notifications.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.io.stream.flutter + + + diff --git a/example/ios/Podfile b/example/ios/Podfile index b30a428b..ad84eb7b 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '9.0' +platform :ios, '11.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -63,7 +63,7 @@ target 'Runner' do # Keep pod path relative so it can be checked into Podfile.lock. pod 'Flutter', :path => 'Flutter' - + pod 'StreamChatClient' # Plugin Pods # Prepare symlinks folder. We use symlinks to avoid having Podfile.lock diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 66ae94b1..a3821dbf 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1,23 +1,168 @@ PODS: + - DKImagePickerController/Core (4.2.2): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.2.2) + - DKImagePickerController/PhotoGallery (4.2.2): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.2.2) + - DKPhotoGallery (0.0.14): + - DKPhotoGallery/Core (= 0.0.14) + - DKPhotoGallery/Model (= 0.0.14) + - DKPhotoGallery/Preview (= 0.0.14) + - DKPhotoGallery/Resource (= 0.0.14) + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Core (0.0.14): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Model (0.0.14): + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Preview (0.0.14): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SDWebImageFLPlugin + - DKPhotoGallery/Resource (0.0.14): + - SDWebImage + - SDWebImageFLPlugin - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery - Flutter + - Firebase/Core (6.20.0): + - Firebase/CoreOnly + - FirebaseAnalytics (= 6.3.1) + - Firebase/CoreOnly (6.20.0): + - FirebaseCore (= 6.6.4) + - Firebase/Messaging (6.20.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 4.3.0) + - firebase_messaging (0.0.1): + - Firebase/Core + - Firebase/Messaging + - Flutter + - FirebaseAnalytics (6.3.1): + - FirebaseCore (~> 6.6) + - FirebaseInstallations (~> 1.1) + - GoogleAppMeasurement (= 6.3.1) + - GoogleUtilities/AppDelegateSwizzler (~> 6.0) + - GoogleUtilities/MethodSwizzler (~> 6.0) + - GoogleUtilities/Network (~> 6.0) + - "GoogleUtilities/NSData+zlib (~> 6.0)" + - nanopb (= 0.3.9011) + - FirebaseAnalyticsInterop (1.5.0) + - FirebaseCore (6.6.4): + - FirebaseCoreDiagnostics (~> 1.2) + - FirebaseCoreDiagnosticsInterop (~> 1.2) + - GoogleUtilities/Environment (~> 6.5) + - GoogleUtilities/Logger (~> 6.5) + - FirebaseCoreDiagnostics (1.2.2): + - FirebaseCoreDiagnosticsInterop (~> 1.2) + - GoogleDataTransportCCTSupport (~> 2.0) + - GoogleUtilities/Environment (~> 6.5) + - GoogleUtilities/Logger (~> 6.5) + - nanopb (~> 0.3.901) + - FirebaseCoreDiagnosticsInterop (1.2.0) + - FirebaseInstallations (1.1.0): + - FirebaseCore (~> 6.6) + - GoogleUtilities/UserDefaults (~> 6.5) + - PromisesObjC (~> 1.2) + - FirebaseInstanceID (4.3.2): + - FirebaseCore (~> 6.6) + - FirebaseInstallations (~> 1.0) + - GoogleUtilities/Environment (~> 6.5) + - GoogleUtilities/UserDefaults (~> 6.5) + - FirebaseMessaging (4.3.0): + - FirebaseAnalyticsInterop (~> 1.5) + - FirebaseCore (~> 6.6) + - FirebaseInstanceID (~> 4.3) + - GoogleUtilities/AppDelegateSwizzler (~> 6.5) + - GoogleUtilities/Environment (~> 6.5) + - GoogleUtilities/Reachability (~> 6.5) + - GoogleUtilities/UserDefaults (~> 6.5) + - Protobuf (>= 3.9.2, ~> 3.9) + - FLAnimatedImage (1.0.12) - Flutter (1.0.0) + - flutter_apns (0.0.1): + - Flutter - flutter_keyboard_visibility (0.7.0): - Flutter + - flutter_local_notifications (0.0.1): + - Flutter - flutter_plugin_android_lifecycle (0.0.1): - Flutter - FMDB (2.7.5): - FMDB/standard (= 2.7.5) - FMDB/standard (2.7.5) + - GoogleAppMeasurement (6.3.1): + - GoogleUtilities/AppDelegateSwizzler (~> 6.0) + - GoogleUtilities/MethodSwizzler (~> 6.0) + - GoogleUtilities/Network (~> 6.0) + - "GoogleUtilities/NSData+zlib (~> 6.0)" + - nanopb (= 0.3.9011) + - GoogleDataTransport (5.0.0) + - GoogleDataTransportCCTSupport (2.0.0): + - GoogleDataTransport (~> 5.0) + - nanopb (~> 0.3.901) + - GoogleUtilities/AppDelegateSwizzler (6.5.2): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (6.5.2) + - GoogleUtilities/Logger (6.5.2): + - GoogleUtilities/Environment + - GoogleUtilities/MethodSwizzler (6.5.2): + - GoogleUtilities/Logger + - GoogleUtilities/Network (6.5.2): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (6.5.2)" + - GoogleUtilities/Reachability (6.5.2): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (6.5.2): + - GoogleUtilities/Logger + - GzipSwift (5.1.1) - image_picker (0.0.1): - Flutter + - moor_ffi (0.0.1): + - Flutter + - nanopb (0.3.9011): + - nanopb/decode (= 0.3.9011) + - nanopb/encode (= 0.3.9011) + - nanopb/decode (0.3.9011) + - nanopb/encode (0.3.9011) - path_provider (0.0.1): - Flutter - path_provider_macos (0.0.1): - Flutter + - PromisesObjC (1.2.8) + - Protobuf (3.11.4) + - ReachabilitySwift (5.0.0) + - SDWebImage (5.8.0): + - SDWebImage/Core (= 5.8.0) + - SDWebImage/Core (5.8.0) + - SDWebImageFLPlugin (0.4.0): + - FLAnimatedImage (>= 1.0.11) + - SDWebImage/Core (~> 5.6) + - shared_preferences (0.0.1): + - Flutter + - shared_preferences_macos (0.0.1): + - Flutter + - shared_preferences_web (0.0.1): + - Flutter - sqflite (0.0.1): - Flutter - FMDB (~> 2.7.2) + - Starscream (3.1.1) + - StreamChatClient (2.0.1): + - GzipSwift (~> 5.1) + - ReachabilitySwift (~> 5.0) + - Starscream (~> 3.1) - url_launcher (0.0.1): - Flutter - url_launcher_macos (0.0.1): @@ -33,13 +178,21 @@ PODS: DEPENDENCIES: - file_picker (from `.symlinks/plugins/file_picker/ios`) + - firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`) - Flutter (from `Flutter`) + - flutter_apns (from `.symlinks/plugins/flutter_apns/ios`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_plugin_android_lifecycle (from `.symlinks/plugins/flutter_plugin_android_lifecycle/ios`) - image_picker (from `.symlinks/plugins/image_picker/ios`) + - moor_ffi (from `.symlinks/plugins/moor_ffi/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) - path_provider_macos (from `.symlinks/plugins/path_provider_macos/ios`) + - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) + - shared_preferences_macos (from `.symlinks/plugins/shared_preferences_macos/ios`) + - shared_preferences_web (from `.symlinks/plugins/shared_preferences_web/ios`) - sqflite (from `.symlinks/plugins/sqflite/ios`) + - StreamChatClient - url_launcher (from `.symlinks/plugins/url_launcher/ios`) - url_launcher_macos (from `.symlinks/plugins/url_launcher_macos/ios`) - url_launcher_web (from `.symlinks/plugins/url_launcher_web/ios`) @@ -49,23 +202,62 @@ DEPENDENCIES: SPEC REPOS: trunk: + - DKImagePickerController + - DKPhotoGallery + - Firebase + - FirebaseAnalytics + - FirebaseAnalyticsInterop + - FirebaseCore + - FirebaseCoreDiagnostics + - FirebaseCoreDiagnosticsInterop + - FirebaseInstallations + - FirebaseInstanceID + - FirebaseMessaging + - FLAnimatedImage - FMDB + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleDataTransportCCTSupport + - GoogleUtilities + - GzipSwift + - nanopb + - PromisesObjC + - Protobuf + - ReachabilitySwift + - SDWebImage + - SDWebImageFLPlugin + - Starscream + - StreamChatClient EXTERNAL SOURCES: file_picker: :path: ".symlinks/plugins/file_picker/ios" + firebase_messaging: + :path: ".symlinks/plugins/firebase_messaging/ios" Flutter: :path: Flutter + flutter_apns: + :path: ".symlinks/plugins/flutter_apns/ios" flutter_keyboard_visibility: :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" flutter_plugin_android_lifecycle: :path: ".symlinks/plugins/flutter_plugin_android_lifecycle/ios" image_picker: :path: ".symlinks/plugins/image_picker/ios" + moor_ffi: + :path: ".symlinks/plugins/moor_ffi/ios" path_provider: :path: ".symlinks/plugins/path_provider/ios" path_provider_macos: :path: ".symlinks/plugins/path_provider_macos/ios" + shared_preferences: + :path: ".symlinks/plugins/shared_preferences/ios" + shared_preferences_macos: + :path: ".symlinks/plugins/shared_preferences_macos/ios" + shared_preferences_web: + :path: ".symlinks/plugins/shared_preferences_web/ios" sqflite: :path: ".symlinks/plugins/sqflite/ios" url_launcher: @@ -82,22 +274,54 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock/ios" SPEC CHECKSUMS: - file_picker: 408623be2125b79a4539cf703be3d4b3abe5e245 + DKImagePickerController: 4a3e7948a848c4348e600b3fe5ce41478835fa10 + DKPhotoGallery: 0290d32343574f06eaa4c26f8f2f8a1035e916be + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 + Firebase: fe7f74012742ab403451dd283e6909b8f1fb348a + firebase_messaging: 21344b3b3a7d9d325d63a70e3750c0c798fe1e03 + FirebaseAnalytics: 572e467f3d977825266e8ccd52674aa3e6f47eac + FirebaseAnalyticsInterop: 3f86269c38ae41f47afeb43ebf32a001f58fcdae + FirebaseCore: ed0a24c758a57c2b88c5efa8e6a8195e868af589 + FirebaseCoreDiagnostics: e9b4cd8ba60dee0f2d13347332e4b7898cca5b61 + FirebaseCoreDiagnosticsInterop: 296e2c5f5314500a850ad0b83e9e7c10b011a850 + FirebaseInstallations: 575cd32f2aec0feeb0e44f5d0110a09e5e60b47b + FirebaseInstanceID: 7ee0d6777013bb952f377b41965bf132b6a075be + FirebaseMessaging: 4ec33842d36b3319e062e51fb8b35a74f726950d + FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31 Flutter: 0e3d915762c693b495b44d77113d4970485de6ec + flutter_apns: f516b118e423fe7c0a38771180549c4d6cb67c2f flutter_keyboard_visibility: 6195387fb6d8f46e5cd6dda4a4154e41f800f545 - flutter_plugin_android_lifecycle: 47de533a02850f070f5696a623995e93eddcdb9b + flutter_local_notifications: 9e4738ce2471c5af910d961a6b7eadcf57c50186 + flutter_plugin_android_lifecycle: dc0b544e129eebb77a6bfb1239d4d1c673a60a35 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a - image_picker: e3eacd46b94694dde7cf2705955cece853aa1a8f - path_provider: fb74bd0465e96b594bb3b5088ee4a4e7bb1f2a9d + GoogleAppMeasurement: c29d405ff76e18551b5d158eaba6753fda8c7542 + GoogleDataTransport: a857c6a002d201b524dd4bc2ed7e7355ed07e785 + GoogleDataTransportCCTSupport: 32f75fbe904c82772fcbb6b6bd4525bfb6f2a862 + GoogleUtilities: ad0f3b691c67909d03a3327cc205222ab8f42e0e + GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa + image_picker: 66aa71bc96850a90590a35d4c4a2907b0d823109 + moor_ffi: d66c9470c18e9cb333423bbcb493c105c6c774c6 + nanopb: 18003b5e52dab79db540fe93fe9579f399bd1ccd + path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0 + PromisesObjC: c119f3cd559f50b7ae681fa59dc1acd19173b7e6 + Protobuf: 176220c526ad8bd09ab1fb40a978eac3fef665f7 + ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825 + SDWebImage: 84000f962cbfa70c07f19d2234cbfcf5d779b5dc + SDWebImageFLPlugin: 6c2295fb1242d44467c6c87dc5db6b0a13228fd8 + shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d + shared_preferences_macos: f3f29b71ccbb56bf40c9dd6396c9acf15e214087 + shared_preferences_web: 141cce0c3ed1a1c5bf2a0e44f52d31eeb66e5ea9 sqflite: 4001a31ff81d210346b500c55b17f4d6c7589dd0 - url_launcher: a1c0cc845906122c4784c542523d8cacbded5626 + Starscream: 4bb2f9942274833f7b4d296a55504dcfc7edb7b0 + StreamChatClient: 91b0f585e7dc92ade58e657daffafb16d485b2a6 + url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef url_launcher_macos: fd7894421cd39320dce5f292fc99ea9270b2a313 url_launcher_web: e5527357f037c87560776e36436bf2b0288b965c - video_player: 69c5f029fac4ffe4fc8a85ea7f7b793709661549 + video_player: 9cc823b1d9da7e8427ee591e8438bfbcde500e6e video_player_web: da8cadb8274ed4f8dbee8d7171b420dedd437ce7 wakelock: 0d4a70faf8950410735e3f61fb15d517c8a6efc4 -PODFILE CHECKSUM: 1b66dae606f75376c5f2135a8290850eeb09ae83 +PODFILE CHECKSUM: 5cc7e2f1316491ee530029e2e8391f4100d41fbd COCOAPODS: 1.8.4 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 42d1671f..456c5e1f 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -7,28 +7,45 @@ 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 */; }; - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7DEC2743BD66C91B700A3B97 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BB2E5E4E236267EDF0D8817 /* Pods_Runner.framework */; }; - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 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; dstPath = ""; dstSubfolderSpec = 10; files = ( - 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, - 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -36,11 +53,15 @@ /* 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 = ""; }; - 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; 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 = ""; }; @@ -49,7 +70,6 @@ 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 = ""; }; - 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; 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 = ""; }; @@ -58,12 +78,17 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 0BC14C4A242B5A7A0028DE94 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, - 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, 7DEC2743BD66C91B700A3B97 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -71,12 +96,20 @@ /* 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 = ( - 3B80C3931E831B6300D905FE /* App.framework */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEBA1CF902C7004384FC /* Flutter.framework */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, @@ -87,8 +120,9 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( - 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + 9740EEB11CF90186004384FC /* Flutter */, + 0BC14C4E242B5A7A0028DE94 /* Notifications */, 97C146EF1CF9000F007C117D /* Products */, CF168B61BAB91958681C7C21 /* Pods */, BC09A38346C8B2CD72199469 /* Frameworks */, @@ -99,6 +133,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 0BC14C4D242B5A7A0028DE94 /* Notifications.appex */, ); name = Products; sourceTree = ""; @@ -106,6 +141,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 0BC14C5B242B5FF50028DE94 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -147,6 +183,23 @@ /* 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" */; @@ -159,10 +212,12 @@ 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 5702861DACEDB848A3E454E8 /* [CP] Embed Pods Frameworks */, + 0BC14C55242B5A7A0028DE94 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( + 0BC14C53242B5A7A0028DE94 /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -175,13 +230,20 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { + LastSwiftUpdateCheck = 1140; LastUpgradeCheck = 1020; ORGANIZATIONNAME = "The Chromium Authors"; TargetAttributes = { + 0BC14C4C242B5A7A0028DE94 = { + CreatedOnToolsVersion = 11.4; + DevelopmentTeam = EHV7XZLAHA; + ProvisioningStyle = Manual; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; DevelopmentTeam = EHV7XZLAHA; LastSwiftMigration = 1100; + ProvisioningStyle = Manual; }; }; }; @@ -199,11 +261,19 @@ 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; @@ -230,7 +300,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; 5702861DACEDB848A3E454E8 /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; @@ -286,6 +356,14 @@ /* 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; @@ -297,6 +375,14 @@ }; /* 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; @@ -317,6 +403,90 @@ /* 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; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; @@ -372,8 +542,12 @@ 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; @@ -382,13 +556,15 @@ "$(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.stream.flutter; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter; 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"; @@ -507,8 +683,12 @@ 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; @@ -517,13 +697,15 @@ "$(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.stream.flutter; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter; 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; @@ -535,8 +717,12 @@ 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; @@ -545,13 +731,15 @@ "$(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.stream.flutter; + PRODUCT_BUNDLE_IDENTIFIER = io.getstream.flutter; 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"; @@ -561,6 +749,16 @@ /* 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/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift index 70693e4a..3f549155 100644 --- a/example/ios/Runner/AppDelegate.swift +++ b/example/ios/Runner/AppDelegate.swift @@ -3,11 +3,39 @@ 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) - } + let sharedDefaults = UserDefaults(suiteName: "group.io.stream.flutter") + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + if let messageQueue = sharedDefaults?.stringArray(forKey: "messageQueue") { + UserDefaults.standard.setValue(messageQueue, forKey: "flutter.messageQueue") + sharedDefaults?.removeObject(forKey: "messageQueue") + } + + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + override func applicationDidEnterBackground(_ application: UIApplication) { + if let apiKey = UserDefaults.standard.string(forKey: "flutter.KEY_API_KEY") { + sharedDefaults?.setValue(apiKey, forKey: "KEY_API_KEY") + } + + if let token = UserDefaults.standard.string(forKey: "flutter.KEY_TOKEN") { + sharedDefaults?.setValue(token, forKey: "KEY_TOKEN") + } + + if let userId = UserDefaults.standard.string(forKey: "flutter.KEY_USER_ID") { + sharedDefaults?.setValue(userId, forKey: "KEY_USER_ID") + } + } + + override func applicationWillEnterForeground(_ application: UIApplication) { + if let messageQueue = sharedDefaults?.stringArray(forKey: "messageQueue") { + UserDefaults.standard.setValue(messageQueue, forKey: "flutter.messageQueue") + sharedDefaults?.removeObject(forKey: "messageQueue") + } + } } diff --git a/example/ios/Runner/Runner.entitlements b/example/ios/Runner/Runner.entitlements new file mode 100644 index 00000000..967ba7f2 --- /dev/null +++ b/example/ios/Runner/Runner.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.security.application-groups + + group.io.stream.flutter + + + diff --git a/example/lib/custom_message.dart b/example/lib/custom_message.dart index 8d9e726a..5da4541a 100644 --- a/example/lib/custom_message.dart +++ b/example/lib/custom_message.dart @@ -16,13 +16,13 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// or to retrieve outer scope needed such as messages from the [Channel.state]. void main() async { final client = Client( - 'b67pax5b2wdq', + 's2dxdhpxd94g', logLevel: Level.INFO, ); await client.setUser( - User(id: 'falling-mountain-7'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZmFsbGluZy1tb3VudGFpbi03In0.AKgRXHMQQMz6vJAKszXdY8zMFfsAgkoUeZHlI-Szz9E', + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); runApp(MyApp(client)); @@ -36,12 +36,11 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - home: Container( - child: StreamChat( - client: client, - child: ChannelListPage(), - ), + builder: (context, child) => StreamChat( + child: child, + client: client, ), + home: ChannelListPage(), ); } } @@ -50,17 +49,19 @@ 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, + body: ChannelsBloc( + child: ChannelListView( + filter: { + 'members': { + '\$in': [StreamChat.of(context).user.id], + } + }, + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), ), - channelWidget: ChannelPage(), ), ); } @@ -88,7 +89,12 @@ class ChannelPage extends StatelessWidget { ); } - Widget _messageBuilder(context, message, index) { + Widget _messageBuilder( + BuildContext context, + MessageDetails details, + List messages, + ) { + final message = details.message; final isCurrentUser = StreamChat.of(context).user.id == message.user.id; final textAlign = isCurrentUser ? TextAlign.right : TextAlign.left; final color = isCurrentUser ? Colors.blueGrey : Colors.blue; diff --git a/example/lib/custom_theme.dart b/example/lib/custom_theme.dart index 078faeb8..21db1c6c 100644 --- a/example/lib/custom_theme.dart +++ b/example/lib/custom_theme.dart @@ -20,13 +20,13 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// You can perform these more granular style changes using [StreamChatTheme.copyWith]. void main() async { final client = Client( - 'b67pax5b2wdq', + 's2dxdhpxd94g', logLevel: Level.INFO, ); await client.setUser( - User(id: 'falling-mountain-7'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZmFsbGluZy1tb3VudGFpbi03In0.AKgRXHMQQMz6vJAKszXdY8zMFfsAgkoUeZHlI-Szz9E', + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); runApp(MyApp(client)); @@ -45,23 +45,22 @@ class MyApp extends StatelessWidget { return MaterialApp( theme: theme, - home: Container( - child: StreamChat( - streamChatThemeData: StreamChatThemeData.fromTheme(theme).copyWith( - ownMessageTheme: MessageTheme( - messageBackgroundColor: Colors.black, - messageText: TextStyle( - color: Colors.white, - ), - avatarTheme: AvatarTheme( - borderRadius: BorderRadius.circular(8), - ), + 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), ), ), - client: client, - child: ChannelListPage(), ), ), + home: ChannelListPage(), ); } } @@ -70,17 +69,19 @@ 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, + body: ChannelsBloc( + child: ChannelListView( + filter: { + 'members': { + '\$in': [StreamChat.of(context).user.id], + } + }, + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), ), - channelWidget: ChannelPage(), ), ); } diff --git a/example/lib/customize_channel_preview.dart b/example/lib/customize_channel_preview.dart index 949624d1..55da346c 100644 --- a/example/lib/customize_channel_preview.dart +++ b/example/lib/customize_channel_preview.dart @@ -21,13 +21,13 @@ 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( - 'b67pax5b2wdq', + 's2dxdhpxd94g', logLevel: Level.INFO, ); await client.setUser( - User(id: 'falling-mountain-7'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZmFsbGluZy1tb3VudGFpbi03In0.AKgRXHMQQMz6vJAKszXdY8zMFfsAgkoUeZHlI-Szz9E', + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); runApp(MyApp(client)); @@ -41,12 +41,11 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - home: Container( - child: StreamChat( - client: client, - child: ChannelListPage(), - ), + builder: (context, child) => StreamChat( + child: child, + client: client, ), + home: ChannelListPage(), ); } } @@ -55,29 +54,31 @@ class ChannelListPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - body: ChannelListView( - filter: { - 'members': { - '\$in': [StreamChat.of(context).user.id], + body: ChannelsBloc( + child: ChannelListView( + filter: { + 'members': { + '\$in': [StreamChat.of(context).user.id], + }, }, - }, - channelPreviewBuilder: _channelPreviewBuilder, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( - limit: 20, + channelPreviewBuilder: _channelPreviewBuilder, + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), ), - channelWidget: ChannelPage(), ), ); } Widget _channelPreviewBuilder(BuildContext context, Channel channel) { final lastMessage = channel.state.messages.reversed.firstWhere( - (message) => message.type != "deleted", + (message) => message.type != 'deleted', 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( diff --git a/example/lib/customize_message_widget.dart b/example/lib/customize_message_widget.dart new file mode 100644 index 00000000..fc614125 --- /dev/null +++ b/example/lib/customize_message_widget.dart @@ -0,0 +1,133 @@ +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/lib/main.dart b/example/lib/main.dart index 32af2bd3..f81c61be 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,10 +1,75 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_apns/apns.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' + hide Message; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +void showLocalNotification(Message message, ChannelModel channel) async { + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + final initializationSettingsAndroid = + AndroidInitializationSettings('launch_background'); + final initializationSettingsIOS = IOSInitializationSettings(); + final initializationSettings = InitializationSettings( + initializationSettingsAndroid, + initializationSettingsIOS, + ); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + await flutterLocalNotificationsPlugin.show( + message.id.hashCode, + '${message.user.name} @ ${channel.name}', + message.text, + NotificationDetails( + AndroidNotificationDetails( + 'message channel', + 'Message channel', + 'Channel used for showing messages', + priority: Priority.High, + importance: Importance.High, + ), + IOSNotificationDetails(), + ), + ); +} + +Future backgroundHandler(Map notification) async { + final messageId = notification['data']['message_id']; + + final notificationData = + await NotificationService.getAndStoreMessage(messageId); + + showLocalNotification( + notificationData.message, + notificationData.channel, + ); +} + +void _initNotifications(Client client) { + final connector = createPushConnector(); + connector.configure( + onBackgroundMessage: backgroundHandler, + ); + + connector.requestNotificationPermissions(); + connector.token.addListener(() { + if (connector.token.value != null) { + client.addDevice( + connector.token.value, + Platform.isAndroid ? 'firebase' : 'apn', + ); + } + }); +} + void main() async { final client = Client( 's2dxdhpxd94g', logLevel: Level.INFO, + showLocalNotification: Platform.isAndroid ? showLocalNotification : null, + persistenceEnabled: true, ); await client.setUser( @@ -12,6 +77,8 @@ void main() async { 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); + _initNotifications(client); + runApp(MyApp(client)); } @@ -26,12 +93,13 @@ class MyApp extends StatelessWidget { theme: ThemeData.light(), darkTheme: ThemeData.dark(), themeMode: ThemeMode.system, - home: Container( - child: StreamChat( + builder: (context, widget) { + return StreamChat( + child: widget, client: client, - child: ChannelListPage(), - ), - ), + ); + }, + home: ChannelListPage(), ); } } @@ -40,17 +108,19 @@ 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, + body: ChannelsBloc( + child: ChannelListView( + filter: { + 'members': { + '\$in': [StreamChat.of(context).user.id], + } + }, + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), ), - channelWidget: ChannelPage(), ), ); } diff --git a/example/lib/multiple_conversation.dart b/example/lib/multiple_conversation.dart index 56e5ed1e..647ae7dd 100644 --- a/example/lib/multiple_conversation.dart +++ b/example/lib/multiple_conversation.dart @@ -20,13 +20,13 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// [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( - 'b67pax5b2wdq', + 's2dxdhpxd94g', logLevel: Level.INFO, ); await client.setUser( - User(id: 'falling-mountain-7'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZmFsbGluZy1tb3VudGFpbi03In0.AKgRXHMQQMz6vJAKszXdY8zMFfsAgkoUeZHlI-Szz9E', + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); runApp(MyApp(client)); @@ -40,12 +40,11 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( - home: Container( - child: StreamChat( - client: client, - child: ChannelListPage(), - ), + builder: (context, child) => StreamChat( + client: client, + child: child, ), + home: ChannelListPage(), ); } } @@ -54,17 +53,19 @@ 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, + body: ChannelsBloc( + child: ChannelListView( + filter: { + 'members': { + '\$in': [StreamChat.of(context).user.id], + } + }, + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), ), - channelWidget: ChannelPage(), ), ); } diff --git a/example/lib/single_conversation.dart b/example/lib/single_conversation.dart index a7863fc0..5f5d03ea 100644 --- a/example/lib/single_conversation.dart +++ b/example/lib/single_conversation.dart @@ -26,13 +26,13 @@ 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( - 'b67pax5b2wdq', + 's2dxdhpxd94g', logLevel: Level.INFO, ); await client.setUser( - User(id: 'falling-mountain-7'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZmFsbGluZy1tb3VudGFpbi03In0.AKgRXHMQQMz6vJAKszXdY8zMFfsAgkoUeZHlI-Szz9E', + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); final channel = client.channel('messaging', id: 'godevs'); diff --git a/example/lib/threads.dart b/example/lib/threads.dart index 092a5c7f..f89e6fce 100644 --- a/example/lib/threads.dart +++ b/example/lib/threads.dart @@ -11,13 +11,13 @@ 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( - 'b67pax5b2wdq', + 's2dxdhpxd94g', logLevel: Level.INFO, ); await client.setUser( - User(id: 'falling-mountain-7'), - 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiZmFsbGluZy1tb3VudGFpbi03In0.AKgRXHMQQMz6vJAKszXdY8zMFfsAgkoUeZHlI-Szz9E', + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', ); runApp(MyApp(client)); @@ -31,11 +31,12 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + builder: (context, child) => StreamChat( + child: child, + client: client, + ), home: Container( - child: StreamChat( - client: client, - child: ChannelListPage(), - ), + child: ChannelListPage(), ), ); } @@ -45,17 +46,19 @@ 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, + body: ChannelsBloc( + child: ChannelListView( + filter: { + 'members': { + '\$in': [StreamChat.of(context).user.id], + } + }, + sort: [SortOption('last_message_at')], + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), ), - channelWidget: ChannelPage(), ), ); } diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 2968dbca..266926c8 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,6 +11,8 @@ dependencies: sdk: flutter stream_chat_flutter: path: ../ + flutter_apns: ^1.1.0 + flutter_local_notifications: ^1.4.3 dev_dependencies: flutter_test: diff --git a/lib/src/attachment_actions.dart b/lib/src/attachment_actions.dart new file mode 100644 index 00000000..f101ef53 --- /dev/null +++ b/lib/src/attachment_actions.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class AttachmentActions extends StatelessWidget { + final Attachment attachment; + final Message message; + + const AttachmentActions({ + Key key, + this.attachment, + this.message, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final streamChannel = StreamChannel.of(context); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: attachment.actions?.map((action) { + if (action.style == 'primary') { + return FlatButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Text('${action.text}'), + color: action.style == 'primary' + ? StreamChatTheme.of(context).accentColor + : null, + textColor: Colors.white, + onPressed: () { + streamChannel.channel.sendAction(message, { + action.name: action.value, + }); + }, + ); + } + return OutlineButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Text('${action.text}'), + color: StreamChatTheme.of(context).accentColor, + onPressed: () { + streamChannel.channel.sendAction(message, { + action.name: action.value, + }); + }, + ); + })?.toList(), + ); + } +} diff --git a/lib/src/attachment_error.dart b/lib/src/attachment_error.dart new file mode 100644 index 00000000..e08867b8 --- /dev/null +++ b/lib/src/attachment_error.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; + +class AttachmentError extends StatelessWidget { + final Attachment attachment; + final Size size; + + const AttachmentError({ + Key key, + @required this.attachment, + this.size, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (attachment.localUri != null) { + return Image.file( + File(attachment.localUri.path), + ); + } + return Center( + child: Container( + width: size?.width, + height: size?.height, + color: Color(0xffd0021B).withOpacity(.1), + child: Center( + child: Icon( + Icons.error_outline, + color: Colors.white, + ), + ), + ), + ); + } +} diff --git a/lib/src/attachment_title.dart b/lib/src/attachment_title.dart new file mode 100644 index 00000000..ab9cd2da --- /dev/null +++ b/lib/src/attachment_title.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'stream_chat_theme.dart'; +import 'utils.dart'; + +class AttachmentTitle extends StatelessWidget { + const AttachmentTitle({ + Key key, + @required this.attachment, + @required this.messageTheme, + }) : super(key: key); + + final MessageTheme messageTheme; + final Attachment attachment; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + if (attachment.titleLink != null) { + launchURL(context, attachment.titleLink); + } + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + attachment.title, + overflow: TextOverflow.ellipsis, + style: messageTheme.messageText.copyWith( + color: StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold, + ), + ), + if (attachment.titleLink != null || attachment.ogScrapeUrl != null) + Text( + Uri.parse(attachment.titleLink ?? attachment.ogScrapeUrl) + .authority + .split('.') + .reversed + .take(2) + .toList() + .reversed + .join('.'), + style: messageTheme.messageText, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/channel_header.dart b/lib/src/channel_header.dart index 5a7a146c..26668697 100644 --- a/lib/src/channel_header.dart +++ b/lib/src/channel_header.dart @@ -157,9 +157,6 @@ class ChannelHeader extends StatelessWidget implements PreferredSizeWidget { child: Icon( Icons.arrow_back_ios, size: 15, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, ), ), ); diff --git a/lib/src/channel_image.dart b/lib/src/channel_image.dart index a3918d9d..bef666db 100644 --- a/lib/src/channel_image.dart +++ b/lib/src/channel_image.dart @@ -43,6 +43,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. /// Modify it to change the widget appearance. class ChannelImage extends StatelessWidget { + /// Instantiate a new ChannelImage const ChannelImage({ Key key, this.channel, diff --git a/lib/src/channel_list_view.dart b/lib/src/channel_list_view.dart index 1d5992c7..782dd772 100644 --- a/lib/src/channel_list_view.dart +++ b/lib/src/channel_list_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/channels_bloc.dart'; import '../stream_chat_flutter.dart'; import 'channel_preview.dart'; @@ -46,6 +47,7 @@ typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); /// 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, @@ -53,12 +55,14 @@ class ChannelListView extends StatefulWidget { this.sort, this.pagination, this.onChannelTap, + this.onChannelLongPress, this.channelWidget, this.channelPreviewBuilder, this.errorBuilder, this.onImageTap, }) : super(key: key); + /// The builder that will be used in case of error final Widget Function(Error error) errorBuilder; /// The query filters to use. @@ -89,6 +93,9 @@ class ChannelListView extends StatefulWidget { /// 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; @@ -102,16 +109,17 @@ class ChannelListView extends StatefulWidget { _ChannelListViewState createState() => _ChannelListViewState(); } -class _ChannelListViewState extends State { +class _ChannelListViewState extends State + with WidgetsBindingObserver { final ScrollController _scrollController = ScrollController(); @override Widget build(BuildContext context) { - final streamChat = StreamChat.of(context); + final channelsProvider = ChannelsBloc.of(context); + return RefreshIndicator( onRefresh: () async { - streamChat.clearChannels(); - return streamChat.queryChannels( + return channelsProvider.queryChannels( filter: widget.filter, sortOptions: widget.sort, paginationParams: widget.pagination, @@ -119,7 +127,7 @@ class _ChannelListViewState extends State { ); }, child: StreamBuilder>( - stream: streamChat.channelsStream, + stream: channelsProvider.channelsStream, builder: (context, snapshot) { if (snapshot.hasError) { if (snapshot.error is Error) { @@ -157,7 +165,7 @@ class _ChannelListViewState extends State { TextSpan(text: 'Error loading channels'), ], ), - style: Theme.of(context).textTheme.title, + style: Theme.of(context).textTheme.headline6, ), Padding( padding: const EdgeInsets.only( @@ -167,7 +175,7 @@ class _ChannelListViewState extends State { ), FlatButton( onPressed: () { - streamChat.queryChannels( + channelsProvider.queryChannels( filter: widget.filter, sortOptions: widget.sort, paginationParams: widget.pagination, @@ -215,7 +223,7 @@ class _ChannelListViewState extends State { i = i ~/ 2; - final streamChat = StreamChat.of(context); + final channelsProvider = ChannelsBloc.of(context); if (i < channels.length) { final channel = channels[i]; @@ -267,6 +275,7 @@ class _ChannelListViewState extends State { ); } else { child = ChannelPreview( + onLongPress: widget.onChannelLongPress, channel: channel, onImageTap: widget.onImageTap != null ? () { @@ -283,15 +292,27 @@ class _ChannelListViewState extends State { ), ); } else { - return _buildQueryProgressIndicator(context, streamChat); + return _buildQueryProgressIndicator(context, channelsProvider); } } - Widget _buildQueryProgressIndicator(context, StreamChatState streamChat) { + Widget _buildQueryProgressIndicator( + context, ChannelsBlocState channelsProvider) { return StreamBuilder( - stream: streamChat.queryChannelsLoading, + stream: channelsProvider.queryChannelsLoading, initialData: false, builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: Color(0xffd0021B).withAlpha(26), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text('Error loading channels'), + ), + ), + ); + } return Container( height: 100, padding: EdgeInsets.all(32), @@ -312,14 +333,15 @@ class _ChannelListViewState extends State { ); } - void _listenChannelPagination(StreamChatState streamChat) { + void _listenChannelPagination(ChannelsBlocState channelsProvider) { if (_scrollController.position.maxScrollExtent == - _scrollController.offset) { - streamChat.queryChannels( + _scrollController.offset && + _scrollController.offset != 0) { + channelsProvider.queryChannels( filter: widget.filter, sortOptions: widget.sort, paginationParams: widget.pagination.copyWith( - offset: streamChat.channels.length, + offset: channelsProvider.channels.length, ), options: widget.options, ); @@ -330,8 +352,11 @@ class _ChannelListViewState extends State { void initState() { super.initState(); - final streamChat = StreamChat.of(context); - streamChat.queryChannels( + final channelsBloc = ChannelsBloc.of(context); + + WidgetsBinding.instance.addObserver(this); + + channelsBloc.queryChannels( filter: widget.filter, sortOptions: widget.sort, paginationParams: widget.pagination, @@ -339,7 +364,34 @@ class _ChannelListViewState extends State { ); _scrollController.addListener(() { - _listenChannelPagination(streamChat); + channelsBloc.queryChannelsLoading.first.then((loading) { + if (!loading) { + _listenChannelPagination(channelsBloc); + } + }); + }); + + final client = StreamChat.of(context).client; + + client + .on( + EventType.connectionRecovered, + EventType.notificationAddedToChannel, + EventType.channelVisible, + ) + .listen((event) { + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); }); } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } } diff --git a/lib/src/channel_name.dart b/lib/src/channel_name.dart index 2200b351..ef7599b7 100644 --- a/lib/src/channel_name.dart +++ b/lib/src/channel_name.dart @@ -8,6 +8,7 @@ import 'stream_channel.dart'; /// /// 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, diff --git a/lib/src/channel_preview.dart b/lib/src/channel_preview.dart index 9b949a68..5a3432aa 100644 --- a/lib/src/channel_preview.dart +++ b/lib/src/channel_preview.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/unread_indicator.dart'; import '../stream_chat_flutter.dart'; import 'channel_name.dart'; @@ -22,6 +23,9 @@ 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; @@ -32,6 +36,7 @@ class ChannelPreview extends StatelessWidget { @required this.channel, Key key, this.onTap, + this.onLongPress, this.onImageTap, }) : super(key: key); @@ -39,7 +44,14 @@ class ChannelPreview extends StatelessWidget { Widget build(BuildContext context) { return ListTile( onTap: () { - onTap(channel); + if (onTap != null) { + onTap(channel); + } + }, + onLongPress: () { + if (onLongPress != null) { + onLongPress(channel); + } }, leading: ChannelImage( onTap: onImageTap, @@ -53,16 +65,8 @@ class ChannelPreview extends StatelessWidget { children: [ _buildDate(context), if (channel.state.unreadCount > 0) - Padding( - padding: const EdgeInsets.only(left: 8.0), - child: CircleAvatar( - backgroundColor: Color(0xffd0021B), - radius: 6, - child: Text( - '${channel.state.unreadCount}', - style: TextStyle(fontSize: 8), - ), - ), + UnreadIndicator( + channel: channel, ), ], ), @@ -118,16 +122,15 @@ class ChannelPreview extends StatelessWidget { stream: channel.state.messagesStream, initialData: channel.state.messages, builder: (context, snapshot) { - final messages = snapshot.data; - final lastMessage = messages.isNotEmpty ? messages.last : null; + final lastMessage = channel.state.lastMessage; if (lastMessage == null) { return SizedBox(); } - String text; + var text = lastMessage.text; if (lastMessage.isDeleted) { text = 'This message was deleted.'; - } else { + } else if (lastMessage.attachments != null) { final prefix = lastMessage.attachments .map((e) { if (e.type == 'image') { diff --git a/lib/src/channels_bloc.dart b/lib/src/channels_bloc.dart new file mode 100644 index 00000000..b38c6030 --- /dev/null +++ b/lib/src/channels_bloc.dart @@ -0,0 +1,144 @@ +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'; + +/// Widget dedicated to the management of a channel list with pagination +class ChannelsBloc extends StatefulWidget { + /// The widget child + final Widget child; + + /// Instantiate a new ChannelsBloc + const ChannelsBloc({ + Key key, + this.child, + }) : super(key: key); + + @override + ChannelsBlocState createState() => ChannelsBlocState(); + + /// Use this method to get the current [ChannelsBlocState] instance + static ChannelsBlocState of(BuildContext context) { + ChannelsBlocState streamChatState; + + streamChatState = context.findAncestorStateOfType(); + + if (streamChatState == null) { + throw Exception('You must have a ChannelsBloc widget as anchestor'); + } + + return streamChatState; + } +} + +/// The current state of the [ChannelsBloc] +class ChannelsBlocState extends State + with AutomaticKeepAliveClientMixin { + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + /// The current channel list + List get channels => _channelsController.value; + + /// The current channel list as a stream + Stream> get channelsStream => _channelsController.stream; + + final BehaviorSubject _queryChannelsLoadingController = + BehaviorSubject.seeded(false); + + final BehaviorSubject> _channelsController = + BehaviorSubject.seeded([]); + + /// The stream notifying the state of queryChannel call + Stream get queryChannelsLoading => + _queryChannelsLoadingController.stream; + + /// Calls [client.queryChannels] updating [queryChannelsLoading] stream + Future queryChannels({ + Map filter, + List sortOptions, + PaginationParams paginationParams, + Map options, + bool onlyOffline = false, + }) async { + if (_queryChannelsLoadingController.value == true) { + return; + } + _queryChannelsLoadingController.sink.add(true); + + try { + final clear = paginationParams == null || + paginationParams.offset == null || + paginationParams.offset == 0; + final oldChannels = List.from(channels); + StreamChat.of(context) + .client + .queryChannels( + filter: filter, + sort: sortOptions, + options: options, + paginationParams: paginationParams, + onlyOffline: onlyOffline, + ) + .listen((channels) { + if (clear) { + _channelsController.add(channels); + } else { + final l = oldChannels + channels; + _channelsController.add(l); + } + }, onDone: () { + _queryChannelsLoadingController.sink.add(false); + }, onError: (err, stackTrace) { + print(err); + print(stackTrace); + _queryChannelsLoadingController.addError(err, stackTrace); + }); + } catch (err, stackTrace) { + _queryChannelsLoadingController.addError(err, stackTrace); + } + } + + final List _subscriptions = []; + + @override + void initState() { + super.initState(); + + final client = StreamChat.of(context).client; + + _subscriptions.add(client.on(EventType.messageNew).listen((e) { + final newChannels = List.from(channels ?? []); + final index = newChannels.indexWhere((c) => c.cid == e.cid); + if (index > 0) { + final channel = newChannels.removeAt(index); + newChannels.insert(0, channel); + _channelsController.add(newChannels); + } + })); + + _subscriptions.add(client + .on(EventType.channelDeleted, EventType.notificationRemovedFromChannel) + .listen((e) { + final channel = e.channel; + _channelsController + .add(List.from(channels..removeWhere((c) => c.cid == channel.cid))); + })); + } + + @override + void dispose() { + _channelsController.close(); + _queryChannelsLoadingController.close(); + _subscriptions.forEach((s) => s.cancel()); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/src/date_divider.dart b/lib/src/date_divider.dart new file mode 100644 index 00000000..102c6e14 --- /dev/null +++ b/lib/src/date_divider.dart @@ -0,0 +1,79 @@ +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 new file mode 100644 index 00000000..93fd62ff --- /dev/null +++ b/lib/src/deleted_message.dart @@ -0,0 +1,27 @@ +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 new file mode 100644 index 00000000..05318862 --- /dev/null +++ b/lib/src/file_attachment.dart @@ -0,0 +1,32 @@ +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 new file mode 100644 index 00000000..c6f5804f --- /dev/null +++ b/lib/src/full_screen_image.dart @@ -0,0 +1,29 @@ +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 Container( + child: 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 new file mode 100644 index 00000000..46325b07 --- /dev/null +++ b/lib/src/full_screen_video.dart @@ -0,0 +1,78 @@ +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( + 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 new file mode 100644 index 00000000..62de980b --- /dev/null +++ b/lib/src/giphy_attachment.dart @@ -0,0 +1,93 @@ +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/image_attachment.dart b/lib/src/image_attachment.dart new file mode 100644 index 00000000..a4bd1a0f --- /dev/null +++ b/lib/src/image_attachment.dart @@ -0,0 +1,99 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../stream_chat_flutter.dart'; +import 'attachment_error.dart'; +import 'attachment_title.dart'; +import 'full_screen_image.dart'; +import 'utils.dart'; + +class ImageAttachment extends StatelessWidget { + final Attachment attachment; + final Message message; + final MessageTheme messageTheme; + final Size size; + + const ImageAttachment({ + Key key, + @required this.attachment, + @required this.message, + this.messageTheme, + 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 SizedBox.fromSize( + size: size, + child: Stack( + children: [ + Column( + children: [ + Expanded( + child: 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) + Material( + color: messageTheme.messageBackgroundColor, + child: AttachmentTitle( + messageTheme: messageTheme, + attachment: attachment, + ), + ), + ], + ), + if (attachment.titleLink != null || attachment.ogScrapeUrl != null) + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => launchURL( + context, + attachment.titleLink ?? attachment.ogScrapeUrl, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/message_actions_bottom_sheet.dart b/lib/src/message_actions_bottom_sheet.dart new file mode 100644 index 00000000..1e81900f --- /dev/null +++ b/lib/src/message_actions_bottom_sheet.dart @@ -0,0 +1,209 @@ +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 '../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)) + 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/message_input.dart b/lib/src/message_input.dart index 6338732b..6358e39e 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -17,6 +17,21 @@ import '../stream_chat_flutter.dart'; import 'stream_channel.dart'; typedef FileUploader = Future Function(File, Channel); +typedef AttachmentThumbnailBuilder = Widget Function( + BuildContext, + _SendingAttachment, +); + +enum ActionsLocation { + left, + right, +} + +enum DefaultAttachmentTypes { + image, + video, + file, +} /// Inactive state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) @@ -61,9 +76,11 @@ typedef FileUploader = Future Function(File, Channel); /// 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, @@ -72,6 +89,10 @@ class MessageInput extends StatefulWidget { this.doImageUploadRequest, this.doFileUploadRequest, this.initialMessage, + this.textEditingController, + this.actions, + this.actionsLocation = ActionsLocation.left, + this.attachmentThumbnailBuilders, }) : super(key: key); /// Message to edit @@ -83,6 +104,10 @@ class MessageInput extends StatefulWidget { /// 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; @@ -101,33 +126,49 @@ class MessageInput extends StatefulWidget { /// 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; + @override - _MessageInputState createState() => _MessageInputState( - doFileUploadRequest: doFileUploadRequest, - doImageUploadRequest: doImageUploadRequest, - ); + 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 anchestor of your widget tree'); + } + + return messageInputState; + } } -class _MessageInputState extends State { +class MessageInputState extends State { final List<_SendingAttachment> _attachments = []; final _focusNode = FocusNode(); final List _mentionedUsers = []; - FileUploader doImageUploadRequest; - FileUploader doFileUploadRequest; - TextEditingController _textController; + final _imagePicker = ImagePicker(); bool _inputEnabled = true; bool _messageIsPresent = false; bool _typingStarted = false; OverlayEntry _commandsOverlay, _mentionsOverlay; - _MessageInputState({ - this.doImageUploadRequest, - this.doFileUploadRequest, - }) { - doImageUploadRequest ??= _uploadImage; - doFileUploadRequest ??= _uploadFile; - } + /// The editing controller passed to the input TextField + TextEditingController textEditingController; @override Widget build(BuildContext context) { @@ -164,8 +205,12 @@ class _MessageInputState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!widget.disableAttachments) _buildAttachmentButton(), + if (widget.actionsLocation == ActionsLocation.left) + ...widget.actions ?? [], _buildTextInput(context), _animateSendButton(context), + if (widget.actionsLocation == ActionsLocation.right) + ...widget.actions ?? [], ], ); } @@ -193,10 +238,10 @@ class _MessageInputState extends State { minLines: null, maxLines: null, onSubmitted: (_) { - _sendMessage(context); + sendMessage(); }, keyboardType: widget.keyboardType, - controller: _textController, + controller: textEditingController, focusNode: _focusNode, onChanged: (s) { StreamChannel.of(context).channel.keyStroke(); @@ -215,10 +260,10 @@ class _MessageInputState extends State { Overlay.of(context).insert(_commandsOverlay); } - if (_textController.selection.isCollapsed && - (s[_textController.selection.start - 1] == '@' || - _textController.text - .substring(0, _textController.selection.start) + if (textEditingController.selection.isCollapsed && + (s[textEditingController.selection.start - 1] == '@' || + textEditingController.text + .substring(0, textEditingController.selection.start) .split(' ') .last .contains('@'))) { @@ -231,7 +276,7 @@ class _MessageInputState extends State { _typingStarted = true; }); }, - style: Theme.of(context).textTheme.body1, + style: Theme.of(context).textTheme.bodyText2, autofocus: false, decoration: InputDecoration( hintText: 'Write a message', @@ -278,7 +323,7 @@ class _MessageInputState extends State { } OverlayEntry _buildCommandsOverlayEntry() { - final text = _textController.text; + final text = textEditingController.text; final commands = StreamChannel.of(context) .channel .config @@ -343,11 +388,21 @@ class _MessageInputState extends State { } OverlayEntry _buildMentionsOverlayEntry() { - final splits = _textController.text - .substring(0, _textController.value.selection.start) + 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() ?? @@ -375,36 +430,42 @@ class _MessageInputState extends State { ], color: StreamChatTheme.of(context).primaryColor, ), - child: ListView( - padding: const EdgeInsets.all(0), - shrinkWrap: true, - children: members - .map((m) => ListTile( - leading: UserAvatar( - user: m.user, - ), - title: Text('${m.user.name}'), - onTap: () { - _mentionedUsers.add(m.user); + child: FutureBuilder>( + future: queryMembers ?? Future.value(members), + initialData: members, + builder: (context, snapshot) { + return ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: snapshot.data + .map((m) => ListTile( + leading: UserAvatar( + user: m.user, + ), + title: Text('${m.user.name}'), + onTap: () { + _mentionedUsers.add(m.user); - splits[splits.length - 1] = m.user.name; - final rejoin = splits.join('@'); + splits[splits.length - 1] = m.user.name; + final rejoin = splits.join('@'); - _textController.value = TextEditingValue( - text: rejoin + - _textController.text - .substring(_textController.selection.start), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); + textEditingController.value = TextEditingValue( + text: rejoin + + textEditingController.text.substring( + textEditingController + .selection.start), + selection: TextSelection.collapsed( + offset: rejoin.length, + ), + ); - _mentionsOverlay?.remove(); - _mentionsOverlay = null; - }, - )) - .toList(), - ), + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + }, + )) + .toList(), + ); + }), ), ), ); @@ -412,7 +473,7 @@ class _MessageInputState extends State { } void _setCommand(Command c) { - _textController.value = TextEditingValue( + textEditingController.value = TextEditingValue( text: '/${c.name} ', selection: TextSelection.collapsed( offset: c.name.length + 2, @@ -451,35 +512,7 @@ class _MessageInputState extends State { width: 50, child: _buildAttachment(attachment), ), - Positioned( - height: 16, - width: 16, - 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: Colors.white.withOpacity(.5), - child: Center( - child: Icon( - Icons.close, - size: 15, - color: Colors.black, - ), - ), - ), - ), + _buildRemoveButton(attachment), attachment.uploaded ? SizedBox() : Positioned.fill( @@ -499,20 +532,62 @@ class _MessageInputState extends State { ); } + Positioned _buildRemoveButton(_SendingAttachment attachment) { + return Positioned( + height: 16, + width: 16, + 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: Colors.white.withOpacity(.5), + child: Center( + child: Icon( + Icons.close, + size: 15, + ), + ), + ), + ); + } + Widget _buildAttachment(_SendingAttachment attachment) { - switch (attachment.type) { - case FileType.image: + if (widget.attachmentThumbnailBuilders + ?.containsKey(attachment.attachment.type) == + true) { + return widget.attachmentThumbnailBuilders[attachment.attachment.type]( + context, + attachment, + ); + } + + switch (attachment.attachment.type) { + case 'image': + case 'giphy': return attachment.file != null ? Image.file( attachment.file, fit: BoxFit.cover, ) : Image.network( - attachment.url, + attachment.attachment.imageUrl ?? + attachment.attachment.thumbUrl, fit: BoxFit.cover, ); break; - case FileType.video: + case 'video': return Container( child: Icon(Icons.videocam), color: Colors.black26, @@ -535,7 +610,7 @@ class _MessageInputState extends State { color: Colors.transparent, child: IconButton( onPressed: () { - _showAttachmentModal(); + showAttachmentModal(); }, icon: Icon( Icons.add_circle_outline, @@ -544,7 +619,8 @@ class _MessageInputState extends State { ); } - void _showAttachmentModal() { + /// Show the attachment modal, making the user choose where to pick a media from + void showAttachmentModal() { if (_focusNode.hasFocus) { _focusNode.unfocus(); } @@ -575,7 +651,7 @@ class _MessageInputState extends State { leading: Icon(Icons.image), title: Text('Upload a photo'), onTap: () { - _pickFile(FileType.image, false); + pickFile(DefaultAttachmentTypes.image, false); Navigator.pop(context); }, ), @@ -583,7 +659,7 @@ class _MessageInputState extends State { leading: Icon(Icons.video_library), title: Text('Upload a video'), onTap: () { - _pickFile(FileType.video, false); + pickFile(DefaultAttachmentTypes.video, false); Navigator.pop(context); }, ), @@ -591,7 +667,7 @@ class _MessageInputState extends State { leading: Icon(Icons.camera_alt), title: Text('Photo from camera'), onTap: () { - _pickFile(FileType.image, true); + pickFile(DefaultAttachmentTypes.image, true); Navigator.pop(context); }, ), @@ -599,7 +675,7 @@ class _MessageInputState extends State { leading: Icon(Icons.videocam), title: Text('Video from camera'), onTap: () { - _pickFile(FileType.video, true); + pickFile(DefaultAttachmentTypes.video, true); Navigator.pop(context); }, ), @@ -607,7 +683,7 @@ class _MessageInputState extends State { leading: Icon(Icons.insert_drive_file), title: Text('Upload a file'), onTap: () { - _pickFile(FileType.any, false); + pickFile(DefaultAttachmentTypes.file, false); Navigator.pop(context); }, ), @@ -616,20 +692,52 @@ class _MessageInputState extends State { }); } - void _pickFile(FileType type, bool camera) async { + /// 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; }); File 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) { - if (type == FileType.image) { - file = await ImagePicker.pickImage(source: ImageSource.camera); - } else if (type == FileType.video) { - file = await ImagePicker.pickVideo(source: ImageSource.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); } + file = File(pickedFile.path); } 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; + } file = await FilePicker.getFile(type: type); } @@ -645,16 +753,27 @@ class _MessageInputState extends State { final attachment = _SendingAttachment( file: file, - type: type, + attachment: Attachment( + localUri: file.uri, + type: attachmentType, + ), ); setState(() { _attachments.add(attachment); }); - final url = await _uploadAttachment(file, type, channel); + final url = await _uploadAttachment(file, fileType, channel); - attachment.url = url; + if (fileType == DefaultAttachmentTypes.image) { + attachment.attachment = attachment.attachment.copyWith( + imageUrl: url, + ); + } else { + attachment.attachment = attachment.attachment.copyWith( + assetUrl: url, + ); + } setState(() { attachment.uploaded = true; @@ -663,14 +782,22 @@ class _MessageInputState extends State { Future _uploadAttachment( File file, - FileType type, + DefaultAttachmentTypes type, Channel channel, ) async { String url; - if (type == FileType.image) { - url = await doImageUploadRequest(file, channel); + if (type == DefaultAttachmentTypes.image) { + if (widget.doImageUploadRequest != null) { + url = await widget.doImageUploadRequest(file, channel); + } else { + url = await _uploadImage(file, channel); + } } else { - url = await doFileUploadRequest(file, channel); + if (widget.doFileUploadRequest != null) { + url = await widget.doFileUploadRequest(file, channel); + } else { + url = await _uploadFile(file, channel); + } } return url; } @@ -714,25 +841,27 @@ class _MessageInputState extends State { child: IconButton( key: Key('sendButton'), onPressed: () { - _sendMessage(context); + sendMessage(); }, icon: Icon( Icons.send, + color: StreamChatTheme.of(context).accentColor, ), ), ), ); } - void _sendMessage(BuildContext context) { - final text = _textController.text.trim(); + /// Sends the current message + void sendMessage() async { + final text = textEditingController.text.trim(); if (text.isEmpty && _attachments.isEmpty) { return; } final attachments = List<_SendingAttachment>.from(_attachments); - _textController.clear(); + textEditingController.clear(); _attachments.clear(); setState(() { @@ -751,21 +880,11 @@ class _MessageInputState extends State { Message message; if (widget.editMessage != null) { message = widget.editMessage.copyWith( - parentId: widget.parentMessage?.id, text: text, - attachments: widget.editMessage.attachments - .where((attachment) => attachment.type == 'giphy') - .toList() + - _getAttachments(attachments).toList(), + attachments: _getAttachments(attachments).toList(), mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); - - if (widget.editMessage.status == MessageSendingStatus.FAILED) { - sendingFuture = channel.sendMessage(message); - } - - sendingFuture = StreamChat.of(context).client.updateMessage(message); } else { message = (widget.initialMessage ?? Message()).copyWith( parentId: widget.parentMessage?.id, @@ -774,34 +893,36 @@ class _MessageInputState extends State { mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); - sendingFuture = channel.sendMessage(message); } - sendingFuture.whenComplete(() { + if (widget.preMessageSending != null) { + message = await widget.preMessageSending(message); + } + + 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.whenComplete(() { if (widget.onMessageSent != null) { widget.onMessageSent(message); + } else { + if (widget.editMessage != null) { + Navigator.pop(context); + } } }); } Iterable _getAttachments(List<_SendingAttachment> attachments) { return attachments.map((attachment) { - String type; - switch (attachment.type) { - case FileType.image: - type = 'image'; - break; - case FileType.video: - type = 'video'; - break; - default: - type = 'file'; - } - return Attachment( - imageUrl: attachment.type == FileType.image ? attachment.url : null, - assetUrl: attachment.url, - type: type, - ); + return attachment.attachment; }); } @@ -811,12 +932,10 @@ class _MessageInputState extends State { void initState() { super.initState(); - StreamChannel.of(context).queryMembersAndWatchers(); - _keyboardListener = KeyboardVisibility.onChange.listen((visible) { if (visible) { if (_commandsOverlay != null) { - if (_textController.text.startsWith('/')) { + if (textEditingController.text.startsWith('/')) { WidgetsBinding.instance.addPostFrameCallback((_) { _commandsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_commandsOverlay); @@ -825,7 +944,7 @@ class _MessageInputState extends State { } if (_mentionsOverlay != null) { - if (_textController.text.contains('@')) { + if (textEditingController.text.contains('@')) { WidgetsBinding.instance.addPostFrameCallback((_) { _mentionsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_mentionsOverlay); @@ -842,44 +961,24 @@ class _MessageInputState extends State { } }); - if (widget.editMessage != null) { - _parseExistingMessage(widget.editMessage); - } else if (widget.initialMessage != null) { - _parseExistingMessage(widget.initialMessage); - } else { - _textController = TextEditingController(); + textEditingController = + widget.textEditingController ?? TextEditingController(); + if (widget.editMessage != null || widget.initialMessage != null) { + _parseExistingMessage(widget.editMessage ?? widget.initialMessage); } } void _parseExistingMessage(Message message) { - _textController = TextEditingController(text: message.text); + textEditingController.text = message.text; _typingStarted = true; _messageIsPresent = true; message.attachments?.forEach((attachment) { - if (attachment.type == 'image') { - _attachments.add(_SendingAttachment( - type: FileType.image, - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl ?? - attachment.ogScrapeUrl, - uploaded: true, - )); - } else if (attachment.type == 'video') { - _attachments.add(_SendingAttachment( - type: FileType.video, - url: attachment.assetUrl, - uploaded: true, - )); - } else if (attachment.type != 'giphy') { - _attachments.add(_SendingAttachment( - type: FileType.any, - url: attachment.assetUrl, - uploaded: true, - )); - } + _attachments.add(_SendingAttachment( + attachment: attachment, + uploaded: true, + )); }); } @@ -903,15 +1002,13 @@ class _MessageInputState extends State { } class _SendingAttachment { - final File file; - final FileType type; - String url; + File file; + Attachment attachment; bool uploaded; _SendingAttachment({ - this.url, this.file, - this.type, + this.attachment, this.uploaded = false, }); } diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 13ea7233..a48fee83 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -3,17 +3,56 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/message_widget.dart'; +import 'package:stream_chat_flutter/src/system_message.dart'; import 'package:visibility_detector/visibility_detector.dart'; import '../stream_chat_flutter.dart'; -import 'message_widget.dart'; +import 'date_divider.dart'; import 'stream_channel.dart'; -typedef MessageBuilder = Widget Function(BuildContext, Message, int index); -typedef ParentMessageBuilder = Widget Function(BuildContext, Message); +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); +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) /// @@ -55,6 +94,7 @@ typedef ThreadTapCallback = void Function(Message, Widget); /// 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.messageBuilder, @@ -62,11 +102,8 @@ class MessageListView extends StatefulWidget { this.parentMessage, this.threadBuilder, this.onThreadTap, - this.showOtherMessageUsername = false, - this.showVideoFullScreen = true, - this.onMentionTap, - this.onUserAvatarTap, - this.onMessageActions, + this.dateDividerBuilder, + this.scrollPhysics = const AlwaysScrollableScrollPhysics(), }) : super(key: key); /// Function used to build a custom message widget @@ -85,20 +122,11 @@ class MessageListView extends StatefulWidget { /// Parent message in case of a thread final Message parentMessage; - /// If true show the other users username next to the timestamp of the message - final bool showOtherMessageUsername; + /// Builder used to render date dividers + final Widget Function(DateTime) dateDividerBuilder; - /// True if the video player will allow fullscreen mode - final bool showVideoFullScreen; - - /// Function called on message mention tap - final void Function(User) onMentionTap; - - /// Function called on User Avatar tap - final void Function(User) onUserAvatarTap; - - /// Function called on message long press - final Function(BuildContext, Message) onMessageActions; + /// The ScrollPhysics used by the ListView + final ScrollPhysics scrollPhysics; @override _MessageListViewState createState() => _MessageListViewState(); @@ -130,7 +158,7 @@ class _MessageListViewState extends State { }, child: ListView.custom( key: Key('messageListView'), - physics: AlwaysScrollableScrollPhysics(), + physics: widget.scrollPhysics, controller: _scrollController, reverse: true, childrenDelegate: SliverChildBuilderDelegate( @@ -146,21 +174,7 @@ class _MessageListViewState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - MessageWidget( - key: ValueKey( - 'PARENT-MESSAGE-${widget.parentMessage.id}'), - previousMessage: null, - message: widget.parentMessage, - nextMessage: null, - onThreadTap: _onThreadTap, - isParent: true, - showVideoFullScreen: widget.showVideoFullScreen, - showOtherMessageUsername: - widget.showOtherMessageUsername, - onMentionTap: widget.onMentionTap, - onUserAvatarTap: widget.onUserAvatarTap, - onMessageActions: widget.onMessageActions, - ), + buildParentMessage(widget.parentMessage), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Container( @@ -184,124 +198,58 @@ class _MessageListViewState extends State { return _buildLoadingIndicator(streamChannel); } final message = _messages[i]; - - final previousMessage = - i < _messages.length - 1 ? _messages[i + 1] : null; final nextMessage = i > 0 ? _messages[i - 1] : null; Widget messageWidget; if (i == 0) { messageWidget = _buildBottomMessage( - streamChannel, - previousMessage, - message, context, + message, + _messages, + streamChannel, ); } else if (i == _messages.length - 1) { messageWidget = _buildTopMessage( - message, - nextMessage, - streamChannel, context, + message, + _messages, + streamChannel, ); } else { if (widget.messageBuilder != null) { messageWidget = Builder( key: ValueKey('MESSAGE-${message.id}'), - builder: (_) => widget.messageBuilder(context, message, i), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + _messages, + i, + ), + _messages), ); } else { - messageWidget = MessageWidget( - key: ValueKey('MESSAGE-${message.id}'), - previousMessage: previousMessage, - message: message, - nextMessage: nextMessage, - onThreadTap: _onThreadTap, - showOtherMessageUsername: widget.showOtherMessageUsername, - showVideoFullScreen: widget.showVideoFullScreen, - onMentionTap: widget.onMentionTap, - onUserAvatarTap: widget.onUserAvatarTap, - onMessageActions: widget.onMessageActions, - ); + messageWidget = buildMessage(message, _messages, i); } } if (nextMessage != null && !Jiffy(message.createdAt.toLocal()) .isSame(nextMessage.createdAt.toLocal(), Units.DAY)) { - final divider = Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Divider(), - ), - ); - - final createdAt = Jiffy(nextMessage.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 Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ messageWidget, Padding( - padding: const EdgeInsets.only(top: 24.0), - child: 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 - .title - .color - .withOpacity(.5), - ), + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: widget.dateDividerBuilder != null + ? widget + .dateDividerBuilder(nextMessage.createdAt.toLocal()) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), ), - ), - divider, - ], - ), ), ], ); @@ -329,9 +277,11 @@ class _MessageListViewState extends State { initialData: false, builder: (context, snapshot) { if (snapshot.hasError) { - print((snapshot.error as Error).stackTrace.toString()); - return Center( - child: Text(snapshot.error.toString()), + return Container( + color: Color(0xffd0021B).withAlpha(26), + child: Center( + child: Text('Error loading messages'), + ), ); } if (!snapshot.data) { @@ -348,30 +298,28 @@ class _MessageListViewState extends State { } Widget _buildTopMessage( - Message message, - Message nextMessage, - StreamChannelState streamChannelState, BuildContext context, + Message message, + List messages, + StreamChannelState streamChannel, ) { Widget messageWidget; if (widget.messageBuilder != null) { messageWidget = Builder( - key: ValueKey('MESSAGE-${message.id}'), - builder: (_) => widget.messageBuilder(context, message, 0), + key: ValueKey('TOP-MESSAGE'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + _messages, + _messages.length - 1, + ), + _messages, + ), ); } else { - messageWidget = MessageWidget( - key: ValueKey('MESSAGE-${message.id}'), - previousMessage: null, - message: message, - nextMessage: nextMessage, - onThreadTap: _onThreadTap, - showVideoFullScreen: widget.showVideoFullScreen, - showOtherMessageUsername: widget.showOtherMessageUsername, - onMentionTap: widget.onMentionTap, - onUserAvatarTap: widget.onUserAvatarTap, - onMessageActions: widget.onMessageActions, - ); + messageWidget = buildMessage(message, messages, _messages.length - 1); } return VisibilityDetector( @@ -380,7 +328,7 @@ class _MessageListViewState extends State { onVisibilityChanged: (visibility) { final topIsVisible = visibility.visibleBounds != Rect.zero; if (topIsVisible && !_topWasVisible) { - streamChannelState.queryMessages(); + streamChannel.queryMessages(); } _topWasVisible = topIsVisible; }, @@ -388,37 +336,35 @@ class _MessageListViewState extends State { } Widget _buildBottomMessage( - StreamChannelState streamChannel, - Message previousMessage, - Message message, BuildContext context, + Message message, + List messages, + StreamChannelState streamChannel, ) { Widget messageWidget; if (widget.messageBuilder != null) { messageWidget = Builder( - key: ValueKey('MESSAGE-${message.id}'), - builder: (_) => widget.messageBuilder(context, message, 0), + key: ValueKey('BOTTOM-MESSAGE'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + _messages, + 0, + ), + _messages, + ), ); } else { - messageWidget = MessageWidget( - key: ValueKey('MESSAGE-${message.id}'), - previousMessage: previousMessage, - message: message, - nextMessage: null, - onThreadTap: _onThreadTap, - showVideoFullScreen: widget.showVideoFullScreen, - showOtherMessageUsername: widget.showOtherMessageUsername, - onMentionTap: widget.onMentionTap, - onUserAvatarTap: widget.onUserAvatarTap, - onMessageActions: widget.onMessageActions, - ); + messageWidget = buildMessage(message, messages, 0); } return VisibilityDetector( key: ValueKey('BOTTOM-MESSAGE'), onVisibilityChanged: (visibility) { _isBottom = visibility.visibleBounds != Rect.zero; - if (_isBottom && streamChannel.channel.config.readEvents) { + if (_isBottom && streamChannel.channel.config?.readEvents == true) { if (streamChannel.channel.state.unreadCount > 0) { streamChannel.channel.markRead(); } @@ -428,6 +374,103 @@ class _MessageListViewState extends State { ); } + Widget buildParentMessage( + Message message, + ) { + final isMyMessage = message.user.id == StreamChat.of(context).user.id; + + return MessageWidget( + showReplyIndicator: false, + message: message, + reverse: isMyMessage, + showUsername: !isMyMessage, + padding: EdgeInsets.only( + top: 8.0, + left: 8.0, + right: 8.0, + bottom: 16.0, + ), + showSendingIndicator: DisplayWidget.hide, + onThreadTap: _onThreadTap, + showEditMessage: false, + showDeleteMessage: false, + borderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(2), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + borderSide: isMyMessage ? BorderSide.none : null, + showUserAvatar: DisplayWidget.show, + messageTheme: isMyMessage + ? StreamChatTheme.of(context).ownMessageTheme + : StreamChatTheme.of(context).otherMessageTheme, + ); + } + + Widget buildMessage( + Message message, + List messages, + int index, + ) { + if (message.type == 'system' && message.text?.isNotEmpty == true) { + return SystemMessage( + message: message, + ); + } + + final userId = StreamChat.of(context).user.id; + final isMyMessage = message.user.id == userId; + final isLastUser = index + 1 < messages.length && + message.user.id == messages[index + 1]?.user?.id; + final isNextUser = + index - 1 >= 0 && message.user.id == messages[index - 1]?.user?.id; + + final readList = StreamChannel.of(context) + .channel + .state + ?.read + ?.where((element) => element.user.id != userId) + ?.where((read) => + read.lastRead.isAfter(message.createdAt) && + (index == 0 || + read.lastRead.isBefore(messages[index - 1].createdAt))) + ?.toList(); + + return MessageWidget( + message: message, + reverse: isMyMessage, + showReactions: !message.isDeleted, + padding: EdgeInsets.only( + left: 8.0, + right: 8.0, + bottom: index == 0 ? 30 : (isNextUser ? 5 : 10), + ), + showUsername: !isMyMessage && !isNextUser, + showSendingIndicator: isMyMessage && + (index == 0 || message.status != MessageSendingStatus.SENT) + ? DisplayWidget.show + : DisplayWidget.hide, + showTimestamp: !isNextUser || readList?.isNotEmpty == true, + showEditMessage: isMyMessage, + showDeleteMessage: isMyMessage, + borderSide: isMyMessage ? BorderSide.none : null, + onThreadTap: _onThreadTap, + attachmentBorderRadiusGeometry: BorderRadius.circular(16), + borderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(isLastUser ? 2 : 16), + bottomLeft: Radius.circular(2), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + showUserAvatar: isNextUser ? DisplayWidget.hide : DisplayWidget.show, + messageTheme: isMyMessage + ? StreamChatTheme.of(context).ownMessageTheme + : StreamChatTheme.of(context).otherMessageTheme, + readList: readList, + ); + } + StreamSubscription _streamListener; @override @@ -442,11 +485,7 @@ class _MessageListViewState extends State { Stream> stream; if (widget.parentMessage == null) { - stream = streamChannel.channel.state.messagesStream.map((messages) => - messages - .where((m) => - !(m.status == MessageSendingStatus.FAILED && m.isDeleted)) - .toList()); + stream = streamChannel.channel.state.messagesStream; } else { streamChannel.getReplies(widget.parentMessage.id); stream = streamChannel.channel.state.threadsStream @@ -454,7 +493,14 @@ class _MessageListViewState extends State { .map((threads) => threads[widget.parentMessage.id]); } - _streamListener = stream.listen((newMessages) { + _streamListener = stream + .map((messages) => + messages + ?.where((m) => + !(m.status == MessageSendingStatus.FAILED && m.isDeleted)) + ?.toList() ?? + []) + .listen((newMessages) { newMessages = newMessages.reversed.toList(); if (_messages.isEmpty || newMessages.isEmpty || diff --git a/lib/src/message_text.dart b/lib/src/message_text.dart new file mode 100644 index 00000000..f35b8549 --- /dev/null +++ b/lib/src/message_text.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'stream_chat_theme.dart'; +import 'utils.dart'; + +class MessageText extends StatelessWidget { + const MessageText({ + Key key, + @required this.message, + @required this.messageTheme, + this.onMentionTap, + }) : super(key: key); + + final Message message; + final void Function(User) onMentionTap; + final MessageTheme messageTheme; + + @override + Widget build(BuildContext context) { + final text = _replaceMentions(message.text); + return MarkdownBody( + data: text, + onTapLink: (link) { + if (link.startsWith('@')) { + final mentionedUser = message.mentionedUsers.firstWhere( + (u) => '@${u.name.replaceAll(' ', '')}' == link, + orElse: () => null, + ); + + if (onMentionTap != null) { + onMentionTap(mentionedUser); + } else { + print('tap on ${mentionedUser.name}'); + } + } else { + launchURL(context, link); + } + }, + styleSheet: MarkdownStyleSheet.fromTheme( + Theme.of(context).copyWith( + textTheme: Theme.of(context).textTheme.apply( + bodyColor: messageTheme.messageText.color, + decoration: messageTheme.messageText.decoration, + decorationColor: messageTheme.messageText.decorationColor, + decorationStyle: messageTheme.messageText.decorationStyle, + fontFamily: messageTheme.messageText.fontFamily, + ), + ), + ).copyWith( + p: messageTheme.messageText, + ), + ); + } + + String _replaceMentions(String text) { + message.mentionedUsers?.forEach((u) { + text = text.replaceAll( + '@${u.name}', '[@${u.name}](@${u.name.replaceAll(' ', '')})'); + }); + return text; + } +} diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 5445fc59..3294ab17 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -1,23 +1,29 @@ import 'dart:math'; +import 'dart:ui'; -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:chewie/chewie.dart'; -import 'package:flutter/foundation.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_portal/flutter_portal.dart'; import 'package:jiffy/jiffy.dart'; -import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter/src/message_input.dart'; -import 'package:stream_chat_flutter/src/message_list_view.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/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/user_avatar.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:video_player/video_player.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'stream_chat.dart'; +import 'message_actions_bottom_sheet.dart'; +import 'message_text.dart'; + +typedef AttachmentBuilder = Widget Function(BuildContext, Message, Attachment); + +/// 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) @@ -29,654 +35,469 @@ import 'stream_chat.dart'; /// 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 { - const MessageWidget({ - Key key, - @required this.previousMessage, - @required this.message, - @required this.nextMessage, - this.onThreadTap, - this.onUserAvatarTap, - this.onMessageActions, - this.isParent = false, - this.onMentionTap, - this.showOtherMessageUsername = false, - this.showVideoFullScreen = true, - }) : super(key: key); - /// Function called on mention tap final void Function(User) onMentionTap; - /// Function called on long press - final Function(BuildContext, Message) onMessageActions; - - /// If true show the other users username next to the timestamp of the message - final bool showOtherMessageUsername; - - /// This message - final Message message; - - /// The previous message - final Message previousMessage; - - /// The next message - final Message nextMessage; - /// The function called when tapping on replies final void Function(Message) onThreadTap; + 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 DisplayWidget showSendingIndicator; + + /// If true the widget will show the reactions + final bool showReactions; + + /// If true the widget will show the reply indicator + final bool showReplyIndicator; /// The function called when tapping on UserAvatar final void Function(User) onUserAvatarTap; - /// True if this is the parent of the thread being showed - final bool isParent; + final List readList; - /// True if the video player will allow fullscreen mode - final bool showVideoFullScreen; + /// If true show the users username next to the timestamp of the message + final bool showUsername; + final bool showTimestamp; + final bool showDeleteMessage; + final bool showEditMessage; + final Map attachmentBuilders; + + MessageWidget({ + Key key, + @required this.message, + @required this.messageTheme, + this.reverse = false, + this.shape, + this.attachmentShape, + this.borderSide, + this.attachmentBorderSide, + this.borderRadiusGeometry, + this.attachmentBorderRadiusGeometry, + this.onMentionTap, + this.showUserAvatar = DisplayWidget.show, + this.showSendingIndicator = DisplayWidget.show, + this.showReplyIndicator = true, + this.onThreadTap, + this.showUsername = true, + this.showTimestamp = true, + this.showReactions = true, + this.showDeleteMessage = true, + this.showEditMessage = true, + this.onUserAvatarTap, + this.onMessageActions, + this.editMessageInputBuilder, + this.textBuilder, + Map customAttachmentBuilders, + this.readList, + this.padding, + this.textPadding = const EdgeInsets.all(8.0), + this.attachmentPadding = EdgeInsets.zero, + }) : 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, + ), + ); + }, + '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, + ), + ); + }, + '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, + ), + ); + }, + '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 - with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { - final Map _videoControllers = {}; - final Map _chuwieControllers = {}; +class _MessageWidgetState extends State { + final Map _reactionToEmoji = { + 'love': '❤️️', + 'haha': '😂', + 'like': '👍', + 'sad': '😕', + 'angry': '😡', + 'wow': '😲', + }; - MessageTheme _messageTheme; - StreamChatState _streamChat; - StreamChannelState _streamChannel; - bool _isMyMessage; - - String _currentUserId; - String _messageUserId; - String _previousUserId; - String _nextUserId; - bool _isLastUser; - bool _isNextUser; + final GlobalKey _reactionPickerKey = GlobalKey(); + double _reactionPadding = 0; @override Widget build(BuildContext context) { - super.build(context); - - _messageTheme = _isMyMessage - ? StreamChatTheme.of(context).ownMessageTheme - : StreamChatTheme.of(context).otherMessageTheme; - - final alignment = - _isMyMessage ? Alignment.centerRight : Alignment.centerLeft; - - var row = [ - Column( - crossAxisAlignment: - _isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, - children: [ - widget.message.isDeleted - ? _buildDeletedMessage(alignment) - : _buildBubble(context), - if (_streamChannel.channel.config.replies) - _buildThreadIndicator(context), - if (!_isNextUser) _buildTimestamp(alignment), - ], - ), - _isNextUser - ? Container( - width: 40, - ) - : _buildUserAvatar(), - ]; - - if (!_isMyMessage) { - row = row.reversed.toList(); + var leftPadding = widget.showUserAvatar != DisplayWidget.gone + ? widget.messageTheme.avatarTheme.constraints.maxWidth + 23.0 + : 12.0; + if (widget.showSendingIndicator == DisplayWidget.gone) { + leftPadding -= 7; } - - return Container( - padding: EdgeInsets.symmetric( - horizontal: (_isMyMessage && widget.nextMessage == null) ? 0.0 : 10, - ), - margin: EdgeInsets.only( - top: _isLastUser ? 5 : 24, - bottom: widget.nextMessage == null ? 30 : 0, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: - _isMyMessage ? MainAxisAlignment.end : MainAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: row, + return Portal( + child: Padding( + padding: widget.padding ?? EdgeInsets.all(8), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + child: Container( + alignment: Alignment.centerLeft, + child: Container( + constraints: BoxConstraints.loose( + Size.fromWidth(MediaQuery.of(context).size.width * 0.8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + IntrinsicWidth( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.showSendingIndicator == + DisplayWidget.show) + _buildSendingIndicator(), + SizedBox( + width: 2, + ), + if (widget.showSendingIndicator == + DisplayWidget.hide) + SizedBox( + width: 8, + ), + if (widget.showUserAvatar == DisplayWidget.show) + _buildUserAvatar(), + SizedBox( + width: 6, + ), + if (widget.showUserAvatar == DisplayWidget.hide) + SizedBox( + width: widget.messageTheme.avatarTheme + .constraints.maxWidth + + 8, + ), + Flexible( + child: Padding( + padding: widget.showReactions + ? EdgeInsets.only( + top: _reactionPadding, + ) + : EdgeInsets.zero, + child: PortalEntry( + portalAnchor: Alignment(0, 1), + childAnchor: Alignment.topRight, + portal: _buildReactionIndicator(context), + child: (widget.message.isDeleted && + widget.message.status != + MessageSendingStatus + .FAILED_DELETE) + ? Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY( + widget.reverse ? pi : 0), + child: DeletedMessage( + messageTheme: widget.messageTheme, + ), + ) + : Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ..._parseAttachments(context), + if (widget.message.text + .trim() + .isNotEmpty) + _buildTextBubble(context), + ], + ), + ), + ), + ), + ], + ), + if (widget.showReplyIndicator && + widget.message.replyCount > 0) + _buildReplyIndicator(leftPadding), + ], + ), + ), + if ((widget.message.createdAt != null && + widget.showTimestamp) || + widget.showUsername || + widget.readList?.isNotEmpty == true) + _buildBottomRow(leftPadding), + ], + ), + ), + ), + ), ), ); } - Padding _buildUserAvatar() { - return Padding( - padding: EdgeInsets.only( - left: _isMyMessage ? 8.0 : 0, - right: _isMyMessage ? 0 : 8.0, - ), - child: Row( - children: [ - UserAvatar( - user: widget.message.user, - onTap: widget.onUserAvatarTap, - ), - if (_isMyMessage && - widget.nextMessage == null && - (widget.message.status == MessageSendingStatus.SENT || - widget.message.status == null)) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 1.0, - ), - child: CircleAvatar( - radius: 4, - backgroundColor: Theme.of(context).accentColor, - child: Icon( - Icons.done, - color: Colors.white, - size: 4, - ), - ), - ), - if (_isMyMessage && - widget.message.status == MessageSendingStatus.SENDING) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 1.0, - ), - child: CircleAvatar( - radius: 4, - backgroundColor: Colors.grey, - child: Icon( - Icons.access_time, - size: 4, - color: Colors.white, - ), - ), - ), - if (_isMyMessage && - widget.message.status == MessageSendingStatus.FAILED) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 1.0, - ), - child: CircleAvatar( - radius: 4, - backgroundColor: Color(0xffd0021B).withOpacity(.1), - child: Icon( - Icons.error_outline, - size: 4, - color: Colors.white, - ), - ), - ), - ], - ), - ); + @override + void didUpdateWidget(MessageWidget oldWidget) { + super.didUpdateWidget(oldWidget); + _updateReactionPadding(); } @override void initState() { super.initState(); - - _streamChat = StreamChat.of(context); - _streamChannel = StreamChannel.of(context); - - _currentUserId = _streamChat.client.state.user.id; - _messageUserId = widget.message.user.id; - _previousUserId = widget.previousMessage?.user?.id; - _nextUserId = widget.nextMessage?.user?.id; - _isLastUser = _previousUserId == _messageUserId; - _isNextUser = _nextUserId == _messageUserId; - - _isMyMessage = _messageUserId == _currentUserId; + _updateReactionPadding(); } - Align _buildDeletedMessage(Alignment alignment) { - return Align( - alignment: alignment, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, - ), - child: Text( - 'This message was deleted...', - style: _messageTheme.messageText.copyWith( - fontStyle: FontStyle.italic, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - ), - ), - ), - ); - } - - Widget _buildThreadIndicator(BuildContext context) { - var row = [ - Text( - 'Replies: ${widget.message.replyCount}', - style: _messageTheme.replies, - ), - Transform( - transform: Matrix4.rotationY(_isMyMessage ? 0 : pi), - alignment: Alignment.center, - child: Icon( - Icons.subdirectory_arrow_left, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white12 - : Colors.black12, - ), - ), - ]; - - if (!_isMyMessage) { - row = row.reversed.toList(); - } - - return widget.message.replyCount > 0 - ? GestureDetector( - onTap: () { - if (widget.isParent) { - return; - } - if (widget.onThreadTap != null) { - widget.onThreadTap(widget.message); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2.0), - child: Row( - children: row, - ), - ), - ) - : SizedBox(); - } - - Widget _buildBubble( - BuildContext context, - ) { - var nOfAttachmentWidgets = 0; - - final column = - List.from(widget.message.attachments.map((attachment) { - nOfAttachmentWidgets++; - - Widget attachmentWidget; - if (attachment.type == 'video') { - attachmentWidget = _buildVideo(attachment); - } else if (attachment.type == 'image' || attachment.type == 'giphy') { - attachmentWidget = _buildImage(attachment); - } else if (attachment.type == 'file') { - attachmentWidget = Material( - child: InkWell( - onTap: () { - _launchURL(attachment.assetUrl); - }, - child: Container( - width: 100, - height: 100, - child: Center( - child: Icon(Icons.attach_file), - ), - ), - ), - ); + void _updateReactionPadding() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; } - - if (attachmentWidget != null) { - return _buildAttachment( - attachmentWidget, - attachment, - nOfAttachmentWidgets, - context, - ); + if (_reactionPickerKey.currentContext != null && + widget.message.reactionCounts != null && + widget.message.reactionCounts.values + .where((element) => element > 0) + .isNotEmpty) { + setState(() { + _reactionPadding = _reactionPickerKey.currentContext.size.height; + }); + } else { + setState(() { + _reactionPadding = 0; + }); } + }); + } - nOfAttachmentWidgets--; - return SizedBox(); - })); - - if (widget.message.text.trim().isNotEmpty) { - var text = widget.message.text; - text = _replaceMentions(text); - - column.addAll( - [ - Column( - crossAxisAlignment: _isMyMessage - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - if (_streamChannel.channel.config.reactions && - nOfAttachmentWidgets == 0) - Align( - child: _buildReactions(), - alignment: _isMyMessage - ? Alignment.centerLeft - : Alignment.centerRight, + Widget _buildReactionsTail(BuildContext context) { + return AnimatedSwitcher( + duration: Duration(milliseconds: 300), + child: widget.message.reactionCounts?.isNotEmpty == true + ? Transform.translate( + offset: Offset(4, 0), + child: CustomPaint( + painter: ReactionBubblePainter( + Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black, ), - Stack( - overflow: Overflow.visible, - children: [ - if (nOfAttachmentWidgets == 0) _buildReactionPaint(), - _buildMessageText(nOfAttachmentWidgets, text, context), - ], ), - ], - ), - ], - ); - } - - if (_streamChannel.channel.config.reactions && nOfAttachmentWidgets > 0) { - column.insert( - 0, - Align( - child: _buildReactions(), - alignment: - _isMyMessage ? Alignment.centerLeft : Alignment.centerRight, - ), - ); - column[1] = Stack( - overflow: Overflow.visible, - children: [ - Padding( - padding: EdgeInsets.only( - right: _isMyMessage ? 0.0 : 8.0, - left: _isMyMessage ? 8.0 : 0.0, - ), - child: column[1], - ), - _buildReactionPaint(), - ], - ); - } - - return GestureDetector( - child: IntrinsicWidth( - child: Column( - children: column, - crossAxisAlignment: - _isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, - ), - ), - onTap: () { - if (widget.message.status == MessageSendingStatus.FAILED) { - StreamChannel.of(context).channel.sendMessage(widget.message); - return; - } - }, - onLongPress: () { - if (widget.message.isEphemeral || - widget.message.status == MessageSendingStatus.SENDING) { - return; - } - - if (widget.onMessageActions != null) { - widget.onMessageActions(context, widget.message); - } else { - _showMessageBottomSheet(context); - } - }, + ) + : SizedBox(), ); } - Padding _buildAttachment( - Widget attachmentWidget, - Attachment attachment, - int nOfAttachmentWidgets, - BuildContext context, - ) { - final boxDecoration = _buildBoxDecoration(_isLastUser); - return Padding( - padding: const EdgeInsets.only(bottom: 2.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipRRect( - borderRadius: boxDecoration.borderRadius, - child: Container( - decoration: boxDecoration, - constraints: BoxConstraints.loose( - Size.fromWidth(MediaQuery.of(context).size.width * 0.7), - ), - child: Stack( - children: [ - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - attachmentWidget, - if (attachment.title != null) - _buildAttachmentTitle(attachment), - ], - ), - if (attachment.type == 'image' && - attachment.titleLink != null) - _buildPreviewInkwell(attachment), - ], - ), - margin: EdgeInsets.only( - top: nOfAttachmentWidgets > 1 ? 5 : 0, - ), - ), - ), - if (attachment.actions != null) - _buildAttachmentActions(attachment, context), - ], - ), - ); - } - - Padding _buildMessageText( - int nOfAttachmentWidgets, - String text, - BuildContext context, - ) { + Padding _buildBottomRow(double leftPadding) { return Padding( padding: EdgeInsets.only( - right: _isMyMessage ? 0.0 : 8.0, - left: _isMyMessage ? 8.0 : 0.0, + left: leftPadding, + top: 2, ), - child: Container( - decoration: - _buildBoxDecoration(_isLastUser || nOfAttachmentWidgets > 0), - padding: EdgeInsets.all(10), - constraints: BoxConstraints.loose( - Size.fromWidth(MediaQuery.of(context).size.width * 0.7)), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.message.status == MessageSendingStatus.FAILED) - Text( - 'MESSAGE FAILED · CLICK TO TRY AGAIN', - style: _messageTheme.messageText.copyWith( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withOpacity(.5) - : Colors.black.withOpacity(.5), - fontSize: 11, - ), - ), - MarkdownBody( - data: text, - onTapLink: (link) { - if (link.startsWith('@')) { - final mentionedUser = - widget.message.mentionedUsers.firstWhere( - (u) => '@${u.name.replaceAll(' ', '')}' == link, - orElse: () => null, - ); - - if (widget.onMentionTap != null) { - widget.onMentionTap(mentionedUser); - } else { - print('tap on ${mentionedUser.name}'); - } - } else { - _launchURL(link); - } - }, - styleSheet: MarkdownStyleSheet.fromTheme( - Theme.of(context).copyWith( - textTheme: Theme.of(context).textTheme.apply( - bodyColor: _messageTheme.messageText.color, - decoration: _messageTheme.messageText.decoration, - decorationColor: - _messageTheme.messageText.decorationColor, - decorationStyle: - _messageTheme.messageText.decorationStyle, - fontFamily: _messageTheme.messageText.fontFamily, - ), - ), - ).copyWith( - p: _messageTheme.messageText, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + child: RichText( + text: TextSpan( + style: widget.messageTheme.createdAt, + children: [ + if (widget.showUsername) + TextSpan( + text: widget.message.user.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + if (widget.message.createdAt != null && widget.showTimestamp) + TextSpan( + text: Jiffy(widget.message.createdAt.toLocal()) + .format(' HH:mm'), + ), + ], ), ), - ], - ), + ), + if (widget.readList?.isNotEmpty == true) + SizedBox.fromSize( + size: Size((widget.readList.length * 10.0) + 10, 17), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + child: Padding( + padding: const EdgeInsets.only(left: 4.0), + child: _buildReadIndicator(), + ), + ), + ), + ], ), ); } - String _replaceMentions(String text) { - widget.message.mentionedUsers?.forEach((u) { - text = text.replaceAll( - '@${u.name}', '[@${u.name}](@${u.name.replaceAll(' ', '')})'); - }); - return text; - } - - Row _buildAttachmentActions(Attachment attachment, BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.end, - children: attachment.actions?.map((action) { - if (action.style == 'primary') { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: FlatButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), + Widget _buildReadIndicator() { + var padding = 0.0; + return Stack( + children: widget.readList.map((e) { + padding += 10.0; + return Positioned( + left: padding - 10, + bottom: 0, + top: 0, + child: Material( + color: Colors.white, + shape: CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(1.0), + child: UserAvatar( + user: e.user, + constraints: BoxConstraints.loose(Size.fromRadius(16)), ), - child: Text('${action.text}'), - color: action.style == 'primary' - ? StreamChatTheme.of(context).accentColor - : null, - textColor: Colors.white, - onPressed: () { - _streamChannel.channel.sendAction(widget.message.id, { - action.name: action.value, - }); - }, ), - ); - } - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4.0), - child: OutlineButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Text('${action.text}'), - color: StreamChatTheme.of(context).accentColor, - onPressed: () { - _streamChannel.channel.sendAction(widget.message.id, { - action.name: action.value, - }); - }, ), ); - })?.toList(), + }).toList(), ); } - Positioned _buildPreviewInkwell(Attachment attachment) { - return Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _launchURL(attachment.titleLink), - ), - ), - ); - } - - GestureDetector _buildAttachmentTitle(Attachment attachment) { - return GestureDetector( - onTap: () { - if (attachment.titleLink != null) { - _launchURL(attachment.titleLink); - } - }, - child: Container( - constraints: BoxConstraints.loose( - Size( - MediaQuery.of(context).size.width * 0.7, - 500, - ), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - attachment.title, - overflow: TextOverflow.ellipsis, - style: _messageTheme.messageText.copyWith( - color: StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold, + Widget _buildReactionIndicator(BuildContext context) { + return AnimatedSwitcher( + key: _reactionPickerKey, + duration: Duration(milliseconds: 300), + child: (widget.showReactions && + widget.message.reactionCounts?.isNotEmpty == true && + !widget.message.isDeleted) + ? Container( + child: GestureDetector( + onTap: () => onLongPress(context), + child: Container( + width: MediaQuery.of(context).size.width * 0.3, + padding: const EdgeInsets.only( + bottom: 4.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: + Theme.of(context).brightness == Brightness.dark + ? Colors.white + : Colors.black, + borderRadius: BorderRadius.all(Radius.circular(14)), + ), + child: _buildReactionsText(context), + ), + ), + _buildReactionsTail(context), + ], + ), ), ), - if (attachment.titleLink != null || - attachment.ogScrapeUrl != null) - Text( - Uri.parse(attachment.titleLink ?? attachment.ogScrapeUrl) - .authority - .split('.') - .reversed - .take(2) - .toList() - .reversed - .join('.'), - overflow: TextOverflow.ellipsis, - style: _messageTheme.createdAt, - ), - ], - ), - ), - ), + ) + : SizedBox(), ); } - Widget _buildReactionPaint() { - return widget.message.reactionCounts?.isNotEmpty == true - ? Positioned( - left: _isMyMessage ? 8 : null, - right: !_isMyMessage ? 8 : null, - top: -6, - child: CustomPaint( - painter: _ReactionBubblePainter( - Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - ), - ), - ) - : SizedBox(); + Text _buildReactionsText(BuildContext context) { + return Text( + widget.message.reactionCounts.keys.map((reactionType) { + return _reactionToEmoji[reactionType] ?? '?'; + }).join(' ') + + ' ${widget.message.reactionCounts.values.fold(0, (t, v) => v + t).toString()}', + style: TextStyle( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white, + ), + textAlign: TextAlign.justify, + ); } void _showMessageBottomSheet(BuildContext context) { - if (!_streamChannel.channel.config.reactions && - !_streamChannel.channel.config.replies) { - return; - } - - final theme = Theme.of(context); - + final channel = StreamChannel.of(context).channel; showModalBottomSheet( clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( @@ -686,449 +507,292 @@ class _MessageWidgetState extends State ), ), context: context, - builder: (_) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - color: Colors.black87, - child: (_streamChannel.channel.config.reactions && - widget.message.status != MessageSendingStatus.FAILED) - ? ReactionPicker( - channel: StreamChannel.of(context).channel, - reactionToEmoji: reactionToEmoji, - message: widget.message, - ) - : SizedBox(), - ), - _isMyMessage - ? FlatButton( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Delete message', - style: theme.textTheme.headline - .copyWith(color: Colors.red), - ), - ), - onPressed: () { - Navigator.pop(context); - StreamChat.of(context).client.deleteMessage( - widget.message, - _streamChannel.channel.cid, - ); - }, - ) - : SizedBox(), - _isMyMessage - ? FlatButton( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Edit message', - style: theme.textTheme.headline, - ), - ), - onPressed: () async { - Navigator.pop(context); - - _showEditBottomSheet(context); - }, - ) - : SizedBox(), - (_streamChannel.channel.config.replies && - widget.message.status != MessageSendingStatus.FAILED && - widget.message.parentId == null && - !widget.isParent) - ? FlatButton( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - 'Start a thread', - style: theme.textTheme.headline, - ), - ), - onPressed: () { - Navigator.pop(context); - widget.onThreadTap(widget.message); - }, - ) - : SizedBox(), - ], + builder: (context) { + return StreamChannel( + channel: channel, + child: MessageActionsBottomSheet( + showDeleteMessage: widget.showDeleteMessage, + message: widget.message, + editMessageInputBuilder: widget.editMessageInputBuilder, + onThreadTap: widget.onThreadTap, + showEditMessage: widget.showEditMessage, + showReactions: widget.showReactions, + showReply: + widget.showReplyIndicator && widget.onThreadTap != null, ), ); }); } - void _showEditBottomSheet(BuildContext context) { - 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: _streamChannel.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.title, - ), - 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, + List _parseAttachments(BuildContext context) { + return widget.message.attachments?.map((attachment) { + final attachmentBuilder = widget.attachmentBuilders[attachment.type]; + + if (attachmentBuilder == null) { + return SizedBox(); + } + + return Padding( + padding: EdgeInsets.only( + bottom: 4, + ), + child: GestureDetector( + onTap: () => retryMessage(context), + onLongPress: () => onLongPress(context), + child: Material( + color: _getBackgroundColor(), + clipBehavior: Clip.hardEdge, + shape: widget.attachmentShape ?? + widget.shape ?? + ContinuousRectangleBorder( + side: widget.attachmentBorderSide ?? + widget.borderSide ?? + BorderSide( color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, + ? Colors.white.withAlpha(24) + : Colors.black.withAlpha(24), ), - ), - ), + borderRadius: widget.attachmentBorderRadiusGeometry ?? + widget.borderRadiusGeometry ?? + BorderRadius.zero, ), - ], + child: Padding( + padding: widget.attachmentPadding, + child: Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + getFailedMessageWidget( + context, + padding: const EdgeInsets.all(8.0), + ), + attachmentBuilder( + context, + widget.message, + attachment, + ), + ], + ), + ), ), ), - Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: MessageInput( - editMessage: widget.message, - parentMessage: widget.isParent - ? StreamChannel.of(context) - .channel - .state - .messages - .firstWhere((message) => - message.id == widget.message.parentId) - : null, - onMessageSent: (_) { - FocusScope.of(context).unfocus(); - Navigator.pop(context); - }, - ), - ), - ], - ), - ); - }, - ); - } - - Widget _buildReactions() { - return GestureDetector( - onTap: () { - if (widget.onMessageActions != null) { - widget.onMessageActions(context, widget.message); - } else { - _showMessageBottomSheet(context); - } - }, - child: Padding( - padding: EdgeInsets.symmetric( - vertical: widget.message.reactionCounts?.isNotEmpty == true ? 4.0 : 0, - ), - child: Container( - padding: widget.message.reactionCounts?.isNotEmpty == true - ? const EdgeInsets.all(8) - : EdgeInsets.zero, - decoration: BoxDecoration( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, - borderRadius: BorderRadius.all(Radius.circular(14))), - child: AnimatedSwitcher( - duration: Duration(milliseconds: 300), - reverseDuration: Duration(milliseconds: 0), - child: (widget.message.reactionCounts != null && - widget.message.reactionCounts.isNotEmpty) - ? _buildReactionRow() - : SizedBox(), - ), - ), - ), - ); - } - - Row _buildReactionRow() { - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - ...widget.message.reactionCounts.keys.map((reactionType) { - return Text( - reactionToEmoji[reactionType] ?? '?', - ); - }), - Padding( - padding: const EdgeInsets.only(left: 4.0), - child: Text( - widget.message.reactionCounts.values - .fold(0, (t, v) => v + t) - .toString(), - style: TextStyle( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.black - : Colors.white, ), - ), - ), - ], - ); + ); + })?.toList() ?? + []; } - final Map reactionToEmoji = { - 'love': '❤️️', - 'haha': '😂', - 'like': '👍', - 'sad': '😕', - 'angry': '😡', - 'wow': '😲', - }; - - Widget _buildImage( - Attachment attachment, - ) { - final errorWidget = Container( - width: 200, - height: 140, - color: Color(0xffd0021B).withOpacity(.1), - child: Center( - child: Icon( - Icons.error_outline, - color: Colors.white, - ), - ), - ); - - if (attachment.thumbUrl == null && - attachment.imageUrl == null && - attachment.assetUrl == null) { - return errorWidget; + void onLongPress(BuildContext context) { + if (widget.message.isEphemeral || + widget.message.status == MessageSendingStatus.SENDING) { + return; } - return CachedNetworkImage( - imageUrl: - attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl, - errorWidget: (context, url, error) { - return errorWidget; - }, - fit: BoxFit.cover, - ); - } - - Widget _buildErrorImage(Attachment attachment) { - return Center( - child: Container( - width: 200, - height: 140, - color: Color(0xffd0021B).withOpacity(.1), - child: Center( - child: Icon( - Icons.error_outline, - color: Colors.white, - ), - ), - ), - ); - } - - Widget _buildVideo( - Attachment attachment, - ) { - VideoPlayerController videoController; - if (_videoControllers.containsKey(attachment.assetUrl)) { - videoController = _videoControllers[attachment.assetUrl]; + if (widget.onMessageActions != null) { + widget.onMessageActions(context, widget.message); } else { - videoController = VideoPlayerController.network(attachment.assetUrl); - _videoControllers[attachment.assetUrl] = videoController; + _showMessageBottomSheet(context); } + return; + } - return FutureBuilder( - future: videoController.value.initialized - ? Future.value(true) - : videoController.initialize(), - builder: (_, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return Container( - height: 100, - width: 100, - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - ChewieController chewieController; - if (_chuwieControllers.containsKey(attachment.assetUrl)) { - chewieController = _chuwieControllers[attachment.assetUrl]; - } else { - chewieController = ChewieController( - allowFullScreen: widget.showVideoFullScreen, - videoPlayerController: videoController, - autoInitialize: false, - aspectRatio: videoController.value.aspectRatio, - errorBuilder: (_, e) { - if (attachment.thumbUrl != null) { - return Stack( - children: [ - Container( - decoration: BoxDecoration( - image: DecorationImage( - fit: BoxFit.cover, - image: CachedNetworkImageProvider( - attachment.thumbUrl, - ), - ), - ), - ), - if (attachment.titleLink != null) - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _launchURL(attachment.titleLink), - ), - ), - ], - ); + Widget _buildReplyIndicator(double leftPadding) { + return Padding( + padding: EdgeInsets.only( + left: leftPadding, + ), + child: Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: ReplyIndicator( + message: widget.message, + reversed: widget.reverse, + messageTheme: widget.messageTheme, + onTap: widget.onThreadTap != null + ? () { + widget.onThreadTap(widget.message); } - - return _buildErrorImage(attachment); - }); - _chuwieControllers[attachment.assetUrl] = chewieController; - } - return Chewie( - key: ValueKey( - 'ATTACHMENT-${attachment.title}-${widget.message.id}'), - controller: chewieController, - ); - }, + : null, + ), + ), ); } - Future _launchURL(String url) async { - if (await canLaunch(url)) { - await launch(url); - } else { - Scaffold.of(context).showSnackBar( - SnackBar( - content: Text('Cannot launch the url'), + Widget _buildSendingIndicator() { + return Transform.translate( + offset: Offset( + 0, + 4, + ), + child: Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: SendingIndicator( + message: widget.message, + ), + ), + ); + } + + Widget _buildUserAvatar() => Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Transform.translate( + offset: Offset( + 0, widget.messageTheme.avatarTheme.constraints.maxHeight / 2), + child: UserAvatar( + user: widget.message.user, + onTap: widget.onUserAvatarTap, + constraints: widget.messageTheme.avatarTheme.constraints, + ), + ), + ), + ); + + Widget getFailedMessageWidget( + BuildContext context, { + EdgeInsetsGeometry padding, + }) { + Widget failedWidget; + if (widget.message.status == MessageSendingStatus.FAILED) { + failedWidget = Text( + 'MESSAGE FAILED · CLICK TO TRY AGAIN', + style: widget.messageTheme.messageText.copyWith( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(.5) + : Colors.black.withOpacity(.5), + fontSize: 11, ), ); } + if (widget.message.status == MessageSendingStatus.FAILED_UPDATE) { + failedWidget = Text( + 'MESSAGE UPDATE FAILED · CLICK TO TRY AGAIN', + style: widget.messageTheme.messageText.copyWith( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(.5) + : Colors.black.withOpacity(.5), + fontSize: 11, + ), + ); + } + if (widget.message.status == MessageSendingStatus.FAILED_DELETE) { + failedWidget = Text( + 'MESSAGE DELETE FAILED · CLICK TO TRY AGAIN', + style: widget.messageTheme.messageText.copyWith( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(.5) + : Colors.black.withOpacity(.5), + fontSize: 11, + ), + ); + } + + if (failedWidget != null) { + return Padding( + padding: padding ?? EdgeInsets.zero, + child: failedWidget, + ); + } + + return SizedBox(); } - @override - void dispose() { - _videoControllers.values.forEach((element) { - element.dispose(); - }); - super.dispose(); - } - - Widget _buildTimestamp(Alignment alignment) { - return Padding( - padding: const EdgeInsets.only(top: 5.0), - child: RichText( - text: TextSpan( - style: _messageTheme.createdAt, - children: [ - if (!_isMyMessage && widget.showOtherMessageUsername) - TextSpan( - text: widget.message.user.name, - style: TextStyle(fontWeight: FontWeight.bold), - ), - if (widget.message.createdAt != null) - TextSpan( - text: - Jiffy(widget.message.createdAt.toLocal()).format(' HH:mm'), - ), - ], + Widget _buildTextBubble(BuildContext context) { + return GestureDetector( + onTap: () => retryMessage(context), + onLongPress: () => onLongPress(context), + child: Material( + shape: widget.shape ?? + ContinuousRectangleBorder( + side: widget.borderSide ?? + BorderSide( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withAlpha(24) + : Colors.black.withAlpha(24), + ), + borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, + ), + color: _getBackgroundColor(), + child: Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: Padding( + padding: widget.textPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + getFailedMessageWidget(context), + _buildText(context), + ], + ), + ), ), ), ); } - BoxDecoration _buildBoxDecoration(bool rectBorders) { - return BoxDecoration( - border: _isMyMessage - ? null - : Border.all( - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white.withAlpha(24) - : Colors.black.withAlpha(24)), - borderRadius: BorderRadius.only( - topLeft: Radius.circular((_isMyMessage || !rectBorders) ? 16 : 2), - bottomLeft: Radius.circular(_isMyMessage ? 16 : 2), - topRight: Radius.circular((_isMyMessage && rectBorders) ? 2 : 16), - bottomRight: Radius.circular(_isMyMessage ? 2 : 16), - ), - color: widget.message.status == MessageSendingStatus.FAILED - ? Color(0xffd0021B).withOpacity(.1) - : _messageTheme.messageBackgroundColor, - ); + Color _getBackgroundColor() { + return (widget.message.status == MessageSendingStatus.FAILED || + widget.message.status == MessageSendingStatus.FAILED_UPDATE || + widget.message.status == MessageSendingStatus.FAILED_DELETE) + ? Color(0xffd0021B).withOpacity(.1) + : widget.messageTheme.messageBackgroundColor; } - @override - bool get wantKeepAlive { - return widget.message.attachments.isNotEmpty; + 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; + } + } + + Widget _buildText(BuildContext context) { + return widget.textBuilder != null + ? widget.textBuilder(context, widget.message) + : MessageText( + message: widget.message, + onMentionTap: widget.onMentionTap, + messageTheme: widget.messageTheme, + ); } } -class _ReactionBubblePainter extends CustomPainter { +class ReactionBubblePainter extends CustomPainter { final Color color; - _ReactionBubblePainter(this.color); + ReactionBubblePainter(this.color); @override void paint(Canvas canvas, Size size) { final paint = Paint()..color = color; final path = Path(); - path.arcToPoint(Offset(-6, -6)); - path.arcToPoint(Offset(0, 10)); - path.arcToPoint(Offset(6, -6)); + path.lineTo(-2, -6); + path.lineTo(0, 10); + path.lineTo(10, -6); + path.lineTo(-2, -6); canvas.drawPath(path, paint); } diff --git a/lib/src/reaction_picker.dart b/lib/src/reaction_picker.dart index 221dd3d8..56127476 100644 --- a/lib/src/reaction_picker.dart +++ b/lib/src/reaction_picker.dart @@ -24,56 +24,62 @@ class ReactionPicker extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: reactionToEmoji.keys.map((reactionType) { - final ownReactionIndex = message.ownReactions - ?.indexWhere((reaction) => reaction.type == reactionType) ?? - -1; - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IconButton( - iconSize: size, - icon: Text( - reactionToEmoji[reactionType], - style: TextStyle( - fontSize: size - 10, + return Container( + color: Colors.black87, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: reactionToEmoji.keys.map((reactionType) { + final ownReactionIndex = message.ownReactions + ?.indexWhere((reaction) => reaction.type == reactionType) ?? + -1; + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + iconSize: size, + icon: Text( + reactionToEmoji[reactionType], + style: TextStyle( + fontSize: size - 10, + ), ), + onPressed: () { + if (ownReactionIndex != -1) { + removeReaction( + context, message.ownReactions[ownReactionIndex]); + } else { + sendReaction(context, reactionType); + } + }, ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction(context, reactionType); - } else { - sendReaction(context, reactionType); - } - }, - ), - ownReactionIndex != -1 - ? Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: Text( - message.ownReactions[ownReactionIndex].score.toString(), - style: TextStyle(color: Colors.white), - ), - ) - : SizedBox(), - ], - ); - }).toList(), + ownReactionIndex != -1 + ? Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + message.ownReactions[ownReactionIndex].score.toString(), + style: TextStyle(color: Colors.white), + ), + ) + : SizedBox(), + ], + ); + }).toList(), + ), ); } + /// Add a reaction to the message void sendReaction(BuildContext context, String reactionType) { - channel.sendReaction(message.id, reactionType); + channel.sendReaction(message, reactionType); Navigator.of(context).pop(); } - void removeReaction(BuildContext context, String reactionType) { - channel.deleteReaction(message.id, reactionType); + /// Remove a reaction from the message + void removeReaction(BuildContext context, Reaction reaction) { + channel.deleteReaction(message, reaction); Navigator.of(context).pop(); } } diff --git a/lib/src/reply_indicator.dart b/lib/src/reply_indicator.dart new file mode 100644 index 00000000..cdb1cd57 --- /dev/null +++ b/lib/src/reply_indicator.dart @@ -0,0 +1,56 @@ +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 new file mode 100644 index 00000000..81e7dfef --- /dev/null +++ b/lib/src/sending_indicator.dart @@ -0,0 +1,54 @@ +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 index 2943441e..62775281 100644 --- a/lib/src/stream_channel.dart +++ b/lib/src/stream_channel.dart @@ -50,8 +50,14 @@ class StreamChannelState extends State { /// 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 (_paginationEnded) { + return; + } + _queryMessageController.add(true); String firstId; @@ -67,6 +73,9 @@ class StreamChannelState extends State { ), ) .then((res) { + if (res.messages.isEmpty) { + _paginationEnded = true; + } _queryMessageController.add(false); }).catchError((e, stack) { _queryMessageController.addError(e, stack); @@ -75,8 +84,11 @@ class StreamChannelState extends State { /// Calls [channel.getReplies] updating [queryMessage] stream Future getReplies(String parentId) async { + if (_paginationEnded) { + return; + } + _queryMessageController.add(true); - print('PARENT $parentId'); String firstId; if (widget.channel.state.threads.containsKey(parentId)) { @@ -96,12 +108,16 @@ class StreamChannelState extends State { ), ) .then((res) { + if (res.messages.isEmpty) { + _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( @@ -133,14 +149,18 @@ class StreamChannelState extends State { initialData: widget.channel.state != null, builder: (context, snapshot) { if (!snapshot.hasData || !snapshot.data) { - return Scaffold( - body: Center( + return Container( + height: 30, + child: Center( child: CircularProgressIndicator(), ), ); } else if (snapshot.hasError) { - return Center( - child: Text(snapshot.error), + return Container( + height: 30, + child: Center( + child: Text(snapshot.error), + ), ); } else { return widget.child; diff --git a/lib/src/stream_chat.dart b/lib/src/stream_chat.dart index acd829ff..92394ff0 100644 --- a/lib/src/stream_chat.dart +++ b/lib/src/stream_chat.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; 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_theme.dart'; @@ -59,47 +58,27 @@ class StreamChat extends StatefulWidget { } } -class StreamChatState extends State - with AutomaticKeepAliveClientMixin { - final List _subscriptions = []; +/// The current state of the StreamChat widget +class StreamChatState extends State with WidgetsBindingObserver { Client get client => widget.client; - final GlobalKey _navigatorKey = GlobalKey(); + Timer _disconnectTimer; @override Widget build(BuildContext context) { - super.build(context); final theme = _getTheme(context, widget.streamChatThemeData); return StreamChatTheme( data: theme, child: Builder( builder: (context) { final materialTheme = Theme.of(context); - final streamChatTheme = StreamChatTheme.of(context); + final streamTheme = StreamChatTheme.of(context); return Theme( data: materialTheme.copyWith( - accentColor: streamChatTheme.accentColor, - scaffoldBackgroundColor: streamChatTheme.backgroundColor, - ), - child: WillPopScope( - onWillPop: () async { - if (_navigatorKey.currentState.canPop()) { - _navigatorKey.currentState.pop(); - return false; - } else { - return true; - } - }, - child: Navigator( - initialRoute: '/', - key: _navigatorKey, - onGenerateRoute: (settings) { - return MaterialPageRoute( - settings: settings, - builder: (_) => widget.child, - ); - }, - ), + primaryIconTheme: streamTheme.primaryIconTheme, + accentColor: streamTheme.accentColor, + scaffoldBackgroundColor: streamTheme.backgroundColor, ), + child: widget.child, ); }, ), @@ -114,6 +93,7 @@ class StreamChatState extends State final theme = defaultTheme.copyWith( primaryColor: themeData?.primaryColor, defaultChannelImage: themeData?.defaultChannelImage, + primaryIconTheme: themeData?.primaryIconTheme, defaultUserImage: themeData?.defaultUserImage, backgroundColor: themeData?.backgroundColor, channelTheme: defaultTheme.channelTheme.copyWith( @@ -174,86 +154,85 @@ class StreamChatState extends State title: themeData?.channelPreviewTheme?.title, lastMessageAt: themeData?.channelPreviewTheme?.lastMessageAt, subtitle: themeData?.channelPreviewTheme?.subtitle, + unreadCounterColor: themeData?.channelPreviewTheme?.unreadCounterColor, ), ); return theme; } - @override - void initState() { - super.initState(); - _subscriptions.add(widget.client.on('message.new').listen((Event e) { - final index = channels.indexWhere((c) => c.cid == e.cid); - if (index > 0) { - final channel = channels.removeAt(index); - channels.insert(0, channel); - _channelsController.add(channels); - } - })); - } - /// The current user User get user => widget.client.state.user; /// The current user as a stream Stream get userStream => widget.client.state.userStream; - /// The current channel list - final List channels = []; - - /// The current channel list as a stream - Stream> get channelsStream => _channelsController.stream; - - final BehaviorSubject> _channelsController = BehaviorSubject(); - - final BehaviorSubject _queryChannelsLoadingController = - BehaviorSubject.seeded(false); - - /// The stream notifying the state of queryChannel call - Stream get queryChannelsLoading => - _queryChannelsLoadingController.stream; - - /// Calls [client.queryChannels] updating [queryChannelsLoading] stream - Future queryChannels({ - Map filter, - List sortOptions, - PaginationParams paginationParams, - Map options, - }) async { - if (_queryChannelsLoadingController.value) { - return; - } - _queryChannelsLoadingController.sink.add(true); - - try { - final res = await widget.client.queryChannels( - filter: filter, - sort: sortOptions, - options: options, - paginationParams: paginationParams, - ); - channels.addAll(res); - _channelsController.sink.add(channels); - } catch (e) { - _channelsController.sink.addError(e); - } finally { - _queryChannelsLoadingController.sink.add(false); - } + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); } - /// Clear the current channel list - void clearChannels() { - channels.clear(); + StreamSubscription _newMessageSubscription; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + if (client.showLocalNotification != null) { + _newMessageSubscription = client + .on(EventType.messageNew) + .where((e) => e.user?.id != user.id) + .where((e) => e.message.silent != true) + .listen((event) async { + var channel = client.state.channels[event.cid]; + + if (channel == null) { + channel = client.channel( + event.type, + id: event.cid.split(':')[1], + ); + await channel.query(); + } + + client.showLocalNotification( + event.message, + ChannelModel( + id: channel.id, + createdAt: channel.createdAt, + extraData: channel.extraData, + type: channel.type, + memberCount: channel.memberCount, + frozen: channel.frozen, + cid: channel.cid, + deletedAt: channel.deletedAt, + config: channel.config, + createdBy: channel.createdBy, + updatedAt: channel.updatedAt, + lastMessageAt: channel.lastMessageAt, + ), + ); + }); + _disconnectTimer = Timer(client.backgroundKeepAlive, () { + client.disconnect(); + }); + } else { + client.disconnect(); + } + } else if (state == AppLifecycleState.resumed) { + _newMessageSubscription?.cancel(); + if (_disconnectTimer?.isActive == true) { + _disconnectTimer.cancel(); + } else { + if (client.wsConnectionStatus.value == ConnectionStatus.disconnected) { + NotificationService.handleIosMessageQueue(client); + client.connect(); + } + } + } } @override void dispose() { - _subscriptions.forEach((s) => s.cancel()); - _queryChannelsLoadingController.close(); - _channelsController.close(); + WidgetsBinding.instance.removeObserver(this); super.dispose(); } - - @override - bool get wantKeepAlive => true; } diff --git a/lib/src/stream_chat_theme.dart b/lib/src/stream_chat_theme.dart index ca574716..e75d30c3 100644 --- a/lib/src/stream_chat_theme.dart +++ b/lib/src/stream_chat_theme.dart @@ -24,7 +24,16 @@ class StreamChatTheme extends InheritedWidget { /// Use this method to get the current [StreamChatThemeData] instance static StreamChatThemeData of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType().data; + 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; } } @@ -60,6 +69,9 @@ class StreamChatThemeData { /// The widget that will be built when the user image is unavailable final Widget Function(BuildContext, User) defaultUserImage; + /// Primary icon theme + final IconThemeData primaryIconTheme; + /// Create a theme from scratch StreamChatThemeData({ this.primaryColor, @@ -72,6 +84,7 @@ class StreamChatThemeData { this.ownMessageTheme, this.defaultChannelImage, this.defaultUserImage, + this.primaryIconTheme, }); /// Create a theme from a Material [Theme] @@ -80,6 +93,7 @@ class StreamChatThemeData { return defaultTheme.copyWith( accentColor: theme.accentColor, + primaryIconTheme: theme.primaryIconTheme, primaryColor: theme.colorScheme.primary, secondaryColor: theme.colorScheme.secondary, backgroundColor: theme.scaffoldBackgroundColor, @@ -114,10 +128,12 @@ class StreamChatThemeData { MessageTheme otherMessageTheme, Widget Function(BuildContext, Channel) defaultChannelImage, Widget Function(BuildContext, User) defaultUserImage, + IconThemeData primaryIconTheme, }) => StreamChatThemeData( primaryColor: primaryColor ?? this.primaryColor, secondaryColor: secondaryColor ?? this.secondaryColor, + primaryIconTheme: primaryIconTheme ?? this.primaryIconTheme, accentColor: accentColor ?? this.accentColor, defaultChannelImage: defaultChannelImage ?? this.defaultChannelImage, defaultUserImage: defaultUserImage ?? this.defaultUserImage, @@ -190,6 +206,8 @@ class StreamChatThemeData { return StreamChatThemeData( accentColor: accentColor, primaryColor: isDark ? Colors.black : Colors.white, + primaryIconTheme: + IconThemeData(color: isDark ? Colors.white : Colors.black), defaultChannelImage: (context, channel) => SizedBox(), backgroundColor: isDark ? Colors.black : Colors.white, defaultUserImage: (context, user) => Center( @@ -199,6 +217,7 @@ class StreamChatThemeData { ), ), channelPreviewTheme: ChannelPreviewTheme( + unreadCounterColor: Color(0xffd0021B), avatarTheme: AvatarTheme( borderRadius: BorderRadius.circular(20), constraints: BoxConstraints.tightFor( @@ -423,12 +442,14 @@ class ChannelPreviewTheme { final TextStyle subtitle; final TextStyle lastMessageAt; final AvatarTheme avatarTheme; + final Color unreadCounterColor; const ChannelPreviewTheme({ this.title, this.subtitle, this.lastMessageAt, this.avatarTheme, + this.unreadCounterColor, }); ChannelPreviewTheme copyWith({ @@ -436,12 +457,14 @@ class ChannelPreviewTheme { TextStyle subtitle, TextStyle lastMessageAt, AvatarTheme avatarTheme, + Color unreadCounterColor, }) => ChannelPreviewTheme( title: title ?? this.title, subtitle: subtitle ?? this.subtitle, lastMessageAt: lastMessageAt ?? this.lastMessageAt, avatarTheme: avatarTheme ?? this.avatarTheme, + unreadCounterColor: unreadCounterColor ?? this.unreadCounterColor, ); } diff --git a/lib/src/system_message.dart b/lib/src/system_message.dart new file mode 100644 index 00000000..7454b0ee --- /dev/null +++ b/lib/src/system_message.dart @@ -0,0 +1,100 @@ +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 { + final Message message; + + const SystemMessage({ + Key key, + @required this.message, + }) : 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 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/lib/src/thread_header.dart index 07f10e60..ebff1b02 100644 --- a/lib/src/thread_header.dart +++ b/lib/src/thread_header.dart @@ -62,6 +62,7 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { /// The message parent of this thread final Message parent; + /// Instantiate a new ThreadHeader ThreadHeader({ Key key, @required this.parent, @@ -129,9 +130,6 @@ class ThreadHeader extends StatelessWidget implements PreferredSizeWidget { child: Icon( Icons.close, size: 15, - color: Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, ), ), ), diff --git a/lib/src/typing_indicator.dart b/lib/src/typing_indicator.dart index a759ecde..57427da6 100644 --- a/lib/src/typing_indicator.dart +++ b/lib/src/typing_indicator.dart @@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/src/stream_channel.dart'; /// Widget to show the current list of typing users class TypingIndicator extends StatelessWidget { + /// Instantiate a new TypingIndicator const TypingIndicator({ Key key, this.channel, diff --git a/lib/src/unread_indicator.dart b/lib/src/unread_indicator.dart new file mode 100644 index 00000000..73f57d05 --- /dev/null +++ b/lib/src/unread_indicator.dart @@ -0,0 +1,28 @@ +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 Padding( + padding: const EdgeInsets.only(left: 8.0), + child: CircleAvatar( + backgroundColor: + StreamChatTheme.of(context).channelPreviewTheme.unreadCounterColor, + radius: 6, + child: Text( + '${channel.state.unreadCount}', + style: TextStyle(fontSize: 8), + ), + ), + ); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart new file mode 100644 index 00000000..c427099b --- /dev/null +++ b/lib/src/utils.dart @@ -0,0 +1,14 @@ +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/lib/src/video_attachment.dart b/lib/src/video_attachment.dart new file mode 100644 index 00000000..a903147b --- /dev/null +++ b/lib/src/video_attachment.dart @@ -0,0 +1,155 @@ +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/utils.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:video_player/video_player.dart'; + +import 'attachment_error.dart'; +import 'attachment_title.dart'; + +class VideoAttachment extends StatefulWidget { + final Attachment attachment; + final MessageTheme messageTheme; + final Size size; + + VideoAttachment({ + Key key, + @required this.attachment, + @required this.messageTheme, + this.size, + }) : super(key: key); + + @override + _VideoAttachmentState createState() => _VideoAttachmentState(); +} + +class _VideoAttachmentState extends State { + ChewieController _chewieController; + VideoPlayerController _videoPlayerController; + bool initialized = false; + + @override + Widget build(BuildContext context) { + if (!initialized) { + return Container( + height: widget.size?.height ?? 100, + width: widget.size?.width ?? 100, + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + _chewieController = ChewieController( + videoPlayerController: _videoPlayerController, + autoInitialize: true, + showControls: false, + aspectRatio: _videoPlayerController.value.aspectRatio, + errorBuilder: (_, e) { + if (widget.attachment.thumbUrl != null) { + return Stack( + children: [ + Container( + height: widget.size?.height, + width: widget.size?.width, + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.cover, + image: CachedNetworkImageProvider( + widget.attachment.thumbUrl, + ), + ), + ), + ), + if (widget.attachment.titleLink != null) + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => + launchURL(context, widget.attachment.titleLink), + ), + ), + ], + ); + } + return AttachmentError( + attachment: widget.attachment, + size: widget.size, + ); + }); + + return GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => FullScreenVideo( + attachment: widget.attachment, + ), + ), + ); + }, + child: Container( + height: widget.size?.height, + width: widget.size?.width, + child: Flex( + direction: Axis.vertical, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: FittedBox( + fit: BoxFit.cover, + child: Stack( + children: [ + Chewie( + controller: _chewieController, + ), + Positioned.fill( + child: Center( + child: Material( + shape: CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Icon(Icons.play_arrow), + ), + ), + ), + ), + ], + ), + ), + ), + if (widget.attachment.title != null) + Material( + child: AttachmentTitle( + messageTheme: widget.messageTheme, + attachment: widget.attachment, + ), + ), + ], + ), + ), + ); + } + + @override + void initState() { + super.initState(); + _videoPlayerController = + VideoPlayerController.network(widget.attachment.assetUrl); + _videoPlayerController.initialize().whenComplete(() { + setState(() { + initialized = true; + }); + }); + } + + @override + void dispose() { + _videoPlayerController?.dispose(); + _chewieController?.dispose(); + super.dispose(); + } +} diff --git a/lib/stream_chat_flutter.dart b/lib/stream_chat_flutter.dart index 857336ce..3000a503 100644 --- a/lib/stream_chat_flutter.dart +++ b/lib/stream_chat_flutter.dart @@ -5,12 +5,25 @@ export 'src/channel_image.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/giphy_attachment.dart'; +export 'src/image_attachment.dart'; export 'src/message_input.dart'; 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/system_message.dart'; export 'src/thread_header.dart'; export 'src/typing_indicator.dart'; +export 'src/user_avatar.dart'; +export 'src/video_attachment.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index e81a8654..77840411 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 0.1.35 +version: 0.2.1-alpha+11 environment: sdk: ">=2.3.0 <3.0.0" @@ -9,19 +9,21 @@ environment: dependencies: flutter: sdk: flutter - rxdart: ^0.24.0 + photo_view: ^0.9.2 + rxdart: ^0.24.1 jiffy: ^3.0.1 - cached_network_image: ^2.1.0+1 - flutter_markdown: ^0.3.5 - url_launcher: ^5.4.2 - video_player: ^0.10.8+1 + flutter_portal: ^0.1.0 + cached_network_image: ^2.2.0+1 + flutter_markdown: ^0.4.2 + url_launcher: ^5.4.11 + video_player: ^0.10.11+1 chewie: ^0.9.10 - file_picker: ^1.6.3+2 - image_picker: ^0.6.5 - flutter_keyboard_visibility: ^2.0.0 - stream_chat: ^0.1.28 + file_picker: ^1.12.0 + image_picker: ^0.6.7+2 + flutter_keyboard_visibility: ^3.2.1 + stream_chat: ^0.2.0-alpha+23 mime: ^0.9.6+3 - visibility_detector: ^0.1.4 + visibility_detector: ^0.1.5 http_parser: ^3.1.4 dev_dependencies: