feat: implement real cross-platform voice recording utilizing record package with mic permission configuration
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:record/record.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../services/whatsapp_service.dart';
|
||||
import '../models/conversation_model.dart';
|
||||
import '../models/message_model.dart';
|
||||
@@ -17,6 +21,13 @@ class ChatController extends GetxController {
|
||||
|
||||
final inputCtrl = TextEditingController();
|
||||
final scrollCtrl = ScrollController();
|
||||
final hasText = false.obs;
|
||||
|
||||
// Recording State
|
||||
final audioRecord = AudioRecorder();
|
||||
final isRecording = false.obs;
|
||||
final recordDuration = 0.obs;
|
||||
Timer? _recordTimer;
|
||||
|
||||
StreamSubscription? _eventSub;
|
||||
|
||||
@@ -32,6 +43,10 @@ class ChatController extends GetxController {
|
||||
Get.find<ConversationsController>().clearUnreadCount(conversation.id);
|
||||
} catch (_) {}
|
||||
|
||||
inputCtrl.addListener(() {
|
||||
hasText.value = inputCtrl.text.trim().isNotEmpty;
|
||||
});
|
||||
|
||||
loadMessages();
|
||||
markAsRead();
|
||||
|
||||
@@ -45,6 +60,8 @@ class ChatController extends GetxController {
|
||||
_svc.activeChatId.value = null;
|
||||
}
|
||||
_eventSub?.cancel();
|
||||
_recordTimer?.cancel();
|
||||
audioRecord.dispose();
|
||||
inputCtrl.dispose();
|
||||
scrollCtrl.dispose();
|
||||
super.onClose();
|
||||
@@ -228,4 +245,72 @@ class ChatController extends GetxController {
|
||||
return DateFormat('MMMM d, yyyy').format(dt);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Audio Recording Engine ───────────────────────────────────────────────
|
||||
Future<void> startRecording() async {
|
||||
try {
|
||||
if (await audioRecord.hasPermission()) {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final path = '${tempDir.path}/rec_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
|
||||
await audioRecord.start(
|
||||
const RecordConfig(encoder: AudioEncoder.aacLc),
|
||||
path: path,
|
||||
);
|
||||
|
||||
recordDuration.value = 0;
|
||||
isRecording.value = true;
|
||||
|
||||
_recordTimer?.cancel();
|
||||
_recordTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
recordDuration.value++;
|
||||
});
|
||||
} else {
|
||||
Get.snackbar(
|
||||
'Permission Denied',
|
||||
'Microphone permission is required to record voice notes.',
|
||||
snackPosition: SnackPosition.BOTTOM,
|
||||
backgroundColor: Colors.redAccent.withOpacity(0.8),
|
||||
colorText: Colors.white,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[START RECORDING ERROR] $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stopAndSendRecording() async {
|
||||
try {
|
||||
_recordTimer?.cancel();
|
||||
final path = await audioRecord.stop();
|
||||
isRecording.value = false;
|
||||
|
||||
if (path != null && recordDuration.value > 0) {
|
||||
final file = File(path);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
final base64String = base64Encode(bytes);
|
||||
|
||||
await sendMediaMessage(
|
||||
base64String,
|
||||
'audio/mp4', // Recorded as M4A (AAC), perfect for all platforms natively!
|
||||
'voice_note.m4a',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print('[STOP RECORDING ERROR] $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> cancelRecording() async {
|
||||
try {
|
||||
_recordTimer?.cancel();
|
||||
await audioRecord.stop();
|
||||
isRecording.value = false;
|
||||
recordDuration.value = 0;
|
||||
} catch (e) {
|
||||
print('[CANCEL RECORDING ERROR] $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,62 +139,114 @@ class ChatScreen extends StatelessWidget {
|
||||
color: AppTheme.surface,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
// Attachment button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: AppTheme.primary, size: 28),
|
||||
onPressed: () => _showAttachmentSheet(ctrl),
|
||||
),
|
||||
// Input
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: ctrl.inputCtrl,
|
||||
style: const TextStyle(color: AppTheme.textPrimary),
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message',
|
||||
hintStyle: const TextStyle(color: AppTheme.textSecondary),
|
||||
filled: true,
|
||||
fillColor: AppTheme.surfaceLight,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
child: Obx(() {
|
||||
if (ctrl.isRecording.value) {
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(width: 12),
|
||||
const Icon(Icons.fiber_manual_record, color: Colors.red, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Recording...',
|
||||
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${(ctrl.recordDuration.value ~/ 60).toString().padLeft(2, '0')}:${(ctrl.recordDuration.value % 60).toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(color: AppTheme.textPrimary, fontSize: 14, fontFamily: 'monospace'),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.delete, color: Colors.redAccent, size: 18),
|
||||
label: const Text('Cancel', style: TextStyle(color: Colors.redAccent)),
|
||||
onPressed: ctrl.cancelRecording,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: ctrl.stopAndSendRecording,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => ctrl.sendMessage(),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// Attachment button
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: AppTheme.primary, size: 28),
|
||||
onPressed: () => _showAttachmentSheet(ctrl),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Send button
|
||||
Obx(() => GestureDetector(
|
||||
onTap: ctrl.sendMessage,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
// Input
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: ctrl.inputCtrl,
|
||||
style: const TextStyle(color: AppTheme.textPrimary),
|
||||
maxLines: 5,
|
||||
minLines: 1,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Message',
|
||||
hintStyle: const TextStyle(color: AppTheme.textSecondary),
|
||||
filled: true,
|
||||
fillColor: AppTheme.surfaceLight,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 10,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => ctrl.sendMessage(),
|
||||
),
|
||||
child: ctrl.isSending.value
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Dynamic Send / Mic Button
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (ctrl.hasText.value) {
|
||||
ctrl.sendMessage();
|
||||
} else {
|
||||
ctrl.startRecording();
|
||||
}
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: ctrl.isSending.value
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
ctrl.hasText.value ? Icons.send : Icons.mic,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.send, color: Colors.white, size: 20),
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user