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,4 +1,10 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application <application
android:label="mywhatsapp" android:label="mywhatsapp"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -1,7 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:record/record.dart';
import 'package:path_provider/path_provider.dart';
import '../services/whatsapp_service.dart'; import '../services/whatsapp_service.dart';
import '../models/conversation_model.dart'; import '../models/conversation_model.dart';
import '../models/message_model.dart'; import '../models/message_model.dart';
@@ -17,6 +21,13 @@ class ChatController extends GetxController {
final inputCtrl = TextEditingController(); final inputCtrl = TextEditingController();
final scrollCtrl = ScrollController(); final scrollCtrl = ScrollController();
final hasText = false.obs;
// Recording State
final audioRecord = AudioRecorder();
final isRecording = false.obs;
final recordDuration = 0.obs;
Timer? _recordTimer;
StreamSubscription? _eventSub; StreamSubscription? _eventSub;
@@ -32,6 +43,10 @@ class ChatController extends GetxController {
Get.find<ConversationsController>().clearUnreadCount(conversation.id); Get.find<ConversationsController>().clearUnreadCount(conversation.id);
} catch (_) {} } catch (_) {}
inputCtrl.addListener(() {
hasText.value = inputCtrl.text.trim().isNotEmpty;
});
loadMessages(); loadMessages();
markAsRead(); markAsRead();
@@ -45,6 +60,8 @@ class ChatController extends GetxController {
_svc.activeChatId.value = null; _svc.activeChatId.value = null;
} }
_eventSub?.cancel(); _eventSub?.cancel();
_recordTimer?.cancel();
audioRecord.dispose();
inputCtrl.dispose(); inputCtrl.dispose();
scrollCtrl.dispose(); scrollCtrl.dispose();
super.onClose(); super.onClose();
@@ -228,4 +245,72 @@ class ChatController extends GetxController {
return DateFormat('MMMM d, yyyy').format(dt); 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,7 +139,47 @@ class ChatScreen extends StatelessWidget {
color: AppTheme.surface, color: AppTheme.surface,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SafeArea( child: SafeArea(
child: Row( 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),
),
),
const SizedBox(width: 8),
],
);
}
return Row(
children: [ children: [
// Attachment button // Attachment button
IconButton( IconButton(
@@ -171,9 +211,15 @@ class ChatScreen extends StatelessWidget {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Send button // Dynamic Send / Mic Button
Obx(() => GestureDetector( GestureDetector(
onTap: ctrl.sendMessage, onTap: () {
if (ctrl.hasText.value) {
ctrl.sendMessage();
} else {
ctrl.startRecording();
}
},
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
width: 48, width: 48,
@@ -190,11 +236,17 @@ class ChatScreen extends StatelessWidget {
color: Colors.white, color: Colors.white,
), ),
) )
: const Icon(Icons.send, color: Colors.white, size: 20), : Icon(
ctrl.hasText.value ? Icons.send : Icons.mic,
color: Colors.white,
size: 20,
), ),
)), ),
),
const SizedBox(width: 4),
], ],
), );
}),
), ),
); );

View File

@@ -8,6 +8,7 @@
#include <audioplayers_linux/audioplayers_linux_plugin.h> #include <audioplayers_linux/audioplayers_linux_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <record_linux/record_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
@@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) record_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
record_linux_plugin_register_with_registrar(record_linux_registrar);
} }

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_linux audioplayers_linux
file_selector_linux file_selector_linux
record_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -10,6 +10,7 @@ import file_selector_macos
import firebase_core import firebase_core
import firebase_messaging import firebase_messaging
import flutter_local_notifications import flutter_local_notifications
import record_darwin
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
@@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
} }

View File

@@ -601,7 +601,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
@@ -680,6 +680,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
record:
dependency: "direct main"
description:
name: record
sha256: "2e3d56d196abcd69f1046339b75e5f3855b2406fc087e5991f6703f188aa03a6"
url: "https://pub.dev"
source: hosted
version: "5.2.1"
record_android:
dependency: transitive
description:
name: record_android
sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1"
url: "https://pub.dev"
source: hosted
version: "1.5.1"
record_darwin:
dependency: transitive
description:
name: record_darwin
sha256: e487eccb19d82a9a39cd0126945cfc47b9986e0df211734e2788c95e3f63c82c
url: "https://pub.dev"
source: hosted
version: "1.2.2"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: "74d41a9ebb1eb498a38e9a813dd524e8f0b4fdd627270bda9756f437b110a3e3"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
record_use: record_use:
dependency: transitive dependency: transitive
description: description:
@@ -688,6 +728,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" version: "0.6.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb
url: "https://pub.dev"
source: hosted
version: "1.1.9"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.dev"
source: hosted
version: "1.0.7"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:

View File

@@ -23,6 +23,8 @@ dependencies:
flutter_contacts: ^1.1.7 flutter_contacts: ^1.1.7
image_picker: ^1.0.7 image_picker: ^1.0.7
audioplayers: ^6.0.0 audioplayers: ^6.0.0
record: ^5.1.2
path_provider: ^2.1.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -9,6 +9,7 @@
#include <audioplayers_windows/audioplayers_windows_plugin.h> #include <audioplayers_windows/audioplayers_windows_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <record_windows/record_windows_plugin_c_api.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
AudioplayersWindowsPluginRegisterWithRegistrar( AudioplayersWindowsPluginRegisterWithRegistrar(
@@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
RecordWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
} }

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
audioplayers_windows audioplayers_windows
file_selector_windows file_selector_windows
firebase_core firebase_core
record_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST