Gửi tin nhắn thoại tức thời với Stream Chat và Flutter (Bài dịch) - Đã thử và thành công he he
Bài đăng này đã không được cập nhật trong 3 năm
Hiện nay có rất nhiều ứng dụng nhắn tin cho phép chúng ta gửi những ghi chú thoại như là những tin nhắn. Trong hướng dẫn này, bạn sẽ học được cách gửi các ghi chú thoại, hoặc tệp audio đính kèm, trong ứng dụng Stream Chat Flutter của bạn. Đến cuối cùng, ứng dụng của bạn sẽ có tính năng trải nghiệm trò chuyện như ở đây
Hướng dẫn này sẽ có những phần chi tiết sau:
- Cài đặt tài khoản Stream
- Tạo tài khoản người dùng demo
- Tắt xác thực cho sự phát triển (Disable Authentication for Development)
- Cài đặt tài khoản flutter
- Tạo một trang danh sách kênh (Channel List Page)
- Tạo một trang kênh để xem các tin nhắn
- Tạo một tiện ích hành động tùy chỉnh để ghi lại ghi chú bằng giọng nói (Custom Action Widget)
- Thêm trình tạo tệp đính kèm tùy chỉnh
Cài đặt tài khoản Stream của bạn
Để bắt đầu, bạn sẽ cần một tài khoản Stream để truy cập vào Strean Chat Messaging API. Nếu chưa có sẵn tài khoản Stream nào, bạn có thể đăng kí để có 30 ngày dùng thử miễn phí
Nếu bạn đang làm dự án cá nhân hoặc làm chủ một doanh nghiệp nhỏ, bạn có thể đăng kí một tài khoản Stream Maker và truy cập Stream Chat miễn phí vô thời hạn
Sau khi tạo xong tài khoản, tạo mới 1 app và đặt tên cho nó:
- Vào Stream Dashboard của bạn
- Chọn Create App
- Đặt tên App Name (như Audio Attachment Demo trong ví dụ)
- Cài đặt Feeds Server Location
- Chọn Development ở Environment
- Bấm Create App
Sau khi tạo app, bạn sẽ thất nó được liệt kê trong bảng điều khiển Stream của bạn cùng với API KEY và Secret tương ứng.
API Key của bạn chỉ là cái để định danh app và nó an toàn để chia sẻ công khai. Secret của bạn giúp sinh ra những tokens người dùng được xác thực và nên giữ riêng tư.
Từ bảng điều khiển này, bạn có thể sỉnh sửa app, truy cập dữ liệu, và tạo những app mới.
Tạo những tài khoản người dùng Demo
Stream cung cấp rất nhiều phương thức tạo tài khoản người dùng. Trong môi trường production, bạn nên lí tưởng hóa việc quản lí tạo người dùng và sinh token ở phía server.
Tuy nhiên, vì mục đích là làm demo, nên nó dễ dàng hơn để tạo các tài khoản trên bảng điều khiển Stream của bạn (Stream Dashboard)
Để tạo một tài khoản demo mới cho Stream app:
- Vào Audio Attachment Demo (vừa tạo).
- Trong menu bảng điều khiển nav, chọn Open in Chat Explorer. (Cái này sẽ dẫn bạn đến bảng điều khiển Explorer, nơi mà có thể tạo các kênh và người dùng)
- Chọn Users, sau đó Create New User.
- Trong cửa sổ Create New User, nhập User Name và User Id.
- Trong dropdown menu User Application Role, chọn User
Bạn có thể tạo bao nhiêu tài khoản mà bạn thích cho app demo của bạn
Tắt Xác thực cho Phát triển (Disable Authentication for Development)
Bất cứ tài khoản nào bạn tạo ra sẽ yêu cầu mã token xác thực để truy cập được vào Stream API. Vì ta đang làm demo thoi, nên là bạn nên tắt những kiểm tra xác thực này đi và thay vào đó là dùng tokens của developer
Để tắt xác thực cho tài khoản của bạn:
- Vào Audio Attachment Demo app.
- Trong menu bảng điều khiển nav, chọn Chat.
- Từ dropdown Chat, chọn Overview.
- Lăn xuống phần Authentication.
- Bật nút toggle Disable Auth Checks.
- Đừng quên bấm Save nhé.
Nếu bạn không muốn tắc xác thực, bạn có thể dễ dàng sử dụng sinh những mã token bằng Stream’s User JWT Generator.
⚠️Ghi chú: Trong kịch bản production, bạn bắt buộc phải sinh một token cho server của bạn và một trong các máy chủ SDKs của Stream. Bạn đừng bao giờ nên hardcode mã token người dùng cho một ứng dụng production, việc này giống như đi chơi gái mà không dùng bao vậy, sida lúc nào không ai biết đâu, anh Hoàng Code dạo đã từng nói vậy đấy 🤣.
Cài đặt Flutter App của bạn
Nếu bạn mới làm quen Stream (như mình), hãy xem qua Stream Flutter Chat tutorial để có những giới thiệu kĩ lưỡng với tất cả các components căn bản có sẵn cho bạn.
Không thì, tiếp tục và tạo nột ứng dụng Flutter từ terminal hoặc IDE ưa thích của bạn bằng câu lệnh sau:
flutter create audio_attachment_tutorial
Ghi chú: Bài hướng dẫn này dùng Flutter version 2.2.3
Mở dự án và thêm những dòng này vào trong file pubspec.yaml
của bạn?
just_audio: ^0.9.6
record: ^3.0.0
stream_chat_flutter: ^2.2.0
Ghi chú: Những phiên bản trong tương lai có thể có những thay đổi đột phá.
Tạo file config.dart
và thêm code sau:
// Stream config
import 'package:flutter/material.dart';
const streamKey = 'p64gqrwac8k4';
const userGordon = DemoUser(
id: 'gordon',
name: 'Gordon Hayes',
image:
'https://pbs.twimg.com/profile_images/1262058845192335360/Ys_-zu6W_400x400.jpg',
);
const userSalvatore = DemoUser(
id: 'salvatore',
name: 'Salvatore Giordano',
image:
'https://pbs.twimg.com/profile_images/1252869649349238787/cKVPSIyG_400x400.jpg',
);
@immutable
class DemoUser {
final String id;
final String name;
final String image;
const DemoUser({
required this.id,
required this.name,
required this.image,
});
}
Giải thích code -Trong đoạn trích trên, bạn đã:
- Set key duy nhất App key cho hằng
streamKey
. (Bạn có thể lấy key duy nhất này từ bảng điều khiển của app trong Stream.) - Tạo một model DemoUser để lưu thông tin người dùng.
- Tạo mới 2 demo user. Cái này nên dùng giống với ids mà bạn đã set trong bảng điều khiển Stream. (Lưu ý là bạn đang hardcode cho name và image; lí tưởng lên, những giá trị này nên được set dùng server của bạn và một server SDKs của Stream.)
Thay thế code trongmain.dart
bằng những dòng sau:
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'channel_list_page.dart';
import 'config.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
final client = StreamChatClient(streamKey);
runApp(MyApp(client: client));
}
class MyApp extends StatelessWidget {
const MyApp({Key? key, required this.client}) : super(key: key);
final StreamChatClient client;
@override
Widget build(BuildContext context) {
return MaterialApp(
builder: (context, widget) {
return StreamChat(
child: widget!,
client: client,
);
},
debugShowCheckedModeBanner: false,
home: const SelectUserPage(),
);
}
}
class SelectUserPage extends StatelessWidget {
const SelectUserPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Padding(
padding: EdgeInsets.all(8.0),
child: Text(
'Select a user',
style: TextStyle(fontSize: 24),
),
),
SelectUserButton(user: userGordon),
SelectUserButton(user: userSalvatore),
],
),
),
),
);
}
}
class SelectUserButton extends StatelessWidget {
const SelectUserButton({
Key? key,
required this.user,
}) : super(key: key);
final DemoUser user;
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final client = StreamChat.of(context).client;
await client.connectUser(
User(
id: user.id,
extraData: {
'name': user.name,
'image': user.image,
},
),
client.devToken(user.id).rawValue,
);
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const ChannelListPage()),
);
},
child: Text(user.name),
);
}
}
Giải thích code -Trong đoạn trích trên, bạn đã:
- Khởi tạo một StreamChatClient sử dụng Flutter SDK của Stream.
- Tạo một tiện ích MyApp vơi thuộc tính home được set thành SlectUserPage, và gói ứng dụng bằng một trình tạo mà tạo ra một tiện ích con StreamChat, tiện ích này xử lí rất nhiều logic phần chat.
- Tạo các tiện ích SelectUserPage và SelectUserButton, 2 cái này hiển thị 2 tài khoản demo để chọn.
- Tạo một trình xử lí onPressed để kết nối với một người dùng Stream khách. (Bạn chỉ có thể gọi đến
client.devToken(user.id)
-devToken
nếu bạn đã tắt trình xác thực (Authentication) - Điều houowngs người dùng đến ChannelListPage sau khi kết nối chúng. Cái ChannelListPage này liệt kê ra tất tần tật các kênh (channel) cho app Stream của bạn.
Tạo một trang để liệt kê tất cả các kênh (Channel)
Tiếp theo, bạn sẽ hiển thị một danh sách các kênh nơi mà người dùng hiện tại là một member. Nó sẽ tốt hơn nết chia code thành nhiều file để dễ maintain hơn khi khối lượng code lớn.
Tạo một file tên là channel_list_page.dart
and thêm những dòng sau vào:
import 'package:audio_attachment_tutorial/main.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'channel_page.dart';
class ChannelListPage extends StatelessWidget {
const ChannelListPage({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Stream Chat'),
actions: [
GestureDetector(
onTap: () async {
await StreamChat.of(context).client.disconnectUser();
Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => const SelectUserPage()),
);
},
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Center(
child: Text('Switch user'),
),
),
)
],
),
body: ChannelsBloc(
child: ChannelListView(
emptyBuilder: (conext) {
return Center(
child: ElevatedButton(
onPressed: () async {
final channel = StreamChat.of(context).client.channel(
"messaging",
id: "test-gordon",
extraData: {
"name": "Flutter Chat",
"image":
"https://flutter.dev/assets/images/shared/brand/flutter/logo/flutter-lockup.png",
"members": [userGordon.id, userSalvatore.id]
},
);
await channel.create();
},
child: const Text('Create channel'),
),
);
},
filter:
Filter.in_('members', [StreamChat.of(context).currentUser!.id]),
sort: const [SortOption('last_message_at')],
pagination: const PaginationParams(limit: 30),
channelWidget: const ChannelPage(),
),
),
);
}
}
Giải thích code -Trong đoạn trích trên, bạn đã:
- Tạo một Scaffold với body được set là một dánh sách các kênh và một nút trong thuộc tính actions để ngắt kết nối với user hiện tại và điều hướng cho nó back lại SelectUserPage.
- Tạo 2 tiện ích (Widget) ChannelsBloc và ChannelListView (được cung cấp bởi các gói Stream) mà 2 tiện ích này sẽ hiển thị tất cả các kênh cho app Stream.
- Tạo một emptyBuilder, bạn trả về một nút (button) mà nó tạo ra một kênh và đặt các members là các user bạn đã tạo.)
- Chỉ định trang mở ra khi một kênh được chọn trong thuộc tính channelWidget, đặt nó thành ChannelPage (một tiện ích tùy chỉnh mà bạn sẽ tạo tiếp).
- Thêm một filter để chỉ hiển thị những kênh nơi mà người dùng hiện tại là member.
- Thêm sort (sắp xếp) và pagination (phân trang), cái mà bạn có thể tùy chỉnh khi cần thiết.
Tạo một trang kênh để xem các tin nhắn
Để hiển thị danh sách tin nhắn, tạo một file mới có tên là channel_page.dart
và thêm code sau vào:
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
class ChannelPage extends StatefulWidget {
const ChannelPage({
Key? key,
}) : super(key: key);
@override
_ChannelPageState createState() => _ChannelPageState();
}
class _ChannelPageState extends State<ChannelPage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const ChannelHeader(),
body: Column(
children: <Widget>[
Expanded(
child: MessageListView(),
),
MessageInput(),
],
),
);
}
Giải thích code -Trong đoạn trích trên, bạn đã:
- Tạo một Scaffold cho trang mới.
- Đặt appBar thành ChannelHeader (điều này để hiển thị tên kênh và hình ảnh).
- Tạo một Column với một MessageListView được mở rộng (một danh sách mà hiển thị tất cả các kênh nhắn tin, hình ảnh, và file đính kèm tùy chỉnh).
- Tạo một MessageInput ở dưới Column, cái mà sẽ được dùng để gửi những tin nhắn mới và các tệp đính kèm cho kênh.
Nếu bạn chạy app lúc này, bạn sẽ tìm thấy - chỉ với một ít code - là bạn đã có một ứng dụng nhắn tin khá mạnh với đầy đủ các chức năng cần thiết.
Bây giờ, bạn đang ở điểm mà có thể thêm các chức năng để hỗ trợ tin nhắn thoại.
Thêm một tiện ích hành động tùy chỉnh (Custom Action Widget) để ghi lại ghi chú bằng giọng nói
Để hỗ trợ tin nhắn thoại, bạn sẽ thêm một tiện ích hành động tùy chỉnh để người dùng có thể ghi lại một ghi chú bằng giọng nói và gửi nó như là một tin nhắn. Kết thúc phần này, tiện ích của bạn trông như thế này:
Trong MessageInput
của bạn cung cấp hành động tùy chỉnh sau:
MessageInput(
actions: [
RecordButton(
recordingFinishedCallback: _recordingFinishedCallback,
),
],
),
Bạn sẽ tạo phương thức _recordingFinishedCallback sau. Trước tiên, tạo một file tên là record_button.dart
và thêm vào code sau:
import 'package:flutter/material.dart';
import 'package:record/record.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
typedef RecordCallback = void Function(String);
class RecordButton extends StatefulWidget {
const RecordButton({
Key? key,
required this.recordingFinishedCallback,
}) : super(key: key);
final RecordCallback recordingFinishedCallback;
@override
_RecordButtonState createState() => _RecordButtonState();
}
class _RecordButtonState extends State<RecordButton> {
bool _isRecording = false;
final _audioRecorder = Record();
Future<void> _start() async {
try {
if (await _audioRecorder.hasPermission()) {
await _audioRecorder.start();
bool isRecording = await _audioRecorder.isRecording();
setState(() {
_isRecording = isRecording;
});
}
} catch (e) {
print(e);
}
}
Future<void> _stop() async {
final path = await _audioRecorder.stop();
widget.recordingFinishedCallback(path!);
setState(() => _isRecording = false);
}
@override
Widget build(BuildContext context) {
late final IconData icon;
late final Color? color;
if (_isRecording) {
icon = Icons.stop;
color = Colors.red.withOpacity(0.3);
} else {
color = StreamChatTheme.of(context).primaryIconTheme.color;
icon = Icons.mic;
}
return GestureDetector(
onTap: () {
_isRecording ? _stop() : _start();
},
child: Icon(
icon,
color: color,
),
);
}
}
Giải thích code -Trong đoạn trích trên, bạn đã:
- Tạo ra một instance của Record có tên _audioRecorder, cái mà dùng Record package để làm cho nó dễ ghi lại các bản ghi âm trong Flutter. (Gói ghi ân này có yêu cầu một vài cài đặt tối thiểu cho iOS và Android để dùng; đọc docs Flutter Packages để có thêm thông tin chi tiết.)
- Tạo các phương thức _start và _stop để điều khiển trình ghi âm.
- Tạo một phương thức build mà nó dùng một GestureDetector để bắt đầu/dừng một bản ghi.
- Sử dụng một định nghĩa kiểu RecordCallback để gửi lại chuỗi đường dẫn của file đã ghi âm. (được gọi trong _stop method).
Quay trở lại channel_page.dart
và tạo phương thức _recordingFinishedCallback trong lớp _ChannelPageState.
void _recordingFinishedCallback(String path) {
final uri = Uri.parse(path);
File file = File(uri.path);
file.length().then(
(fileSize) {
StreamChannel.of(context).channel.sendMessage(
Message(
attachments: [
Attachment(
type: 'voicenote',
file: AttachmentFile(
size: fileSize,
path: uri.path,
),
)
],
),
);
},
);
}
Khi ghi xong, _recordingFinishedCallback sẽ được gọi. Nó làm những cái dưới đây:
- Dán đường dẫn vào URI.
- Tạo một file mới từ
uri.path
. - Dùng callback then trong
file.length
để xử lí độ dài file khi mà nó được lấy ra (độ dài file thì cần để tải lên file đính kèm lên Stream). - Lấy ra kênh hiện tại dùng
StreamChannel.of(context).channel
. - Gọi sendMessage trong kênh và cung cấp một Message với một Attachment (tệp đính kèm).
- Đặt kiểu cho voicenote (có thể là bất cứ định danh nào) và tạo AttachmentFile với đường dẫn và kích thước file. Bây giờ, bạn đã có chức năng cần thiết để ghi file ghi âm và upload nó lên Stream.
Tạo một Trình tạo tệp đính kèm có thể tùy chỉnh
Với code hiện tại, MessageListView vẫn chưa biết cách để render ra các file đính kèm với type: 'voicenote'
. Bạn phải nói với nó làm thế nào với đối số messageBuilder
Trong channel_page.dart
, thay đổi MessageListView thành như sau:
MessageListView(
messageBuilder: (context, details, messages, defaultMessage) {
return defaultMessage.copyWith(
customAttachmentBuilders: {
'voicenote': (context, defaultMessage, attachments) {
final url = attachments.first.assetUrl;
if (url == null) {
return const AudioLoadingMessage();
}
return AudioPlayerMessage(
source: AudioSource.uri(Uri.parse(url)),
id: defaultMessage.id,
);
}
},
);
},
),
messageBuilder ở đây lấy ra defaultMessage, cái mà có một phương thức copyWith để ghi đè vào customAttachmentBuilders cho danh sách view. Bạn tạo ra một trình xây dựng tùy chỉnh cho kiểu của voicenote (một kiểu bạn đã chỉ định).
Trong trình tạo, bạn kiểm tra để xem xem assetUrl đầu tiên của file đính kèm không phải là null
. Nếu mà null
, thì trả về AssetLoadingMessage. Nếu không thì, bạn trả về AudioPlayerMessage và xác định AudioSource. (AudioSource này là một class đến từ gói just_audio và dùng URL file đính kèm để load âm thanh.)
Kế tiếp, tạo ra một file có tên audio_loading_message.dart
và thêm vào code sau:
import 'package:flutter/material.dart';
class AudioLoadingMessage extends StatelessWidget {
const AudioLoadingMessage({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
Padding(
padding: EdgeInsets.only(left: 16.0),
child: Icon(Icons.mic),
),
],
),
);
}
}
Code này sẽ hiển thị ra đang load khi gửi nội dung.
Cuối cùng, tạo một file tên là audio_loading_message.dart
và thêm vào code này:
import 'dart:async';
import 'package:audio_attachment_tutorial/audio_loading_message.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
class AudioPlayerMessage extends StatefulWidget {
const AudioPlayerMessage({
Key? key,
required this.source,
required this.id,
}) : super(key: key);
final AudioSource source;
final String id;
@override
AudioPlayerMessageState createState() => AudioPlayerMessageState();
}
class AudioPlayerMessageState extends State<AudioPlayerMessage> {
final _audioPlayer = AudioPlayer();
late StreamSubscription<PlayerState> _playerStateChangedSubscription;
late Future<Duration?> futureDuration;
@override
void initState() {
super.initState();
_playerStateChangedSubscription =
_audioPlayer.playerStateStream.listen(playerStateListener);
futureDuration = _audioPlayer.setAudioSource(widget.source);
}
void playerStateListener(PlayerState state) async {
if (state.processingState == ProcessingState.completed) {
await reset();
}
}
@override
void dispose() {
_playerStateChangedSubscription.cancel();
_audioPlayer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<Duration?>(
future: futureDuration,
builder: (context, snapshot) {
if (snapshot.hasData) {
return Row(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
_controlButtons(),
_slider(snapshot.data),
],
);
}
return const AudioLoadingMessage();
},
);
}
Widget _controlButtons() {
return StreamBuilder<bool>(
stream: _audioPlayer.playingStream,
builder: (context, _) {
final color =
_audioPlayer.playerState.playing ? Colors.red : Colors.blue;
final icon =
_audioPlayer.playerState.playing ? Icons.pause : Icons.play_arrow;
return Padding(
padding: const EdgeInsets.all(4.0),
child: GestureDetector(
onTap: () {
if (_audioPlayer.playerState.playing) {
pause();
} else {
play();
}
},
child: SizedBox(
width: 40,
height: 40,
child: Icon(icon, color: color, size: 30),
),
),
);
},
);
}
Widget _slider(Duration? duration) {
return StreamBuilder<Duration>(
stream: _audioPlayer.positionStream,
builder: (context, snapshot) {
if (snapshot.hasData && duration != null) {
return CupertinoSlider(
value: snapshot.data!.inMicroseconds / duration.inMicroseconds,
onChanged: (val) {
_audioPlayer.seek(duration * val);
},
);
} else {
return const SizedBox.shrink();
}
},
);
}
Future<void> play() {
return _audioPlayer.play();
}
Future<void> pause() {
return _audioPlayer.pause();
}
Future<void> reset() async {
await _audioPlayer.stop();
return _audioPlayer.seek(const Duration(milliseconds: 0));
}
}
Tiện ích này sẽ điều kiển âm thanh playback cho một ghi chú âm thanh, cho phép bạn phát, dừng, và bỏ qua những phần khác của một file âm thanh với một thanh trượt slider.
Để có thêm thông tin cách dùng package này, xem tài liệu just_audio package
Cuối cùng, channel_page.dart
của bạn sẽ có phần import trong tương tự thế này:
import 'dart:io';
import 'package:audio_attachment_tutorial/audio_loading_message.dart';
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'audio_player_message.dart';
import 'record_button.dart';
Kết thúc
Hết rồi🎉! Bạn nên xem một trang kênh như thế ở dưới chp phép bạn ghi và gửi các ghi chú giọng nói:
Xem đầy đủ source code cho Stream Chat Flutter app, xem Stream Audio Attachment Tutorial Github
Có rất nhiều packages Stream Flutter khác nhau mà cung cấp đa dạng level của UI và level thấp điều khiển chat, bao gồm hỗ trợ offline và bản địa hóa (localization). Xem Stream Chat Flutter GitHub để có nhiều thông tin hơn.
Cuối cùng, theo dõi kênh du túp Stream Developers để xem nhiều nội dung hấp dẫn hơn.
Happy coding!
Bài được dịch từ bài này trên getstream.io
Link git mà toi đã làm theooo: https://github.com/trantuyet/flutter-audio-chat-app
Cảm ơn các hạ đã xem đến tận đây, chúc các bạn một đời bình an <33333
All rights reserved