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
/// 
/// 
@@ -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 @@
+