feat: implement real cross-platform voice recording utilizing record package with mic permission configuration
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
<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
|
||||
android:label="mywhatsapp"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
#include <audioplayers_linux/audioplayers_linux_plugin.h>
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <record_linux/record_linux_plugin.h>
|
||||
|
||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar =
|
||||
@@ -16,4 +17,7 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
||||
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_linux
|
||||
file_selector_linux
|
||||
record_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -10,6 +10,7 @@ import file_selector_macos
|
||||
import firebase_core
|
||||
import firebase_messaging
|
||||
import flutter_local_notifications
|
||||
import record_darwin
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
|
||||
@@ -19,6 +20,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||
RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
}
|
||||
|
||||
@@ -601,7 +601,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
@@ -680,6 +680,46 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -688,6 +728,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -23,6 +23,8 @@ dependencies:
|
||||
flutter_contacts: ^1.1.7
|
||||
image_picker: ^1.0.7
|
||||
audioplayers: ^6.0.0
|
||||
record: ^5.1.2
|
||||
path_provider: ^2.1.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <audioplayers_windows/audioplayers_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <record_windows/record_windows_plugin_c_api.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AudioplayersWindowsPluginRegisterWithRegistrar(
|
||||
@@ -17,4 +18,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseCorePluginCApi"));
|
||||
RecordWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("RecordWindowsPluginCApi"));
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
audioplayers_windows
|
||||
file_selector_windows
|
||||
firebase_core
|
||||
record_windows
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Reference in New Issue
Block a user