diff --git a/.github/workflows/flutter-analyze.yml b/.github/workflows/flutter-analyze.yml new file mode 100644 index 00000000..312a65ba --- /dev/null +++ b/.github/workflows/flutter-analyze.yml @@ -0,0 +1,24 @@ +name: Flutter Analyze +on: + push: + branches: + - master + + pull_request: + branches: + - '*' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: subosito/flutter-action@v1 + with: + channel: 'beta' + - run: flutter pub get + + - name: 'Flutter Format Check' + run: flutter format --set-exit-if-changed --dry-run . + + - run: flutter analyze diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..eb658d4d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,19 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "stream-chat-flutter", + "request": "launch", + "type": "dart" + }, + { + "name": "example", + "cwd": "example", + "request": "launch", + "type": "dart" + } + ] +} \ No newline at end of file diff --git a/example/ios/Flutter/.last_build_id b/example/ios/Flutter/.last_build_id new file mode 100644 index 00000000..1e10204b --- /dev/null +++ b/example/ios/Flutter/.last_build_id @@ -0,0 +1 @@ +c1153e968f5d6bcc19c41ac2592825f6 \ No newline at end of file diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock new file mode 100644 index 00000000..47b68109 --- /dev/null +++ b/example/ios/Podfile.lock @@ -0,0 +1,263 @@ +PODS: + - DKImagePickerController/Core (4.3.2): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.2) + - DKImagePickerController/PhotoGallery (4.3.2): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.2) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Firebase/CoreOnly (6.33.0): + - FirebaseCore (= 6.10.3) + - Firebase/Messaging (6.33.0): + - Firebase/CoreOnly + - FirebaseMessaging (~> 4.7.0) + - firebase_core (0.5.3): + - Firebase/CoreOnly (~> 6.33.0) + - Flutter + - firebase_messaging (7.0.3): + - Firebase/CoreOnly (~> 6.33.0) + - Firebase/Messaging (~> 6.33.0) + - firebase_core + - Flutter + - FirebaseCore (6.10.3): + - FirebaseCoreDiagnostics (~> 1.6) + - GoogleUtilities/Environment (~> 6.7) + - GoogleUtilities/Logger (~> 6.7) + - FirebaseCoreDiagnostics (1.7.0): + - GoogleDataTransport (~> 7.4) + - GoogleUtilities/Environment (~> 6.7) + - GoogleUtilities/Logger (~> 6.7) + - nanopb (~> 1.30906.0) + - FirebaseInstallations (1.7.0): + - FirebaseCore (~> 6.10) + - GoogleUtilities/Environment (~> 6.7) + - GoogleUtilities/UserDefaults (~> 6.7) + - PromisesObjC (~> 1.2) + - FirebaseInstanceID (4.8.0): + - FirebaseCore (~> 6.10) + - FirebaseInstallations (~> 1.6) + - GoogleUtilities/Environment (~> 6.7) + - GoogleUtilities/UserDefaults (~> 6.7) + - FirebaseMessaging (4.7.1): + - FirebaseCore (~> 6.10) + - FirebaseInstanceID (~> 4.7) + - GoogleUtilities/AppDelegateSwizzler (~> 6.7) + - GoogleUtilities/Environment (~> 6.7) + - GoogleUtilities/Reachability (~> 6.7) + - GoogleUtilities/UserDefaults (~> 6.7) + - Protobuf (>= 3.9.2, ~> 3.9) + - Flutter (1.0.0) + - flutter_apns (0.0.1): + - Flutter + - flutter_keyboard_visibility (0.0.1): + - Flutter + - flutter_local_notifications (0.0.1): + - Flutter + - FMDB (2.7.5): + - FMDB/standard (= 2.7.5) + - FMDB/standard (2.7.5) + - GoogleDataTransport (7.5.1): + - nanopb (~> 1.30906.0) + - GoogleUtilities/AppDelegateSwizzler (6.7.2): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (6.7.2): + - PromisesObjC (~> 1.2) + - GoogleUtilities/Logger (6.7.2): + - GoogleUtilities/Environment + - GoogleUtilities/Network (6.7.2): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (6.7.2)" + - GoogleUtilities/Reachability (6.7.2): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (6.7.2): + - GoogleUtilities/Logger + - image_picker (0.0.1): + - Flutter + - nanopb (1.30906.0): + - nanopb/decode (= 1.30906.0) + - nanopb/encode (= 1.30906.0) + - nanopb/decode (1.30906.0) + - nanopb/encode (1.30906.0) + - path_provider (0.0.1): + - Flutter + - PromisesObjC (1.2.11) + - Protobuf (3.13.0) + - SDWebImage (5.10.0): + - SDWebImage/Core (= 5.10.0) + - SDWebImage/Core (5.10.0) + - shared_preferences (0.0.1): + - Flutter + - sqflite (0.0.2): + - Flutter + - FMDB (>= 2.7.5) + - sqlite3 (3.32.3): + - sqlite3/common (= 3.32.3) + - sqlite3/common (3.32.3) + - sqlite3/fts5 (3.32.3): + - sqlite3/common + - sqlite3/json1 (3.32.3): + - sqlite3/common + - sqlite3/perf-threadsafe (3.32.3): + - sqlite3/common + - sqlite3/rtree (3.32.3): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - sqlite3 (~> 3.32.3) + - sqlite3/fts5 + - sqlite3/json1 + - sqlite3/perf-threadsafe + - sqlite3/rtree + - Starscream (4.0.4) + - StreamChatClient (2.4.2): + - Starscream (~> 4.0) + - SwiftyGif (5.3.0) + - url_launcher (0.0.1): + - Flutter + - video_player (0.0.1): + - Flutter + - wakelock (0.0.1): + - Flutter + +DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/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`) + - image_picker (from `.symlinks/plugins/image_picker/ios`) + - path_provider (from `.symlinks/plugins/path_provider/ios`) + - shared_preferences (from `.symlinks/plugins/shared_preferences/ios`) + - sqflite (from `.symlinks/plugins/sqflite/ios`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`) + - StreamChatClient + - url_launcher (from `.symlinks/plugins/url_launcher/ios`) + - video_player (from `.symlinks/plugins/video_player/ios`) + - wakelock (from `.symlinks/plugins/wakelock/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - Firebase + - FirebaseCore + - FirebaseCoreDiagnostics + - FirebaseInstallations + - FirebaseInstanceID + - FirebaseMessaging + - FMDB + - GoogleDataTransport + - GoogleUtilities + - nanopb + - PromisesObjC + - Protobuf + - SDWebImage + - sqlite3 + - Starscream + - StreamChatClient + - SwiftyGif + +EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/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" + image_picker: + :path: ".symlinks/plugins/image_picker/ios" + path_provider: + :path: ".symlinks/plugins/path_provider/ios" + shared_preferences: + :path: ".symlinks/plugins/shared_preferences/ios" + sqflite: + :path: ".symlinks/plugins/sqflite/ios" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/ios" + url_launcher: + :path: ".symlinks/plugins/url_launcher/ios" + video_player: + :path: ".symlinks/plugins/video_player/ios" + wakelock: + :path: ".symlinks/plugins/wakelock/ios" + +SPEC CHECKSUMS: + DKImagePickerController: b5eb7f7a388e4643264105d648d01f727110fc3d + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 + Firebase: 8db6f2d1b2c5e2984efba4949a145875a8f65fe5 + firebase_core: 5d6a02f3d85acd5f8321c2d6d62877626a670659 + firebase_messaging: 0aea2cd5885b65e19ede58ee3507f485c992cc75 + FirebaseCore: d889d9e12535b7f36ac8bfbf1713a0836a3012cd + FirebaseCoreDiagnostics: 770ac5958e1372ce67959ae4b4f31d8e127c3ac1 + FirebaseInstallations: 466c7b4d1f58fe16707693091da253726a731ed2 + FirebaseInstanceID: bd3ffc24367f901a43c063b36c640b345a4a5dd1 + FirebaseMessaging: 5eca4ef173de76253352511aafef774caa1cba2a + Flutter: 0e3d915762c693b495b44d77113d4970485de6ec + flutter_apns: ddc629f26016140bf52165040b0a8e8869f9ce32 + flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 + FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a + GoogleDataTransport: f56af7caa4ed338dc8e138a5d7c5973e66440833 + GoogleUtilities: 7f2f5a07f888cdb145101d6042bc4422f57e70b3 + image_picker: 9c3312491f862b28d21ecd8fdf0ee14e601b3f09 + nanopb: 59317e09cf1f1a0af72f12af412d54edf52603fc + path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c + PromisesObjC: 8c196f5a328c2cba3e74624585467a557dcb482f + Protobuf: 3dac39b34a08151c6d949560efe3f86134a3f748 + SDWebImage: 9169792e9eec3e45bba2a0c02f74bf8bd922d1ee + shared_preferences: af6bfa751691cdc24be3045c43ec037377ada40d + sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904 + sqlite3: 8f7d2078ae27778699a622a94b853285793422a2 + sqlite3_flutter_libs: 5651f8ff48e3b44d910863c4ea5916085b1b245f + Starscream: 5178aed56b316f13fa3bc55694e583d35dd414d9 + StreamChatClient: fc6419fa7ceda9c048188bb7c8e9f07755ced05f + SwiftyGif: e466e86c660d343357ab944a819a101c4127cb40 + url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef + video_player: 9cc823b1d9da7e8427ee591e8438bfbcde500e6e + wakelock: bfc7955c418d0db797614075aabbc58a39ab5107 + +PODFILE CHECKSUM: eb001256612a59f8f9e4d083ad8b9671e69dd184 + +COCOAPODS: 1.10.0 diff --git a/example/ios/Runner/AppDelegate.swift b/example/ios/Runner/AppDelegate.swift new file mode 100644 index 00000000..12b4b519 --- /dev/null +++ b/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,45 @@ +import UIKit +import Flutter + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate { + let sharedDefaults = UserDefaults(suiteName: "group.io.stream.flutter") + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate + } + 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/lib/main.dart b/example/lib/main.dart new file mode 100644 index 00000000..4b8418e9 --- /dev/null +++ b/example/lib/main.dart @@ -0,0 +1,398 @@ +import 'dart:io'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.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( + android: initializationSettingsAndroid, + iOS: initializationSettingsIOS, + ); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + await flutterLocalNotificationsPlugin.show( + message.id.hashCode, + '${message.user.name} @ ${channel.name}', + message.text, + NotificationDetails( + android: AndroidNotificationDetails( + 'message channel', + 'Message channel', + 'Channel used for showing messages', + priority: Priority.high, + importance: Importance.high, + ), + iOS: 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 ? PushProvider.firebase : PushProvider.apn, + ); + } + }); +} + +void main() async { + final client = Client( + 's2dxdhpxd94g', + logLevel: Level.INFO, + showLocalNotification: + (!kIsWeb && Platform.isAndroid) ? showLocalNotification : null, + persistenceEnabled: true, + ); + + await client.setUser( + User(id: 'super-band-9'), + 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoic3VwZXItYmFuZC05In0.0L6lGoeLwkz0aZRUcpZKsvaXtNEDHBcezVTZ0oPq40A', + ); + + if (!kIsWeb) { + _initNotifications(client); + } + + runApp(MyApp(client)); +} + +class MyApp extends StatelessWidget { + final Client client; + + MyApp(this.client); + + @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(), + ); + } +} + +class ChannelListPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Scaffold( + floatingActionButton: FloatingActionButton( + child: Icon(Icons.add), + onPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) { + return CreateChannelPage(); + })); + }, + ), + 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: Stack( + children: [ + MessageListView( + threadBuilder: (_, parentMessage) { + return ThreadPage( + parent: parentMessage, + ); + }, + ), + Positioned.fill( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4, + ), + child: TypingIndicator( + alignment: Alignment.bottomRight, + ), + ), + ), + ], + ), + ), + MessageInput(), + ], + ), + ); + } +} + +class ThreadPage extends StatelessWidget { + final Message parent; + + ThreadPage({ + Key key, + this.parent, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: ThreadHeader( + parent: parent, + ), + body: Column( + children: [ + Expanded( + child: MessageListView( + parentMessage: parent, + ), + ), + if (parent.type != 'deleted') + MessageInput( + parentMessage: parent, + ), + ], + ), + ); + } +} + +class CreateChannelPage extends StatefulWidget { + @override + _CreateChannelPageState createState() => _CreateChannelPageState(); +} + +class _CreateChannelPageState extends State { + final ScrollController _scrollController = ScrollController(); + Client client; + List users = []; + List selectedUsers = []; + int offset = 0; + bool loading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.transparent, + title: Text( + 'Create a channel', + style: Theme.of(context).textTheme.headline6, + ), + ), + floatingActionButton: + selectedUsers.isNotEmpty ? _buildFAB(context) : SizedBox(), + body: _buildListView(), + ); + } + + ListView _buildListView() { + return ListView.builder( + controller: _scrollController, + itemBuilder: _itemBuilder, + itemCount: users.length, + ); + } + + Widget _itemBuilder(context, i) { + final user = users[i]; + return ListTile( + onLongPress: () { + _selectUser(user); + }, + selected: selectedUsers.contains(user), + onTap: () { + if (selectedUsers.isNotEmpty) { + return _selectUser(user); + } + _createChannel(context, [user]); + }, + leading: UserAvatar( + user: user, + ), + title: Text(user.name), + ); + } + + Widget _buildFAB(BuildContext context) { + return FloatingActionButton( + child: Icon(Icons.done), + onPressed: () async { + String name; + if (selectedUsers.length > 1) { + name = await _showEnterNameDialog(context); + if (name?.isNotEmpty != true) { + return; + } + } + + _createChannel(context, selectedUsers, name); + }, + ); + } + + Future _showEnterNameDialog(BuildContext context) { + final controller = TextEditingController(); + return showDialog( + context: context, + builder: (context) => SimpleDialog( + contentPadding: const EdgeInsets.all(16), + title: Text('Enter a name for the channel'), + children: [ + TextField( + controller: controller, + decoration: InputDecoration( + border: OutlineInputBorder(), + ), + ), + ButtonBar( + children: [ + FlatButton( + onPressed: () => Navigator.pop(context), + child: Text('Cancel'), + ), + FlatButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text('Ok'), + ), + ], + ), + ], + ), + ); + } + + Future _createChannel( + BuildContext context, + List users, [ + String name, + ]) async { + final channel = client.channel('messaging', extraData: { + 'members': [ + client.state.user.id, + ...users.map((e) => e.id), + ], + if (name != null) 'name': name, + }); + await channel.watch(); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + child: ChannelPage(), + channel: channel, + ); + }, + ), + ); + } + + void _selectUser(User user) { + if (!selectedUsers.contains(user)) { + setState(() { + selectedUsers.add(user); + }); + } else { + setState(() { + selectedUsers.remove(user); + }); + } + } + + @override + void initState() { + super.initState(); + + client = StreamChat.of(context).client; + + _scrollController.addListener(() async { + if (!loading && + _scrollController.offset >= + _scrollController.position.maxScrollExtent - 100) { + offset += 25; + await _queryUsers(); + } + }); + + _queryUsers(); + } + + Future _queryUsers() { + loading = true; + return client.queryUsers( + pagination: PaginationParams( + limit: 25, + offset: offset, + ), + sort: [ + SortOption( + 'name', + direction: SortOption.ASC, + ), + ], + ).then((value) { + setState(() { + users = [ + ...users, + ...value.users, + ]; + }); + }).whenComplete(() => loading = false); + } +} diff --git a/example/pubspec.yaml b/example/pubspec.yaml new file mode 100644 index 00000000..ca750779 --- /dev/null +++ b/example/pubspec.yaml @@ -0,0 +1,24 @@ +name: example +description: A new Flutter project. +version: 1.0.27+28 + +environment: + sdk: ">=2.2.2 <3.0.0" + +dependencies: + flutter: + sdk: flutter + stream_chat_flutter: + path: ../ + flutter_apns: ^1.3.1 + flutter_local_notifications: ^2.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_driver: + sdk: flutter + test: any + +flutter: + uses-material-design: true diff --git a/lib/src/channel_list_view.dart b/lib/src/channel_list_view.dart new file mode 100644 index 00000000..51c02611 --- /dev/null +++ b/lib/src/channel_list_view.dart @@ -0,0 +1,472 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/channels_bloc.dart'; + +import '../stream_chat_flutter.dart'; +import 'channel_preview.dart'; +import 'stream_channel.dart'; +import 'stream_chat.dart'; + +/// Callback called when tapping on a channel +typedef ChannelTapCallback = void Function(Channel, Widget); + +/// Builder used to create a custom [ChannelPreview] from a [Channel] +typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_list_view.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_list_view_paint.png) +/// +/// It shows the list of current channels. +/// +/// ```dart +/// class ChannelListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: ChannelListView( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// channelWidget: ChannelPage(), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [StreamChat] ancestor in order to provide the information about the channels. +/// The widget uses a [ListView.custom] to render the list of channels. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class ChannelListView extends StatefulWidget { + /// Instantiate a new ChannelListView + ChannelListView({ + Key key, + this.filter, + this.options, + this.sort, + this.pagination, + this.onChannelTap, + this.onChannelLongPress, + this.channelWidget, + this.channelPreviewBuilder, + this.separatorBuilder, + this.errorBuilder, + this.emptyBuilder, + this.onImageTap, + this.loadingBuilder, + this.pullToRefresh = true, + }) : super(key: key); + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filter; + + /// The builder used while loading. + final WidgetBuilder loadingBuilder; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sort; + + /// Pagination parameters + /// limit: the number of channels to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams pagination; + + /// Function called when tapping on a channel + /// By default it calls [Navigator.push] building a [MaterialPageRoute] + /// with the widget [channelWidget] as child. + final ChannelTapCallback onChannelTap; + + /// Function called when long pressing on a channel + final Function(Channel) onChannelLongPress; + + /// Widget used when opening a channel + final Widget channelWidget; + + /// Builder used to create a custom channel preview + final ChannelPreviewBuilder channelPreviewBuilder; + + /// Builder used to create a custom item separator + final Function(BuildContext, int) separatorBuilder; + + /// The function called when the image is tapped + final Function(Channel) onImageTap; + + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + + @override + _ChannelListViewState createState() => _ChannelListViewState(); +} + +class _ChannelListViewState extends State + with WidgetsBindingObserver { + final ScrollController _scrollController = ScrollController(); + + @override + Widget build(BuildContext context) { + final channelsBloc = ChannelsBloc.of(context); + + if (!widget.pullToRefresh) { + return _buildListView(channelsBloc); + } + + return RefreshIndicator( + onRefresh: () async { + return channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + }, + child: _buildListView(channelsBloc), + ); + } + + StreamBuilder> _buildListView( + ChannelsBlocState channelsBlocState, + ) { + return StreamBuilder>( + stream: channelsBlocState.channelsStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + if (widget.errorBuilder != null) { + return widget.errorBuilder(snapshot.error); + } + + var message = snapshot.error.toString(); + if (snapshot.error is DioError) { + final dioError = snapshot.error as DioError; + if (dioError.type == DioErrorType.RESPONSE) { + message = dioError.message; + } else { + message = 'Check your connection and retry'; + } + } + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only( + right: 2.0, + ), + child: Icon(Icons.error_outline), + ), + ), + TextSpan(text: 'Error loading channels'), + ], + ), + style: Theme.of(context).textTheme.headline6, + ), + Padding( + padding: const EdgeInsets.only( + top: 16.0, + ), + child: Text(message), + ), + FlatButton( + onPressed: () { + channelsBlocState.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + }, + child: Text('Retry'), + ), + ], + ), + ); + } + + if (!snapshot.hasData) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: widget.loadingBuilder != null + ? widget.loadingBuilder(context) + : Center( + child: CircularProgressIndicator(), + ), + ), + ); + }, + ); + } + + final channels = snapshot.data; + + if (channels.isEmpty && widget.emptyBuilder != null) { + return widget.emptyBuilder(context); + } + + if (channels.isEmpty && widget.emptyBuilder == null) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Text('You have no channels currently'), + ), + ), + ); + }, + ); + } + + return ListView.custom( + physics: AlwaysScrollableScrollPhysics(), + controller: _scrollController, + childrenDelegate: SliverChildBuilderDelegate( + (context, i) { + return _itemBuilder(context, i, channels); + }, + childCount: (channels.length * 2) + 1, + findChildIndexCallback: (key) { + final ValueKey valueKey = key; + final index = channels.indexWhere( + (channel) => 'CHANNEL-${channel.id}' == valueKey.value); + return index != -1 ? (index * 2) : null; + }, + ), + ); + }); + } + + Widget _itemBuilder(context, int i, List channels) { + if (i % 2 != 0) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, i); + } + return _separatorBuilder(context, i); + } + + i = i ~/ 2; + + final channelsProvider = ChannelsBloc.of(context); + if (i < channels.length) { + final channel = channels[i]; + + ChannelTapCallback onTap; + if (widget.onChannelTap != null) { + onTap = widget.onChannelTap; + } else { + onTap = (client, _) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + child: widget.channelWidget, + channel: client, + ); + }, + ), + ); + }; + } + + return StreamChannel( + key: ValueKey('CHANNEL-${channel.id}'), + channel: channel, + child: Builder( + builder: (context) { + Widget child; + if (widget.channelPreviewBuilder != null) { + child = widget.channelPreviewBuilder( + context, + channel, + ); + } else { + child = ChannelPreview( + onLongPress: widget.onChannelLongPress, + channel: channel, + onImageTap: widget.onImageTap != null + ? () { + widget.onImageTap(channel); + } + : null, + onTap: (channel) { + onTap(channel, widget.channelWidget); + }, + ); + } + return child; + }, + ), + ); + } else { + if (widget.loadingBuilder != null) { + return widget.loadingBuilder(context); + } + return _buildQueryProgressIndicator(context, channelsProvider); + } + } + + Widget _buildQueryProgressIndicator( + context, ChannelsBlocState channelsProvider) { + return StreamBuilder( + 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), + child: Center( + child: snapshot.data ? CircularProgressIndicator() : Container(), + ), + ); + }); + } + + Widget _separatorBuilder(context, i) { + return Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + margin: EdgeInsets.symmetric(horizontal: 16), + ); + } + + void _listenChannelPagination(ChannelsBlocState channelsProvider) { + if (_scrollController.position.maxScrollExtent == + _scrollController.offset && + _scrollController.offset != 0) { + channelsProvider.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination.copyWith( + offset: channelsProvider.channels?.length ?? 0, + ), + options: widget.options, + ); + } + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addObserver(this); + + final channelsBloc = ChannelsBloc.of(context); + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + + _scrollController.addListener(() { + channelsBloc.queryChannelsLoading.first.then((loading) { + if (!loading) { + _listenChannelPagination(channelsBloc); + } + }); + }); + + final client = StreamChat.of(context).client; + + client + .on( + EventType.connectionRecovered, + EventType.notificationAddedToChannel, + EventType.notificationMessageNew, + EventType.channelVisible, + ) + .listen((event) { + channelsBloc.queryChannels( + filter: widget.filter, + sortOptions: widget.sort, + paginationParams: widget.pagination, + options: widget.options, + ); + }); + } + + @override + void didUpdateWidget(ChannelListView oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.filter?.toString() != oldWidget.filter?.toString() || + jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.pagination?.toJson()?.toString() != + oldWidget.pagination?.toJson()?.toString() || + widget.options?.toString() != oldWidget.options?.toString()) { + final channelsBloc = ChannelsBloc.of(context); + 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_preview.dart b/lib/src/channel_preview.dart new file mode 100644 index 00000000..61603055 --- /dev/null +++ b/lib/src/channel_preview.dart @@ -0,0 +1,176 @@ +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'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/channel_preview_paint.png) +/// +/// It shows the current [Channel] preview. +/// +/// The widget uses a [StreamBuilder] to render the channel information image as soon as it updates. +/// +/// Usually you don't use this widget as it's the default channel preview used by [ChannelListView]. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class ChannelPreview extends StatelessWidget { + /// Function called when tapping this widget + final void Function(Channel) onTap; + + /// Function called when long pressing this widget + final void Function(Channel) onLongPress; + + /// Channel displayed + final Channel channel; + + /// The function called when the image is tapped + final VoidCallback onImageTap; + + ChannelPreview({ + @required this.channel, + Key key, + this.onTap, + this.onLongPress, + this.onImageTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () { + if (onTap != null) { + onTap(channel); + } + }, + onLongPress: () { + if (onLongPress != null) { + onLongPress(channel); + } + }, + leading: ChannelImage( + onTap: onImageTap, + ), + title: ChannelName( + textStyle: StreamChatTheme.of(context).channelPreviewTheme.title, + ), + subtitle: _buildSubtitle(context), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDate(context), + if (channel.state.unreadCount > 0) + UnreadIndicator( + channel: channel, + ), + ], + ), + ); + } + + Widget _buildDate(BuildContext context) { + return StreamBuilder( + stream: channel.lastMessageAtStream, + initialData: channel.lastMessageAt, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return SizedBox(); + } + final lastMessageAt = snapshot.data.toLocal(); + + String stringDate; + final now = DateTime.now(); + + if (now.year != lastMessageAt.year || + now.month != lastMessageAt.month || + now.day != lastMessageAt.day) { + stringDate = Jiffy(lastMessageAt.toLocal()).format('dd/MM/yyyy'); + } else { + stringDate = Jiffy(lastMessageAt.toLocal()).format('HH:mm'); + } + + return Text( + stringDate, + style: StreamChatTheme.of(context).channelPreviewTheme.lastMessageAt, + ); + }, + ); + } + + Widget _buildSubtitle(BuildContext context) { + return StreamBuilder( + initialData: channel.state.unreadCount, + stream: channel.state.unreadCountStream, + builder: (context, snapshot) { + final opacity = (snapshot.data ?? 0) > 0 ? 1.0 : 0.5; + return TypingIndicator( + channel: channel, + alternativeWidget: _buildLastMessage(context, opacity), + style: + StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + color: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .color + .withOpacity(opacity), + ), + ); + }, + ); + } + + Widget _buildLastMessage(BuildContext context, double opacity) { + return StreamBuilder>( + stream: channel.state.messagesStream, + initialData: channel.state.messages, + builder: (context, snapshot) { + final lastMessage = snapshot.data?.lastWhere( + (m) => m.shadowed != true, + orElse: () => null, + ); + if (lastMessage == null) { + return SizedBox(); + } + + var text = lastMessage.text; + if (lastMessage.isDeleted) { + text = 'This message was deleted.'; + } else if (lastMessage.attachments != null) { + final prefix = lastMessage.attachments + .map((e) { + if (e.type == 'image') { + return '๐Ÿ“ท'; + } else if (e.type == 'video') { + return '๐ŸŽฌ'; + } else if (e.type == 'giphy') { + return 'GIF'; + } + return null; + }) + .where((e) => e != null) + .join(' '); + + text = '$prefix ${lastMessage.text ?? ''}'; + } + + return Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: + StreamChatTheme.of(context).channelPreviewTheme.subtitle.copyWith( + color: StreamChatTheme.of(context) + .channelPreviewTheme + .subtitle + .color + .withOpacity(opacity), + ), + ); + }, + ); + } +} diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart new file mode 100644 index 00000000..c8d089f1 --- /dev/null +++ b/lib/src/message_input.dart @@ -0,0 +1,1084 @@ +import 'dart:async'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/message_list_view.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/user_avatar.dart'; + +import '../stream_chat_flutter.dart'; +import 'stream_channel.dart'; + +typedef FileUploader = Future Function(PlatformFile, Channel); +typedef AttachmentThumbnailBuilder = Widget Function( + BuildContext, + _SendingAttachment, +); + +enum ActionsLocation { + left, + right, +} + +enum SendButtonLocation { + inside, + outside, +} + +enum DefaultAttachmentTypes { + image, + video, + file, +} + +/// Inactive state +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input_paint.png) +/// Focused state +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2_paint.png) +/// +/// Widget used to enter the message and add attachments +/// +/// ```dart +/// class ChannelPage extends StatelessWidget { +/// const ChannelPage({ +/// Key key, +/// }) : super(key: key); +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// appBar: ChannelHeader(), +/// body: Column( +/// children: [ +/// Expanded( +/// child: MessageListView( +/// threadBuilder: (_, parentMessage) { +/// return ThreadPage( +/// parent: parentMessage, +/// ); +/// }, +/// ), +/// ), +/// MessageInput(), +/// ], +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// You usually put this widget in the same page of a [MessageListView] as the bottom widget. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageInput extends StatefulWidget { + /// Instantiate a new MessageInput + MessageInput({ + Key key, + this.onMessageSent, + this.preMessageSending, + this.parentMessage, + this.editMessage, + this.maxHeight = 150, + this.keyboardType = TextInputType.multiline, + this.disableAttachments = false, + this.doImageUploadRequest, + this.doFileUploadRequest, + this.initialMessage, + this.textEditingController, + this.actions, + this.actionsLocation = ActionsLocation.left, + this.attachmentThumbnailBuilders, + this.inputTextStyle, + this.attachmentIconColor, + this.autofocus = false, + this.sendButtonLocation = SendButtonLocation.inside, + this.animationDuration = const Duration(milliseconds: 300), + }) : super(key: key); + + /// Message to edit + final Message editMessage; + + /// Message to start with + final Message initialMessage; + + /// If set true TextField will be active by default. Default is false. + final bool autofocus; + + /// Function called after sending the message + final void Function(Message) onMessageSent; + + /// Function called right before sending the message + /// Use this to transform the message + final FutureOr Function(Message) preMessageSending; + + /// Parent message in case of a thread + final Message parentMessage; + + /// Maximum Height for the TextField to grow before it starts scrolling + final double maxHeight; + + /// The keyboard type assigned to the TextField + final TextInputType keyboardType; + + /// If true the attachments button will not be displayed + final bool disableAttachments; + + /// Override image upload request + final FileUploader doImageUploadRequest; + + /// Override file upload request + final FileUploader doFileUploadRequest; + + /// The text controller of the TextField + final TextEditingController textEditingController; + + /// List of action widgets + final List actions; + + /// The location of the custom actions + final ActionsLocation actionsLocation; + + /// The location of the send button + final SendButtonLocation sendButtonLocation; + + /// Map that defines a thumbnail builder for an attachment type + final Map attachmentThumbnailBuilders; + + /// Text style used in message text field. If null, [MessageInput] uses + /// `Theme.of(context).textTheme.bodyText2`. + final TextStyle inputTextStyle; + + /// The duration of the send button animation + final Duration animationDuration; + + /// Color used for attachment icon. + final Color attachmentIconColor; + + @override + MessageInputState createState() => MessageInputState(); + + /// Use this method to get the current [StreamChatState] instance + static MessageInputState of(BuildContext context) { + MessageInputState messageInputState; + + messageInputState = context.findAncestorStateOfType(); + + if (messageInputState == null) { + throw Exception( + 'You must have a MessageInput widget as anchestor of your widget tree'); + } + + return messageInputState; + } +} + +class MessageInputState extends State { + final List<_SendingAttachment> _attachments = []; + final _focusNode = FocusNode(); + final List _mentionedUsers = []; + + final _imagePicker = ImagePicker(); + bool _inputEnabled = true; + bool _messageIsPresent = false; + bool _typingStarted = false; + OverlayEntry _commandsOverlay, _mentionsOverlay; + + /// The editing controller passed to the input TextField + TextEditingController textEditingController; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: GestureDetector( + onPanUpdate: (details) { + if (details.delta.dy > 0) { + _focusNode.unfocus(); + } + }, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Stack( + clipBehavior: Clip.none, + children: [ + _buildBorder(context), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAttachments(), + _buildTextField(context), + ], + ), + ], + ), + ), + if (widget.sendButtonLocation == SendButtonLocation.outside) + _animateSendButton(context), + ], + ), + ), + ), + ); + } + + Flex _buildTextField(BuildContext context) { + return Flex( + direction: Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!widget.disableAttachments) _buildAttachmentButton(), + if (widget.actionsLocation == ActionsLocation.left) + ...widget.actions ?? [], + _buildTextInput(context), + if (widget.actionsLocation == ActionsLocation.right) + ...widget.actions ?? [], + if (widget.sendButtonLocation == SendButtonLocation.inside) + _animateSendButton(context), + ], + ); + } + + Widget _animateSendButton(BuildContext context) { + if (widget.animationDuration == Duration.zero) { + return _buildSendButton(context); + } + return AnimatedCrossFade( + crossFadeState: ((_messageIsPresent || _attachments.isNotEmpty) && + _attachments.every((a) => a.uploaded == true)) + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: _buildSendButton(context), + secondChild: SizedBox(), + duration: widget.animationDuration, + alignment: Alignment.center, + ); + } + + Expanded _buildTextInput(BuildContext context) { + return Expanded( + child: LimitedBox( + maxHeight: widget.maxHeight, + child: TextField( + key: Key('messageInputText'), + enabled: _inputEnabled, + minLines: null, + maxLines: null, + onSubmitted: (_) { + sendMessage(); + }, + keyboardType: widget.keyboardType, + controller: textEditingController, + focusNode: _focusNode, + onTap: () { + setState(() { + _typingStarted = true; + }); + }, + style: widget.inputTextStyle ?? Theme.of(context).textTheme.bodyText2, + autofocus: widget.autofocus, + decoration: + StreamChatTheme.of(context).channelTheme.messageInputDecoration ?? + InputDecoration( + hintText: 'Write a message', + hintStyle: widget.inputTextStyle ?? + Theme.of(context).textTheme.bodyText2, + prefixText: ' ', + border: InputBorder.none, + ), + textCapitalization: TextCapitalization.sentences, + ), + ), + ); + } + + Positioned _buildBorder(BuildContext context) { + return Positioned.fill( + child: Container( + width: MediaQuery.of(context).size.width, + padding: EdgeInsets.all(2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + gradient: _getGradient(context), + ), + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context) + .channelTheme + .inputBackground + .withAlpha(255), + borderRadius: BorderRadius.circular(10.0), + ), + child: Container( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).channelTheme.inputBackground, + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: _typingStarted + ? Colors.transparent + : Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(.2) + : Colors.black.withOpacity(.2)), + ), + ), + ), + ), + ); + } + + OverlayEntry _buildCommandsOverlayEntry() { + final text = textEditingController.text; + final commands = StreamChannel.of(context) + .channel + .config + .commands + .where((c) => c.name.contains(text.replaceFirst('/', ''))) + .toList(); + + RenderBox renderBox = context.findRenderObject(); + final size = renderBox.size; + + return OverlayEntry(builder: (context) { + return Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: Material( + color: StreamChatTheme.of(context).primaryColor, + child: Container( + constraints: BoxConstraints.loose(Size.fromHeight(400)), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + spreadRadius: -8, + blurRadius: 5.0, + offset: Offset(0, -4), + ), + ], + color: StreamChatTheme.of(context).primaryColor, + ), + child: ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: commands + .map( + (c) => ListTile( + title: Text.rich( + TextSpan( + text: '${c.name}', + style: TextStyle(fontWeight: FontWeight.bold), + children: [ + TextSpan( + text: ' ${c.args}', + style: TextStyle( + fontWeight: FontWeight.w300, + ), + ), + ], + ), + ), + subtitle: Text(c.description), + onTap: () { + _setCommand(c); + }, + ), + ) + .toList(), + ), + ), + ), + ); + }); + } + + OverlayEntry _buildMentionsOverlayEntry() { + final splits = textEditingController.text + .substring(0, textEditingController.value.selection.baseOffset) + .split('@'); + final query = splits.last.toLowerCase(); + + Future> queryMembers; + + if (query.isNotEmpty) { + queryMembers = StreamChannel.of(context).channel.queryMembers( + filter: { + 'name': { + '\$autocomplete': query, + }, + }, + sort: [ + SortOption( + 'name', + direction: SortOption.ASC, + ), + ], + ).then((res) => res.members); + } + + final members = StreamChannel.of(context).channel.state.members?.where((m) { + return m.user.name.toLowerCase().contains(query); + })?.toList() ?? + []; + + RenderBox renderBox = context.findRenderObject(); + final size = renderBox.size; + + return OverlayEntry(builder: (context) { + return Positioned( + bottom: size.height + MediaQuery.of(context).viewInsets.bottom, + left: 0, + right: 0, + child: Material( + color: StreamChatTheme.of(context).primaryColor, + child: Container( + constraints: BoxConstraints.loose(Size.fromHeight(400)), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + spreadRadius: -8, + blurRadius: 5.0, + offset: Offset(0, -4), + ), + ], + color: StreamChatTheme.of(context).primaryColor, + ), + child: FutureBuilder>( + future: queryMembers ?? Future.value(members), + initialData: members, + builder: (context, snapshot) { + return ListView( + padding: const EdgeInsets.all(0), + shrinkWrap: true, + children: (snapshot.data ?? members) + .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('@'); + + textEditingController.value = TextEditingValue( + text: rejoin + + textEditingController.text.substring( + textEditingController + .selection.baseOffset), + selection: TextSelection.collapsed( + offset: rejoin.length, + ), + ); + + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + }, + )) + .toList(), + ); + }), + ), + ), + ); + }); + } + + void _setCommand(Command c) { + textEditingController.value = TextEditingValue( + text: '/${c.name} ', + selection: TextSelection.collapsed( + offset: c.name.length + 2, + ), + ); + _commandsOverlay?.remove(); + _commandsOverlay = null; + } + + Gradient _getGradient(BuildContext context) { + if (_typingStarted) { + if (widget.editMessage == null) { + return StreamChatTheme.of(context).channelTheme.inputGradient; + } + return LinearGradient( + colors: [Colors.lightGreen, Colors.green], + ); + } else { + return null; + } + } + + Widget _buildAttachments() { + return Wrap( + direction: Axis.horizontal, + children: _attachments + .map( + (attachment) => Padding( + padding: const EdgeInsets.all(8.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + children: [ + Container( + height: 50, + width: 50, + child: _buildAttachment(attachment), + ), + _buildRemoveButton(attachment), + attachment.uploaded + ? SizedBox() + : Positioned.fill( + child: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ), + ), + ], + ), + ), + ), + ) + .toList(), + ); + } + + 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) { + 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.memory( + attachment.file.bytes, + fit: BoxFit.cover, + ) + : Image.network( + attachment.attachment.imageUrl ?? + attachment.attachment.thumbUrl, + fit: BoxFit.cover, + ); + break; + case 'video': + return Container( + child: Icon(Icons.videocam), + color: Colors.black26, + ); + break; + default: + return Container( + child: Icon(Icons.insert_drive_file), + color: Colors.black26, + ); + } + } + + Material _buildAttachmentButton() { + return Material( + clipBehavior: Clip.hardEdge, + type: MaterialType.transparency, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(32), + ), + child: IconButton( + onPressed: () { + showAttachmentModal(); + }, + icon: Icon( + Icons.add_circle_outline, + color: widget.attachmentIconColor, + ), + ), + ); + } + + /// Show the attachment modal, making the user choose where to pick a media from + void showAttachmentModal() { + if (_focusNode.hasFocus) { + _focusNode.unfocus(); + } + + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + isScrollControlled: true, + builder: (_) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + 'Add a file', + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + ListTile( + leading: Icon(Icons.image), + title: Text('Upload a photo'), + onTap: () { + pickFile(DefaultAttachmentTypes.image, false); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.video_library), + title: Text('Upload a video'), + onTap: () { + pickFile(DefaultAttachmentTypes.video, false); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: Icon(Icons.camera_alt), + title: Text('Photo from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.image, true); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: Icon(Icons.videocam), + title: Text('Video from camera'), + onTap: () { + pickFile(DefaultAttachmentTypes.video, true); + Navigator.pop(context); + }, + ), + ListTile( + leading: Icon(Icons.insert_drive_file), + title: Text('Upload a file'), + onTap: () { + pickFile(DefaultAttachmentTypes.file, false); + Navigator.pop(context); + }, + ), + ], + ); + }); + } + + /// Add an attachment to the sending message + /// Use this to add custom type attachments + void addAttachment(Attachment attachment) { + setState(() { + _attachments.add(_SendingAttachment( + attachment: attachment, + uploaded: true, + )); + }); + } + + /// Pick a file from the device + /// If [camera] is true then the camera will open + void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async { + setState(() { + _inputEnabled = false; + }); + + PlatformFile file; + String attachmentType; + + if (fileType == DefaultAttachmentTypes.image) { + attachmentType = 'image'; + } else if (fileType == DefaultAttachmentTypes.video) { + attachmentType = 'video'; + } else if (fileType == DefaultAttachmentTypes.file) { + attachmentType = 'file'; + } + + if (camera) { + PickedFile pickedFile; + if (fileType == DefaultAttachmentTypes.image) { + pickedFile = await _imagePicker.getImage(source: ImageSource.camera); + } else if (fileType == DefaultAttachmentTypes.video) { + pickedFile = await _imagePicker.getVideo(source: ImageSource.camera); + } + final bytes = await pickedFile.readAsBytes(); + file = PlatformFile( + path: pickedFile.path, + bytes: bytes, + ); + } else { + FileType type; + if (fileType == DefaultAttachmentTypes.image) { + type = FileType.image; + } else if (fileType == DefaultAttachmentTypes.video) { + type = FileType.video; + } else if (fileType == DefaultAttachmentTypes.file) { + type = FileType.any; + } + final res = await FilePicker.platform.pickFiles( + type: type, + withData: true, + ); + if (res?.files?.isNotEmpty == true) { + file = res.files.single; + print('file.bytes?.length: ${file.bytes?.length}'); + } + } + + setState(() { + _inputEnabled = true; + }); + + if (file == null) { + return; + } + + final channel = StreamChannel.of(context).channel; + final attachment = _SendingAttachment( + file: file, + attachment: Attachment( + localUri: file.path != null ? Uri.parse(file.path) : null, + type: attachmentType, + ), + ); + + setState(() { + _attachments.add(attachment); + }); + + final url = await _uploadAttachment(file, fileType, channel); + + if (fileType == DefaultAttachmentTypes.image) { + attachment.attachment = attachment.attachment.copyWith( + imageUrl: url, + ); + } else { + attachment.attachment = attachment.attachment.copyWith( + assetUrl: url, + ); + } + + setState(() { + attachment.uploaded = true; + }); + } + + Future _uploadAttachment( + PlatformFile file, + DefaultAttachmentTypes type, + Channel channel, + ) async { + String url; + if (type == DefaultAttachmentTypes.image) { + if (widget.doImageUploadRequest != null) { + url = await widget.doImageUploadRequest(file, channel); + } else { + url = await _uploadImage(file, channel); + } + } else { + if (widget.doFileUploadRequest != null) { + url = await widget.doFileUploadRequest(file, channel); + } else { + url = await _uploadFile(file, channel); + } + } + return url; + } + + Future _uploadImage(PlatformFile file, Channel channel) async { + final filename = file.name ?? file.path?.split('/')?.last; + final bytes = file.bytes; + final res = await channel.sendImage( + MultipartFile.fromBytes( + bytes, + filename: filename, + contentType: MediaType.parse(lookupMimeType(filename)), + ), + ); + return res.file; + } + + Future _uploadFile(PlatformFile file, Channel channel) async { + final filename = file.name ?? file.path?.split('/')?.last; + final bytes = file.bytes; + final res = await channel.sendFile( + MultipartFile.fromBytes( + bytes, + filename: filename, + contentType: MediaType.parse(lookupMimeType(filename)), + ), + ); + return res.file; + } + + Widget _buildSendButton(BuildContext context) { + return IconTheme( + data: + StreamChatTheme.of(context).channelTheme.messageInputButtonIconTheme, + child: Material( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(32), + ), + color: Colors.transparent, + child: IconButton( + key: Key('sendButton'), + onPressed: (textEditingController.text.trim().isEmpty && + _attachments.isEmpty) + ? null + : () { + sendMessage(); + }, + icon: Icon( + Icons.send, + ), + ), + ), + ); + } + + /// Sends the current message + void sendMessage() async { + final text = textEditingController.text.trim(); + if (text.isEmpty && _attachments.isEmpty) { + return; + } + + final attachments = List<_SendingAttachment>.from(_attachments); + + textEditingController.clear(); + _attachments.clear(); + + setState(() { + _messageIsPresent = false; + _typingStarted = false; + }); + + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + + final channel = StreamChannel.of(context).channel; + + Future sendingFuture; + Message message; + if (widget.editMessage != null) { + message = widget.editMessage.copyWith( + text: text, + attachments: _getAttachments(attachments).toList(), + mentionedUsers: + _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), + ); + } else { + message = (widget.initialMessage ?? Message()).copyWith( + parentId: widget.parentMessage?.id, + text: text, + attachments: _getAttachments(attachments).toList(), + mentionedUsers: + _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), + ); + } + + 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.then((resp) { + if (widget.onMessageSent != null) { + widget.onMessageSent(resp.message); + } else { + if (widget.editMessage != null) { + Navigator.pop(context); + } + } + }); + } + + Iterable _getAttachments(List<_SendingAttachment> attachments) { + return attachments.map((attachment) { + return attachment.attachment; + }); + } + + StreamSubscription _keyboardListener; + + @override + void initState() { + super.initState(); + + if (!kIsWeb) { + _keyboardListener = + KeyboardVisibilityController().onChange.listen((visible) { + if (visible) { + _onChange(); + } else { + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + } + }); + } + + textEditingController = + widget.textEditingController ?? TextEditingController(); + + textEditingController.addListener(_onChange); + + if (widget.editMessage != null || widget.initialMessage != null) { + _parseExistingMessage(widget.editMessage ?? widget.initialMessage); + } + } + + Timer _debounce; + void _onChange() { + if (_debounce?.isActive == true) _debounce.cancel(); + _debounce = Timer( + const Duration(milliseconds: 350), + () { + if (!mounted) { + return; + } + final s = textEditingController.text; + StreamChannel.of(context).channel.keyStroke( + widget.parentMessage?.id, + ); + + setState(() { + _messageIsPresent = s.trim().isNotEmpty; + }); + + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + + if (s.trim().startsWith('/')) { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + } + + if (_messageIsPresent && + textEditingController.selection.isCollapsed && + textEditingController.selection.baseOffset > 0 && + textEditingController.text + .substring(0, textEditingController.selection.baseOffset) + .split(' ') + .last + .contains('@')) { + _mentionsOverlay = _buildMentionsOverlayEntry(); + Overlay.of(context).insert(_mentionsOverlay); + } + }, + ); + } + + void _parseExistingMessage(Message message) { + textEditingController.text = message.text; + + _typingStarted = true; + _messageIsPresent = true; + + message.attachments?.forEach((attachment) { + _attachments.add(_SendingAttachment( + attachment: attachment, + uploaded: true, + )); + }); + } + + @override + void dispose() { + _commandsOverlay?.remove(); + _mentionsOverlay?.remove(); + _keyboardListener?.cancel(); + super.dispose(); + } + + bool _initialized = false; + @override + void didChangeDependencies() { + if (widget.editMessage != null && !_initialized) { + FocusScope.of(context).requestFocus(_focusNode); + _initialized = true; + } + super.didChangeDependencies(); + } +} + +class _SendingAttachment { + PlatformFile file; + Attachment attachment; + bool uploaded; + + _SendingAttachment({ + this.file, + this.attachment, + this.uploaded = false, + }); +} diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart new file mode 100644 index 00000000..f968d850 --- /dev/null +++ b/lib/src/message_list_view.dart @@ -0,0 +1,621 @@ +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 'date_divider.dart'; +import 'stream_channel.dart'; + +typedef MessageBuilder = Widget Function( + BuildContext, + MessageDetails, + List, +); +typedef ParentMessageBuilder = Widget Function( + BuildContext, + Message, +); +typedef ThreadBuilder = Widget Function(BuildContext context, Message parent); +typedef ThreadTapCallback = void Function(Message, Widget); + +class MessageDetails { + /// True if the message belongs to the current user + bool isMyMessage; + + /// True if the user message is the same of the previous message + bool isLastUser; + + /// True if the user message is the same of the next message + bool isNextUser; + + /// The message + Message message; + + /// The index of the message + int index; + + MessageDetails( + BuildContext context, + this.message, + List messages, + this.index, + ) { + isMyMessage = message.user.id == StreamChat.of(context).user.id; + isLastUser = index + 1 < messages.length && + message.user.id == messages[index + 1]?.user?.id; + isNextUser = + index - 1 >= 0 && message.user.id == messages[index - 1]?.user?.id; + } +} + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_listview.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_listview_paint.png) +/// +/// It shows the list of messages of the current channel. +/// +/// ```dart +/// class ChannelPage extends StatelessWidget { +/// const ChannelPage({ +/// Key key, +/// }) : super(key: key); +/// +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// appBar: ChannelHeader(), +/// body: Column( +/// children: [ +/// Expanded( +/// child: MessageListView( +/// threadBuilder: (_, parentMessage) { +/// return ThreadPage( +/// parent: parentMessage, +/// ); +/// }, +/// ), +/// ), +/// MessageInput(), +/// ], +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [StreamChannel] ancestor in order to provide the information about the channels. +/// The widget uses a [ListView.custom] to render the list of channels. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageListView extends StatefulWidget { + /// Instantiate a new MessageListView + MessageListView({ + Key key, + this.messageBuilder, + this.parentMessageBuilder, + this.parentMessage, + this.threadBuilder, + this.onThreadTap, + this.dateDividerBuilder, + this.onMessageTap, + this.onSystemMessageTap, + this.onParentMessageTap, + this.scrollPhysics = const AlwaysScrollableScrollPhysics(), + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.messageFilter, + this.loadingBuilder, + }) : super(key: key); + + /// Function used to build a custom message widget + final MessageBuilder messageBuilder; + + /// Function used to build a custom parent message widget + final ParentMessageBuilder parentMessageBuilder; + + /// Function used to build a custom thread widget + final ThreadBuilder threadBuilder; + + /// The builder used while loading. + final WidgetBuilder loadingBuilder; + + /// Filter applied to the message list before rendering + final bool Function(Message) messageFilter; + + /// Function called when tapping on a thread + /// By default it calls [Navigator.push] using the widget built using [threadBuilder] + final ThreadTapCallback onThreadTap; + + /// The function called when tapping on the message when the message is not failed + final Function(Message) onMessageTap; + + /// The function called when tapping on a system message + final Function(Message) onSystemMessageTap; + + /// The function called when tapping on the parent message when the message is not failed + final Function(Message) onParentMessageTap; + + /// Parent message in case of a thread + final Message parentMessage; + + /// Builder used to render date dividers + final Widget Function(DateTime) dateDividerBuilder; + + /// The ScrollPhysics used by the ListView + final ScrollPhysics scrollPhysics; + + /// The [ScrollViewKeyboardDismissBehavior] used by the ListView + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + @override + _MessageListViewState createState() => _MessageListViewState(); +} + +class _MessageListViewState extends State { + static const _newMessageLoadingOffset = 100; + final ScrollController _scrollController = ScrollController(); + bool _bottomWasVisible = true; + bool _topWasVisible = false; + List _messages = []; + List _newMessageList = []; + Function _onThreadTap; + + @override + Widget build(BuildContext context) { + final streamChannel = StreamChannel.of(context); + + /// TODO: find a better solution when (https://github.com/flutter/flutter/issues/21023) is fixed + return NotificationListener( + onNotification: (_) { + if (_scrollController.offset < 150 && _newMessageList.isNotEmpty) { + setState(() { + _messages.insertAll(0, _newMessageList); + _newMessageList.clear(); + }); + } + return true; + }, + child: ListView.custom( + key: Key('messageListView'), + physics: widget.scrollPhysics, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + controller: _scrollController, + reverse: true, + childrenDelegate: SliverChildBuilderDelegate( + (context, i) { + if (i == _messages.length + 1) { + if (widget.parentMessage != null) { + if (widget.parentMessageBuilder != null) { + return widget.parentMessageBuilder( + context, + widget.parentMessage, + ); + } else { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + buildParentMessage(widget.parentMessage), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Container( + padding: const EdgeInsets.all(8), + child: Text( + 'Start of thread', + textAlign: TextAlign.center, + ), + color: Theme.of(context).accentColor.withAlpha(50), + ), + ), + ], + ); + } + } else { + return SizedBox(); + } + } + + if (i == _messages.length) { + return _buildLoadingIndicator(streamChannel); + } + final message = _messages[i]; + final nextMessage = i > 0 ? _messages[i - 1] : null; + + Widget messageWidget; + + if (i == 0) { + messageWidget = _buildBottomMessage( + context, + message, + _messages, + streamChannel, + ); + } else if (i == _messages.length - 1) { + messageWidget = _buildTopMessage( + context, + message, + _messages, + streamChannel, + ); + } else { + if (widget.messageBuilder != null) { + messageWidget = Builder( + key: ValueKey('MESSAGE-${message.id}'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + _messages, + i, + ), + _messages), + ); + } else { + messageWidget = buildMessage(message, _messages, i); + } + } + + if (nextMessage != null && + !Jiffy(message.createdAt.toLocal()) + .isSame(nextMessage.createdAt.toLocal(), Units.DAY)) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + messageWidget, + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: widget.dateDividerBuilder != null + ? widget + .dateDividerBuilder(nextMessage.createdAt.toLocal()) + : DateDivider( + dateTime: nextMessage.createdAt.toLocal(), + ), + ), + ], + ); + } + + return messageWidget; + }, + childCount: _messages.length + 2, + findChildIndexCallback: (key) { + final ValueKey valueKey = key; + final index = _messages + .indexWhere((m) => 'MESSAGE-${m.id}' == valueKey.value); + return index != -1 ? index : null; + }, + ), + ), + ); + } + + Container _buildLoadingIndicator(StreamChannelState streamChannel) { + return Container( + height: 50, + child: StreamBuilder( + stream: streamChannel.queryMessage, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: Color(0xffd0021B).withAlpha(26), + child: Center( + child: Text('Error loading messages'), + ), + ); + } + if (!snapshot.data) { + return Container(); + } + + if (widget.loadingBuilder != null) { + return widget.loadingBuilder(context); + } + + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator(), + ), + ); + }), + ); + } + + Widget _buildTopMessage( + BuildContext context, + Message message, + List messages, + StreamChannelState streamChannel, + ) { + Widget messageWidget; + if (widget.messageBuilder != null) { + messageWidget = Builder( + key: ValueKey('TOP-MESSAGE'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + _messages, + _messages.length - 1, + ), + _messages, + ), + ); + } else { + messageWidget = buildMessage(message, messages, _messages.length - 1); + } + + return VisibilityDetector( + key: ValueKey('TOP-MESSAGE'), + child: messageWidget, + onVisibilityChanged: (visibility) { + final topIsVisible = visibility.visibleBounds != Rect.zero; + if (topIsVisible && !_topWasVisible) { + if (widget.parentMessage == null) { + streamChannel.queryMessages(); + } else { + streamChannel.getReplies(widget.parentMessage.id); + } + } + _topWasVisible = topIsVisible; + }, + ); + } + + Widget _buildBottomMessage( + BuildContext context, + Message message, + List messages, + StreamChannelState streamChannel, + ) { + Widget messageWidget; + if (widget.messageBuilder != null) { + messageWidget = Builder( + key: ValueKey('BOTTOM-MESSAGE'), + builder: (_) => widget.messageBuilder( + context, + MessageDetails( + context, + message, + _messages, + 0, + ), + _messages, + ), + ); + } else { + messageWidget = buildMessage(message, messages, 0); + } + + return VisibilityDetector( + key: ValueKey('BOTTOM-MESSAGE'), + onVisibilityChanged: (visibility) { + final isVisible = visibility.visibleBounds != Rect.zero; + if (isVisible && + !_bottomWasVisible && + streamChannel.channel.config?.readEvents == true) { + if (streamChannel.channel.state.unreadCount > 0) { + streamChannel.channel.markRead(); + } + } + _bottomWasVisible = isVisible; + }, + child: messageWidget, + ); + } + + 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), + ), + onMessageTap: widget.onParentMessageTap, + 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( + onMessageTap: widget.onSystemMessageTap, + 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) || + read.lastRead.isAtSameMomentAs(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, + onMessageTap: widget.onMessageTap, + 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 + void initState() { + super.initState(); + + final streamChannel = StreamChannel.of(context); + + Stream> stream; + + if (widget.parentMessage == null) { + stream = streamChannel.channel.state.messagesStream; + } else { + streamChannel.getReplies(widget.parentMessage.id); + stream = streamChannel.channel.state.threadsStream + .where((threads) => threads.containsKey(widget.parentMessage.id)) + .map((threads) => threads[widget.parentMessage.id]); + } + + _streamListener = stream.map((messages) { + final filteredMessages = messages + ?.where((m) => + !(m.status == MessageSendingStatus.FAILED && m.isDeleted) && + m.shadowed != true) + ?.toList() ?? + []; + + if (widget.messageFilter != null) { + return messages.where(widget.messageFilter).toList(); + } + + return filteredMessages; + }).listen((newMessages) { + newMessages = newMessages.reversed.toList(); + if (_messages.isEmpty || + newMessages.isEmpty || + newMessages.first.id != _messages.first.id) { + if (!_scrollController.hasClients || + _scrollController.offset < _newMessageLoadingOffset) { + if (streamChannel.channel.state.unreadCount > 0 && + streamChannel.channel.config?.readEvents == true) { + streamChannel.channel.markRead(); + } + setState(() { + _messages = newMessages; + }); + } else if (newMessages.first.user.id == + streamChannel.channel.client.state.user.id) { + _scrollController.jumpTo(0); + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _messages = newMessages; + }); + }); + } else { + _newMessageList = newMessages; + } + } else { + setState(() { + _messages = newMessages; + }); + } + }); + + _getOnThreadTap(); + } + + void _getOnThreadTap() { + if (widget.onThreadTap != null) { + _onThreadTap = (Message message) { + widget.onThreadTap( + message, + widget.threadBuilder != null + ? widget.threadBuilder(context, message) + : null); + }; + } else if (widget.threadBuilder != null) { + _onThreadTap = (Message message) { + Navigator.push( + context, + MaterialPageRoute(builder: (_) { + return StreamBuilder( + stream: StreamChannel.of(context) + .channel + .state + .messagesStream + .map((messages) => + messages.firstWhere((m) => m.id == message.id)), + initialData: message, + builder: (_, snapshot) { + return StreamChannel( + channel: StreamChannel.of(context).channel, + child: widget.threadBuilder(context, snapshot.data), + ); + }); + }), + ); + }; + } + } + + @override + void dispose() { + _streamListener.cancel(); + super.dispose(); + } +} diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart new file mode 100644 index 00000000..eb13e2ad --- /dev/null +++ b/lib/src/message_widget.dart @@ -0,0 +1,814 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.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) +/// +/// It shows a message with reactions, replies and user avatar. +/// +/// Usually you don't use this widget as it's the default message widget used by [MessageListView]. +/// +/// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class MessageWidget extends StatefulWidget { + /// Function called on mention tap + final void Function(User) onMentionTap; + + /// The function called when tapping on replies + final void Function(Message) onThreadTap; + + /// The function called when tapping on the message when the message is not failed + final void Function(Message) onMessageTap; + + /// The builder of MessageInput used while editing this message + final Widget Function(BuildContext, Message) editMessageInputBuilder; + + /// The builder of the text of the message + 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; + + /// The function called when tapping on a link + final void Function(String) onLinkTap; + + final List readList; + + /// 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.onMessageTap, + 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.onLinkTap, + 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 { + final Map _reactionToEmoji = { + 'love': 'โค๏ธ๏ธ', + 'haha': '๐Ÿ˜‚', + 'like': '๐Ÿ‘', + 'sad': '๐Ÿ˜•', + 'angry': '๐Ÿ˜ก', + 'wow': '๐Ÿ˜ฒ', + }; + + final GlobalKey _reactionPickerKey = GlobalKey(); + double _reactionPadding = 0; + + @override + Widget build(BuildContext context) { + var leftPadding = widget.showUserAvatar != DisplayWidget.gone + ? widget.messageTheme.avatarTheme.constraints.maxWidth + 23.0 + : 12.0; + if (widget.showSendingIndicator == DisplayWidget.gone) { + leftPadding -= 7; + } + return GestureDetector( + onTap: () => onMessageTap(context), + onLongPress: () => onLongPress(context), + behavior: HitTestBehavior.opaque, + child: Container( + width: double.infinity, + child: Portal( + child: Padding( + padding: widget.padding ?? EdgeInsets.all(8), + child: Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: 0.75, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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), + ], + ), + ), + ), + ), + ), + ), + ); + } + + @override + void didUpdateWidget(MessageWidget oldWidget) { + super.didUpdateWidget(oldWidget); + _updateReactionPadding(); + } + + @override + void initState() { + super.initState(); + _updateReactionPadding(); + } + + void _updateReactionPadding() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + if (!mounted) { + return; + } + 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; + }); + } + }); + } + + 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, + ), + ), + ) + : SizedBox(), + ); + } + + Padding _buildBottomRow(double leftPadding) { + return Padding( + padding: EdgeInsets.only( + left: leftPadding, + top: 2, + ), + 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(), + ), + ), + ), + ], + ), + ); + } + + 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)), + ), + ), + ), + ); + }).toList(), + ); + } + + 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), + ], + ), + ), + ), + ) + : 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) { + final channel = StreamChannel.of(context).channel; + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), + ), + context: context, + 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, + ), + ); + }); + } + + List _parseAttachments(BuildContext context) { + return widget.message.attachments?.map((attachment) { + final attachmentBuilder = widget.attachmentBuilders[attachment.type]; + + if (attachmentBuilder == null) { + return SizedBox(); + } + + return Padding( + key: Key(attachment.hashCode.toString()), + padding: EdgeInsets.only( + bottom: 4, + ), + 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.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, + ), + ], + ), + ), + ), + ), + ); + })?.toList() ?? + []; + } + + void onLongPress(BuildContext context) { + if (widget.message.isEphemeral || + widget.message.status == MessageSendingStatus.SENDING) { + return; + } + + if (widget.onMessageActions != null) { + widget.onMessageActions(context, widget.message); + } else { + _showMessageBottomSheet(context); + } + return; + } + + Widget _buildReplyIndicator(double leftPadding) { + return Padding( + padding: EdgeInsets.only( + left: leftPadding, + ), + child: Transform( + transform: Matrix4.rotationY(widget.reverse ? pi : 0), + alignment: Alignment.center, + child: ReplyIndicator( + message: widget.message, + reversed: widget.reverse, + messageTheme: widget.messageTheme, + onTap: widget.onThreadTap != null + ? () { + widget.onThreadTap(widget.message); + } + : null, + ), + ), + ); + } + + Widget _buildSendingIndicator() { + return 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(); + } + + Widget _buildTextBubble(BuildContext context) { + return 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), + ], + ), + ), + ), + ); + } + + 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; + } + + void onMessageTap(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; + } + + if (widget.onMessageTap != null) { + widget.onMessageTap(widget.message); + } + } + + Widget _buildText(BuildContext context) { + return widget.textBuilder != null + ? widget.textBuilder(context, widget.message) + : MessageText( + onLinkTap: widget.onLinkTap, + message: widget.message, + onMentionTap: widget.onMentionTap, + messageTheme: widget.messageTheme, + ); + } +} + +class ReactionBubblePainter extends CustomPainter { + final Color color; + + ReactionBubblePainter(this.color); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = color; + final path = Path(); + path.lineTo(-2, -6); + path.lineTo(0, 10); + path.lineTo(10, -6); + path.lineTo(-2, -6); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/lib/src/reaction_picker.dart b/lib/src/reaction_picker.dart new file mode 100644 index 00000000..8e2d8b10 --- /dev/null +++ b/lib/src/reaction_picker.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../stream_chat_flutter.dart'; + +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/reaction_picker_paint.png) +/// +/// It shows a reaction picker +/// +/// Usually you don't use this widget as it's one of the default widgets used by [MessageWidget.onMessageActions]. +class ReactionPicker extends StatelessWidget { + const ReactionPicker({ + Key key, + @required this.reactionToEmoji, + @required this.message, + @required this.channel, + this.size = 30, + }) : super(key: key); + + final Map reactionToEmoji; + final Message message; + final double size; + final Channel channel; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black87, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: reactionToEmoji.keys.map((reactionType) { + var user = StreamChat.of(context).user; + final ownReactionIndex = message.latestReactions?.indexWhere( + (reaction) => + reaction.type == reactionType && + reaction.userId == user.id) ?? + -1; + final totalScore = message.latestReactions + .where((r) => r.type == reactionType) + .map((r) => r.score) + .fold(0, (tot, s) => tot + s); + + 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.latestReactions[ownReactionIndex]); + } else { + sendReaction(context, reactionType); + } + }, + ), + totalScore > 0 + ? Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Text( + totalScore.toString(), + style: TextStyle(color: Colors.white), + ), + ) + : SizedBox(), + ], + ); + }).toList(), + ), + ); + } + + /// Add a reaction to the message + void sendReaction(BuildContext context, String reactionType) { + channel.sendReaction(message, reactionType); + Navigator.of(context).pop(); + } + + /// 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/stream_chat.dart b/lib/src/stream_chat.dart new file mode 100644 index 00000000..040409db --- /dev/null +++ b/lib/src/stream_chat.dart @@ -0,0 +1,238 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; + +/// Widget used to provide information about the chat to the widget tree +/// +/// class MyApp extends StatelessWidget { +/// final Client client; +/// +/// MyApp(this.client); +/// +/// @override +/// Widget build(BuildContext context) { +/// return MaterialApp( +/// home: Container( +/// child: StreamChat( +/// client: client, +/// child: ChannelListPage(), +/// ), +/// ), +/// ); +/// } +/// } +/// +/// Use [StreamChat.of] to get the current [StreamChatState] instance. +class StreamChat extends StatefulWidget { + final Client client; + final Widget child; + final StreamChatThemeData streamChatThemeData; + + StreamChat({ + Key key, + @required this.client, + @required this.child, + this.streamChatThemeData, + }) : super( + key: key, + ); + + @override + StreamChatState createState() => StreamChatState(); + + /// Use this method to get the current [StreamChatState] instance + static StreamChatState of(BuildContext context) { + StreamChatState streamChatState; + + streamChatState = context.findAncestorStateOfType(); + + if (streamChatState == null) { + throw Exception( + 'You must have a StreamChat widget at the top of your widget tree'); + } + + return streamChatState; + } +} + +/// The current state of the StreamChat widget +class StreamChatState extends State with WidgetsBindingObserver { + Client get client => widget.client; + Timer _disconnectTimer; + + @override + Widget build(BuildContext context) { + final theme = _getTheme(context, widget.streamChatThemeData); + return StreamChatTheme( + data: theme, + child: Builder( + builder: (context) { + final materialTheme = Theme.of(context); + final streamTheme = StreamChatTheme.of(context); + return Theme( + data: materialTheme.copyWith( + primaryIconTheme: streamTheme.primaryIconTheme, + accentColor: streamTheme.accentColor, + scaffoldBackgroundColor: streamTheme.backgroundColor, + ), + child: widget.child, + ); + }, + ), + ); + } + + StreamChatThemeData _getTheme( + BuildContext context, + StreamChatThemeData themeData, + ) { + final defaultTheme = StreamChatThemeData.getDefaultTheme(Theme.of(context)); + final theme = defaultTheme.copyWith( + primaryColor: themeData?.primaryColor, + defaultChannelImage: themeData?.defaultChannelImage, + primaryIconTheme: themeData?.primaryIconTheme, + defaultUserImage: themeData?.defaultUserImage, + backgroundColor: themeData?.backgroundColor, + channelTheme: defaultTheme.channelTheme.copyWith( + channelHeaderTheme: + defaultTheme.channelTheme.channelHeaderTheme.copyWith( + color: themeData?.channelTheme?.channelHeaderTheme?.color, + lastMessageAt: + themeData?.channelTheme?.channelHeaderTheme?.lastMessageAt, + title: themeData?.channelTheme?.channelHeaderTheme?.title, + avatarTheme: defaultTheme.channelPreviewTheme.avatarTheme.copyWith( + constraints: themeData + ?.channelTheme?.channelHeaderTheme?.avatarTheme?.constraints, + borderRadius: themeData + ?.channelTheme?.channelHeaderTheme?.avatarTheme?.borderRadius, + ), + ), + inputBackground: themeData?.channelTheme?.inputBackground, + messageInputButtonIconTheme: + themeData?.channelTheme?.messageInputButtonIconTheme, + inputGradient: themeData?.channelTheme?.inputGradient, + messageInputButtonTheme: + themeData?.channelTheme?.messageInputButtonTheme, + messageInputDecoration: themeData?.channelTheme?.messageInputDecoration, + ), + ownMessageTheme: defaultTheme.ownMessageTheme.copyWith( + replies: themeData?.ownMessageTheme?.replies, + createdAt: themeData?.ownMessageTheme?.createdAt, + messageText: themeData?.ownMessageTheme?.messageText, + messageBackgroundColor: + themeData?.ownMessageTheme?.messageBackgroundColor, + messageAuthor: themeData?.ownMessageTheme?.messageAuthor, + messageLinks: themeData?.ownMessageTheme?.messageLinks, + avatarTheme: defaultTheme.ownMessageTheme.avatarTheme.copyWith( + constraints: themeData?.ownMessageTheme?.avatarTheme?.constraints, + borderRadius: themeData?.ownMessageTheme?.avatarTheme?.borderRadius, + ), + ), + otherMessageTheme: defaultTheme.otherMessageTheme.copyWith( + replies: themeData?.otherMessageTheme?.replies, + createdAt: themeData?.otherMessageTheme?.createdAt, + messageText: themeData?.otherMessageTheme?.messageText, + messageBackgroundColor: + themeData?.otherMessageTheme?.messageBackgroundColor, + messageAuthor: themeData?.otherMessageTheme?.messageAuthor, + messageLinks: themeData?.otherMessageTheme?.messageLinks, + avatarTheme: defaultTheme.otherMessageTheme.avatarTheme.copyWith( + constraints: themeData?.otherMessageTheme?.avatarTheme?.constraints, + borderRadius: themeData?.otherMessageTheme?.avatarTheme?.borderRadius, + ), + ), + accentColor: themeData?.accentColor, + secondaryColor: themeData?.secondaryColor, + channelPreviewTheme: defaultTheme.channelPreviewTheme.copyWith( + avatarTheme: defaultTheme.channelPreviewTheme.avatarTheme.copyWith( + constraints: themeData?.channelPreviewTheme?.avatarTheme?.constraints, + borderRadius: + themeData?.channelPreviewTheme?.avatarTheme?.borderRadius, + ), + title: themeData?.channelPreviewTheme?.title, + lastMessageAt: themeData?.channelPreviewTheme?.lastMessageAt, + subtitle: themeData?.channelPreviewTheme?.subtitle, + unreadCounterColor: themeData?.channelPreviewTheme?.unreadCounterColor, + ), + ); + return theme; + } + + /// The current user + User get user => widget.client.state.user; + + /// The current user as a stream + Stream get userStream => widget.client.state.userStream; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + StreamSubscription _newMessageSubscription; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (client.state?.user != null) { + 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) + .where((e) => e.message.shadowed != true) + .listen((event) async { + final channel = client.channel( + event.channelType, + id: event.channelId, + ); + + 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() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } +} diff --git a/lib/src/stream_chat_theme.dart b/lib/src/stream_chat_theme.dart new file mode 100644 index 00000000..5af16cab --- /dev/null +++ b/lib/src/stream_chat_theme.dart @@ -0,0 +1,523 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/channel_header.dart'; +import 'package:stream_chat_flutter/src/channel_preview.dart'; +import 'package:stream_chat_flutter/src/message_input.dart'; + +/// Inherited widget providing the [StreamChatThemeData] to the widget tree +class StreamChatTheme extends InheritedWidget { + final StreamChatThemeData data; + + StreamChatTheme({ + Key key, + @required this.data, + Widget child, + }) : super( + key: key, + child: child, + ); + + @override + bool updateShouldNotify(StreamChatTheme old) { + return data != old.data; + } + + /// Use this method to get the current [StreamChatThemeData] instance + static StreamChatThemeData of(BuildContext context) { + final streamChatTheme = + context.dependOnInheritedWidgetOfExactType(); + + if (streamChatTheme == null) { + throw Exception( + 'You must have a StreamChatTheme widget at the top of your widget tree', + ); + } + + return streamChatTheme.data; + } +} + +/// Theme data +class StreamChatThemeData { + /// Primary color of the chat widgets + final Color primaryColor; + + /// Secondary color of the chat widgets + final Color secondaryColor; + + /// Accent color of the chat widgets + final Color accentColor; + + /// Background color of the chat widgets + final Color backgroundColor; + + /// Theme of the [ChannelPreview] + final ChannelPreviewTheme channelPreviewTheme; + + /// Theme of the chat widgets dedicated to a channel + final ChannelTheme channelTheme; + + /// Theme of the current user messages + final MessageTheme ownMessageTheme; + + /// Theme of other users messages + final MessageTheme otherMessageTheme; + + /// The widget that will be built when the channel image is unavailable + final Widget Function(BuildContext, Channel) defaultChannelImage; + + /// The widget that will be built when the user image is unavailable + final Widget Function(BuildContext, User) defaultUserImage; + + /// Primary icon theme + final IconThemeData primaryIconTheme; + + /// Create a theme from scratch + StreamChatThemeData({ + this.primaryColor, + this.secondaryColor, + this.accentColor, + this.backgroundColor, + this.channelPreviewTheme, + this.channelTheme, + this.otherMessageTheme, + this.ownMessageTheme, + this.defaultChannelImage, + this.defaultUserImage, + this.primaryIconTheme, + }); + + /// Create a theme from a Material [Theme] + factory StreamChatThemeData.fromTheme(ThemeData theme) { + final defaultTheme = getDefaultTheme(theme); + + return defaultTheme.copyWith( + accentColor: theme.accentColor, + primaryIconTheme: theme.primaryIconTheme, + primaryColor: theme.colorScheme.primary, + secondaryColor: theme.colorScheme.secondary, + backgroundColor: theme.scaffoldBackgroundColor, + channelTheme: defaultTheme.channelTheme.copyWith( + inputGradient: LinearGradient(colors: [ + theme.accentColor.withOpacity(.5), + theme.accentColor, + ]), + ), + ownMessageTheme: defaultTheme.ownMessageTheme.copyWith( + replies: defaultTheme.ownMessageTheme.replies.copyWith( + color: theme.accentColor, + ), + messageLinks: TextStyle( + color: theme.accentColor, + ), + ), + otherMessageTheme: defaultTheme.otherMessageTheme.copyWith( + replies: defaultTheme.otherMessageTheme.replies.copyWith( + color: theme.accentColor, + ), + messageLinks: TextStyle( + color: theme.accentColor, + ), + ), + ); + } + + /// Creates a copy of [StreamChatThemeData] with specified attributes overridden. + StreamChatThemeData copyWith({ + Color primaryColor, + Color secondaryColor, + Color accentColor, + Color backgroundColor, + ChannelPreviewTheme channelPreviewTheme, + ChannelTheme channelTheme, + MessageTheme ownMessageTheme, + 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, + backgroundColor: backgroundColor ?? this.backgroundColor, + channelPreviewTheme: channelPreviewTheme?.copyWith( + title: + channelPreviewTheme.title ?? this.channelPreviewTheme.title, + subtitle: channelPreviewTheme.subtitle ?? + this.channelPreviewTheme.subtitle, + lastMessageAt: channelPreviewTheme.lastMessageAt ?? + this.channelPreviewTheme.lastMessageAt, + avatarTheme: channelPreviewTheme.avatarTheme ?? + this.channelPreviewTheme.avatarTheme, + ) ?? + this.channelPreviewTheme, + channelTheme: channelTheme?.copyWith( + channelHeaderTheme: channelTheme.channelHeaderTheme ?? + this.channelTheme.channelHeaderTheme, + messageInputButtonIconTheme: + channelTheme.messageInputButtonIconTheme ?? + this.channelTheme.messageInputButtonIconTheme, + messageInputButtonTheme: channelTheme.messageInputButtonTheme ?? + this.channelTheme.messageInputButtonTheme, + inputGradient: + channelTheme.inputGradient ?? this.channelTheme.inputGradient, + inputBackground: channelTheme.inputBackground ?? + this.channelTheme.inputBackground, + messageInputDecoration: channelTheme.messageInputDecoration ?? + this.channelTheme.messageInputDecoration, + ) ?? + this.channelTheme, + ownMessageTheme: ownMessageTheme?.copyWith( + messageText: ownMessageTheme?.messageText ?? + this.ownMessageTheme.messageText, + messageAuthor: ownMessageTheme?.messageAuthor ?? + this.ownMessageTheme.messageAuthor, + messageLinks: ownMessageTheme?.messageLinks ?? + this.ownMessageTheme.messageLinks, + createdAt: + ownMessageTheme?.createdAt ?? this.ownMessageTheme.createdAt, + replies: ownMessageTheme?.replies ?? this.ownMessageTheme.replies, + messageBackgroundColor: ownMessageTheme?.messageBackgroundColor ?? + this.ownMessageTheme.messageBackgroundColor, + avatarTheme: ownMessageTheme?.avatarTheme ?? + this.ownMessageTheme.avatarTheme, + ) ?? + this.ownMessageTheme, + otherMessageTheme: otherMessageTheme?.copyWith( + messageText: otherMessageTheme?.messageText ?? + this.otherMessageTheme.messageText, + messageAuthor: otherMessageTheme?.messageAuthor ?? + this.otherMessageTheme.messageAuthor, + messageLinks: otherMessageTheme?.messageLinks ?? + this.otherMessageTheme.messageLinks, + createdAt: otherMessageTheme?.createdAt ?? + this.otherMessageTheme.createdAt, + replies: + otherMessageTheme?.replies ?? this.otherMessageTheme.replies, + messageBackgroundColor: + otherMessageTheme?.messageBackgroundColor ?? + this.otherMessageTheme.messageBackgroundColor, + avatarTheme: otherMessageTheme?.avatarTheme ?? + this.otherMessageTheme.avatarTheme, + ) ?? + this.otherMessageTheme, + ); + + /// Get the default Stream Chat theme + static StreamChatThemeData getDefaultTheme(ThemeData theme) { + final accentColor = Color(0xff006cff); + final isDark = theme.brightness == Brightness.dark; + 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( + child: Text( + user.name?.substring(0, 1) ?? '', + style: TextStyle(color: Colors.white), + ), + ), + channelPreviewTheme: ChannelPreviewTheme( + unreadCounterColor: Color(0xffd0021B), + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + title: TextStyle( + fontSize: 14, + color: isDark ? Colors.white : Colors.black, + ), + subtitle: TextStyle( + fontSize: 13, + color: isDark ? Colors.white : Colors.black, + ), + lastMessageAt: TextStyle( + fontSize: 11, + color: isDark + ? Colors.white.withOpacity(.5) + : Colors.black.withOpacity(.5), + ), + ), + channelTheme: ChannelTheme( + messageInputDecoration: InputDecoration( + hintText: 'Write a message', + hintStyle: theme.textTheme.bodyText2, + prefixText: ' ', + border: InputBorder.none, + ), + messageInputButtonIconTheme: theme.iconTheme.copyWith( + color: accentColor, + ), + channelHeaderTheme: ChannelHeaderTheme( + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + color: isDark ? Colors.black : Colors.white, + title: TextStyle( + fontSize: 14, + color: isDark ? Colors.white : Colors.black, + ), + lastMessageAt: TextStyle( + fontSize: 11, + color: isDark + ? Colors.white.withOpacity(.5) + : Colors.black.withOpacity(.5), + ), + ), + inputBackground: + isDark ? Colors.black.withAlpha(12) : Colors.white.withAlpha(12), + inputGradient: LinearGradient(colors: [ + Color(0xFF00AEFF), + Color(0xFF0076FF), + ]), + ), + ownMessageTheme: MessageTheme( + messageText: TextStyle( + fontSize: 15, + color: isDark ? Colors.white : Colors.black, + ), + createdAt: TextStyle( + color: isDark + ? Colors.white.withOpacity(.5) + : Colors.black.withOpacity(.5), + fontSize: 11, + ), + replies: TextStyle( + color: accentColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + messageBackgroundColor: Color(0x33ebebeb), + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 32, + width: 32, + ), + ), + messageLinks: TextStyle( + color: accentColor, + ), + ), + otherMessageTheme: MessageTheme( + messageText: TextStyle( + fontSize: 15, + color: isDark ? Colors.white : Colors.black, + ), + createdAt: TextStyle( + color: isDark + ? Colors.white.withOpacity(.5) + : Colors.black.withOpacity(.5), + fontSize: 11, + ), + replies: TextStyle( + color: accentColor, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + messageLinks: TextStyle( + color: accentColor, + ), + messageBackgroundColor: isDark ? Colors.black : Colors.white, + avatarTheme: AvatarTheme( + borderRadius: BorderRadius.circular(20), + constraints: BoxConstraints.tightFor( + height: 32, + width: 32, + ), + ), + ), + ); + } +} + +/// Channel theme data +class ChannelTheme { + /// Theme of the [ChannelHeader] widget + final ChannelHeaderTheme channelHeaderTheme; + + /// IconTheme of the send button in [MessageInput] + final IconThemeData messageInputButtonIconTheme; + + /// Theme of the send button in [MessageInput] + final ButtonThemeData messageInputButtonTheme; + + /// Gradient of [MessageInput] + final Gradient inputGradient; + + /// Background color of [MessageInput] + final Color inputBackground; + + /// InputDecoration of [MessageInput] + final InputDecoration messageInputDecoration; + + ChannelTheme({ + this.channelHeaderTheme, + this.messageInputButtonIconTheme, + this.messageInputButtonTheme, + this.inputBackground, + this.inputGradient, + this.messageInputDecoration, + }); + + /// Creates a copy of [ChannelTheme] with specified attributes overridden. + ChannelTheme copyWith({ + ChannelHeaderTheme channelHeaderTheme, + IconThemeData messageInputButtonIconTheme, + ButtonThemeData messageInputButtonTheme, + Gradient inputGradient, + Color inputBackground, + InputDecoration messageInputDecoration, + }) => + ChannelTheme( + channelHeaderTheme: channelHeaderTheme?.copyWith( + title: channelHeaderTheme?.title ?? this.channelHeaderTheme.title, + lastMessageAt: channelHeaderTheme?.lastMessageAt ?? + this.channelHeaderTheme.lastMessageAt, + avatarTheme: channelHeaderTheme?.avatarTheme ?? + this.channelHeaderTheme.avatarTheme, + color: channelHeaderTheme?.color ?? this.channelHeaderTheme.color, + ) ?? + this.channelHeaderTheme, + messageInputButtonIconTheme: + messageInputButtonIconTheme ?? this.messageInputButtonIconTheme, + messageInputButtonTheme: + messageInputButtonTheme ?? this.messageInputButtonTheme, + inputGradient: inputGradient ?? this.inputGradient, + inputBackground: inputBackground ?? this.inputBackground, + messageInputDecoration: + messageInputDecoration ?? this.messageInputDecoration, + ); +} + +class AvatarTheme { + final BoxConstraints constraints; + final BorderRadius borderRadius; + + AvatarTheme({ + this.constraints, + this.borderRadius, + }); + + AvatarTheme copyWith({ + BoxConstraints constraints, + BorderRadius borderRadius, + }) => + AvatarTheme( + constraints: constraints ?? this.constraints, + borderRadius: borderRadius ?? this.borderRadius, + ); +} + +class MessageTheme { + final TextStyle messageText; + final TextStyle messageAuthor; + final TextStyle messageLinks; + final TextStyle createdAt; + final TextStyle replies; + final Color messageBackgroundColor; + final AvatarTheme avatarTheme; + + const MessageTheme({ + this.replies, + this.messageText, + this.messageAuthor, + this.messageLinks, + this.messageBackgroundColor, + this.avatarTheme, + this.createdAt, + }); + + MessageTheme copyWith({ + TextStyle messageText, + TextStyle messageAuthor, + TextStyle messageLinks, + TextStyle createdAt, + TextStyle replies, + Color messageBackgroundColor, + Color otherMessageBackgroundColor, + AvatarTheme avatarTheme, + }) => + MessageTheme( + messageText: messageText ?? this.messageText, + messageAuthor: messageAuthor ?? this.messageAuthor, + messageLinks: messageLinks ?? this.messageLinks, + createdAt: createdAt ?? this.createdAt, + messageBackgroundColor: + messageBackgroundColor ?? this.messageBackgroundColor, + avatarTheme: avatarTheme ?? this.avatarTheme, + replies: replies ?? this.replies, + ); +} + +class ChannelPreviewTheme { + final TextStyle title; + 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({ + TextStyle title, + 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, + ); +} + +class ChannelHeaderTheme { + final TextStyle title; + final TextStyle lastMessageAt; + final AvatarTheme avatarTheme; + final Color color; + + const ChannelHeaderTheme({ + this.title, + this.lastMessageAt, + this.avatarTheme, + this.color, + }); + + ChannelHeaderTheme copyWith({ + TextStyle title, + TextStyle lastMessageAt, + AvatarTheme avatarTheme, + Color color, + }) => + ChannelHeaderTheme( + title: title ?? this.title, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + avatarTheme: avatarTheme ?? this.avatarTheme, + color: color ?? this.color, + ); +} diff --git a/lib/src/system_message.dart b/lib/src/system_message.dart new file mode 100644 index 00000000..7b4dc243 --- /dev/null +++ b/lib/src/system_message.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// It shows a date divider depending on the date difference +class SystemMessage extends StatelessWidget { + /// This message + final Message message; + + /// The function called when tapping on the message when the message is not failed + final void Function(Message) onMessageTap; + + const SystemMessage({ + Key key, + @required this.message, + this.onMessageTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final divider = Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Divider(), + ), + ); + + final createdAt = Jiffy(message.createdAt.toLocal()); + final now = DateTime.now(); + final hourInfo = createdAt.format('h:mm a'); + + String dayInfo; + if (Jiffy(createdAt).isSame(now, Units.DAY)) { + dayInfo = 'TODAY'; + } else if (Jiffy(createdAt) + .isSame(now.subtract(Duration(days: 1)), Units.DAY)) { + dayInfo = 'YESTERDAY'; + } else if (Jiffy(createdAt).isAfter( + now.subtract(Duration(days: 7)), + Units.DAY, + )) { + dayInfo = createdAt.format('EEEE').toUpperCase(); + } else if (Jiffy(createdAt).isAfter( + Jiffy(now).subtract(years: 1), + Units.DAY, + )) { + dayInfo = createdAt.format('dd/MM').toUpperCase(); + } else { + dayInfo = createdAt.format('dd/MM/yyyy').toUpperCase(); + } + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (onMessageTap != null) { + onMessageTap(message); + } + }, + child: Container( + width: double.infinity, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + divider, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + message.text, + style: TextStyle( + fontSize: 10, + color: Theme.of(context) + .textTheme + .headline6 + .color + .withOpacity(.5), + fontWeight: FontWeight.bold, + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan( + text: dayInfo, + style: TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: ' AT'), + TextSpan(text: ' $hourInfo'), + ], + style: TextStyle( + fontWeight: FontWeight.normal, + ), + ), + style: TextStyle( + fontSize: 10, + color: Theme.of(context) + .textTheme + .headline6 + .color + .withOpacity(.5), + ), + ), + ], + ), + ), + divider, + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 143751e3..ac09329a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,6 +6,19 @@ - Extracted sample app into dedicated [repo](https://github.com/GetStream/flutter-samples) - Reimplemented existing widgets using [stream_chat_flutter_core](https://pub.dev/packages/stream_chat_flutter_core) +## 0.2.21 + +- Add `loadingBuilder` in `MessageListView` +- Add `messageFilter` property in `MessageListView` + +## 0.2.20+4 + +- Fix channelPreview when the message list is empty + +## 0.2.20+3 + +- Fix reaction picker score indicator + ## 0.2.20+2 - Added `shouldAddChannel` to ChannelsBloc in order to check if a channel has to be added to the list when a new message arrives diff --git a/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart b/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart index 5c309d48..6e3e6276 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial-part-3.dart @@ -63,7 +63,7 @@ class ChannelListPage extends StatelessWidget { } }, channelPreviewBuilder: _channelPreviewBuilder, - sort: [SortOption('last_message_at')], + // sort: [SortOption('last_message_at')], pagination: PaginationParams( limit: 20, ), @@ -74,8 +74,10 @@ class ChannelListPage extends StatelessWidget { } Widget _channelPreviewBuilder(BuildContext context, Channel channel) { - final lastMessage = channel.state.messages.reversed - .firstWhere((message) => !message.isDeleted); + final lastMessage = channel.state.messages.reversed.firstWhere( + (message) => !message.isDeleted, + orElse: () => null, + ); final subtitle = (lastMessage == null ? 'nothing yet' : lastMessage.text); final opacity = channel.state.unreadCount > .0 ? 1.0 : 0.5; diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..849339b8 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,35 @@ +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.2.21 +repository: https://github.com/GetStream/stream-chat-flutter +issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues + +environment: + sdk: ">=2.3.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + photo_view: ^0.10.1 + rxdart: ^0.24.1 + jiffy: ^3.0.1 + flutter_portal: ^0.3.0 + cached_network_image: ^2.2.0+1 + flutter_markdown: ^0.5.0 + url_launcher: ^5.4.11 + video_player: ^1.0.0 + chewie: ^0.12.0 + file_picker: ^2.0.12 + image_picker: ^0.6.7+2 + flutter_keyboard_visibility: ^4.0.1 + stream_chat: ^0.2.21+1 + mime: ^0.9.6+3 + visibility_detector: ^0.1.5 + http_parser: ^3.1.4 + +dev_dependencies: + pedantic: ^1.9.0 + flutter_test: + sdk: flutter + mockito: ^4.1.1