Merge branch 'feature/new-ui' of github.com:GetStream/stream-chat-flutter into feature/new-chat-screens
Conflicts: lib/src/message_input.dart
This commit is contained in:
@@ -8,7 +8,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: Flutter action
|
- name: Flutter action
|
||||||
uses: subosito/flutter-action@v1.3.2
|
uses: subosito/flutter-action@v1.4.0
|
||||||
with:
|
with:
|
||||||
channel: 'stable'
|
channel: 'stable'
|
||||||
- name: Get dependencies
|
- name: Get dependencies
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
## 0.2.13+1
|
||||||
|
|
||||||
|
- Use TextEditingController.addListener instead of TextField.onChanged
|
||||||
|
|
||||||
## 0.2.13
|
## 0.2.13
|
||||||
|
|
||||||
- Update llc dependency
|
- Update llc dependency
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
## Docs
|
||||||
|
|
||||||
### Business logic components
|
### Business logic components
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
bb5f9103d9045cd6244bcae1f5f343e5
|
c3e639ccf9b069e37a7d1345194e6b99
|
||||||
+95
-103
@@ -19,6 +19,7 @@ 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/message_list_view.dart';
|
||||||
import 'package:stream_chat_flutter/src/stream_chat_theme.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/user_avatar.dart';
|
||||||
|
import 'package:stream_chat_flutter/src/video_thumbnail.dart';
|
||||||
import 'package:substring_highlight/substring_highlight.dart';
|
import 'package:substring_highlight/substring_highlight.dart';
|
||||||
|
|
||||||
import '../stream_chat_flutter.dart';
|
import '../stream_chat_flutter.dart';
|
||||||
@@ -41,6 +42,8 @@ enum DefaultAttachmentTypes {
|
|||||||
file,
|
file,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _kMinMediaPickerSize = 360.0;
|
||||||
|
|
||||||
/// Inactive state
|
/// Inactive state
|
||||||
/// 
|
/// 
|
||||||
/// 
|
/// 
|
||||||
@@ -182,7 +185,7 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
bool _sendAsDm = false;
|
bool _sendAsDm = false;
|
||||||
bool _openFilePickerSection = false;
|
bool _openFilePickerSection = false;
|
||||||
int _filePickerIndex = 0;
|
int _filePickerIndex = 0;
|
||||||
double _filePickerSize = 250.0;
|
double _filePickerSize = _kMinMediaPickerSize;
|
||||||
|
|
||||||
/// The editing controller passed to the input TextField
|
/// The editing controller passed to the input TextField
|
||||||
TextEditingController textEditingController;
|
TextEditingController textEditingController;
|
||||||
@@ -363,30 +366,6 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
keyboardType: widget.keyboardType,
|
keyboardType: widget.keyboardType,
|
||||||
controller: textEditingController,
|
controller: textEditingController,
|
||||||
focusNode: _focusNode,
|
focusNode: _focusNode,
|
||||||
onChanged: (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, context);
|
|
||||||
|
|
||||||
_checkMentions(s, context);
|
|
||||||
|
|
||||||
_checkEmoji(s, context);
|
|
||||||
},
|
|
||||||
style: Theme.of(context).textTheme.bodyText2,
|
style: Theme.of(context).textTheme.bodyText2,
|
||||||
autofocus: false,
|
autofocus: false,
|
||||||
textAlignVertical: TextAlignVertical.center,
|
textAlignVertical: TextAlignVertical.center,
|
||||||
@@ -443,6 +422,28 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onChanged(BuildContext context, String 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.trim(), context);
|
||||||
|
|
||||||
|
_checkMentions(s, context);
|
||||||
|
|
||||||
|
_checkEmoji(s, context);
|
||||||
|
}
|
||||||
|
|
||||||
String _getHint() {
|
String _getHint() {
|
||||||
if (_commandEnabled && _chosenCommand.name == 'giphy') {
|
if (_commandEnabled && _chosenCommand.name == 'giphy') {
|
||||||
return 'Search GIFs';
|
return 'Search GIFs';
|
||||||
@@ -454,14 +455,14 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _checkEmoji(String s, BuildContext context) {
|
void _checkEmoji(String s, BuildContext context) {
|
||||||
if (textEditingController.selection.isCollapsed &&
|
if (s.isNotEmpty &&
|
||||||
(s.isNotEmpty && s[textEditingController.selection.start - 1] == ':' ||
|
textEditingController.selection.baseOffset > 0 &&
|
||||||
textEditingController.text
|
textEditingController.text
|
||||||
.substring(
|
.substring(
|
||||||
0,
|
0,
|
||||||
textEditingController.selection.start,
|
textEditingController.selection.baseOffset,
|
||||||
)
|
)
|
||||||
.contains(':'))) {
|
.contains(':')) {
|
||||||
final textToSelection = textEditingController.text
|
final textToSelection = textEditingController.text
|
||||||
.substring(0, textEditingController.value.selection.start);
|
.substring(0, textEditingController.value.selection.start);
|
||||||
final splits = textToSelection.split(':');
|
final splits = textToSelection.split(':');
|
||||||
@@ -481,15 +482,13 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _checkMentions(String s, BuildContext context) {
|
void _checkMentions(String s, BuildContext context) {
|
||||||
if (textEditingController.selection.isCollapsed &&
|
if (s.isNotEmpty &&
|
||||||
(s.isNotEmpty &&
|
textEditingController.selection.baseOffset > 0 &&
|
||||||
textEditingController.selection.start > 0 &&
|
textEditingController.text
|
||||||
s[textEditingController.selection.start - 1] == '@' ||
|
.substring(0, textEditingController.selection.baseOffset)
|
||||||
textEditingController.text
|
.split(' ')
|
||||||
.substring(0, textEditingController.selection.start)
|
.last
|
||||||
.split(' ')
|
.contains('@')) {
|
||||||
.last
|
|
||||||
.contains('@'))) {
|
|
||||||
_mentionsOverlay = _buildMentionsOverlayEntry();
|
_mentionsOverlay = _buildMentionsOverlayEntry();
|
||||||
Overlay.of(context).insert(_mentionsOverlay);
|
Overlay.of(context).insert(_mentionsOverlay);
|
||||||
}
|
}
|
||||||
@@ -659,9 +658,11 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: SvgPicture.asset(
|
||||||
StreamIcons.camera,
|
'svgs/icon_camera.svg',
|
||||||
size: 24,
|
package: 'stream_chat_flutter',
|
||||||
|
height: 24,
|
||||||
|
width: 24,
|
||||||
color: _filePickerIndex == 2
|
color: _filePickerIndex == 2
|
||||||
? StreamChatTheme.of(context).accentColor
|
? StreamChatTheme.of(context).accentColor
|
||||||
: Colors.black.withOpacity(0.5),
|
: Colors.black.withOpacity(0.5),
|
||||||
@@ -689,7 +690,7 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_animateContainer = false;
|
_animateContainer = false;
|
||||||
_filePickerSize = (_filePickerSize - update.delta.dy).clamp(
|
_filePickerSize = (_filePickerSize - update.delta.dy).clamp(
|
||||||
240.0,
|
_kMinMediaPickerSize,
|
||||||
MediaQuery.of(context).size.height / 1.7,
|
MediaQuery.of(context).size.height / 1.7,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -758,9 +759,10 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
.any((element) => element.id == media.id)) {
|
.any((element) => element.id == media.id)) {
|
||||||
_addAttachment(media);
|
_addAttachment(media);
|
||||||
} else {
|
} else {
|
||||||
_attachments
|
setState(() {
|
||||||
.removeWhere((element) => element.id == media.id);
|
_attachments
|
||||||
setState(() {});
|
.removeWhere((element) => element.id == media.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -818,35 +820,7 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _addAttachment(Media medium) async {
|
void _addAttachment(Media medium) async {
|
||||||
final mediaFile = await medium.getFile();
|
|
||||||
final thumbBytes = await medium.getThumbnail();
|
|
||||||
|
|
||||||
final file = PlatformFile(
|
|
||||||
path: mediaFile.path,
|
|
||||||
bytes: mediaFile.readAsBytesSync(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final thumbFile = PlatformFile(
|
|
||||||
bytes: thumbBytes,
|
|
||||||
name: '${file.name ?? file.path?.split('/')?.last}_thumbnail.jpeg',
|
|
||||||
);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_inputEnabled = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (file == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final channel = StreamChannel.of(context).channel;
|
|
||||||
final attachment = _SendingAttachment(
|
final attachment = _SendingAttachment(
|
||||||
file: file,
|
|
||||||
thumbFile: thumbFile,
|
|
||||||
attachment: Attachment(
|
|
||||||
localUri: file.path != null ? Uri.parse(file.path) : null,
|
|
||||||
type: medium.mediaType == MediaType.image ? 'image' : 'video',
|
|
||||||
),
|
|
||||||
id: medium.id,
|
id: medium.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -854,11 +828,23 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
_attachments.add(attachment);
|
_attachments.add(attachment);
|
||||||
});
|
});
|
||||||
|
|
||||||
final thumbUrl = await _uploadImage(
|
final mediaFile = await medium.getFile();
|
||||||
thumbFile,
|
|
||||||
channel,
|
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(
|
final url = await _uploadAttachment(
|
||||||
file,
|
file,
|
||||||
medium.mediaType == MediaType.image
|
medium.mediaType == MediaType.image
|
||||||
@@ -873,12 +859,10 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
if (fileType == DefaultAttachmentTypes.image) {
|
if (fileType == DefaultAttachmentTypes.image) {
|
||||||
attachment.attachment = attachment.attachment.copyWith(
|
attachment.attachment = attachment.attachment.copyWith(
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
thumbUrl: thumbUrl,
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
attachment.attachment = attachment.attachment.copyWith(
|
attachment.attachment = attachment.attachment.copyWith(
|
||||||
assetUrl: url,
|
assetUrl: url,
|
||||||
thumbUrl: thumbUrl,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1226,6 +1210,10 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (attachment.attachment == null) {
|
||||||
|
return SizedBox();
|
||||||
|
}
|
||||||
|
|
||||||
switch (attachment.attachment.type) {
|
switch (attachment.attachment.type) {
|
||||||
case 'image':
|
case 'image':
|
||||||
case 'giphy':
|
case 'giphy':
|
||||||
@@ -1235,22 +1223,20 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: Image.network(
|
: Image.network(
|
||||||
attachment.attachment.imageUrl ??
|
attachment.attachment.imageUrl,
|
||||||
attachment.attachment.thumbUrl,
|
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'video':
|
case 'video':
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Positioned.fill(
|
||||||
child: attachment.thumbFile != null
|
child: Container(
|
||||||
? Image.memory(
|
child: VideoThumbnail(
|
||||||
attachment.thumbFile.bytes,
|
file: File(
|
||||||
fit: BoxFit.cover,
|
attachment.file.path,
|
||||||
)
|
)),
|
||||||
: Icon(Icons.videocam),
|
),
|
||||||
color: Colors.black26,
|
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 8,
|
left: 8,
|
||||||
@@ -1319,7 +1305,7 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_animateContainer = true;
|
_animateContainer = true;
|
||||||
_openFilePickerSection = false;
|
_openFilePickerSection = false;
|
||||||
_filePickerSize = 250.0;
|
_filePickerSize = _kMinMediaPickerSize;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
final status = await (Platform.isAndroid
|
final status = await (Platform.isAndroid
|
||||||
@@ -1455,6 +1441,9 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
} else if (fileType == DefaultAttachmentTypes.video) {
|
} else if (fileType == DefaultAttachmentTypes.video) {
|
||||||
pickedFile = await _imagePicker.getVideo(source: ImageSource.camera);
|
pickedFile = await _imagePicker.getVideo(source: ImageSource.camera);
|
||||||
}
|
}
|
||||||
|
if (pickedFile == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
final bytes = await pickedFile.readAsBytes();
|
final bytes = await pickedFile.readAsBytes();
|
||||||
file = PlatformFile(
|
file = PlatformFile(
|
||||||
path: pickedFile.path,
|
path: pickedFile.path,
|
||||||
@@ -1574,9 +1563,14 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: InkWell(
|
||||||
_getIdleSendIcon(),
|
onTap: () {
|
||||||
color: Colors.grey,
|
sendMessage();
|
||||||
|
},
|
||||||
|
child: Icon(
|
||||||
|
_getIdleSendIcon(),
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1718,11 +1712,7 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
|
|
||||||
if (!kIsWeb) {
|
if (!kIsWeb) {
|
||||||
_keyboardListener = KeyboardVisibility.onChange.listen((visible) {
|
_keyboardListener = KeyboardVisibility.onChange.listen((visible) {
|
||||||
if (visible) {
|
_onChanged(context, textEditingController.text);
|
||||||
_checkCommands(textEditingController.text, context);
|
|
||||||
_checkMentions(textEditingController.text, context);
|
|
||||||
_checkEmoji(textEditingController.text, context);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1732,6 +1722,10 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
_parseExistingMessage(widget.editMessage ?? widget.initialMessage);
|
_parseExistingMessage(widget.editMessage ?? widget.initialMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textEditingController.addListener(() {
|
||||||
|
_onChanged(context, textEditingController.text);
|
||||||
|
});
|
||||||
|
|
||||||
_focusNode.addListener(() {
|
_focusNode.addListener(() {
|
||||||
if (_focusNode.hasFocus) {
|
if (_focusNode.hasFocus) {
|
||||||
_openFilePickerSection = false;
|
_openFilePickerSection = false;
|
||||||
@@ -1774,14 +1768,12 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
|
|
||||||
class _SendingAttachment {
|
class _SendingAttachment {
|
||||||
PlatformFile file;
|
PlatformFile file;
|
||||||
PlatformFile thumbFile;
|
|
||||||
Attachment attachment;
|
Attachment attachment;
|
||||||
bool uploaded;
|
bool uploaded;
|
||||||
String id;
|
String id;
|
||||||
|
|
||||||
_SendingAttachment({
|
_SendingAttachment({
|
||||||
this.file,
|
this.file,
|
||||||
this.thumbFile,
|
|
||||||
this.attachment,
|
this.attachment,
|
||||||
this.uploaded = false,
|
this.uploaded = false,
|
||||||
this.id,
|
this.id,
|
||||||
|
|||||||
@@ -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<VideoThumbnail> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: stream_chat_flutter
|
name: stream_chat_flutter
|
||||||
homepage: https://github.com/GetStream/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.
|
description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter.
|
||||||
version: 0.2.13
|
version: 0.2.13+1
|
||||||
repository: https://github.com/GetStream/stream-chat-flutter
|
repository: https://github.com/GetStream/stream-chat-flutter
|
||||||
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 3H9L7 5H3C2.44771 5 2 5.44772 2 6V20C2 20.5523 2.44771 21 3 21H21C21.5523 21 22 20.5523 22 20V6C22 5.44772 21.5523 5 21 5H17L15 3ZM7.828 7L9.828 5H14.172L16.172 7H20V19H4V7H7.828ZM12 18C8.96243 18 6.5 15.5376 6.5 12.5C6.5 9.46243 8.96243 7 12 7C15.0376 7 17.5 9.46243 17.5 12.5C17.5 15.5376 15.0376 18 12 18ZM15.5 12.5C15.5 14.433 13.933 16 12 16C10.067 16 8.5 14.433 8.5 12.5C8.5 10.567 10.067 9 12 9C13.933 9 15.5 10.567 15.5 12.5Z" fill="black"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 606 B |
Reference in New Issue
Block a user