diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 69abf99..5fabde6 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json
index 52e41e2..80467f7 100644
--- a/assets/i18n/en-US.json
+++ b/assets/i18n/en-US.json
@@ -146,7 +146,9 @@
"edited": "Edited",
"addVideo": "Add video",
"addPhoto": "Add photo",
+ "addVoice": "Add your voice",
"addFile": "Add file",
+ "recordAudio": "Record Audio",
"linkAttachment": "Link Attachment",
"fileIdCannotBeEmpty": "File ID cannot be empty",
"failedToFetchFile": "Failed to fetch file: {}",
diff --git a/lib/widgets/post/compose_recorder.dart b/lib/widgets/post/compose_recorder.dart
new file mode 100644
index 0000000..3879d1e
--- /dev/null
+++ b/lib/widgets/post/compose_recorder.dart
@@ -0,0 +1,122 @@
+import 'dart:async';
+import 'dart:developer';
+
+import 'package:easy_localization/easy_localization.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:gap/gap.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:island/services/time.dart';
+import 'package:island/widgets/content/sheet.dart';
+import 'package:material_symbols_icons/symbols.dart';
+import 'package:path_provider/path_provider.dart';
+import 'package:record/record.dart' hide Amplitude;
+import 'package:styled_widget/styled_widget.dart';
+import 'package:uuid/uuid.dart';
+import 'package:waveform_flutter/waveform_flutter.dart';
+
+class ComposeRecorder extends HookConsumerWidget {
+ const ComposeRecorder({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final recording = useState(false);
+ final recordingStartAt = useState(null);
+ final recordingDuration = useState(Duration(seconds: 0));
+
+ StreamSubscription? originalAmplitude;
+ StreamController amplitudeStream = StreamController();
+ var record = AudioRecorder();
+
+ final resultPath = useState(null);
+
+ Future startRecord() async {
+ recording.value = true;
+
+ // Check and request permission if needed
+ final tempPath = !kIsWeb ? (await getTemporaryDirectory()).path : 'temp';
+ final uuid = const Uuid().v4().substring(0, 8);
+ if (!await record.hasPermission()) return;
+
+ const recordConfig = RecordConfig(
+ encoder: AudioEncoder.pcm16bits,
+ autoGain: true,
+ echoCancel: true,
+ noiseSuppress: true,
+ );
+ resultPath.value = '$tempPath/solar-network-record-$uuid.m4a';
+ await record.start(recordConfig, path: resultPath.value!);
+
+ recordingStartAt.value = DateTime.now();
+ originalAmplitude = record
+ .onAmplitudeChanged(const Duration(milliseconds: 100))
+ .listen((value) async {
+ amplitudeStream.add(
+ Amplitude(current: value.current, max: value.max),
+ );
+ recordingDuration.value = DateTime.now().difference(
+ recordingStartAt.value!,
+ );
+ });
+ }
+
+ useEffect(() {
+ return () {
+ // Called when widget is unmounted
+ log('[Recorder] Clean up!');
+ originalAmplitude?.cancel();
+ amplitudeStream.close();
+ record.dispose();
+ };
+ }, []);
+
+ Future stopRecord() async {
+ recording.value = false;
+ await record.pause();
+ final newResult = await record.stop();
+ await record.cancel();
+ if (newResult != null) resultPath.value = newResult;
+
+ if (context.mounted) Navigator.of(context).pop(resultPath.value);
+ }
+
+ return SheetScaffold(
+ titleText: "recordAudio".tr(),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ const Gap(32),
+ Text(
+ recordingDuration.value.formatDuration(),
+ ).fontSize(20).bold().padding(bottom: 8),
+ SizedBox(
+ height: 120,
+ child: Center(
+ child: ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 480),
+ child: Card(
+ color: Theme.of(context).colorScheme.surfaceContainer,
+ child: AnimatedWaveList(stream: amplitudeStream.stream),
+ ),
+ ),
+ ),
+ ),
+ const Gap(12),
+ IconButton.filled(
+ onPressed: recording.value ? stopRecord : startRecord,
+ iconSize: 32,
+ icon:
+ recording.value
+ ? const Icon(Symbols.stop, fill: 1, color: Colors.white)
+ : const Icon(
+ Symbols.play_arrow,
+ fill: 1,
+ color: Colors.white,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/widgets/post/compose_shared.dart b/lib/widgets/post/compose_shared.dart
index 36cf029..fc260de 100644
--- a/lib/widgets/post/compose_shared.dart
+++ b/lib/widgets/post/compose_shared.dart
@@ -15,14 +15,14 @@ import 'package:island/services/file.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
+import 'package:island/widgets/post/compose_recorder.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:pasteboard/pasteboard.dart';
import 'package:styled_widget/styled_widget.dart';
+import 'package:textfield_tags/textfield_tags.dart';
import 'dart:async';
import 'dart:developer';
-import 'package:textfield_tags/textfield_tags.dart';
-
class ComposeState {
final TextEditingController titleController;
final TextEditingController descriptionController;
@@ -399,6 +399,26 @@ class ComposeLogic {
];
}
+ static Future recordAudioMedia(
+ WidgetRef ref,
+ ComposeState state,
+ BuildContext context,
+ ) async {
+ final audioPath = await showModalBottomSheet(
+ context: context,
+ builder: (context) => ComposeRecorder(),
+ );
+ if (audioPath == null) return;
+
+ state.attachments.value = [
+ ...state.attachments.value,
+ UniversalFile(
+ data: XFile(audioPath, mimeType: 'audio/m4a'),
+ type: UniversalFileType.audio,
+ ),
+ ];
+ }
+
static Future linkAttachment(
WidgetRef ref,
ComposeState state,
diff --git a/lib/widgets/post/compose_toolbar.dart b/lib/widgets/post/compose_toolbar.dart
index ca5a3a2..599c666 100644
--- a/lib/widgets/post/compose_toolbar.dart
+++ b/lib/widgets/post/compose_toolbar.dart
@@ -24,6 +24,10 @@ class ComposeToolbar extends HookConsumerWidget {
ComposeLogic.pickVideoMedia(ref, state);
}
+ void addYourVoice() {
+ ComposeLogic.recordAudioMedia(ref, state, context);
+ }
+
void linkAttachment() {
ComposeLogic.linkAttachment(ref, state, context);
}
@@ -72,6 +76,12 @@ class ComposeToolbar extends HookConsumerWidget {
icon: const Icon(Symbols.videocam),
color: colorScheme.primary,
),
+ IconButton(
+ onPressed: addYourVoice,
+ tooltip: 'addYourVoice'.tr(),
+ icon: const Icon(Symbols.mic),
+ color: colorScheme.primary,
+ ),
IconButton(
onPressed: linkAttachment,
icon: const Icon(Symbols.attach_file),
diff --git a/pubspec.lock b/pubspec.lock
index 67e1915..e886c03 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -2572,6 +2572,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
+ waveform_flutter:
+ dependency: "direct main"
+ description:
+ name: waveform_flutter
+ sha256: "08c9e98d4cf119428d8b3c083ed42c11c468623eaffdf30420ae38e36662922a"
+ url: "https://pub.dev"
+ source: hosted
+ version: "1.2.0"
web:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index a701551..4f2fab8 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -132,6 +132,7 @@ dependencies:
html2md: ^1.3.2
flutter_typeahead: ^5.2.0
flutter_langdetect: ^0.0.2
+ waveform_flutter: ^1.2.0
dev_dependencies:
flutter_test: