feat: implement real cross-platform voice recording utilizing record package with mic permission configuration

This commit is contained in:
Hamza-Ayed
2026-05-18 17:32:31 +03:00
parent e18f4195b9
commit c1b149cc21
10 changed files with 262 additions and 50 deletions

View File

@@ -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');
}
}
}

View File

@@ -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),
],
);
}),
),
);