diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a8e87c58..a76cc4c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,11 +8,13 @@ jobs: steps: - uses: actions/checkout@v2 - name: Flutter action - uses: subosito/flutter-action@v1.3.2 + uses: subosito/flutter-action@v1.4.0 with: channel: 'stable' - name: Get dependencies run: flutter pub get + - name: Run formatter + run: flutter format -n --set-exit-if-changed . - name: Coverage fix run: | file=test/coverage_helper_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 03db8a6d..7d5b93f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 0.2.13+1 + +- Use TextEditingController.addListener instead of TextField.onChanged + +## 0.2.13 + +- Update llc dependency +- Send parent_id in typing events +- Expose addition input styling options +- Expose builder for empty channel state + ## 0.2.12 - Upgrade dependencies diff --git a/README.md b/README.md index 7ca34145..ba5de4c1 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ We also use [video_player](https://pub.dev/packages/video_player) to reproduce v To pick images from the camera, we use the [image_picker](https://pub.dev/packages/image_picker) plugin. Follow [these instructions](https://pub.dev/packages/image_picker#ios) to check the requirements. +### Troubleshooting + +It may happen that you have some problems building the app. +If it seems related to the [flutter file picker plugin](https://github.com/miguelpruivo/flutter_file_picker) make sure to check [this page](https://github.com/miguelpruivo/flutter_file_picker/wiki/Troubleshooting) + ## Docs ### Business logic components diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index ca0d2720..0492c6da 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -7,10 +7,13 @@ FlutterApplication and put your custom class here. --> + + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true" + > + + + + diff --git a/example/ios/Flutter/.last_build_id b/example/ios/Flutter/.last_build_id index 8aba7787..20c7c514 100644 --- a/example/ios/Flutter/.last_build_id +++ b/example/ios/Flutter/.last_build_id @@ -1 +1 @@ -bb5f9103d9045cd6244bcae1f5f343e5 \ No newline at end of file +c3e639ccf9b069e37a7d1345194e6b99 \ No newline at end of file diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index f23764fa..131e8aec 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -108,6 +108,8 @@ PODS: - GoogleUtilities/Logger - image_picker (0.0.1): - Flutter + - media_gallery (0.0.1): + - Flutter - nanopb (1.30906.0): - nanopb/decode (= 1.30906.0) - nanopb/encode (= 1.30906.0) @@ -115,6 +117,8 @@ PODS: - nanopb/encode (1.30906.0) - path_provider (0.0.1): - Flutter + - "permission_handler (5.0.1+1)": + - Flutter - PromisesObjC (1.2.11) - Protobuf (3.13.0) - SDWebImage (5.9.4): @@ -165,7 +169,9 @@ DEPENDENCIES: - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - image_picker (from `.symlinks/plugins/image_picker/ios`) + - media_gallery (from `.symlinks/plugins/media_gallery/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) + - permission_handler (from `.symlinks/plugins/permission_handler/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`) @@ -217,8 +223,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" image_picker: :path: ".symlinks/plugins/image_picker/ios" + media_gallery: + :path: ".symlinks/plugins/media_gallery/ios" path_provider: :path: ".symlinks/plugins/path_provider/ios" + permission_handler: + :path: ".symlinks/plugins/permission_handler/ios" shared_preferences: :path: ".symlinks/plugins/shared_preferences/ios" sqflite: @@ -254,8 +264,10 @@ SPEC CHECKSUMS: GoogleDataTransport: f56af7caa4ed338dc8e138a5d7c5973e66440833 GoogleUtilities: 7f2f5a07f888cdb145101d6042bc4422f57e70b3 image_picker: 9c3312491f862b28d21ecd8fdf0ee14e601b3f09 + media_gallery: 834a04455a476b975c280aa1f24ed2853dfe36de nanopb: 59317e09cf1f1a0af72f12af412d54edf52603fc path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c + permission_handler: eac8e15b4a1a3fba55b761d19f3f4e6b005d15b6 PromisesObjC: 8c196f5a328c2cba3e74624585467a557dcb482f Protobuf: 3dac39b34a08151c6d949560efe3f86134a3f748 SDWebImage: b69257f4ab14e9b6a2ef53e910fdf914d8f757c1 @@ -268,7 +280,7 @@ SPEC CHECKSUMS: SwiftyGif: e466e86c660d343357ab944a819a101c4127cb40 url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef video_player: 9cc823b1d9da7e8427ee591e8438bfbcde500e6e - wakelock: 0d4a70faf8950410735e3f61fb15d517c8a6efc4 + wakelock: bfc7955c418d0db797614075aabbc58a39ab5107 PODFILE CHECKSUM: eb001256612a59f8f9e4d083ad8b9671e69dd184 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 1baa240f..709db931 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -327,6 +327,7 @@ "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", "${BUILT_PRODUCTS_DIR}/flutter_secure_storage/flutter_secure_storage.framework", "${BUILT_PRODUCTS_DIR}/image_picker/image_picker.framework", + "${BUILT_PRODUCTS_DIR}/media_gallery/media_gallery.framework", "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", "${BUILT_PRODUCTS_DIR}/path_provider/path_provider.framework", "${BUILT_PRODUCTS_DIR}/shared_preferences/shared_preferences.framework", @@ -357,6 +358,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/media_gallery.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences.framework", diff --git a/example/lib/advanced_options_page.dart b/example/lib/advanced_options_page.dart index 8a4c79c0..c84ef58e 100644 --- a/example/lib/advanced_options_page.dart +++ b/example/lib/advanced_options_page.dart @@ -17,10 +17,13 @@ class _AdvancedOptionsPageState extends State { final _formKey = GlobalKey(); final TextEditingController _apiKeyController = TextEditingController(); + String _apiKeyError; final TextEditingController _userIdController = TextEditingController(); + String _userIdError; final TextEditingController _userTokenController = TextEditingController(); + String _userTokenError; final TextEditingController _usernameController = TextEditingController(); @@ -65,17 +68,34 @@ class _AdvancedOptionsPageState extends State { children: [ TextFormField( controller: _apiKeyController, + onChanged: (_) { + if (_apiKeyError != null) { + setState(() { + _apiKeyError = null; + }); + } + }, validator: (value) { if (value.isEmpty) { - return 'Please enter the Chat API Key'; + setState(() { + _apiKeyError = 'Please enter the Chat API Key'; + }); + return _apiKeyError; } return null; }, + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), decoration: InputDecoration( + errorStyle: TextStyle(fontSize: 0), labelStyle: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(.5), + color: _apiKeyError != null + ? Color(0xffff3742) + : Colors.black.withOpacity(.5), ), border: UnderlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -83,7 +103,8 @@ class _AdvancedOptionsPageState extends State { ), fillColor: Color(0xffF5F5F5), filled: true, - labelText: 'Chat API Key', + labelText: + 'Chat API Key ${_apiKeyError != null ? ':$_apiKeyError' : ''}', ), textInputAction: TextInputAction.next, ), @@ -91,18 +112,35 @@ class _AdvancedOptionsPageState extends State { padding: const EdgeInsets.only(top: 8.0), child: TextFormField( controller: _userIdController, + onChanged: (_) { + if (_userIdError != null) { + setState(() { + _userIdError = null; + }); + } + }, validator: (value) { if (value.isEmpty) { - return 'Please enter the User ID'; + setState(() { + _userIdError = 'Please enter the User ID'; + }); + return _userIdError; } return null; }, + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), textInputAction: TextInputAction.next, decoration: InputDecoration( + errorStyle: TextStyle(fontSize: 0), labelStyle: TextStyle( fontWeight: FontWeight.bold, fontSize: 14, - color: Colors.black.withOpacity(.5), + color: _userIdError != null + ? Color(0xffff3742) + : Colors.black.withOpacity(.5), ), border: UnderlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -110,26 +148,44 @@ class _AdvancedOptionsPageState extends State { ), fillColor: Color(0xffF5F5F5), filled: true, - labelText: 'User ID', + labelText: + 'User ID ${_userIdError != null ? ':$_userIdError' : ''}', ), ), ), Padding( padding: const EdgeInsets.only(top: 8.0), child: TextFormField( + onChanged: (_) { + if (_userTokenError != null) { + setState(() { + _userTokenError = null; + }); + } + }, controller: _userTokenController, validator: (value) { if (value.isEmpty) { - return 'Please enter the user token'; + setState(() { + _userTokenError = 'Please enter the user token'; + }); + return _userTokenError; } return null; }, + style: TextStyle( + fontSize: 14, + color: Colors.black, + ), textInputAction: TextInputAction.next, decoration: InputDecoration( + errorStyle: TextStyle(fontSize: 0), labelStyle: TextStyle( fontWeight: FontWeight.bold, fontSize: 14, - color: Colors.black.withOpacity(.5), + color: _userTokenError != null + ? Color(0xffff3742) + : Colors.black.withOpacity(.5), ), border: UnderlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -137,7 +193,8 @@ class _AdvancedOptionsPageState extends State { ), fillColor: Color(0xffF5F5F5), filled: true, - labelText: 'User Token', + labelText: + 'User Token ${_userTokenError != null ? ':$_userTokenError' : ''}', ), ), ), @@ -183,13 +240,13 @@ class _AdvancedOptionsPageState extends State { if (loading) { return; } - loading = true; if (_formKey.currentState.validate()) { final apiKey = _apiKeyController.text; final userId = _userIdController.text; final userToken = _userTokenController.text; final username = _usernameController.text; + loading = true; showDialog( barrierDismissible: false, context: context, @@ -208,7 +265,6 @@ class _AdvancedOptionsPageState extends State { ), ); - print('CREATE CLIENT'); final client = Client( apiKey, logLevel: Level.INFO, @@ -226,17 +282,19 @@ class _AdvancedOptionsPageState extends State { }), userToken, ); + + if (!kIsWeb) { + initNotifications(client); + } } catch (e) { var errorText = 'Error connecting, retry'; if (e is Map) { errorText = e['message'] ?? errorText; } Navigator.pop(context); - Scaffold.of(context).showSnackBar( - SnackBar( - content: Text(errorText), - ), - ); + setState(() { + _apiKeyError = errorText; + }); loading = false; await client.disconnect(); return; diff --git a/example/lib/chips_input_text_field.dart b/example/lib/chips_input_text_field.dart new file mode 100644 index 00000000..317a68f7 --- /dev/null +++ b/example/lib/chips_input_text_field.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_icons.dart'; + +typedef ChipBuilder = Widget Function(BuildContext context, T chip); +typedef OnChipAdded = void Function(T chip); +typedef OnChipRemoved = void Function(T chip); + +class ChipsInputTextField extends StatefulWidget { + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onInputChanged; + final ChipBuilder chipBuilder; + final OnChipAdded onChipAdded; + final OnChipRemoved onChipRemoved; + final String hint; + + const ChipsInputTextField({ + Key key, + @required this.chipBuilder, + @required this.controller, + this.onInputChanged, + this.focusNode, + this.onChipAdded, + this.onChipRemoved, + this.hint = 'Type a name', + }) : super(key: key); + + @override + ChipInputTextFieldState createState() => ChipInputTextFieldState(); +} + +class ChipInputTextFieldState extends State> { + final _chips = {}; + bool _pauseItemAddition = false; + + void addItem(T item) { + setState(() => _chips.add(item)); + if (widget.onChipAdded != null) widget.onChipAdded(item); + } + + void removeItem(T item) { + setState(() { + _chips.remove(item); + if (_chips.isEmpty) resumeItemAddition(); + }); + if (widget.focusNode != null) widget.focusNode.requestFocus(); + if (widget.onChipRemoved != null) widget.onChipRemoved(item); + } + + void pauseItemAddition() { + if (!_pauseItemAddition) { + setState(() => _pauseItemAddition = true); + } + } + + void resumeItemAddition() { + if (_pauseItemAddition) { + setState(() => _pauseItemAddition = false); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _pauseItemAddition + ? () { + setState(() { + _pauseItemAddition = false; + widget.focusNode?.requestFocus(); + }); + } + : null, + child: Material( + elevation: 1, + color: Colors.white, + child: Container( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Text( + 'TO:', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(0.5), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + spacing: 8.0, + runSpacing: 4.0, + children: _chips.map((item) { + return widget.chipBuilder(context, item); + }).toList(), + ), + if (!_pauseItemAddition) + TextField( + controller: widget.controller, + onChanged: widget.onInputChanged, + focusNode: widget.focusNode, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.only(top: 4.0), + hintText: widget.hint, + hintStyle: TextStyle( + color: Colors.black.withOpacity(0.5), + fontSize: 14, + ), + ), + ), + ], + ), + ), + SizedBox(width: 12), + Align( + alignment: Alignment.bottomCenter, + child: IconButton( + icon: Icon( + _chips.isEmpty + ? StreamIcons.user + : StreamIcons.user_add, + color: Colors.black.withOpacity(0.5), + size: 24, + ), + onPressed: + !_pauseItemAddition ? null : resumeItemAddition, + alignment: Alignment.topRight, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(0), + splashRadius: 24, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/example/lib/choose_user_page.dart b/example/lib/choose_user_page.dart index b5296f8a..4583a2a6 100644 --- a/example/lib/choose_user_page.dart +++ b/example/lib/choose_user_page.dart @@ -59,164 +59,170 @@ class ChooseUserPage extends StatelessWidget { (_, value) => value..extraData['image'] = getRandomPicUrl(value)); return Scaffold( - body: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only( - top: 34, - bottom: 20, - ), - child: Center( - child: SvgPicture.asset( - 'assets/logo.svg', - height: 40, + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + top: 34, + bottom: 20, + ), + child: Center( + child: SvgPicture.asset( + 'assets/logo.svg', + height: 40, + ), ), ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 13.0), - child: Text( - 'Welcome to Stream Chat', + Padding( + padding: const EdgeInsets.only(bottom: 13.0), + child: Text( + 'Welcome to Stream Chat', + style: TextStyle( + fontSize: 22, + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + 'Select a user to try the Flutter SDK:', style: TextStyle( - fontSize: 22, + fontSize: 14.5, color: Colors.black, - fontWeight: FontWeight.bold, ), ), - ), - Text( - 'Select a user to try the Flutter SDK:', - style: TextStyle( - fontSize: 14.5, - color: Colors.black, - ), - ), - Expanded( - child: ListView.separated( - separatorBuilder: (context, i) { - return Container( - width: double.infinity, - color: Colors.black12, - height: 1, - ); - }, - itemCount: users.length + 1, - itemBuilder: (context, i) { - return [ - ...users.entries.map((entry) { - final token = entry.key; - final user = entry.value; - return ListTile( - onTap: () async { - showDialog( - barrierDismissible: false, - context: context, - builder: (context) => Center( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: Colors.white, + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 32), + child: ListView.separated( + separatorBuilder: (context, i) { + return Container( + width: double.infinity, + color: Colors.black12, + height: 1, + ); + }, + itemCount: users.length + 1, + itemBuilder: (context, i) { + return [ + ...users.entries.map((entry) { + final token = entry.key; + final user = entry.value; + return ListTile( + onTap: () async { + showDialog( + barrierDismissible: false, + context: context, + builder: (context) => Center( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Colors.white, + ), + height: 100, + width: 100, + child: Center( + child: CircularProgressIndicator(), + ), + ), ), - height: 100, - width: 100, - child: Center( - child: CircularProgressIndicator(), + ); + + final secureStorage = FlutterSecureStorage(); + final client = StreamChat.of(context).client; + + await client.setUser( + user, + token, + ); + + secureStorage.write( + key: kStreamApiKey, + value: kDefaultStreamApiKey, + ); + secureStorage.write( + key: kStreamUserId, + value: user.id, + ); + secureStorage.write( + key: kStreamToken, + value: token, + ); + + if (!kIsWeb) { + initNotifications(client); + } + + Navigator.pop(context); + await Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) { + return StreamChat( + client: client, + child: ChannelListPage(), + ); + }, ), + ); + }, + leading: UserAvatar( + user: user, + constraints: BoxConstraints.tight( + Size.fromRadius(20), ), ), - ); - - final secureStorage = FlutterSecureStorage(); - final client = StreamChat.of(context).client; - - await client.setUser( - user, - token, - ); - - await Future.wait([ - secureStorage.write( - key: kStreamApiKey, - value: kDefaultStreamApiKey, + title: Text( + user.name, + style: TextStyle(fontWeight: FontWeight.bold), ), - secureStorage.write( - key: kStreamUserId, - value: user.id, - ), - secureStorage.write( - key: kStreamToken, - value: token, - ), - ]); - - if (!kIsWeb) { - initNotifications(client); - } - - Navigator.pop(context); - await Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) { - return StreamChat( - client: client, - child: ChannelListPage(), - ); - }, + subtitle: Text('Stream test account'), + trailing: SvgPicture.asset( + 'assets/icon_arrow_right.svg', + height: 24, + width: 24, ), ); - }, - leading: UserAvatar( - user: user, - constraints: BoxConstraints.tight( - Size.fromRadius(20), + }), + ListTile( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => AdvancedOptionsPage(), + ), + ); + }, + leading: CircleAvatar( + child: Icon( + StreamIcons.settings, + color: Colors.black, + ), + backgroundColor: + StreamChatTheme.of(context).secondaryColor, + ), + title: Text( + 'Advanced Options', + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text('Custom settings'), + trailing: SvgPicture.asset( + 'assets/icon_arrow_right.svg', + height: 24, + width: 24, + clipBehavior: Clip.none, ), ), - title: Text( - user.name, - style: TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text('Stream test account'), - trailing: Icon( - StreamIcons.arrow_right, - color: StreamChatTheme.of(context).accentColor, - ), - ); - }), - ListTile( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => AdvancedOptionsPage(), - ), - ); - }, - leading: CircleAvatar( - child: Icon( - StreamIcons.settings, - color: Colors.black, - ), - backgroundColor: - StreamChatTheme.of(context).secondaryColor, - ), - title: Text( - 'Advanced Options', - style: TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text('Custom settings'), - trailing: Icon( - StreamIcons.arrow_right, - color: StreamChatTheme.of(context).accentColor, - ), - ), - ][i]; - }, + ][i]; + }, + ), + ), ), - ), - StreamVersion(), - ], + StreamVersion(), + ], + ), ), ); } diff --git a/example/lib/group_chat_details_screen.dart b/example/lib/group_chat_details_screen.dart new file mode 100644 index 00000000..6aff5711 --- /dev/null +++ b/example/lib/group_chat_details_screen.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:uuid/uuid.dart'; + +import 'main.dart'; +import 'neumorphic_button.dart'; + +class GroupChatDetailsScreen extends StatefulWidget { + final List selectedUsers; + + const GroupChatDetailsScreen({ + Key key, + @required this.selectedUsers, + }) : super(key: key); + + @override + _GroupChatDetailsScreenState createState() => _GroupChatDetailsScreenState(); +} + +class _GroupChatDetailsScreenState extends State { + final _selectedUsers = []; + + TextEditingController _groupNameController; + + bool _isGroupNameEmpty = true; + + int get _totalUsers => _selectedUsers.length; + + void _groupNameListener() { + final name = _groupNameController.text; + if (mounted) { + setState(() { + _isGroupNameEmpty = name.isEmpty; + }); + } + } + + @override + void initState() { + super.initState(); + _selectedUsers.addAll(widget.selectedUsers); + _groupNameController = TextEditingController() + ..addListener(_groupNameListener); + } + + @override + void dispose() { + _groupNameController?.removeListener(_groupNameListener); + _groupNameController?.clear(); + _groupNameController?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), + appBar: AppBar( + elevation: 1, + backgroundColor: Colors.white, + leading: const StreamBackButton(), + title: Text( + 'Name of Group Chat', + style: TextStyle( + color: Colors.black, + fontSize: 16, + ), + ), + centerTitle: true, + bottom: PreferredSize( + preferredSize: Size.fromHeight(kToolbarHeight), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), + child: Row( + children: [ + Text( + 'NAME', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(0.5), + ), + ), + SizedBox(width: 16), + Expanded( + child: TextField( + controller: _groupNameController, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: const EdgeInsets.all(0), + hintText: 'Choose a group chat name', + hintStyle: TextStyle( + fontSize: 14, color: Colors.black.withOpacity(.5)), + ), + ), + ), + ], + ), + ), + ), + actions: [ + NeumorphicButton( + child: IconButton( + padding: const EdgeInsets.all(0), + icon: Icon(StreamIcons.check), + color: Color(0xFF006CFF), + onPressed: _isGroupNameEmpty + ? null + : () async { + final groupName = _groupNameController.text; + final client = StreamChat.of(context).client; + final channel = client + .channel('messaging', id: Uuid().v4(), extraData: { + 'members': [ + client.state.user.id, + ..._selectedUsers.map((e) => e.id), + ], + 'name': groupName, + }); + await channel.watch(); + Navigator.of(context) + ..pop() + ..pushReplacement( + MaterialPageRoute( + builder: (context) { + return StreamChannel( + child: ChannelPage(), + channel: channel, + ); + }, + ), + ); + }, + ), + ), + ], + ), + body: Column( + children: [ + Container( + width: double.maxFinite, + color: Colors.grey.shade50, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Text( + '$_totalUsers ${_totalUsers > 1 ? 'Members' : 'Member'}', + style: TextStyle( + fontWeight: FontWeight.w500, + ), + ), + ), + ), + Expanded( + child: ListView.separated( + itemCount: _selectedUsers.length + 1, + separatorBuilder: (_, __) => Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + ), + itemBuilder: (_, index) { + if (index == _selectedUsers.length) { + return Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + ); + } + final user = _selectedUsers[index]; + return ListTile( + key: ObjectKey(user), + leading: UserAvatar( + user: user, + constraints: BoxConstraints.tightFor( + width: 40, + height: 40, + ), + ), + title: Text( + user.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + trailing: IconButton( + icon: Icon( + Icons.clear_rounded, + color: Colors.black, + ), + padding: const EdgeInsets.all(0), + splashRadius: 24, + onPressed: () { + setState(() { + _selectedUsers.remove(user); + }); + if (_selectedUsers.isEmpty) { + Navigator.pop(context); + } + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 1da02072..a7d5e473 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,6 +1,8 @@ import 'dart:io'; import 'package:example/choose_user_page.dart'; +import 'package:example/new_chat_screen.dart'; +import 'package:example/new_group_chat_screen.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -30,6 +32,9 @@ void main() async { User(id: userId), token, ); + if (!kIsWeb) { + initNotifications(client); + } } runApp(MyApp(client)); @@ -43,6 +48,7 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + debugShowCheckedModeBanner: false, theme: ThemeData.light(), darkTheme: ThemeData.dark(), //TODO change to system once dark theme is implemented @@ -63,7 +69,46 @@ class ChannelListPage extends StatelessWidget { Widget build(BuildContext context) { final user = StreamChat.of(context).user; return Scaffold( - drawer: Drawer( + appBar: AppBar( + backgroundColor: Colors.white, + title: Text( + 'Stream Chat', + style: TextStyle(color: Colors.black), + ), + ), + drawer: _buildDrawer(context, user), + drawerEdgeDragWidth: 50, + body: ChannelsBloc( + child: ChannelListView( + onStartChatPressed: () { + Navigator.of(context).push(MaterialPageRoute(builder: (context) { + return NewChatScreen(); + })); + }, + swipeToAction: true, + filter: { + 'members': { + '\$in': [user.id], + }, + 'draft': { + r'$ne': true, + }, + }, + options: { + 'presence': true, + }, + pagination: PaginationParams( + limit: 20, + ), + channelWidget: ChannelPage(), + ), + ), + ); + } + + Drawer _buildDrawer(BuildContext context, User user) { + return Drawer( + child: SafeArea( child: Padding( padding: EdgeInsets.only( top: MediaQuery.of(context).viewPadding.top + 8, @@ -97,6 +142,13 @@ class ChannelListPage extends StatelessWidget { ), ListTile( leading: Icon(StreamIcons.edit), + onTap: () { + Navigator.of(context) + ..pop() + ..push(MaterialPageRoute(builder: (context) { + return NewChatScreen(); + })); + }, title: Text( 'New direct message', style: TextStyle( @@ -106,6 +158,13 @@ class ChannelListPage extends StatelessWidget { ), ListTile( leading: Icon(StreamIcons.group), + onTap: () { + Navigator.of(context) + ..pop() + ..push(MaterialPageRoute(builder: (context) { + return NewGroupChatScreen(); + })); + }, title: Text( 'New group', style: TextStyle( @@ -144,32 +203,6 @@ class ChannelListPage extends StatelessWidget { ), ), ), - floatingActionButton: FloatingActionButton( - child: Icon(Icons.add), - onPressed: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) { - return CreateChannelPage(); - })); - }, - ), - body: ChannelsBloc( - child: ChannelListView( - swipeToAction: true, - filter: { - 'members': { - '\$in': [user.id], - } - }, - options: { - 'presence': true, - }, - sort: [SortOption('last_message_at')], - pagination: PaginationParams( - limit: 20, - ), - channelWidget: ChannelPage(), - ), - ), ); } } @@ -246,189 +279,3 @@ class ThreadPage extends StatelessWidget { ); } } - -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/lib/neumorphic_button.dart b/example/lib/neumorphic_button.dart new file mode 100644 index 00000000..69454fb2 --- /dev/null +++ b/example/lib/neumorphic_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class NeumorphicButton extends StatelessWidget { + final Widget child; + final Color backgroundColor; + + const NeumorphicButton({ + Key key, + @required this.child, + this.backgroundColor = Colors.white, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + child: child, + margin: EdgeInsets.all(8.0), + height: 40, + width: 40, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.grey[700], + offset: Offset(0, 1.0), + blurRadius: 0.5, + spreadRadius: 0, + ), + BoxShadow( + color: Colors.white, + offset: Offset.zero, + blurRadius: 0.5, + spreadRadius: 0, + ), + ], + ), + ); + } +} diff --git a/example/lib/new_chat_screen.dart b/example/lib/new_chat_screen.dart new file mode 100644 index 00000000..9ec2ca7e --- /dev/null +++ b/example/lib/new_chat_screen.dart @@ -0,0 +1,346 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'chips_input_text_field.dart'; +import 'main.dart'; +import 'neumorphic_button.dart'; +import 'new_group_chat_screen.dart'; + +class NewChatScreen extends StatefulWidget { + @override + _NewChatScreenState createState() => _NewChatScreenState(); +} + +class _NewChatScreenState extends State { + final _chipInputTextFieldStateKey = + GlobalKey>(); + + TextEditingController _controller; + + ChipInputTextFieldState get _chipInputTextFieldState => + _chipInputTextFieldStateKey.currentState; + + String _userNameQuery = ''; + + final _selectedUsers = {}; + + final _searchFocusNode = FocusNode(); + final _messageInputFocusNode = FocusNode(); + + bool _isSearchActive = false; + + Channel channel; + + Timer _debounce; + + bool _showUserList = true; + + void _userNameListener() { + if (_debounce?.isActive ?? false) _debounce.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) + setState(() { + _userNameQuery = _controller.text; + _isSearchActive = _userNameQuery.isNotEmpty; + }); + }); + } + + @override + void initState() { + super.initState(); + channel = StreamChat.of(context).client.channel('messaging'); + _controller = TextEditingController()..addListener(_userNameListener); + + _searchFocusNode.addListener(() async { + if (_searchFocusNode.hasFocus && !_showUserList) { + if (channel.extraData['draft'] == true) { + await channel.stopWatching(); + channel.dispose(); + channel.client.state.channels.remove(channel.cid); + } + setState(() { + _showUserList = true; + }); + } + }); + + _messageInputFocusNode.addListener(() async { + if (_messageInputFocusNode.hasFocus && _selectedUsers.isNotEmpty) { + final chatState = StreamChat.of(context); + + channel = chatState.client.channel( + 'messaging', + extraData: { + 'members': [ + ..._selectedUsers.map((e) => e.id), + chatState.user.id, + ], + 'draft': true, + }, + ); + + if (!chatState.client.state.channels.containsKey(channel.cid)) { + await channel.watch(); + } + + setState(() { + _showUserList = false; + }); + } + }); + } + + @override + void dispose() { + _searchFocusNode.dispose(); + _messageInputFocusNode.dispose(); + _controller?.clear(); + _controller?.removeListener(_userNameListener); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), + appBar: AppBar( + elevation: 0, + backgroundColor: Colors.white, + leading: const StreamBackButton(), + title: Text( + 'New Chat', + style: TextStyle( + color: Colors.black, + fontSize: 16, + ), + ), + centerTitle: true, + ), + body: StreamChannel( + showLoading: false, + channel: channel, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ChipsInputTextField( + key: _chipInputTextFieldStateKey, + controller: _controller, + focusNode: _searchFocusNode, + chipBuilder: (context, user) { + return GestureDetector( + onTap: () { + _chipInputTextFieldState.removeItem(user); + }, + child: Stack( + alignment: AlignmentDirectional.centerStart, + children: [ + Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.only(left: 24), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 12, 4), + child: Text( + user.name, + style: TextStyle(color: Colors.black), + ), + ), + ), + Opacity( + opacity: .8, + child: UserAvatar( + showOnlineStatus: false, + user: user, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + ), + ), + Positioned( + child: Icon( + StreamIcons.close, + color: Colors.white, + ), + ), + ], + ), + ); + }, + onChipAdded: (user) { + setState(() => _selectedUsers.add(user)); + }, + onChipRemoved: (user) { + setState(() => _selectedUsers.remove(user)); + }, + ), + if (!_isSearchActive && !_selectedUsers.isNotEmpty) + Container( + child: InkWell( + onTap: () { + Navigator.push( + context, + MaterialPageRoute(builder: (_) => NewGroupChatScreen()), + ); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + NeumorphicButton( + child: Icon( + StreamIcons.group, + color: Color(0xFF006CFF), + ), + ), + SizedBox(width: 8), + Text( + 'Create a Group', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ), + if (_showUserList) + Container( + width: double.maxFinite, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.black.withOpacity(0.02), + Colors.white.withOpacity(0.05), + ], + stops: [0, 1], + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Text( + _isSearchActive + ? "Matches for \"$_userNameQuery\"" + : 'On the platform', + style: TextStyle( + color: Colors.black.withOpacity(0.5), + ), + ), + ), + ), + Expanded( + child: _showUserList + ? UsersBloc( + child: UserListView( + selectedUsers: _selectedUsers, + groupAlphabetically: _isSearchActive ? false : true, + onUserTap: (user, _) { + _controller.clear(); + if (!_selectedUsers.contains(user)) { + _chipInputTextFieldState + ..addItem(user) + ..pauseItemAddition(); + } else { + _chipInputTextFieldState.removeItem(user); + } + }, + pagination: PaginationParams( + limit: 25, + ), + filter: { + if (_userNameQuery.isNotEmpty) + 'name': { + r'$autocomplete': _userNameQuery, + }, + 'id': { + r'$ne': StreamChat.of(context).user.id, + }, + }, + sort: [ + SortOption( + 'name', + direction: 1, + ), + ], + emptyBuilder: (_) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Icon( + StreamIcons.search, + size: 96, + color: Colors.grey, + ), + ), + Text( + 'No user matches these keywords...'), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ) + : MessageListView(), + ), + MessageInput( + focusNode: _messageInputFocusNode, + onMessageSent: (m) { + if (!m.isEphemeral) { + _updateChannelAndNavigate(context); + } else { + channel.on('message.new').first.then((_) { + _updateChannelAndNavigate(context); + }); + } + }, + ), + ], + ), + ), + ); + } + + void _updateChannelAndNavigate(BuildContext context) { + channel.update({ + 'draft': false, + }); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + child: ChannelPage(), + channel: channel, + ); + }, + ), + ); + } +} diff --git a/example/lib/new_group_chat_screen.dart b/example/lib/new_group_chat_screen.dart new file mode 100644 index 00000000..b75e7510 --- /dev/null +++ b/example/lib/new_group_chat_screen.dart @@ -0,0 +1,285 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'group_chat_details_screen.dart'; + +class NewGroupChatScreen extends StatefulWidget { + @override + _NewGroupChatScreenState createState() => _NewGroupChatScreenState(); +} + +class _NewGroupChatScreenState extends State { + TextEditingController _controller; + + String _userNameQuery = ''; + + final _selectedUsers = {}; + + bool _isSearchActive = false; + + Timer _debounce; + + void _userNameListener() { + if (_debounce?.isActive ?? false) _debounce.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (mounted) + setState(() { + _userNameQuery = _controller.text; + _isSearchActive = _userNameQuery.isNotEmpty; + }); + }); + } + + @override + void initState() { + super.initState(); + _controller = TextEditingController()..addListener(_userNameListener); + } + + @override + void dispose() { + _controller?.clear(); + _controller?.removeListener(_userNameListener); + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Color.fromRGBO(252, 252, 252, 1), + appBar: AppBar( + elevation: 1, + backgroundColor: Colors.white, + leading: const StreamBackButton(), + title: Text( + 'Add Group Members', + style: TextStyle( + color: Colors.black, + fontSize: 16, + ), + ), + centerTitle: true, + actions: [ + if (_selectedUsers.isNotEmpty) + IconButton( + icon: Icon( + StreamIcons.arrow_right, + color: Color(0xFF006CFF), + ), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => GroupChatDetailsScreen( + selectedUsers: _selectedUsers.toList(growable: false), + ), + ), + ); + }, + ) + ], + ), + body: UsersBloc( + child: Column( + children: [ + Container( + height: 36, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all( + color: Colors.grey.shade300, + ), + borderRadius: BorderRadius.circular(24), + ), + margin: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: TextField( + controller: _controller, + decoration: InputDecoration( + prefixIcon: Icon( + StreamIcons.search, + color: Colors.black, + size: 24, + ), + hintText: 'Search', + hintStyle: TextStyle( + color: Colors.black.withOpacity(0.5), + fontSize: 14, + ), + contentPadding: const EdgeInsets.all(0), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ), + if (_selectedUsers.isNotEmpty) + Container( + height: 104, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _selectedUsers.length, + padding: const EdgeInsets.all(8), + separatorBuilder: (_, __) => SizedBox(width: 16), + itemBuilder: (_, index) { + final user = _selectedUsers.elementAt(index); + return Column( + children: [ + Stack( + children: [ + UserAvatar( + onlineIndicatorAlignment: Alignment(0.9, 0.9), + user: user, + showOnlineStatus: true, + borderRadius: BorderRadius.circular(32), + constraints: BoxConstraints.tightFor( + height: 64, + width: 64, + ), + ), + Positioned( + top: -4, + right: -4, + child: GestureDetector( + onTap: () { + if (_selectedUsers.contains(user)) { + setState(() => _selectedUsers.remove(user)); + } + }, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: Colors.grey.shade100, + ), + ), + child: Padding( + padding: const EdgeInsets.all(0.0), + child: Icon( + StreamIcons.close, + size: 24, + ), + ), + ), + ), + ) + ], + ), + SizedBox(height: 4), + Text( + user.name.split(' ')[0], + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ], + ); + }, + ), + ), + Container( + width: double.maxFinite, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.black.withOpacity(0.02), + Colors.white.withOpacity(0.05), + ], + stops: [0, 1], + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 8, + ), + child: Text( + _isSearchActive + ? 'Matches for \"$_userNameQuery\"' + : 'On the platform', + style: TextStyle( + color: Colors.black.withOpacity(0.5), + ), + ), + ), + ), + Expanded( + child: UserListView( + selectedUsers: _selectedUsers, + groupAlphabetically: _isSearchActive ? false : true, + onUserTap: (user, _) { + if (!_selectedUsers.contains(user)) { + setState(() { + _selectedUsers.add(user); + }); + } else { + setState(() { + _selectedUsers.remove(user); + }); + } + }, + pagination: PaginationParams( + limit: 25, + ), + filter: { + if (_userNameQuery.isNotEmpty) + 'name': { + r'$autocomplete': _userNameQuery, + }, + 'id': { + r'$ne': StreamChat.of(context).user.id, + } + }, + sort: [ + SortOption( + 'name', + direction: 1, + ), + ], + emptyBuilder: (_) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(24), + child: Icon( + StreamIcons.search, + size: 96, + color: Colors.grey, + ), + ), + Text('No user matches these keywords...'), + ], + ), + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/example/lib/stream_version.dart b/example/lib/stream_version.dart index ee340f89..113e36c0 100644 --- a/example/lib/stream_version.dart +++ b/example/lib/stream_version.dart @@ -24,7 +24,6 @@ class StreamVersion extends StatelessWidget { final streamChatDep = yaml['packages']['stream_chat_flutter']['version']; - print('streamChatDep: ${streamChatDep}'); return Text( 'Stream SDK v ${streamChatDep}', style: TextStyle( diff --git a/example/pubspec.yaml b/example/pubspec.yaml index c6743cd4..e5daa043 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: example description: A new Flutter project. -version: 1.0.53+55 +version: 1.0.66+68 environment: sdk: ">=2.2.2 <3.0.0" @@ -15,6 +15,7 @@ dependencies: flutter_svg: ^0.19.1 flutter_secure_storage: ^3.3.5 yaml: ^2.2.1 + uuid: ^2.2.2 dev_dependencies: flutter_test: diff --git a/lib/src/channel_list_view.dart b/lib/src/channel_list_view.dart index 9f09235b..9dae4213 100644 --- a/lib/src/channel_list_view.dart +++ b/lib/src/channel_list_view.dart @@ -4,6 +4,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/channels_bloc.dart'; import 'package:stream_chat_flutter/src/utils.dart'; @@ -67,7 +68,9 @@ class ChannelListView extends StatefulWidget { this.channelPreviewBuilder, this.separatorBuilder, this.errorBuilder, + this.emptyBuilder, this.onImageTap, + this.onStartChatPressed, this.swipeToAction = false, this.pullToRefresh = true, }) : super(key: key); @@ -78,6 +81,9 @@ class ChannelListView extends StatefulWidget { /// If true a default swipe to action behaviour will be added to this widget final bool swipeToAction; + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + /// The query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. /// You can also filter other built-in channel fields. @@ -124,6 +130,9 @@ class ChannelListView extends StatefulWidget { /// Set it to false to disable the pull-to-refresh widget final bool pullToRefresh; + /// Callback used in the default empty list widget + final VoidCallback onStartChatPressed; + @override _ChannelListViewState createState() => _ChannelListViewState(); } @@ -159,123 +168,265 @@ class _ChannelListViewState extends State return StreamBuilder>( stream: channelsBlocState.channelsStream, builder: (context, snapshot) { + var child; if (snapshot.hasError) { - if (snapshot.error is Error) { - print((snapshot.error as Error).stackTrace); + child = _buildErrorWidget( + snapshot, + context, + channelsBlocState, + ); + } else if (!snapshot.hasData) { + child = _buildLoadingWidget(); + } else { + final channels = snapshot.data; + + if (channels.isEmpty && widget.emptyBuilder != null) { + child = widget.emptyBuilder(context); } - 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( + if (channels.isEmpty && widget.emptyBuilder == null) { + child = LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: Stack( children: [ - WidgetSpan( - child: Padding( - padding: const EdgeInsets.only( - right: 2.0, - ), - child: Icon(Icons.error_outline), + ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Icon( + StreamIcons.message, + size: 136, + color: Color(0xffDBDBDB), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + 'Let’s start chatting!', + style: TextStyle( + fontSize: 16, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 8.0, + horizontal: 52, + ), + child: Text( + 'How about sending your first message to a friend?', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + color: Color(0xff7A7A7A), + ), + ), + ), + ], ), ), - TextSpan(text: 'Error loading channels'), + if (widget.onStartChatPressed != null) + Positioned( + right: 0, + left: 0, + bottom: 32, + child: Center( + child: FlatButton( + onPressed: widget.onStartChatPressed, + child: Text( + 'Start a chat', + style: TextStyle( + color: + StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ), ], ), - 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 (channels.isNotEmpty) { + child = 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; + }, + ), + ); + } } - if (!snapshot.hasData) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: CircularProgressIndicator(), - ), - ), - ); - }, - ); - } - - final channels = snapshot.data; - - if (channels.isEmpty) { - 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; - }, - ), + return AnimatedSwitcher( + child: child, + duration: Duration(milliseconds: 500), ); }); } + Widget _buildLoadingWidget() { + return ListView( + physics: AlwaysScrollableScrollPhysics(), + children: List.generate( + 25, + (i) { + if (i % 2 != 0) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, i); + } + return _separatorBuilder(context, i); + } + return _buildLoadingItem(); + }, + ), + ); + } + + Shimmer _buildLoadingItem() { + return Shimmer.fromColors( + baseColor: Color(0xffE5E5E5), + highlightColor: Color(0xffffffff), + child: ListTile( + leading: Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + title: Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 82, + ), + ), + ), + subtitle: Row( + children: [ + Align( + alignment: Alignment.centerLeft, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 238, + ), + ), + ), + Container( + margin: const EdgeInsets.only(left: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(11), + ), + constraints: BoxConstraints.tightFor( + height: 16, + width: 42, + ), + ), + ], + ), + ), + ); + } + + Widget _buildErrorWidget( + AsyncSnapshot> snapshot, + BuildContext context, + ChannelsBlocState channelsBlocState, + ) { + 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'), + ), + ], + ), + ); + } + Widget _itemBuilder(context, int i, List channels) { if (i % 2 != 0) { if (widget.separatorBuilder != null) { @@ -433,7 +584,9 @@ class _ChannelListViewState extends State } Widget _buildQueryProgressIndicator( - context, ChannelsBlocState channelsProvider) { + context, + ChannelsBlocState channelsProvider, + ) { return StreamBuilder( stream: channelsProvider.queryChannelsLoading, initialData: false, diff --git a/lib/src/channels_bloc.dart b/lib/src/channels_bloc.dart index 3af2300d..474d9599 100644 --- a/lib/src/channels_bloc.dart +++ b/lib/src/channels_bloc.dart @@ -31,7 +31,7 @@ class ChannelsBloc extends StatefulWidget { streamChatState = context.findAncestorStateOfType(); if (streamChatState == null) { - throw Exception('You must have a ChannelsBloc widget as anchestor'); + throw Exception('You must have a ChannelsBloc widget as ancestor'); } return streamChatState; diff --git a/lib/src/giphy_attachment.dart b/lib/src/giphy_attachment.dart index e5464987..f6d1d473 100644 --- a/lib/src/giphy_attachment.dart +++ b/lib/src/giphy_attachment.dart @@ -41,219 +41,209 @@ class GiphyAttachment extends StatelessWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - DecoratedBox( - decoration: BoxDecoration(), - child: Card( - elevation: 2, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), - bottomRight: Radius.circular(0.0), - topLeft: Radius.circular(16.0), - bottomLeft: Radius.circular(16.0), - ), + Card( + elevation: 2, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16.0), + bottomRight: Radius.circular(0.0), + topLeft: Radius.circular(16.0), + bottomLeft: Radius.circular(16.0), ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Stack( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GestureDetector( - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push(context, - MaterialPageRoute(builder: (_) { - return StreamChannel( - channel: channel, - child: FullScreenImage( - urls: [attachment.imageUrl ?? + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) { + return FullScreenImage( + urls: [attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl], userName: message.user.name, sentAt: message.createdAt, message: message, - ), - ); - })); - }, - child: CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, - ), - fit: BoxFit.cover, - ), - ), - ), - Positioned( - left: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only( - bottomRight: Radius.circular(16.0), - )), - child: Padding( - padding: const EdgeInsets.only( - left: 8.0, - right: 8.0, - top: 8.0, - bottom: 4.0, - ), - child: Row( - children: [ - Icon( - StreamIcons.lightning, - color: StreamChatTheme.of(context).accentColor, - size: 16.0, - ), - Text( - 'GIPHY', - style: TextStyle( - color: - StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold, - fontSize: 11.0, - ), - ), - ], - ), - ), - ), - ), - ], - ), - if (attachment.title != null) - Container( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Card( - elevation: 2, - child: IconButton( - padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tight(Size(32, 32)), - icon: Icon( - StreamIcons.left, - size: 24.0, - ), - splashRadius: 16, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'shuffle', - }); - }, - ), - shape: CircleBorder(), - ), - Expanded( + ); + })); + }, + child: CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, child: Center( - child: Text( - '"${attachment.title}"', - style: TextStyle( - fontStyle: FontStyle.italic, - ), - ), + child: CircularProgressIndicator(), ), - ), - Card( - elevation: 2, - child: IconButton( - padding: const EdgeInsets.all(0), - constraints: BoxConstraints.tight(Size(32, 32)), - icon: Icon( - StreamIcons.right, - size: 24.0, - ), - splashRadius: 16, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'shuffle', - }); - }, - ), - shape: CircleBorder(), - ), - ], + ); + }, + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, ), ), ), - SizedBox( - height: 4.0, - ), - Container( - color: Colors.black.withOpacity(0.2), - width: double.infinity, - height: 0.5, - ), - Row( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: FlatButton( - height: 50, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'cancel', - }); - }, - child: Text( - 'Cancel', - style: TextStyle( - fontWeight: FontWeight.bold, - color: Colors.black.withOpacity(0.5)), + Positioned( + left: 0, + top: 0, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + bottomRight: Radius.circular(16.0), + )), + child: Padding( + padding: const EdgeInsets.only( + left: 8.0, + right: 8.0, + top: 8.0, + bottom: 4.0, ), - ), - ), - Container( - width: 0.5, - color: Colors.black.withOpacity(0.2), - height: 50.0, - ), - Expanded( - child: FlatButton( - height: 50, - onPressed: () { - streamChannel.channel.sendAction(message, { - 'image_action': 'send', - }); - }, - child: Text( - 'Send', - style: TextStyle( + child: Row( + children: [ + Icon( + StreamIcons.lightning, color: StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold), + size: 16.0, + ), + Text( + 'GIPHY', + style: TextStyle( + color: StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold, + fontSize: 11.0, + ), + ), + ], ), ), ), - ], + ), + ], + ), + if (attachment.title != null) + Container( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Card( + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: Icon( + StreamIcons.left, + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, + ), + shape: CircleBorder(), + ), + Expanded( + child: Center( + child: Text( + '"${attachment.title}"', + style: TextStyle( + fontStyle: FontStyle.italic, + ), + ), + ), + ), + Card( + elevation: 2, + child: IconButton( + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tight(Size(32, 32)), + icon: Icon( + StreamIcons.right, + size: 24.0, + ), + splashRadius: 16, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'shuffle', + }); + }, + ), + shape: CircleBorder(), + ), + ], + ), + ), ), - ], - ), + SizedBox( + height: 4.0, + ), + Container( + color: Colors.black.withOpacity(0.2), + width: double.infinity, + height: 0.5, + ), + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'cancel', + }); + }, + child: Text( + 'Cancel', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.black.withOpacity(0.5)), + ), + ), + ), + Container( + width: 0.5, + color: Colors.black.withOpacity(0.2), + height: 50.0, + ), + Expanded( + child: FlatButton( + height: 50, + onPressed: () { + streamChannel.channel.sendAction(message, { + 'image_action': 'send', + }); + }, + child: Text( + 'Send', + style: TextStyle( + color: StreamChatTheme.of(context).accentColor, + fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + ], ), ), SizedBox( @@ -291,88 +281,77 @@ class GiphyAttachment extends StatelessWidget { Widget _buildSentAttachment(context) { return Container( - child: Column( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16.0), - bottomRight: Radius.circular(0.0), - topLeft: Radius.circular(16.0), - bottomLeft: Radius.circular(16.0), - ), - ), - clipBehavior: Clip.antiAlias, - child: GestureDetector( - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push(context, MaterialPageRoute(builder: (_) { - return StreamChannel( - channel: channel, - child: FullScreenImage( - urls: [attachment.imageUrl ?? + child: GestureDetector( + onTap: () { + Navigator.push(context, MaterialPageRoute(builder: (_) { + return FullScreenImage( + urls: [attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl], userName: message.user.name, sentAt: message.createdAt, message: message, - ), - ); - })); + ); + })); + }, + child: Stack( + children: [ + CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); }, - child: CachedNetworkImage( - height: size?.height, - width: size?.width, - placeholder: (_, __) { - return Container( - width: size?.width, - height: size?.height, - child: Center( - child: CircularProgressIndicator(), - ), - ); - }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, + ), + Positioned( + bottom: 8, + left: 8, + child: Material( + color: Colors.black.withOpacity(.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + child: Row( + children: [ + Icon( + StreamIcons.lightning, + color: Colors.white, + size: 16, + ), + Text( + 'GIPHY', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 11, + ), + ), + ], + ), ), - fit: BoxFit.cover, ), ), - ), - Padding( - padding: const EdgeInsets.only( - top: 8.0, - bottom: 8, - ), - child: Row( - children: [ - Row( - children: [ - Icon( - StreamIcons.lightning, - color: StreamChatTheme.of(context).accentColor, - size: 15.0, - ), - Text( - 'GIPHY', - style: TextStyle( - color: StreamChatTheme.of(context).accentColor, - fontWeight: FontWeight.bold, - fontSize: 11.0, - ), - ), - ], - ), - ], - mainAxisAlignment: MainAxisAlignment.start, - ), - ) - ], + ], + ), ), ); } diff --git a/lib/src/group_image.dart b/lib/src/group_image.dart index 2385007d..96e25f74 100644 --- a/lib/src/group_image.dart +++ b/lib/src/group_image.dart @@ -46,9 +46,16 @@ class GroupImage extends StatelessWidget { .take(2) .map((url) => Flexible( fit: FlexFit.tight, - child: CachedNetworkImage( - imageUrl: url, + child: FittedBox( fit: BoxFit.cover, + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.2, + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + ), + ), ), )) .toList(), @@ -64,9 +71,16 @@ class GroupImage extends StatelessWidget { .skip(2) .map((url) => Flexible( fit: FlexFit.tight, - child: CachedNetworkImage( - imageUrl: url, + child: FittedBox( fit: BoxFit.cover, + clipBehavior: Clip.antiAlias, + child: Transform.scale( + scale: 1.2, + child: CachedNetworkImage( + imageUrl: url, + fit: BoxFit.cover, + ), + ), ), )) .toList(), diff --git a/lib/src/media_list_view.dart b/lib/src/media_list_view.dart new file mode 100644 index 00000000..cd414fa9 --- /dev/null +++ b/lib/src/media_list_view.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:media_gallery/media_gallery.dart'; +import 'package:stream_chat_flutter/src/stream_icons.dart'; +import 'package:transparent_image/transparent_image.dart'; + +class MediaListView extends StatefulWidget { + final List selectedIds; + final void Function(Media media) onSelect; + + const MediaListView({ + Key key, + this.selectedIds = const [], + this.onSelect, + }) : super(key: key); + @override + _MediaListViewState createState() => _MediaListViewState(); +} + +class _MediaListViewState extends State { + final _media = []; + final ScrollController _scrollController = ScrollController(); + + @override + Widget build(BuildContext context) { + return GridView.builder( + itemCount: _media.length, + controller: _scrollController, + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), + itemBuilder: ( + context, + position, + ) { + final media = _media.elementAt(position); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.0, vertical: 1.0), + child: InkWell( + child: Stack( + children: [ + AspectRatio( + aspectRatio: 1.0, + child: FadeInImage( + fadeInDuration: Duration(milliseconds: 300), + placeholder: MemoryImage(kTransparentImage), + image: MediaThumbnailProvider( + media: media, + highQuality: true, + ), + fit: BoxFit.cover, + ), + ), + Positioned.fill( + child: IgnorePointer( + child: AnimatedOpacity( + duration: Duration(milliseconds: 300), + opacity: widget.selectedIds.any((id) => id == media.id) + ? 1.0 + : 0.0, + child: Container( + color: Colors.black.withOpacity(0.5), + alignment: Alignment.topRight, + padding: const EdgeInsets.only( + top: 8, + right: 8, + ), + child: CircleAvatar( + radius: 12, + backgroundColor: Colors.white, + child: Icon( + StreamIcons.check, + size: 24, + color: Colors.black, + ), + ), + ), + ), + ), + ), + if (media.mediaType == MediaType.video) + Positioned( + left: 8, + bottom: 10, + child: SvgPicture.asset( + 'svgs/video_call_icon.svg', + package: 'stream_chat_flutter', + ), + ), + ], + ), + onTap: () { + if (widget.onSelect != null) { + widget.onSelect(media); + } + }, + ), + ); + }, + ); + } + + @override + void initState() { + super.initState(); + _getMedia(); + } + + void _getMedia() async { + final List collections = + await MediaGallery.listMediaCollections( + mediaTypes: [ + MediaType.video, + MediaType.image, + ], + ); + + if (collections.isEmpty) { + return; + } + final collection = collections.firstWhere( + (element) => element.isAllCollection, + orElse: () => collections.first, + ); + + final videoPage = await collection.getMedias( + mediaType: MediaType.video, + take: 500, + ); + final imagePage = await collection.getMedias( + mediaType: MediaType.image, + take: 500, + ); + + final allItems = [ + ...videoPage.items, + ...imagePage.items, + ]..sort(( + a, + b, + ) => + b.creationDate.compareTo(a.creationDate)); + + setState(() { + _media.addAll(allItems); + }); + } +} diff --git a/lib/src/message_actions_modal.dart b/lib/src/message_actions_modal.dart index 00354a49..16c48e25 100644 --- a/lib/src/message_actions_modal.dart +++ b/lib/src/message_actions_modal.dart @@ -45,14 +45,15 @@ class MessageActionsModal extends StatelessWidget { @override Widget build(BuildContext context) { - var size = MediaQuery.of(context).size; - var user = StreamChat.of(context).user; + final size = MediaQuery.of(context).size; + final user = StreamChat.of(context).user; - var roughMaxSize = 2 * size.width / 3; - var roughSentenceSize = + final roughMaxSize = 2 * size.width / 3; + final roughSentenceSize = message.text.length * messageTheme.messageText.fontSize * 1.2; - var divFactor = - roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize); + final divFactor = message.attachments?.isNotEmpty == true + ? 1 + : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); return GestureDetector( behavior: HitTestBehavior.translucent, diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 89891ace..6845d1a4 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math'; import 'package:emojis/emoji.dart'; @@ -8,13 +9,17 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:http_parser/http_parser.dart'; +import 'package:http_parser/http_parser.dart' as httpParser; import 'package:image_picker/image_picker.dart'; +import 'package:media_gallery/media_gallery.dart'; import 'package:mime/mime.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/media_list_view.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; +import 'package:stream_chat_flutter/src/video_thumbnail.dart'; import 'package:substring_highlight/substring_highlight.dart'; import '../stream_chat_flutter.dart'; @@ -37,6 +42,8 @@ enum DefaultAttachmentTypes { file, } +const _kMinMediaPickerSize = 360.0; + /// 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) @@ -97,6 +104,7 @@ class MessageInput extends StatefulWidget { this.actions, this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, + this.focusNode, }) : super(key: key); /// Message to edit @@ -142,6 +150,9 @@ class MessageInput extends StatefulWidget { /// Map that defines a thumbnail builder for an attachment type final Map attachmentThumbnailBuilders; + /// The focus node associated to the TextField + final FocusNode focusNode; + @override MessageInputState createState() => MessageInputState(); @@ -162,13 +173,13 @@ class MessageInput extends StatefulWidget { class MessageInputState extends State { final List<_SendingAttachment> _attachments = []; - final _focusNode = FocusNode(); final List _mentionedUsers = []; final _imagePicker = ImagePicker(); + FocusNode _focusNode; bool _inputEnabled = true; bool _messageIsPresent = false; - bool _typingStarted = false; + bool _animateContainer = true; bool _commandEnabled = false; OverlayEntry _commandsOverlay, _mentionsOverlay, _emojiOverlay; Iterable _emojiNames; @@ -176,6 +187,9 @@ class MessageInputState extends State { Command _chosenCommand; bool _actionsShrunk = false; bool _sendAsDm = false; + bool _openFilePickerSection = false; + int _filePickerIndex = 0; + double _filePickerSize = _kMinMediaPickerSize; /// The editing controller passed to the input TextField TextEditingController textEditingController; @@ -201,6 +215,7 @@ class MessageInputState extends State { padding: const EdgeInsets.symmetric(horizontal: 8.0), child: _buildDmCheckbox(), ), + _buildFilePickerSection(), ], ), ), @@ -210,7 +225,7 @@ class MessageInputState extends State { Flex _buildTextField(BuildContext context) { return Flex( direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!_commandEnabled) _buildExpandActionsButton(), if (widget.actionsLocation == ActionsLocation.left) @@ -333,7 +348,7 @@ class MessageInputState extends State { child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32.0), + borderRadius: BorderRadius.circular(20.0), border: Border.all( color: Colors.grey, ), @@ -355,40 +370,11 @@ class MessageInputState extends State { keyboardType: widget.keyboardType, controller: textEditingController, focusNode: _focusNode, - onChanged: (s) { - StreamChannel.of(context).channel.keyStroke(); - - setState(() { - _messageIsPresent = s.trim().isNotEmpty; - _actionsShrunk = s.trim().isNotEmpty; - }); - - _commandsOverlay?.remove(); - _commandsOverlay = null; - _mentionsOverlay?.remove(); - _mentionsOverlay = null; - _emojiOverlay?.remove(); - _emojiOverlay = null; - - _checkCommands(s.trimLeft(), context); - - _checkMentions(s, context); - - _checkEmoji(s, context); - }, - onTap: () { - setState(() { - _typingStarted = true; - }); - }, style: Theme.of(context).textTheme.bodyText2, autofocus: false, textAlignVertical: TextAlignVertical.center, decoration: InputDecoration( - hintText: - (_commandEnabled && _chosenCommand.name == 'giphy') - ? 'Search GIFs' - : 'Write a message', + hintText: _getHint(), prefixText: _commandEnabled ? null : ' ', border: OutlineInputBorder( borderSide: BorderSide(color: Colors.transparent)), @@ -440,15 +426,47 @@ class MessageInputState extends State { ); } + void _onChanged(BuildContext context, String s) { + StreamChannel.of(context).channel.keyStroke().catchError((e) {}); + + setState(() { + _messageIsPresent = s.trim().isNotEmpty; + _actionsShrunk = s.trim().isNotEmpty; + }); + + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + _emojiOverlay?.remove(); + _emojiOverlay = null; + + _checkCommands(s.trim(), context); + + _checkMentions(s, context); + + _checkEmoji(s, context); + } + + String _getHint() { + if (_commandEnabled && _chosenCommand.name == 'giphy') { + return 'Search GIFs'; + } + if (_attachments.isNotEmpty) { + return 'Add a comment or send'; + } + return 'Write a message'; + } + void _checkEmoji(String s, BuildContext context) { - if (textEditingController.selection.isCollapsed && - (s.isNotEmpty && s[textEditingController.selection.start - 1] == ':' || - textEditingController.text - .substring( - 0, - textEditingController.selection.start, - ) - .contains(':'))) { + if (s.isNotEmpty && + textEditingController.selection.baseOffset > 0 && + textEditingController.text + .substring( + 0, + textEditingController.selection.baseOffset, + ) + .contains(':')) { final textToSelection = textEditingController.text .substring(0, textEditingController.value.selection.start); final splits = textToSelection.split(':'); @@ -468,13 +486,13 @@ class MessageInputState extends State { } void _checkMentions(String s, BuildContext context) { - if (textEditingController.selection.isCollapsed && - (s.isNotEmpty && s[textEditingController.selection.start - 1] == '@' || - textEditingController.text - .substring(0, textEditingController.selection.start) - .split(' ') - .last - .contains('@'))) { + if (s.isNotEmpty && + textEditingController.selection.baseOffset > 0 && + textEditingController.text + .substring(0, textEditingController.selection.baseOffset) + .split(' ') + .last + .contains('@')) { _mentionsOverlay = _buildMentionsOverlayEntry(); Overlay.of(context).insert(_mentionsOverlay); } @@ -548,8 +566,10 @@ class MessageInputState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), - child: Icon(StreamIcons.lightning, - color: StreamChatTheme.of(context).accentColor), + child: Icon( + StreamIcons.lightning, + color: StreamChatTheme.of(context).accentColor, + ), ), Text( 'Instant Commands', @@ -604,6 +624,257 @@ class MessageInputState extends State { }); } + Widget _buildFilePickerSection() { + return AnimatedContainer( + duration: _animateContainer ? Duration(milliseconds: 300) : Duration.zero, + height: _openFilePickerSection ? _filePickerSize : 0, + child: Material( + color: Color(0xFFF2F2F2), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + IconButton( + icon: Icon( + StreamIcons.picture, + size: 24, + color: _filePickerIndex == 0 + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.5), + ), + onPressed: () { + setState(() { + _filePickerIndex = 0; + }); + }, + ), + IconButton( + icon: Icon( + StreamIcons.folder, + size: 24, + color: _filePickerIndex == 1 + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.5), + ), + onPressed: () { + pickFile(DefaultAttachmentTypes.file, false); + }, + ), + IconButton( + icon: SvgPicture.asset( + 'svgs/icon_camera.svg', + package: 'stream_chat_flutter', + height: 24, + width: 24, + color: _filePickerIndex == 2 + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.5), + ), + onPressed: () { + pickFile(DefaultAttachmentTypes.image, true); + }, + ), + IconButton( + icon: Icon( + StreamIcons.record, + size: 24, + color: _filePickerIndex == 2 + ? StreamChatTheme.of(context).accentColor + : Colors.black.withOpacity(0.5), + ), + onPressed: () { + pickFile(DefaultAttachmentTypes.video, true); + }, + ), + ], + ), + GestureDetector( + onVerticalDragUpdate: (update) { + setState(() { + _animateContainer = false; + _filePickerSize = (_filePickerSize - update.delta.dy).clamp( + _kMinMediaPickerSize, + MediaQuery.of(context).size.height / 1.7, + ); + }); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16.0), + topRight: Radius.circular(16.0), + ), + ), + child: Container( + width: double.infinity, + child: Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + width: 40.0, + height: 4.0, + decoration: BoxDecoration( + color: Color(0xFFF2F2F2), + borderRadius: BorderRadius.circular(4.0), + ), + ), + ), + ), + ), + ), + ), + if (_openFilePickerSection) + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8.0), + ), + child: _buildPickerSection(), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPickerSection() { + switch (_filePickerIndex) { + case 0: + return FutureBuilder( + future: Platform.isAndroid + ? Permission.storage.status + : Permission.photos.status, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + + if (snapshot.data.isGranted) { + return MediaListView( + selectedIds: _attachments.map((e) => e.id).toList(), + onSelect: (media) { + if (!_attachments + .any((element) => element.id == media.id)) { + _addAttachment(media); + } else { + setState(() { + _attachments + .removeWhere((element) => element.id == media.id); + }); + } + }, + ); + } + + return InkWell( + onTap: () async { + var status = await (Platform.isAndroid + ? Permission.storage.status + : Permission.photos.status); + print('status: ${status}'); + if (status.isPermanentlyDenied || status.isDenied) { + if (await openAppSettings()) { + setState(() {}); + } + } else { + status = await (Platform.isAndroid + ? Permission.storage + : Permission.photos) + .request(); + if (status.isGranted) { + setState(() {}); + } + } + }, + child: Container( + color: Color(0xFFF2F2F2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + 'svgs/icon_picture_empty_state.svg', + package: 'stream_chat_flutter', + height: 140, + color: StreamChatTheme.of(context).accentColor, + ), + Center( + child: Text( + 'Allow access to your gallery', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: StreamChatTheme.of(context).accentColor, + ), + ), + ), + ], + ), + ), + ); + }); + break; + } + } + + void _addAttachment(Media medium) async { + final attachment = _SendingAttachment( + id: medium.id, + ); + + setState(() { + _attachments.add(attachment); + }); + + final mediaFile = await medium.getFile(); + + final file = PlatformFile( + path: mediaFile.path, + bytes: mediaFile.readAsBytesSync(), + ); + + final channel = StreamChannel.of(context).channel; + setState(() { + attachment + ..file = file + ..attachment = Attachment( + localUri: file.path != null ? Uri.parse(file.path) : null, + type: medium.mediaType == MediaType.image ? 'image' : 'video', + ); + }); + + final url = await _uploadAttachment( + file, + medium.mediaType == MediaType.image + ? DefaultAttachmentTypes.image + : DefaultAttachmentTypes.video, + channel); + + final fileType = medium.mediaType == MediaType.image + ? DefaultAttachmentTypes.image + : DefaultAttachmentTypes.video; + + if (fileType == DefaultAttachmentTypes.image) { + attachment.attachment = attachment.attachment.copyWith( + imageUrl: url, + ); + } else { + attachment.attachment = attachment.attachment.copyWith( + assetUrl: url, + ); + } + + setState(() { + attachment.uploaded = true; + }); + } + CircleAvatar _buildGiphyIcon() { if (kIsWeb) { return CircleAvatar( @@ -857,24 +1128,11 @@ class MessageInputState extends State { _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 _attachments.isEmpty ? Container() : LimitedBox( - maxHeight: 76.0, + maxHeight: 104.0, child: ListView( scrollDirection: Axis.horizontal, children: _attachments @@ -888,8 +1146,8 @@ class MessageInputState extends State { AspectRatio( aspectRatio: 1.0, child: Container( - height: 50, - width: 50, + height: 104, + width: 104, child: _buildAttachment(attachment), ), ), @@ -916,8 +1174,8 @@ class MessageInputState extends State { Positioned _buildRemoveButton(_SendingAttachment attachment) { return Positioned( - height: 16, - width: 16, + height: 24, + width: 24, top: 4, right: 4, child: RawMaterialButton( @@ -934,11 +1192,12 @@ class MessageInputState extends State { _attachments.remove(attachment); }); }, - fillColor: Colors.white.withOpacity(.5), + fillColor: Colors.black.withOpacity(.5), child: Center( child: Icon( - Icons.close, - size: 15, + StreamIcons.close, + size: 24, + color: Colors.white, ), ), ), @@ -955,6 +1214,10 @@ class MessageInputState extends State { ); } + if (attachment.attachment == null) { + return SizedBox(); + } + switch (attachment.attachment.type) { case 'image': case 'giphy': @@ -964,15 +1227,30 @@ class MessageInputState extends State { fit: BoxFit.cover, ) : Image.network( - attachment.attachment.imageUrl ?? - attachment.attachment.thumbUrl, + attachment.attachment.imageUrl, fit: BoxFit.cover, ); break; case 'video': - return Container( - child: Icon(Icons.videocam), - color: Colors.black26, + return Stack( + children: [ + Positioned.fill( + child: Container( + child: VideoThumbnail( + file: File( + attachment.file.path, + )), + ), + ), + Positioned( + left: 8, + bottom: 10, + child: SvgPicture.asset( + 'svgs/video_call_icon.svg', + package: 'stream_chat_flutter', + ), + ), + ], ); break; default: @@ -984,26 +1262,24 @@ class MessageInputState extends State { } Widget _buildCommandButton() { - return Center( - child: InkWell( - child: Padding( - padding: const EdgeInsets.only( - left: 4.0, right: 8.0, top: 8.0, bottom: 8.0), - child: Icon( - StreamIcons.lightning, - color: Color(0xFF000000).withAlpha(128), - ), + return InkWell( + child: Padding( + padding: + const EdgeInsets.only(left: 4.0, right: 8.0, top: 8.0, bottom: 8.0), + child: Icon( + StreamIcons.lightning, + color: Color(0xFF000000).withAlpha(128), ), - onTap: () { - if (_commandsOverlay == null) { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); - } else { - _commandsOverlay?.remove(); - _commandsOverlay = null; - } - }, ), + onTap: () { + if (_commandsOverlay == null) { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + } else { + _commandsOverlay?.remove(); + _commandsOverlay = null; + } + }, ); } @@ -1016,11 +1292,37 @@ class MessageInputState extends State { EdgeInsets.only(left: 8.0, right: padding, top: 8.0, bottom: 8.0), child: Icon( StreamIcons.attach, - color: Color(0xFF000000).withAlpha(128), + color: _openFilePickerSection + ? StreamChatTheme.of(context).accentColor + : Color(0xFF000000).withAlpha(128), ), ), - onTap: () { - showAttachmentModal(); + onTap: () async { + _emojiOverlay?.remove(); + _emojiOverlay = null; + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; + + if (_openFilePickerSection) { + setState(() { + _animateContainer = true; + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + } else { + final status = await (Platform.isAndroid + ? Permission.storage.status + : Permission.photos.status); + if (status.isUndetermined) { + await (Platform.isAndroid + ? Permission.storage + : Permission.photos) + .request(); + } + showAttachmentModal(); + } }, ), ); @@ -1032,73 +1334,79 @@ class MessageInputState extends State { _focusNode.unfocus(); } - showModalBottomSheet( - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), + if (!kIsWeb) { + setState(() { + _openFilePickerSection = true; + }); + } else { + showModalBottomSheet( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), + ), ), - ), - context: context, - isScrollControlled: true, - builder: (_) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - 'Add a file', - style: TextStyle( - fontWeight: FontWeight.bold, + 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'), + leading: Icon(Icons.image), + title: Text('Upload a photo'), onTap: () { - pickFile(DefaultAttachmentTypes.image, true); + pickFile(DefaultAttachmentTypes.image, false); Navigator.pop(context); }, ), - if (!kIsWeb) ListTile( - leading: Icon(Icons.videocam), - title: Text('Video from camera'), + leading: Icon(Icons.video_library), + title: Text('Upload a video'), onTap: () { - pickFile(DefaultAttachmentTypes.video, true); + pickFile(DefaultAttachmentTypes.video, false); Navigator.pop(context); }, ), - ListTile( - leading: Icon(Icons.insert_drive_file), - title: Text('Upload a file'), - onTap: () { - pickFile(DefaultAttachmentTypes.file, 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 @@ -1137,6 +1445,9 @@ class MessageInputState extends State { } else if (fileType == DefaultAttachmentTypes.video) { pickedFile = await _imagePicker.getVideo(source: ImageSource.camera); } + if (pickedFile == null) { + return; + } final bytes = await pickedFile.readAsBytes(); file = PlatformFile( path: pickedFile.path, @@ -1228,7 +1539,9 @@ class MessageInputState extends State { MultipartFile.fromBytes( bytes, filename: filename, - contentType: MediaType.parse(lookupMimeType(filename)), + contentType: filename != null + ? httpParser.MediaType.parse(lookupMimeType(filename)) + : null, ), ); return res.file; @@ -1241,7 +1554,7 @@ class MessageInputState extends State { MultipartFile.fromBytes( bytes, filename: filename, - contentType: MediaType.parse(lookupMimeType(filename)), + contentType: httpParser.MediaType.parse(lookupMimeType(filename)), ), ); return res.file; @@ -1332,7 +1645,6 @@ class MessageInputState extends State { setState(() { _messageIsPresent = false; - _typingStarted = false; _commandEnabled = false; }); @@ -1400,47 +1712,14 @@ class MessageInputState extends State { void initState() { super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + _emojiNames = Emoji.all().map((e) => e.name); if (!kIsWeb) { _keyboardListener = KeyboardVisibility.onChange.listen((visible) { - if (visible) { - if (_commandsOverlay != null) { - if (textEditingController.text.trimLeft().startsWith('/')) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); - }); - } - } - - if (_mentionsOverlay != null) { - if (textEditingController.text.contains('@')) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _mentionsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_mentionsOverlay); - }); - } - } - - if (_emojiOverlay != null) { - if (textEditingController.text.contains(':')) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _emojiOverlay = _buildEmojiOverlay(); - Overlay.of(context).insert(_emojiOverlay); - }); - } - } - } else { - if (_commandsOverlay != null) { - _commandsOverlay.remove(); - } - if (_mentionsOverlay != null) { - _mentionsOverlay.remove(); - } - if (_emojiOverlay != null) { - _emojiOverlay.remove(); - } + if (_focusNode.hasFocus) { + _onChanged(context, textEditingController.text); } }); } @@ -1450,12 +1729,21 @@ class MessageInputState extends State { if (widget.editMessage != null || widget.initialMessage != null) { _parseExistingMessage(widget.editMessage ?? widget.initialMessage); } + + textEditingController.addListener(() { + _onChanged(context, textEditingController.text); + }); + + _focusNode.addListener(() { + if (_focusNode.hasFocus) { + _openFilePickerSection = false; + } + }); } void _parseExistingMessage(Message message) { textEditingController.text = message.text; - _typingStarted = true; _messageIsPresent = true; message.attachments?.forEach((attachment) { @@ -1490,11 +1778,13 @@ class _SendingAttachment { PlatformFile file; Attachment attachment; bool uploaded; + String id; _SendingAttachment({ this.file, this.attachment, this.uploaded = false, + this.id, }); } @@ -1503,3 +1793,44 @@ extension StringExtension on String { return "${this[0].toUpperCase()}${this.substring(1)}"; } } + +/// Represents a 2-tuple, or pair. +class Tuple2 { + /// Returns the first item of the tuple + final T1 item1; + + /// Returns the second item of the tuple + final T2 item2; + + /// Creates a new tuple value with the specified items. + const Tuple2(this.item1, this.item2); + + /// Create a new tuple value with the specified list [items]. + factory Tuple2.fromList(List items) { + if (items.length != 2) { + throw ArgumentError('items must have length 2'); + } + + return Tuple2(items[0] as T1, items[1] as T2); + } + + /// Returns a tuple with the first item set to the specified value. + Tuple2 withItem1(T1 v) => Tuple2(v, item2); + + /// Returns a tuple with the second item set to the specified value. + Tuple2 withItem2(T2 v) => Tuple2(item1, v); + + /// Creates a [List] containing the items of this [Tuple2]. + /// + /// The elements are in item order. The list is variable-length + /// if [growable] is true. + List toList({bool growable = false}) => + List.from([item1, item2], growable: growable); + + @override + String toString() => '[$item1, $item2]'; + + @override + bool operator ==(Object other) => + other is Tuple2 && other.item1 == item1 && other.item2 == item2; +} diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 2a60b75b..4a5e445b 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -180,7 +180,26 @@ class _MessageListViewState extends State { ? streamChannel.channel.state.threads[widget.parentMessage.id] : streamChannel.channel.state.messages, builder: (context, snapshot) { + if (!snapshot.hasData) { + return Center( + child: CircularProgressIndicator(), + ); + } + final messages = snapshot.data?.reversed?.toList() ?? []; + + if (messages.isEmpty) { + return Center( + child: Text( + 'No chats here yet...', + style: TextStyle( + fontSize: 12, + color: Colors.black.withOpacity(.5), + ), + ), + ); + } + return Stack( alignment: Alignment.center, children: [ diff --git a/lib/src/message_reactions_modal.dart b/lib/src/message_reactions_modal.dart index fc3f68a4..d68bd769 100644 --- a/lib/src/message_reactions_modal.dart +++ b/lib/src/message_reactions_modal.dart @@ -36,14 +36,15 @@ class MessageReactionsModal extends StatelessWidget { @override Widget build(BuildContext context) { - var size = MediaQuery.of(context).size; - var user = StreamChat.of(context).user; + final size = MediaQuery.of(context).size; + final user = StreamChat.of(context).user; - var roughMaxSize = 2 * size.width / 3; - var roughSentenceSize = + final roughMaxSize = 2 * size.width / 3; + final roughSentenceSize = message.text.length * messageTheme.messageText.fontSize * 1.2; - var divFactor = - roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize); + final divFactor = message.attachments?.isNotEmpty == true + ? 1 + : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); return GestureDetector( behavior: HitTestBehavior.translucent, diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index f9871d69..86479ebe 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -493,7 +493,10 @@ class _MessageWidgetState extends State { message: widget.message, editMessageInputBuilder: widget.editMessageInputBuilder, onThreadTap: widget.onThreadTap, - showEditMessage: widget.showEditMessage, + showEditMessage: widget.showEditMessage && + widget.message.attachments + ?.any((element) => element.type == 'giphy') != + true, showReactions: widget.showReactions, showReply: widget.showReplyIndicator && widget.onThreadTap != null, @@ -582,14 +585,22 @@ class _MessageWidgetState extends State { widget.message, attachment, ); - return wrapAttachmentWidget(context, attachmentWidget, - attachment: attachment); + return wrapAttachmentWidget( + context, + attachmentWidget, + attachment: attachment, + ); })?.toList() ?? []; } - Padding wrapAttachmentWidget(BuildContext context, Widget attachmentWidget, - {Attachment attachment}) { + Padding wrapAttachmentWidget( + BuildContext context, + Widget attachmentWidget, { + Attachment attachment, + }) { + final attachmentShape = + widget.attachmentShape ?? widget.shape ?? _getDefaultShape(context); return Padding( padding: EdgeInsets.only( bottom: 4, @@ -602,13 +613,13 @@ class _MessageWidgetState extends State { ? Colors.white : _getBackgroundColor(), clipBehavior: Clip.hardEdge, - shape: widget.attachmentShape ?? - widget.shape ?? - _getDefaultShape(context), + shape: attachmentShape, child: Padding( padding: widget.attachmentPadding, - child: ClipRRect( - borderRadius: BorderRadius.circular(6), + child: Material( + clipBehavior: Clip.hardEdge, + shape: attachmentShape, + type: MaterialType.transparency, child: Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, @@ -806,7 +817,8 @@ class _MessageWidgetState extends State { ), ), if (widget.message.attachments - .any((element) => element.ogScrapeUrl != null)) + ?.any((element) => element.ogScrapeUrl != null) == + true) _buildUrlAttachment(), ], ), @@ -833,7 +845,8 @@ class _MessageWidgetState extends State { } if (widget.message.attachments - .any((element) => element.ogScrapeUrl != null)) { + ?.any((element) => element.ogScrapeUrl != null) == + true) { return Color(0xFFE9F2FF); } diff --git a/lib/src/stream_channel.dart b/lib/src/stream_channel.dart index ce19b261..6fafa070 100644 --- a/lib/src/stream_channel.dart +++ b/lib/src/stream_channel.dart @@ -12,12 +12,14 @@ class StreamChannel extends StatefulWidget { Key key, @required this.child, @required this.channel, + this.showLoading = true, }) : super( key: key, ); final Widget child; final Channel channel; + final bool showLoading; /// Use this method to get the current [StreamChannelState] instance static StreamChannelState of(BuildContext context) { @@ -155,7 +157,7 @@ class StreamChannelState extends State { future: widget.channel.initialized, initialData: widget.channel.state != null, builder: (context, snapshot) { - if (!snapshot.hasData || !snapshot.data) { + if (widget.showLoading && (!snapshot.hasData || !snapshot.data)) { return Container( height: 30, child: Center( diff --git a/lib/src/stream_chat_theme.dart b/lib/src/stream_chat_theme.dart index 3b3725ed..967cbbbf 100644 --- a/lib/src/stream_chat_theme.dart +++ b/lib/src/stream_chat_theme.dart @@ -229,6 +229,7 @@ class StreamChatThemeData { backgroundColor: isDark ? Colors.black : Colors.white, defaultUserImage: (context, user) => Center( child: CachedNetworkImage( + filterQuality: FilterQuality.high, imageUrl: getRandomPicUrl(user), fit: BoxFit.cover, ), diff --git a/lib/src/url_attachment.dart b/lib/src/url_attachment.dart index a873217f..d735ad67 100644 --- a/lib/src/url_attachment.dart +++ b/lib/src/url_attachment.dart @@ -4,9 +4,9 @@ import 'package:stream_chat_flutter/src/utils.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class UrlAttachment extends StatelessWidget { - Attachment urlAttachment; - String hostDisplayName; - EdgeInsets textPadding; + final Attachment urlAttachment; + final String hostDisplayName; + final EdgeInsets textPadding; UrlAttachment({ @required this.urlAttachment, diff --git a/lib/src/user_avatar.dart b/lib/src/user_avatar.dart index 86bf7abd..2e1f3e0e 100644 --- a/lib/src/user_avatar.dart +++ b/lib/src/user_avatar.dart @@ -11,16 +11,26 @@ class UserAvatar extends StatelessWidget { this.constraints, this.onlineIndicatorConstraints, this.onTap, + this.onLongPress, this.showOnlineStatus = true, this.borderRadius, + this.onlineIndicatorAlignment = Alignment.topRight, + this.selected = false, + this.selectionColor = const Color(0xFF006CFF), + this.selectionThickness = 4, }) : super(key: key); final User user; + final Alignment onlineIndicatorAlignment; final BoxConstraints constraints; final BorderRadius borderRadius; final BoxConstraints onlineIndicatorConstraints; final void Function(User) onTap; + final void Function(User) onLongPress; final bool showOnlineStatus; + final bool selected; + final Color selectionColor; + final double selectionThickness; @override Widget build(BuildContext context) { @@ -28,42 +38,54 @@ class UserAvatar extends StatelessWidget { user.extraData['image'] != null && user.extraData['image'] != ''; final streamChatTheme = StreamChatTheme.of(context); + + Widget avatar = ClipRRect( + borderRadius: borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, + child: Container( + constraints: constraints ?? + streamChatTheme.ownMessageTheme.avatarTheme.constraints, + decoration: BoxDecoration( + color: streamChatTheme.accentColor, + ), + child: hasImage + ? CachedNetworkImage( + filterQuality: FilterQuality.high, + imageUrl: user.extraData['image'], + errorWidget: (_, __, ___) { + return streamChatTheme.defaultUserImage(context, user); + }, + fit: BoxFit.cover, + ) + : streamChatTheme.defaultUserImage(context, user), + ), + ); + if (selected) { + avatar = ClipRRect( + borderRadius: (borderRadius ?? + streamChatTheme.ownMessageTheme.avatarTheme.borderRadius) + + BorderRadius.circular(selectionThickness), + child: Container( + color: selectionColor, + child: Padding( + padding: EdgeInsets.all(selectionThickness), + child: avatar, + ), + ), + ); + } return GestureDetector( - onTap: onTap != null - ? () { - if (onTap != null) { - onTap(user); - } - } - : null, + onTap: onTap != null ? () => onTap(user) : null, + onLongPress: onLongPress != null ? () => onLongPress(user) : null, child: Stack( children: [ - ClipRRect( - borderRadius: borderRadius ?? - streamChatTheme.ownMessageTheme.avatarTheme.borderRadius, - child: Container( - constraints: constraints ?? - streamChatTheme.ownMessageTheme.avatarTheme.constraints, - decoration: BoxDecoration( - color: streamChatTheme.accentColor, - ), - child: hasImage - ? CachedNetworkImage( - imageUrl: user.extraData['image'], - errorWidget: (_, __, ___) { - return streamChatTheme.defaultUserImage(context, user); - }, - fit: BoxFit.cover, - ) - : streamChatTheme.defaultUserImage(context, user), - ), - ), + avatar, if (showOnlineStatus && user.online == true) - Positioned( - top: 0, - right: 0, - child: Material( - child: Center( + Positioned.fill( + child: Align( + alignment: onlineIndicatorAlignment, + child: Material( + type: MaterialType.circle, child: Container( padding: const EdgeInsets.all(2.0), constraints: onlineIndicatorConstraints ?? @@ -76,9 +98,8 @@ class UserAvatar extends StatelessWidget { color: Color(0xff20E070), ), ), + color: Colors.white, ), - shape: CircleBorder(), - color: Colors.white, ), ), ], diff --git a/lib/src/user_item.dart b/lib/src/user_item.dart new file mode 100644 index 00000000..06ab0848 --- /dev/null +++ b/lib/src/user_item.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/user_list_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'stream_chat_theme.dart'; + +/// +/// It shows the current [User] preview. +/// +/// The widget uses a [StreamBuilder] to render the user information image as soon as it updates. +/// +/// Usually you don't use this widget as it's the default user preview used by [UserListView]. +/// +/// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. +/// Modify it to change the widget appearance. +class UserItem extends StatelessWidget { + /// Instantiate a new UserItem + const UserItem({ + Key key, + @required this.user, + this.onTap, + this.onLongPress, + this.onImageTap, + this.selected = false, + this.showLastOnline = true, + }) : super(key: key); + + /// Function called when tapping this widget + final void Function(User) onTap; + + /// Function called when long pressing this widget + final void Function(User) onLongPress; + + /// User displayed + final User user; + + /// The function called when the image is tapped + final void Function(User) onImageTap; + + /// If true the [UserItem] will show a trailing checkmark + final bool selected; + + /// If true the [UserItem] will show the last seen + final bool showLastOnline; + + @override + Widget build(BuildContext context) { + return ListTile( + onTap: () { + if (onTap != null) { + onTap(user); + } + }, + onLongPress: () { + if (onLongPress != null) { + onLongPress(user); + } + }, + leading: UserAvatar( + user: user, + showOnlineStatus: true, + onTap: (user) { + if (onImageTap != null) { + onImageTap(user); + } + }, + constraints: BoxConstraints.tightFor( + height: 40, + width: 40, + ), + ), + trailing: selected + ? CircleAvatar( + child: Icon( + StreamIcons.check, + size: 20, + ), + radius: 10, + ) + : null, + title: Text( + user.name, + style: TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: showLastOnline ? _buildLastActive(context) : null, + ); + } + + Widget _buildLastActive(context) { + return Text('Last online ${Jiffy(user.lastActive).fromNow()}'); + } +} diff --git a/lib/src/user_list_view.dart b/lib/src/user_list_view.dart new file mode 100644 index 00000000..63285d8b --- /dev/null +++ b/lib/src/user_list_view.dart @@ -0,0 +1,578 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_flutter/src/users_bloc.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'stream_chat.dart'; +import 'user_item.dart'; + +/// Callback called when tapping on a user +typedef UserTapCallback = void Function(User, Widget); + +/// Builder used to create a custom [ListUserItem] from a [User] +typedef UserItemBuilder = Widget Function(BuildContext, User, bool); + +/// +/// It shows the list of current users. +/// +/// ```dart +/// class UsersListPage extends StatelessWidget { +/// @override +/// Widget build(BuildContext context) { +/// return Scaffold( +/// body: UsersListView( +/// filter: { +/// 'members': { +/// '\$in': [StreamChat.of(context).user.id], +/// } +/// }, +/// sort: [SortOption('last_message_at')], +/// pagination: PaginationParams( +/// limit: 20, +/// ), +/// channelWidget: ChannelPage(), +/// ), +/// ); +/// } +/// } +/// ``` +/// +/// +/// Make sure to have a [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 UserListView extends StatefulWidget { + /// Instantiate a new UserListView + const UserListView({ + Key key, + this.errorBuilder, + this.emptyBuilder, + this.filter, + this.options, + this.sort, + this.pagination, + this.onUserTap, + this.onUserLongPress, + this.userWidget, + this.userItemBuilder, + this.separatorBuilder, + this.onImageTap, + this.selectedUsers, + this.swipeToAction = false, + this.pullToRefresh = true, + this.groupAlphabetically = false, + this.crossAxisCount = 1, + }) : assert( + crossAxisCount == 1 || groupAlphabetically == false, + 'Cannot group alphabetically when crossAxisCount > 1', + ), + super(key: key); + + /// The builder that will be used in case of error + final Widget Function(Error error) errorBuilder; + + /// If true a default swipe to action behaviour will be added to this widget + final bool swipeToAction; + + /// The builder used when the channel list is empty. + final WidgetBuilder emptyBuilder; + + /// The query filters to use. + /// You can query on any of the custom fields you've defined on the [Channel]. + /// You can also filter other built-in channel fields. + final Map filter; + + /// Query channels options. + /// + /// state: if true returns the Channel state + /// watch: if true listen to changes to this Channel in real time. + final Map options; + + /// The sorting used for the channels matching the filters. + /// Sorting is based on field and direction, multiple sorting options can be provided. + /// You can sort based on last_updated, last_message_at, updated_at, created_at or member_count. + /// Direction can be ascending or descending. + final List sort; + + /// Pagination parameters + /// limit: the number of users to return (max is 30) + /// offset: the offset (max is 1000) + /// message_limit: how many messages should be included to each channel + final PaginationParams pagination; + + /// Function called when tapping on a channel + /// By default it calls [Navigator.push] building a [MaterialPageRoute] + /// with the widget [userWidget] as child. + final UserTapCallback onUserTap; + + /// Function called when long pressing on a channel + final Function(User) onUserLongPress; + + /// Widget used when opening a channel + final Widget userWidget; + + /// Builder used to create a custom user preview + final UserItemBuilder userItemBuilder; + + /// Builder used to create a custom item separator + final Function(BuildContext, int) separatorBuilder; + + /// The function called when the image is tapped + final Function(User) onImageTap; + + /// Set it to false to disable the pull-to-refresh widget + final bool pullToRefresh; + + /// Sets a blue trailing checkMark in [ListUserItem] for all the [selectedUsers] + final Set selectedUsers; + + /// Set it to true to group users by their first character + /// + /// defaults to false + final bool groupAlphabetically; + + /// The number of children in the cross axis. + final int crossAxisCount; + + @override + _UserListViewState createState() => _UserListViewState(); +} + +class _UserListViewState extends State + with WidgetsBindingObserver { + final ScrollController _scrollController = ScrollController(); + + bool get _isListView => widget.crossAxisCount == 1; + + @override + void initState() { + super.initState(); + final usersBloc = UsersBloc.of(context); + usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + + _scrollController.addListener(() { + usersBloc.queryUsersLoading.first.then((loading) { + if (!loading) { + _listenUserPagination(usersBloc); + } + }); + }); + } + + @override + Widget build(BuildContext context) { + final usersBloc = UsersBloc.of(context); + + if (!widget.pullToRefresh) { + return _buildListView(usersBloc); + } + + return RefreshIndicator( + onRefresh: () async { + return usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + options: widget.options, + pagination: widget.pagination, + ); + }, + child: _buildListView(usersBloc), + ); + } + + bool get isListAlreadySorted => + widget.sort?.any((e) => e.field == 'name' && e.direction == 1) ?? false; + + Stream> _buildUserStream( + UsersBlocState usersBlocState, + ) { + return usersBlocState.usersStream.map( + (users) { + if (widget.groupAlphabetically) { + var temp = users; + if (!isListAlreadySorted) { + temp = users..sort((curr, next) => curr.name.compareTo(next.name)); + } + final groupedUsers = >{}; + for (var e in temp) { + final alphabet = e.name[0]; + groupedUsers[alphabet] = [...groupedUsers[alphabet] ?? [], e]; + } + final items = []; + for (var key in groupedUsers.keys) { + items.add(ListHeaderItem(key)); + items.addAll(groupedUsers[key].map((e) => ListUserItem(e))); + } + return items; + } + return users.map((e) => ListUserItem(e)).toList(); + }, + ); + } + + StreamBuilder> _buildListView( + UsersBlocState usersBlocState, + ) { + return StreamBuilder( + stream: _buildUserStream(usersBlocState), + builder: (context, snapshot) { + if (snapshot.hasError) { + if (snapshot.error is Error) { + print((snapshot.error as Error).stackTrace); + } + + 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: () { + usersBlocState.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: 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: Center( + child: CircularProgressIndicator(), + ), + ), + ); + }, + ); + } + + final items = snapshot.data; + + if (items.isEmpty && widget.emptyBuilder != null) { + return widget.emptyBuilder(context); + } + + if (items.isEmpty && widget.emptyBuilder == null) { + return LayoutBuilder( + builder: (context, viewportConstraints) { + return SingleChildScrollView( + physics: AlwaysScrollableScrollPhysics(), + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: viewportConstraints.maxHeight, + ), + child: Center( + child: Text('There are no users currently'), + ), + ), + ); + }, + ); + } + + if (_isListView) { + return ListView.custom( + physics: AlwaysScrollableScrollPhysics(), + controller: _scrollController, + childrenDelegate: SliverChildBuilderDelegate( + (context, i) { + return _listItemBuilder(context, i, items); + }, + childCount: (items.length * 2) + 1, + findChildIndexCallback: (key) { + final ValueKey valueKey = key; + final index = + items.indexWhere((item) => item.key == valueKey.value); + return index != -1 ? (index * 2) : null; + }, + ), + ); + } + return GridView.custom( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + ), + physics: AlwaysScrollableScrollPhysics(), + controller: _scrollController, + childrenDelegate: SliverChildBuilderDelegate( + (context, i) { + return _gridItemBuilder(context, i, items); + }, + childCount: items.length, + findChildIndexCallback: (key) { + final ValueKey valueKey = key; + final index = + items.indexWhere((item) => item.key == valueKey.value); + return index != -1 ? index : null; + }, + ), + ); + }, + ); + } + + Widget _listItemBuilder(BuildContext context, int i, List items) { + if (i % 2 != 0) { + if (widget.separatorBuilder != null) { + return widget.separatorBuilder(context, i); + } + return _separatorBuilder(context, i); + } + + i = i ~/ 2; + + final usersProvider = UsersBloc.of(context); + if (i < items.length) { + final item = items[i]; + return item.when( + headerItem: (header) { + return Container( + key: ValueKey('HEADER-$header'), + color: Colors.black.withOpacity(0.05), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6), + child: Text( + header, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + color: Colors.black.withOpacity(0.3)), + ), + ), + ); + }, + userItem: (user) { + final selected = widget.selectedUsers?.contains(user) ?? false; + return Container( + key: ValueKey('USER-${user.id}'), + child: widget.userItemBuilder != null + ? widget.userItemBuilder(context, user, selected) + : UserItem( + user: user, + onTap: (user) => widget.onUserTap(user, widget.userWidget), + onLongPress: widget.onUserLongPress, + onImageTap: widget.onImageTap, + selected: selected, + ), + ); + }, + ); + } else { + return _buildQueryProgressIndicator(context, usersProvider); + } + } + + Widget _gridItemBuilder(BuildContext context, int i, List items) { + final usersProvider = UsersBloc.of(context); + if (i < items.length) { + final item = items[i]; + return item.when( + headerItem: (_) => Offstage(), + userItem: (user) { + final selected = widget.selectedUsers?.contains(user) ?? false; + return Container( + key: ValueKey('USER-${user.id}'), + child: widget.userItemBuilder != null + ? widget.userItemBuilder(context, user, selected) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + UserAvatar( + user: user, + borderRadius: BorderRadius.circular(32), + selected: selected, + constraints: BoxConstraints.tightFor( + height: 64, + width: 64, + ), + onTap: (user) => + widget.onUserTap(user, widget.userWidget), + onLongPress: widget.onUserLongPress, + ), + SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + user.name, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + ); + }, + ); + } else { + return _buildQueryProgressIndicator(context, usersProvider); + } + } + + Widget _buildQueryProgressIndicator(context, UsersBlocState usersProvider) { + return StreamBuilder( + stream: usersProvider.queryUsersLoading, + initialData: false, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Container( + color: Color(0xffd0021B).withAlpha(26), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Center( + child: Text('Error loading users'), + ), + ), + ); + } + return Container( + height: 100, + padding: EdgeInsets.all(32), + child: Center( + child: snapshot.data ? CircularProgressIndicator() : Container(), + ), + ); + }); + } + + Widget _separatorBuilder(context, i) { + return Container( + height: 1, + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withOpacity(0.1) + : Colors.black.withOpacity(0.1), + ); + } + + void _listenUserPagination(UsersBlocState usersProvider) { + if (_scrollController.position.maxScrollExtent == + _scrollController.offset && + _scrollController.offset != 0) { + usersProvider.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination.copyWith( + offset: usersProvider.users?.length ?? 0, + ), + options: widget.options, + ); + } + } + + @override + void didUpdateWidget(UserListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.filter?.toString() != oldWidget.filter?.toString() || + jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || + widget.pagination?.toJson()?.toString() != + oldWidget.pagination?.toJson()?.toString() || + widget.options?.toString() != oldWidget.options?.toString()) { + final usersBloc = UsersBloc.of(context); + usersBloc.queryUsers( + filter: widget.filter, + sort: widget.sort, + pagination: widget.pagination, + options: widget.options, + ); + } + } +} + +abstract class ListItem { + String get key { + if (this is ListHeaderItem) { + final header = (this as ListHeaderItem).heading; + return 'HEADER-$header'; + } + if (this is ListUserItem) { + final user = (this as ListUserItem).user; + return 'USER-${user.id}'; + } + } + + Widget when({ + @required Widget Function(String heading) headerItem, + @required Widget Function(User user) userItem, + }) { + if (this is ListHeaderItem) { + return headerItem((this as ListHeaderItem).heading); + } + if (this is ListUserItem) { + return userItem((this as ListUserItem).user); + } + } +} + +class ListHeaderItem extends ListItem { + final String heading; + + ListHeaderItem(this.heading); +} + +class ListUserItem extends ListItem { + final User user; + + ListUserItem(this.user); +} diff --git a/lib/src/users_bloc.dart b/lib/src/users_bloc.dart new file mode 100644 index 00000000..91cbaf80 --- /dev/null +++ b/lib/src/users_bloc.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'stream_chat.dart'; + +/// Widget dedicated to the management of a users list with pagination +class UsersBloc extends StatefulWidget { + /// The widget child + final Widget child; + + /// Instantiate a new UsersBloc + const UsersBloc({ + Key key, + @required this.child, + }) : super(key: key); + + @override + UsersBlocState createState() => UsersBlocState(); + + /// Use this method to get the current [UsersBlocState] instance + static UsersBlocState of(BuildContext context) { + UsersBlocState state; + + state = context.findAncestorStateOfType(); + + if (state == null) { + throw Exception('You must have a UsersBloc widget as ancestor'); + } + + return state; + } +} + +/// The current state of the [UsersBloc] +class UsersBlocState extends State + with AutomaticKeepAliveClientMixin { + /// The current users list + List get users => _usersController.value; + + /// The current users list as a stream + Stream> get usersStream => _usersController.stream; + + final BehaviorSubject> _usersController = BehaviorSubject(); + + final BehaviorSubject _queryUsersLoadingController = + BehaviorSubject.seeded(false); + + /// The stream notifying the state of queryUsers call + Stream get queryUsersLoading => _queryUsersLoadingController.stream; + + /// Calls [Client.queryUsers] updating [queryUsersLoading] stream + Future queryUsers({ + Map filter, + List sort, + Map options, + PaginationParams pagination, + }) async { + final client = StreamChat.of(context).client; + + if (client.state?.user == null || + _queryUsersLoadingController.value == true) { + return; + } + _queryUsersLoadingController.add(true); + try { + final clear = pagination == null || + pagination.offset == null || + pagination.offset == 0; + + final oldUsers = List.from(users ?? []); + + final usersResponse = await client.queryUsers( + filter: filter, + sort: sort, + options: options, + pagination: pagination, + ); + + if (clear) { + _usersController.add(usersResponse.users); + } else { + final temp = oldUsers + usersResponse.users; + _usersController.add(temp); + } + + _queryUsersLoadingController.add(false); + } catch (err, stackTrace) { + _queryUsersLoadingController.addError(err, stackTrace); + } + } + + @override + Widget build(BuildContext context) { + super.build(context); + return widget.child; + } + + @override + void dispose() { + _usersController.close(); + _queryUsersLoadingController.close(); + super.dispose(); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/lib/src/video_thumbnail.dart b/lib/src/video_thumbnail.dart new file mode 100644 index 00000000..c36ab327 --- /dev/null +++ b/lib/src/video_thumbnail.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +class VideoThumbnail extends StatefulWidget { + final File file; + + const VideoThumbnail({ + Key key, + @required this.file, + }) : super(key: key); + + @override + _VideoThumbnailState createState() => _VideoThumbnailState(); +} + +class _VideoThumbnailState extends State { + VideoPlayerController _videoPlayerController; + @override + Widget build(BuildContext context) { + return VideoPlayer(_videoPlayerController); + } + + @override + void initState() { + _videoPlayerController = VideoPlayerController.file(widget.file) + ..initialize(); + super.initState(); + } + + @override + void dispose() { + _videoPlayerController.dispose(); + super.dispose(); + } +} diff --git a/lib/stream_chat_flutter.dart b/lib/stream_chat_flutter.dart index e433e58e..91ac68c8 100644 --- a/lib/stream_chat_flutter.dart +++ b/lib/stream_chat_flutter.dart @@ -30,3 +30,6 @@ export 'src/typing_indicator.dart'; export 'src/user_avatar.dart'; export 'src/utils.dart'; export 'src/video_attachment.dart'; +export 'src/users_bloc.dart'; +export 'src/user_list_view.dart'; +export 'src/user_item.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 78add1a5..012a0293 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 0.2.12 +version: 0.2.13+1 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -19,6 +19,7 @@ dependencies: flutter_svg: ^0.19.1 flutter_portal: ^0.3.0 cached_network_image: ^2.2.0+1 + shimmer: ^1.1.2 flutter_markdown: ^0.5.0 url_launcher: ^5.4.11 emojis: ^0.9.3 @@ -27,7 +28,7 @@ dependencies: file_picker: ^2.0.12 image_picker: ^0.6.7+2 flutter_keyboard_visibility: ^3.3.0 - stream_chat: ^0.2.11 + stream_chat: ^0.2.13+1 mime: ^0.9.6+3 visibility_detector: ^0.1.5 http_parser: ^3.1.4 @@ -37,6 +38,10 @@ dependencies: clipboard: ^0.1.2+8 gallery_saver: ^2.0.1 esys_flutter_share: ^1.0.2 + media_gallery: + git: https://github.com/imtoori/media_gallery.git + permission_handler: ^5.0.1+1 + transparent_image: ^1.0.0 flutter: assets: diff --git a/svgs/icon_camera.svg b/svgs/icon_camera.svg new file mode 100644 index 00000000..0bf3122d --- /dev/null +++ b/svgs/icon_camera.svg @@ -0,0 +1,3 @@ + + + diff --git a/svgs/icon_picture_empty_state.svg b/svgs/icon_picture_empty_state.svg new file mode 100644 index 00000000..135772d0 --- /dev/null +++ b/svgs/icon_picture_empty_state.svg @@ -0,0 +1,4 @@ + + + + diff --git a/svgs/video_call_icon.svg b/svgs/video_call_icon.svg new file mode 100644 index 00000000..1c3832d6 --- /dev/null +++ b/svgs/video_call_icon.svg @@ -0,0 +1,3 @@ + + +