feat(notes): Add audio recording and playback features
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../shared/utils/bytes_audio_source.dart';
|
||||
|
||||
// =============================================================================
|
||||
// TTS Events
|
||||
@@ -142,9 +143,15 @@ class TtsManager {
|
||||
bool _handlersSet = false;
|
||||
Completer<void>? _initCompleter;
|
||||
|
||||
// AudioPlayer for server TTS
|
||||
// AudioPlayer for server TTS (using just_audio)
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
bool _playerConfigured = false;
|
||||
StreamSubscription<PlayerState>? _playerStateSub;
|
||||
|
||||
/// Flag to suppress spurious TtsPaused events during chunk transitions.
|
||||
/// When true, the player is actively switching audio sources and pause
|
||||
/// events should not be emitted to listeners.
|
||||
bool _isTransitioningChunks = false;
|
||||
|
||||
// API service for server TTS (must be set before using server TTS)
|
||||
ApiService? _apiService;
|
||||
@@ -222,22 +229,27 @@ class TtsManager {
|
||||
// Initialize FlutterTts
|
||||
await _ensureTtsInitialized();
|
||||
|
||||
// Configure AudioPlayer for all platforms
|
||||
// Configure AudioPlayer for all platforms (using just_audio)
|
||||
if (!_playerConfigured) {
|
||||
_player.onPlayerComplete.listen((_) => _onServerAudioComplete());
|
||||
_player.onPlayerStateChanged.listen((state) {
|
||||
if (state == PlayerState.playing) {
|
||||
_playerStateSub = _player.playerStateStream.listen((state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_onServerAudioComplete();
|
||||
}
|
||||
if (state.playing) {
|
||||
// Clear transition flag when playback actually starts.
|
||||
// This ensures pause events aren't emitted during the brief window
|
||||
// between play() returning and the player entering playing state.
|
||||
_isTransitioningChunks = false;
|
||||
_emitEvent(const TtsStarted());
|
||||
} else if (state == PlayerState.paused) {
|
||||
} else if (!state.playing &&
|
||||
state.processingState == ProcessingState.ready &&
|
||||
!_isTransitioningChunks) {
|
||||
// Only emit pause when actually paused, ready, and NOT transitioning
|
||||
// between chunks. During chunk transitions, the player briefly enters
|
||||
// a ready-but-not-playing state which should not emit pause events.
|
||||
_emitEvent(const TtsPaused());
|
||||
}
|
||||
});
|
||||
// Android-specific audio context configuration
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
await _player.setAudioContext(
|
||||
AudioContext(android: const AudioContextAndroid()),
|
||||
);
|
||||
}
|
||||
_playerConfigured = true;
|
||||
}
|
||||
|
||||
@@ -334,7 +346,7 @@ class TtsManager {
|
||||
|
||||
try {
|
||||
if (session.useServerTts) {
|
||||
await _player.resume();
|
||||
await _player.play();
|
||||
_emitEvent(const TtsResumed());
|
||||
} else {
|
||||
// Device TTS resume is handled by the native handler
|
||||
@@ -367,6 +379,7 @@ class TtsManager {
|
||||
/// Disposes the manager and releases resources.
|
||||
Future<void> dispose() async {
|
||||
await stop();
|
||||
await _playerStateSub?.cancel();
|
||||
await _player.dispose();
|
||||
await _eventController.close();
|
||||
}
|
||||
@@ -690,9 +703,17 @@ class TtsManager {
|
||||
_serverCurrentIndex = 0;
|
||||
|
||||
await _player.stop();
|
||||
await _player.play(
|
||||
BytesSource(firstChunk.bytes, mimeType: firstChunk.mimeType),
|
||||
);
|
||||
_isTransitioningChunks = true;
|
||||
// Flag will be cleared by state listener when playing=true is received.
|
||||
// This prevents race condition where flag is cleared before state fires.
|
||||
try {
|
||||
await _player.setAudioSource(BytesAudioSource(firstChunk.bytes, firstChunk.mimeType));
|
||||
await _player.play();
|
||||
} catch (e) {
|
||||
// Reset flag on error to avoid suppressing future pause events
|
||||
_isTransitioningChunks = false;
|
||||
rethrow;
|
||||
}
|
||||
_emitEvent(const TtsChunkStarted(0));
|
||||
|
||||
// Prefetch remaining chunks in background
|
||||
@@ -766,7 +787,16 @@ class TtsManager {
|
||||
_serverCurrentIndex = nextIndex;
|
||||
final chunk = _serverAudioBuffer[nextIndex];
|
||||
|
||||
await _player.play(BytesSource(chunk.bytes, mimeType: chunk.mimeType));
|
||||
_isTransitioningChunks = true;
|
||||
// Flag will be cleared by state listener when playing=true is received.
|
||||
try {
|
||||
await _player.setAudioSource(BytesAudioSource(chunk.bytes, chunk.mimeType));
|
||||
await _player.play();
|
||||
} catch (e) {
|
||||
// Reset flag on error to avoid suppressing future pause events
|
||||
_isTransitioningChunks = false;
|
||||
rethrow;
|
||||
}
|
||||
_emitEvent(TtsChunkStarted(nextIndex));
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import 'dart:collection';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/services/background_streaming_handler.dart';
|
||||
import '../../../core/services/callkit_service.dart';
|
||||
import '../../../core/services/socket_service.dart';
|
||||
import '../../../shared/utils/bytes_audio_source.dart';
|
||||
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
|
||||
import '../providers/chat_providers.dart';
|
||||
import 'text_to_speech_service.dart';
|
||||
@@ -64,6 +65,7 @@ class VoiceCallService {
|
||||
bool _listeningSuspendedForSpeech = false;
|
||||
final Map<int, SpeechAudioChunk> _serverAudioBuffer = {};
|
||||
final AudioPlayer _serverAudioPlayer = AudioPlayer();
|
||||
StreamSubscription<PlayerState>? _serverAudioStateSub;
|
||||
int _serverAudioSession = 0;
|
||||
int _pendingServerAudioFetches = 0;
|
||||
bool _serverPipelineActive = false;
|
||||
@@ -102,8 +104,10 @@ class VoiceCallService {
|
||||
// sentence/word callbacks are not required for call UI, but harmless
|
||||
);
|
||||
|
||||
_serverAudioPlayer.onPlayerComplete.listen((_) {
|
||||
_handleServerAudioComplete();
|
||||
_serverAudioStateSub = _serverAudioPlayer.playerStateStream.listen((state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_handleServerAudioComplete();
|
||||
}
|
||||
});
|
||||
|
||||
unawaited(_tts.preloadServerDefaults());
|
||||
@@ -721,9 +725,10 @@ class VoiceCallService {
|
||||
await _prepareForSpeechPlayback();
|
||||
_isSpeaking = true;
|
||||
_updateState(VoiceCallState.speaking);
|
||||
await _serverAudioPlayer.play(
|
||||
BytesSource(chunk.bytes, mimeType: chunk.mimeType),
|
||||
await _serverAudioPlayer.setAudioSource(
|
||||
BytesAudioSource(chunk.bytes, chunk.mimeType),
|
||||
);
|
||||
await _serverAudioPlayer.play();
|
||||
} catch (e) {
|
||||
_isSpeaking = false;
|
||||
_handleTtsError(e.toString());
|
||||
@@ -1003,6 +1008,7 @@ class VoiceCallService {
|
||||
|
||||
_voiceInput.dispose();
|
||||
await _tts.dispose();
|
||||
await _serverAudioStateSub?.cancel();
|
||||
await _serverAudioPlayer.dispose();
|
||||
|
||||
// Cancel notification
|
||||
|
||||
225
lib/features/notes/services/audio_recording_service.dart
Normal file
225
lib/features/notes/services/audio_recording_service.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
/// Minimum valid audio file size (anything smaller is likely just a header)
|
||||
const int _minValidAudioSize = 1000; // 1KB minimum
|
||||
|
||||
/// Exception thrown when audio recording fails.
|
||||
class AudioRecordingException implements Exception {
|
||||
final String message;
|
||||
AudioRecordingException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Service for recording raw audio files without real-time transcription.
|
||||
///
|
||||
/// This is used in the notes feature where users want to preserve their original
|
||||
/// audio recordings for later transcription using server-side Whisper, rather
|
||||
/// than using Apple's real-time speech transcription which:
|
||||
/// - Sends data to Apple's servers (privacy concern for self-hosted setups)
|
||||
/// - Auto-stops after silence periods
|
||||
/// - Loses the original audio after transcription
|
||||
class AudioRecordingService {
|
||||
final AudioRecorder _recorder = AudioRecorder();
|
||||
bool _isRecording = false;
|
||||
String? _currentFilePath;
|
||||
DateTime? _startTime;
|
||||
|
||||
final _durationController = StreamController<Duration>.broadcast();
|
||||
Stream<Duration> get durationStream => _durationController.stream;
|
||||
|
||||
Timer? _durationTimer;
|
||||
|
||||
bool get isRecording => _isRecording;
|
||||
|
||||
Duration get currentDuration => _startTime != null
|
||||
? DateTime.now().difference(_startTime!)
|
||||
: Duration.zero;
|
||||
|
||||
/// Starts recording audio to a file.
|
||||
///
|
||||
/// Returns the file path where audio will be saved.
|
||||
/// Throws an exception if microphone permission is denied.
|
||||
Future<String> startRecording() async {
|
||||
if (_isRecording) {
|
||||
throw StateError('Already recording');
|
||||
}
|
||||
|
||||
// Check/request microphone permission
|
||||
final hasPermission = await _recorder.hasPermission();
|
||||
if (!hasPermission) {
|
||||
throw Exception('Microphone permission denied');
|
||||
}
|
||||
|
||||
// Generate unique file path
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
// Use AAC for iOS (native support) and better compatibility
|
||||
// Use m4a extension which is widely supported
|
||||
_currentFilePath = '${tempDir.path}/note_recording_$timestamp.m4a';
|
||||
|
||||
// Configure recording for high quality audio
|
||||
// Using AAC encoder for good compression and cross-platform compatibility
|
||||
await _recorder.start(
|
||||
const RecordConfig(
|
||||
encoder: AudioEncoder.aacLc,
|
||||
sampleRate: 44100,
|
||||
bitRate: 128000,
|
||||
numChannels: 1,
|
||||
// Don't alter the recording - preserve original audio
|
||||
echoCancel: false,
|
||||
autoGain: false,
|
||||
noiseSuppress: false,
|
||||
),
|
||||
path: _currentFilePath!,
|
||||
);
|
||||
|
||||
_isRecording = true;
|
||||
_startTime = DateTime.now();
|
||||
|
||||
// Start duration timer for UI updates
|
||||
_durationTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||
// Use try-catch to handle race condition where dispose() closes
|
||||
// the controller between the check and the add
|
||||
try {
|
||||
_durationController.add(currentDuration);
|
||||
} catch (_) {
|
||||
// Controller was closed, timer will be cancelled by dispose()
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint('AudioRecordingService: Started recording to $_currentFilePath');
|
||||
return _currentFilePath!;
|
||||
}
|
||||
|
||||
/// Stops recording and returns the recorded file.
|
||||
///
|
||||
/// Returns null if recording was not active, the file doesn't exist,
|
||||
/// or the recording failed (file too small).
|
||||
/// Throws an exception if the recording captured no audio data.
|
||||
Future<File?> stopRecording() async {
|
||||
if (!_isRecording || _currentFilePath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = null;
|
||||
|
||||
final path = await _recorder.stop();
|
||||
_isRecording = false;
|
||||
_startTime = null;
|
||||
|
||||
if (path == null) {
|
||||
debugPrint('AudioRecordingService: Stop returned null path');
|
||||
return null;
|
||||
}
|
||||
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
debugPrint('AudioRecordingService: File does not exist at $path');
|
||||
return null;
|
||||
}
|
||||
|
||||
final fileSize = await file.length();
|
||||
debugPrint(
|
||||
'AudioRecordingService: Recording stopped, file size: $fileSize bytes',
|
||||
);
|
||||
|
||||
// Check if the file is too small (likely just header, no audio data)
|
||||
if (fileSize < _minValidAudioSize) {
|
||||
debugPrint(
|
||||
'AudioRecordingService: Recording failed - file too small '
|
||||
'($fileSize bytes < $_minValidAudioSize minimum). '
|
||||
'This usually means the microphone is not working or not available.',
|
||||
);
|
||||
// Clean up the invalid file
|
||||
try {
|
||||
await file.delete();
|
||||
} catch (_) {}
|
||||
_currentFilePath = null;
|
||||
throw AudioRecordingException(
|
||||
'Recording captured no audio. '
|
||||
'Please check microphone permissions and try again.',
|
||||
);
|
||||
}
|
||||
|
||||
_currentFilePath = null;
|
||||
return file;
|
||||
}
|
||||
|
||||
/// Cancels recording and deletes any recorded data.
|
||||
Future<void> cancelRecording() async {
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = null;
|
||||
|
||||
if (_isRecording) {
|
||||
await _recorder.stop();
|
||||
_isRecording = false;
|
||||
_startTime = null;
|
||||
}
|
||||
|
||||
if (_currentFilePath != null) {
|
||||
try {
|
||||
final file = File(_currentFilePath!);
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
debugPrint('AudioRecordingService: Deleted cancelled recording');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('AudioRecordingService: Failed to delete file: $e');
|
||||
}
|
||||
_currentFilePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream of amplitude values for visualization.
|
||||
///
|
||||
/// Returns amplitude data every 100ms while recording.
|
||||
Stream<Amplitude> get amplitudeStream => _recorder.onAmplitudeChanged(
|
||||
const Duration(milliseconds: 100),
|
||||
);
|
||||
|
||||
/// Disposes of resources used by the service.
|
||||
///
|
||||
/// This should be called when the service is no longer needed to properly
|
||||
/// release native audio resources. If a recording is in progress, it will
|
||||
/// be cancelled and any temp files cleaned up.
|
||||
Future<void> dispose() async {
|
||||
// Cancel any in-progress recording first to clean up temp files.
|
||||
// Wrapped in try-catch to ensure timer/controller cleanup always happens.
|
||||
if (_isRecording) {
|
||||
try {
|
||||
await cancelRecording();
|
||||
} catch (e) {
|
||||
debugPrint('AudioRecordingService: Error cancelling recording in dispose: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel timer BEFORE closing controller to avoid relying on exception
|
||||
// handling for control flow. The try-catch in the timer callback is a
|
||||
// safety net for any remaining race condition.
|
||||
_durationTimer?.cancel();
|
||||
_durationTimer = null;
|
||||
|
||||
if (!_durationController.isClosed) {
|
||||
await _durationController.close();
|
||||
}
|
||||
|
||||
// Await recorder disposal to ensure native resources are released.
|
||||
// Wrapped in try-catch since recorder may be in inconsistent state if
|
||||
// cancelRecording() failed above.
|
||||
try {
|
||||
await _recorder.dispose();
|
||||
} catch (e) {
|
||||
debugPrint('AudioRecordingService: Error disposing recorder: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'dart:io' show File, Platform;
|
||||
import 'dart:ui' show ImageFilter;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
@@ -21,6 +21,9 @@ import '../../../shared/widgets/middle_ellipsis_text.dart';
|
||||
import '../../../shared/widgets/themed_dialogs.dart';
|
||||
import '../../chat/services/voice_input_service.dart';
|
||||
import '../providers/notes_providers.dart';
|
||||
import '../widgets/audio_player_dialog.dart';
|
||||
import '../widgets/audio_recording_overlay.dart';
|
||||
import '../widgets/note_file_attachment.dart';
|
||||
|
||||
/// Page for editing a note with OpenWebUI-style layout.
|
||||
class NoteEditorPage extends ConsumerStatefulWidget {
|
||||
@@ -46,6 +49,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
||||
bool _isGeneratingTitle = false;
|
||||
bool _isEnhancing = false;
|
||||
bool _isRecording = false;
|
||||
bool _isUploadingAudio = false;
|
||||
Note? _note;
|
||||
|
||||
// Voice input
|
||||
@@ -450,6 +454,246 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a bottom sheet to choose between dictation and audio recording.
|
||||
void _showRecordingOptions() {
|
||||
final conduitTheme = context.conduitTheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: conduitTheme.surfaceContainer,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(AppBorderRadius.modal),
|
||||
),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Spacing.md),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Handle bar
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: Spacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: conduitTheme.textSecondary.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||
),
|
||||
),
|
||||
// Dictation option
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: conduitTheme.buttonPrimary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.keyboard
|
||||
: Icons.keyboard_voice_rounded,
|
||||
color: conduitTheme.buttonPrimary,
|
||||
size: IconSize.md,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
l10n.dictation,
|
||||
style: TextStyle(
|
||||
color: conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.dictationDescription,
|
||||
style: TextStyle(
|
||||
color: conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_toggleDictation();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: Spacing.xs),
|
||||
// Audio recording option
|
||||
ListTile(
|
||||
leading: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.mic_fill
|
||||
: Icons.mic_rounded,
|
||||
color: Colors.red,
|
||||
size: IconSize.md,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
l10n.recordAudio,
|
||||
style: TextStyle(
|
||||
color: conduitTheme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
l10n.recordAudioDescription,
|
||||
style: TextStyle(
|
||||
color: conduitTheme.textSecondary,
|
||||
fontSize: AppTypography.bodySmall,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_showAudioRecordingOverlay();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows the full-screen audio recording overlay.
|
||||
void _showAudioRecordingOverlay() {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
barrierDismissible: false,
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: AudioRecordingOverlay(
|
||||
onCancel: () => Navigator.pop(context),
|
||||
onConfirm: (file) async {
|
||||
Navigator.pop(context);
|
||||
await _uploadAudioFile(file);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 200),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 150),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Uploads an audio file to the server and attaches it to the note.
|
||||
Future<void> _uploadAudioFile(File audioFile) async {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (api == null || _note == null) {
|
||||
_showError(l10n.failedToUploadAudio);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isUploadingAudio = true);
|
||||
|
||||
try {
|
||||
// Get file info
|
||||
final fileSize = await audioFile.length();
|
||||
final fileName = 'recording_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
|
||||
// Upload file to Open WebUI with proper content type
|
||||
final fileId = await api.uploadFile(
|
||||
audioFile.path,
|
||||
fileName,
|
||||
contentType: 'audio/mp4',
|
||||
);
|
||||
|
||||
// Get current note files
|
||||
final currentFiles = _note!.data.files ?? [];
|
||||
|
||||
// Generate a local item ID (for OpenWebUI compatibility)
|
||||
final itemId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// Add the new file in OpenWebUI's expected format
|
||||
// Must match the structure in NoteEditor.svelte uploadFileHandler
|
||||
final updatedFiles = [
|
||||
...currentFiles,
|
||||
{
|
||||
'type': 'file',
|
||||
'file': '',
|
||||
'id': fileId,
|
||||
'url': fileId,
|
||||
'name': fileName,
|
||||
'collection_name': '',
|
||||
'status': 'uploaded',
|
||||
'size': fileSize,
|
||||
'error': '',
|
||||
'itemId': itemId,
|
||||
},
|
||||
];
|
||||
|
||||
debugPrint('NoteEditorPage: Saving files: $updatedFiles');
|
||||
|
||||
// Update note with the file attachment
|
||||
final data = <String, dynamic>{
|
||||
'content': <String, dynamic>{
|
||||
'json': null,
|
||||
'html': _markdownToHtml(_contentController.text),
|
||||
'md': _contentController.text,
|
||||
},
|
||||
'files': updatedFiles,
|
||||
};
|
||||
|
||||
debugPrint('NoteEditorPage: Updating note with data: $data');
|
||||
|
||||
final json = await api.updateNote(
|
||||
widget.noteId,
|
||||
title: _titleController.text.isEmpty ? l10n.untitled : _titleController.text,
|
||||
data: data,
|
||||
);
|
||||
|
||||
debugPrint('NoteEditorPage: Update response: $json');
|
||||
debugPrint('NoteEditorPage: Response files: ${json['data']?['files']}');
|
||||
|
||||
final updatedNote = Note.fromJson(json);
|
||||
|
||||
if (mounted) {
|
||||
// Update provider state inside mounted check to avoid accessing
|
||||
// invalid ref after widget disposal
|
||||
ref.read(notesListProvider.notifier).updateNote(updatedNote);
|
||||
|
||||
setState(() {
|
||||
_note = updatedNote;
|
||||
_isUploadingAudio = false;
|
||||
_hasChanges = false;
|
||||
});
|
||||
|
||||
HapticFeedback.mediumImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.audioRecordingSaved),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
try {
|
||||
await audioFile.delete();
|
||||
} catch (_) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isUploadingAudio = false);
|
||||
_showError('Failed to upload audio: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _copyToClipboard() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final content = _contentController.text;
|
||||
@@ -979,6 +1223,9 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
||||
// App bar height: kToolbarHeight + metadata bar (~40)
|
||||
final appBarHeight = kToolbarHeight + 40;
|
||||
|
||||
// Get attached files
|
||||
final files = _note?.data.files ?? [];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _contentFocusNode.requestFocus(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -990,35 +1237,150 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
||||
Spacing.inputPadding,
|
||||
120, // Extra padding for floating buttons
|
||||
),
|
||||
child: TextField(
|
||||
controller: _contentController,
|
||||
focusNode: _contentFocusNode,
|
||||
style: AppTypography.bodyLargeStyle.copyWith(
|
||||
color: theme.textPrimary,
|
||||
height: 1.8,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.writeNote,
|
||||
hintStyle: AppTypography.bodyLargeStyle.copyWith(
|
||||
color: theme.textSecondary.withValues(alpha: 0.35),
|
||||
height: 1.8,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// File attachments section (if any)
|
||||
if (files.isNotEmpty) ...[
|
||||
NoteFilesSection(
|
||||
files: files,
|
||||
onPlayFile: _playAudioFile,
|
||||
onDeleteFile: _removeFile,
|
||||
),
|
||||
const SizedBox(height: Spacing.lg),
|
||||
],
|
||||
// Content editor
|
||||
TextField(
|
||||
controller: _contentController,
|
||||
focusNode: _contentFocusNode,
|
||||
style: AppTypography.bodyLargeStyle.copyWith(
|
||||
color: theme.textPrimary,
|
||||
height: 1.8,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.writeNote,
|
||||
hintStyle: AppTypography.bodyLargeStyle.copyWith(
|
||||
color: theme.textSecondary.withValues(alpha: 0.35),
|
||||
height: 1.8,
|
||||
),
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
maxLines: null,
|
||||
minLines: 20,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
keyboardType: TextInputType.multiline,
|
||||
),
|
||||
filled: false,
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
maxLines: null,
|
||||
minLines: 20,
|
||||
textAlignVertical: TextAlignVertical.top,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
keyboardType: TextInputType.multiline,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Play an audio file attachment.
|
||||
Future<void> _playAudioFile(Map<String, dynamic> file) async {
|
||||
final fileId = file['id']?.toString();
|
||||
if (fileId == null) return;
|
||||
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) return;
|
||||
|
||||
final fileName = file['name']?.toString() ?? 'Audio Recording';
|
||||
|
||||
await AudioPlayerDialog.show(
|
||||
context,
|
||||
fileId: fileId,
|
||||
api: api,
|
||||
fileName: fileName,
|
||||
);
|
||||
}
|
||||
|
||||
/// Remove a file attachment from the note.
|
||||
Future<void> _removeFile(Map<String, dynamic> file) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.removeFile),
|
||||
content: Text(l10n.removeFileConfirm),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: context.conduitTheme.error,
|
||||
),
|
||||
child: Text(l10n.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || _note == null) return;
|
||||
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) return;
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
try {
|
||||
final fileId = file['id']?.toString();
|
||||
final currentFiles = _note!.data.files ?? [];
|
||||
final updatedFiles = currentFiles
|
||||
.where((f) => f['id']?.toString() != fileId)
|
||||
.toList();
|
||||
|
||||
final data = <String, dynamic>{
|
||||
'content': <String, dynamic>{
|
||||
'json': null,
|
||||
'html': _markdownToHtml(_contentController.text),
|
||||
'md': _contentController.text,
|
||||
},
|
||||
'files': updatedFiles,
|
||||
};
|
||||
|
||||
final json = await api.updateNote(
|
||||
widget.noteId,
|
||||
title: _titleController.text.isEmpty
|
||||
? l10n.untitled
|
||||
: _titleController.text,
|
||||
data: data,
|
||||
);
|
||||
|
||||
final updatedNote = Note.fromJson(json);
|
||||
ref.read(notesListProvider.notifier).updateNote(updatedNote);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_note = updatedNote;
|
||||
_isSaving = false;
|
||||
_hasChanges = false;
|
||||
});
|
||||
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.fileRemoved),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isSaving = false);
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFloatingActionsRow(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -1026,7 +1388,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Dictation button
|
||||
// Voice/Recording button - shows menu if not recording, stops if recording
|
||||
_buildFloatingButton(
|
||||
context,
|
||||
icon: _isRecording
|
||||
@@ -1035,9 +1397,11 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
||||
: Icons.stop_rounded)
|
||||
: (Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic_rounded),
|
||||
color: _isRecording ? theme.error : null,
|
||||
isLoading: false,
|
||||
tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation,
|
||||
onPressed: _toggleDictation,
|
||||
isLoading: _isUploadingAudio,
|
||||
tooltip: _isRecording ? l10n.stopRecording : l10n.voiceOptions,
|
||||
onPressed: _isUploadingAudio
|
||||
? null
|
||||
: (_isRecording ? _toggleDictation : _showRecordingOptions),
|
||||
),
|
||||
|
||||
// AI button
|
||||
|
||||
396
lib/features/notes/widgets/audio_player_dialog.dart
Normal file
396
lib/features/notes/widgets/audio_player_dialog.dart
Normal file
@@ -0,0 +1,396 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show File, Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import '../../../core/services/api_service.dart';
|
||||
import '../../../l10n/app_localizations.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// A dialog for playing audio files.
|
||||
class AudioPlayerDialog extends StatefulWidget {
|
||||
/// The file ID for downloading.
|
||||
final String fileId;
|
||||
|
||||
/// The API service for authenticated requests.
|
||||
final ApiService api;
|
||||
|
||||
/// The file name to display.
|
||||
final String fileName;
|
||||
|
||||
const AudioPlayerDialog({
|
||||
super.key,
|
||||
required this.fileId,
|
||||
required this.api,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
/// Shows the audio player dialog.
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String fileId,
|
||||
required ApiService api,
|
||||
required String fileName,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (context) => AudioPlayerDialog(
|
||||
fileId: fileId,
|
||||
api: api,
|
||||
fileName: fileName,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<AudioPlayerDialog> createState() => _AudioPlayerDialogState();
|
||||
}
|
||||
|
||||
class _AudioPlayerDialogState extends State<AudioPlayerDialog> {
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
|
||||
bool _isPlaying = false;
|
||||
bool _isLoading = true;
|
||||
bool _hasError = false;
|
||||
Duration _position = Duration.zero;
|
||||
Duration _duration = Duration.zero;
|
||||
File? _tempFile;
|
||||
|
||||
StreamSubscription<PlayerState>? _stateSub;
|
||||
StreamSubscription<Duration>? _positionSub;
|
||||
StreamSubscription<Duration?>? _durationSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_setupPlayer();
|
||||
}
|
||||
|
||||
Future<void> _setupPlayer() async {
|
||||
try {
|
||||
// Get file info first to determine the correct extension
|
||||
final fileInfo = await widget.api.getFileInfo(widget.fileId);
|
||||
final filename = fileInfo['filename'] as String? ?? 'audio.m4a';
|
||||
final contentType = (fileInfo['meta'] as Map<String, dynamic>?)?['content_type'] as String?;
|
||||
|
||||
debugPrint('AudioPlayerDialog: filename=$filename, contentType=$contentType');
|
||||
debugPrint('AudioPlayerDialog: fileInfo=$fileInfo');
|
||||
|
||||
// Extract extension from filename
|
||||
final extension = filename.contains('.')
|
||||
? filename.substring(filename.lastIndexOf('.'))
|
||||
: '.m4a';
|
||||
|
||||
// Download the file (requires authentication)
|
||||
// Use timestamp suffix to prevent conflicts if same file opened multiple times
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final tempPath = '${tempDir.path}/audio_${widget.fileId}_$timestamp$extension';
|
||||
_tempFile = File(tempPath);
|
||||
|
||||
// Fetch file content through API (authenticated)
|
||||
final response = await widget.api.dio.get(
|
||||
'/api/v1/files/${widget.fileId}/content',
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData is! List<int>) {
|
||||
throw Exception('Unexpected response type: ${responseData.runtimeType}');
|
||||
}
|
||||
final bytes = responseData;
|
||||
debugPrint('AudioPlayerDialog: Downloaded ${bytes.length} bytes');
|
||||
debugPrint('AudioPlayerDialog: First 20 bytes: ${bytes.take(20).toList()}');
|
||||
debugPrint('AudioPlayerDialog: Response content-type: ${response.headers.value('content-type')}');
|
||||
|
||||
await _tempFile!.writeAsBytes(bytes);
|
||||
debugPrint('AudioPlayerDialog: Saved to $tempPath');
|
||||
|
||||
// Setup player state listeners
|
||||
_stateSub = _player.playerStateStream.listen((state) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isPlaying = state.playing;
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_isPlaying = false;
|
||||
_position = _duration;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
_positionSub = _player.positionStream.listen((pos) {
|
||||
if (!mounted) return;
|
||||
setState(() => _position = pos);
|
||||
});
|
||||
|
||||
_durationSub = _player.durationStream.listen((dur) {
|
||||
if (!mounted) return;
|
||||
if (dur != null) {
|
||||
setState(() {
|
||||
_duration = dur;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Load and play the file
|
||||
await _player.setFilePath(_tempFile!.path);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
|
||||
await _player.play();
|
||||
} catch (e) {
|
||||
debugPrint('AudioPlayerDialog: Error loading audio: $e');
|
||||
// Clean up temp file on error to avoid orphaned files
|
||||
_tempFile?.delete().then((_) {
|
||||
debugPrint('AudioPlayerDialog: Cleaned up temp file after error');
|
||||
}).catchError((e) {
|
||||
debugPrint('AudioPlayerDialog: Failed to clean up temp file after error: $e');
|
||||
});
|
||||
_tempFile = null;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePlayPause() async {
|
||||
if (_isPlaying) {
|
||||
await _player.pause();
|
||||
} else {
|
||||
// If at end, restart from beginning
|
||||
if (_position >= _duration && _duration > Duration.zero) {
|
||||
await _player.seek(Duration.zero);
|
||||
}
|
||||
await _player.play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _seekTo(double value) async {
|
||||
final position = Duration(milliseconds: (value * _duration.inMilliseconds).round());
|
||||
await _player.seek(position);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes.toString().padLeft(2, '0');
|
||||
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stateSub?.cancel();
|
||||
_positionSub?.cancel();
|
||||
_durationSub?.cancel();
|
||||
// AudioPlayer.dispose() is async but Flutter's dispose() is sync.
|
||||
// Fire-and-forget is acceptable here as just_audio handles cleanup internally.
|
||||
unawaited(_player.dispose());
|
||||
// Clean up temp file (fire and forget, log errors for debugging)
|
||||
_tempFile?.delete().then((_) {
|
||||
debugPrint('AudioPlayerDialog: Cleaned up temp file');
|
||||
}).catchError((e) {
|
||||
debugPrint('AudioPlayerDialog: Failed to clean up temp file: $e');
|
||||
});
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
final progress = _duration.inMilliseconds > 0
|
||||
? (_position.inMilliseconds / _duration.inMilliseconds).clamp(0.0, 1.0)
|
||||
: 0.0;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: theme.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Spacing.lg),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.waveform
|
||||
: Icons.audio_file_rounded,
|
||||
color: Colors.orange,
|
||||
size: IconSize.lg,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.fileName,
|
||||
style: AppTypography.bodyMediumStyle.copyWith(
|
||||
color: theme.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
l10n.audioAttachment,
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.xl),
|
||||
|
||||
// Error state
|
||||
if (_hasError)
|
||||
Column(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.exclamationmark_circle
|
||||
: Icons.error_outline,
|
||||
color: theme.error,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
l10n.failedToLoadAudio,
|
||||
style: AppTypography.bodyMediumStyle.copyWith(
|
||||
color: theme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
// Loading state
|
||||
else if (_isLoading)
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation(theme.buttonPrimary),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Spacing.md),
|
||||
Text(
|
||||
l10n.loadingAudio,
|
||||
style: AppTypography.bodyMediumStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
// Player controls
|
||||
else ...[
|
||||
// Progress slider
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 4,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||
activeTrackColor: Colors.orange,
|
||||
inactiveTrackColor: theme.surfaceContainerHighest,
|
||||
thumbColor: Colors.orange,
|
||||
overlayColor: Colors.orange.withValues(alpha: 0.2),
|
||||
),
|
||||
child: Slider(
|
||||
value: progress,
|
||||
onChanged: _seekTo,
|
||||
),
|
||||
),
|
||||
|
||||
// Time display
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Spacing.sm),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(_position),
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(_duration),
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
|
||||
// Play/Pause button
|
||||
GestureDetector(
|
||||
onTap: _togglePlayPause,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.orange,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.orange.withValues(alpha: 0.3),
|
||||
blurRadius: 12,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
_isPlaying
|
||||
? (Platform.isIOS
|
||||
? CupertinoIcons.pause_fill
|
||||
: Icons.pause_rounded)
|
||||
: (Platform.isIOS
|
||||
? CupertinoIcons.play_fill
|
||||
: Icons.play_arrow_rounded),
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
388
lib/features/notes/widgets/audio_recording_overlay.dart
Normal file
388
lib/features/notes/widgets/audio_recording_overlay.dart
Normal file
@@ -0,0 +1,388 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' show FontFeature, ImageFilter;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../services/audio_recording_service.dart';
|
||||
|
||||
/// Full-screen overlay for audio recording in notes.
|
||||
///
|
||||
/// Shows recording visualization, duration, and controls to confirm or cancel.
|
||||
/// The recorded audio is returned as a file for upload to the server.
|
||||
class AudioRecordingOverlay extends StatefulWidget {
|
||||
/// Called when the user cancels recording.
|
||||
final VoidCallback onCancel;
|
||||
|
||||
/// Called when the user confirms the recording with the audio file.
|
||||
final void Function(File audioFile) onConfirm;
|
||||
|
||||
const AudioRecordingOverlay({
|
||||
super.key,
|
||||
required this.onCancel,
|
||||
required this.onConfirm,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioRecordingOverlay> createState() => _AudioRecordingOverlayState();
|
||||
}
|
||||
|
||||
class _AudioRecordingOverlayState extends State<AudioRecordingOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
final AudioRecordingService _recordingService = AudioRecordingService();
|
||||
|
||||
bool _isRecording = false;
|
||||
bool _isProcessing = false;
|
||||
bool _hasError = false;
|
||||
Duration _duration = Duration.zero;
|
||||
double _amplitude = 0.0;
|
||||
|
||||
StreamSubscription<Duration>? _durationSub;
|
||||
StreamSubscription<Amplitude>? _amplitudeSub;
|
||||
|
||||
late AnimationController _pulseController;
|
||||
late Animation<double> _pulseAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Pulse animation for the recording indicator
|
||||
_pulseController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
);
|
||||
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.15).animate(
|
||||
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||
);
|
||||
_pulseController.repeat(reverse: true);
|
||||
|
||||
_startRecording();
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
try {
|
||||
await _recordingService.startRecording();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isRecording = true);
|
||||
HapticFeedback.heavyImpact();
|
||||
|
||||
// Set up stream listeners only if still mounted.
|
||||
// Each callback also checks mounted to handle rapid disposal.
|
||||
if (!mounted) return;
|
||||
|
||||
_durationSub = _recordingService.durationStream.listen((duration) {
|
||||
if (mounted) setState(() => _duration = duration);
|
||||
});
|
||||
|
||||
_amplitudeSub = _recordingService.amplitudeStream.listen((amp) {
|
||||
if (mounted) {
|
||||
// Normalize amplitude to 0-1 range
|
||||
// amp.current is in dBFS, typically -160 to 0
|
||||
// We normalize from -60 to 0 for a reasonable range
|
||||
final normalized = ((amp.current + 60) / 60).clamp(0.0, 1.0);
|
||||
setState(() => _amplitude = normalized);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _hasError = true);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.microphonePermissionDenied),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
// Delay briefly to show the error message
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (mounted) widget.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmRecording() async {
|
||||
if (_isProcessing || !_isRecording || !mounted) return;
|
||||
|
||||
setState(() => _isProcessing = true);
|
||||
HapticFeedback.mediumImpact();
|
||||
|
||||
try {
|
||||
final file = await _recordingService.stopRecording();
|
||||
|
||||
if (file != null && mounted) {
|
||||
widget.onConfirm(file);
|
||||
} else if (mounted) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.recordingFailed),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
widget.onCancel();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.toString()),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
duration: const Duration(seconds: 4),
|
||||
),
|
||||
);
|
||||
widget.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelRecording() async {
|
||||
HapticFeedback.lightImpact();
|
||||
await _recordingService.cancelRecording();
|
||||
if (mounted) widget.onCancel();
|
||||
}
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
final minutes = duration.inMinutes.toString().padLeft(2, '0');
|
||||
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$minutes:$seconds';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_durationSub?.cancel();
|
||||
_amplitudeSub?.cancel();
|
||||
_pulseController.dispose();
|
||||
// Recording service dispose is async but Flutter's dispose() is sync.
|
||||
// Fire-and-forget is acceptable here as the service handles its own cleanup.
|
||||
unawaited(_recordingService.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Material(
|
||||
color: Colors.black.withValues(alpha: 0.92),
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Background blur effect
|
||||
Positioned.fill(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: const SizedBox(),
|
||||
),
|
||||
),
|
||||
// Content
|
||||
Column(
|
||||
children: [
|
||||
// Header with cancel button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(Spacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton.icon(
|
||||
onPressed: _cancelRecording,
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.xmark
|
||||
: Icons.close_rounded,
|
||||
color: Colors.white70,
|
||||
size: IconSize.md,
|
||||
),
|
||||
label: Text(
|
||||
l10n.cancel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: AppTypography.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Recording visualization
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Animated recording indicator
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnimation,
|
||||
builder: (context, child) {
|
||||
final scale = _isRecording
|
||||
? _pulseAnimation.value + (_amplitude * 0.3)
|
||||
: 1.0;
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 140,
|
||||
height: 140,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.red.withValues(alpha: 0.4),
|
||||
Colors.red.withValues(alpha: 0.1),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.3, 0.7, 1.0],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.red.withValues(alpha: 0.2),
|
||||
border: Border.all(
|
||||
color: Colors.red,
|
||||
width: 3,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.red.withValues(alpha: 0.4),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.mic_fill
|
||||
: Icons.mic_rounded,
|
||||
size: 48,
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.xxl),
|
||||
|
||||
// Duration display
|
||||
Text(
|
||||
_formatDuration(_duration),
|
||||
style: theme.textTheme.displayMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 4,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.md),
|
||||
|
||||
// Status text
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(
|
||||
_hasError
|
||||
? l10n.microphonePermissionDenied
|
||||
: (_isRecording
|
||||
? l10n.recordingAudio
|
||||
: l10n.preparingRecording),
|
||||
key: ValueKey(_isRecording),
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white60,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
// Hint text
|
||||
if (_isRecording && !_hasError)
|
||||
Text(
|
||||
l10n.recordingHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.white38,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Confirm button
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Spacing.xl,
|
||||
Spacing.md,
|
||||
Spacing.xl,
|
||||
Spacing.xxl,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 56,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed:
|
||||
_isProcessing || !_isRecording || _hasError
|
||||
? null
|
||||
: _confirmRecording,
|
||||
icon: _isProcessing
|
||||
? SizedBox(
|
||||
width: IconSize.md,
|
||||
height: IconSize.md,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.stop_fill
|
||||
: Icons.stop_rounded,
|
||||
size: IconSize.lg,
|
||||
),
|
||||
label: Text(
|
||||
_isProcessing
|
||||
? l10n.processingRecording
|
||||
: l10n.stopAndSaveRecording,
|
||||
style: const TextStyle(
|
||||
fontSize: AppTypography.bodyLarge,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.red.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
disabledForegroundColor: Colors.white54,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppBorderRadius.button,
|
||||
),
|
||||
),
|
||||
elevation: 4,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
312
lib/features/notes/widgets/note_file_attachment.dart
Normal file
312
lib/features/notes/widgets/note_file_attachment.dart
Normal file
@@ -0,0 +1,312 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:conduit/l10n/app_localizations.dart';
|
||||
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
|
||||
/// A widget that displays a file attachment in a note.
|
||||
///
|
||||
/// Supports different file types with appropriate icons and actions.
|
||||
class NoteFileAttachment extends StatelessWidget {
|
||||
/// The file data from the note.
|
||||
final Map<String, dynamic> file;
|
||||
|
||||
/// Called when the file is tapped.
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Called when the delete button is pressed.
|
||||
final VoidCallback? onDelete;
|
||||
|
||||
/// Whether the file is currently loading.
|
||||
final bool isLoading;
|
||||
|
||||
/// Whether to show the delete button.
|
||||
final bool showDelete;
|
||||
|
||||
const NoteFileAttachment({
|
||||
super.key,
|
||||
required this.file,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
this.isLoading = false,
|
||||
this.showDelete = true,
|
||||
});
|
||||
|
||||
String get _fileName => file['name']?.toString() ?? 'Unknown file';
|
||||
String get _fileType => file['type']?.toString() ?? 'file';
|
||||
int? get _fileSize => file['size'] as int?;
|
||||
|
||||
bool get _isAudio =>
|
||||
_fileType == 'audio' ||
|
||||
_fileName.endsWith('.m4a') ||
|
||||
_fileName.endsWith('.mp3') ||
|
||||
_fileName.endsWith('.wav') ||
|
||||
_fileName.endsWith('.aac');
|
||||
|
||||
bool get _isImage => _fileType == 'image';
|
||||
|
||||
IconData get _icon {
|
||||
if (_isAudio) {
|
||||
return Platform.isIOS
|
||||
? CupertinoIcons.waveform
|
||||
: Icons.audio_file_rounded;
|
||||
}
|
||||
if (_isImage) {
|
||||
return Platform.isIOS ? CupertinoIcons.photo : Icons.image_rounded;
|
||||
}
|
||||
return Platform.isIOS
|
||||
? CupertinoIcons.doc_fill
|
||||
: Icons.insert_drive_file_rounded;
|
||||
}
|
||||
|
||||
Color _iconColor(ConduitThemeExtension theme) {
|
||||
if (_isAudio) return Colors.orange;
|
||||
if (_isImage) return Colors.blue;
|
||||
return theme.textSecondary;
|
||||
}
|
||||
|
||||
String _formatFileSize(int? bytes) {
|
||||
if (bytes == null) return '';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.conduitTheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: Spacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.surfaceContainer.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
border: Border.all(
|
||||
color: theme.cardBorder.withValues(alpha: 0.3),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// File icon
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _iconColor(theme).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||
),
|
||||
child: isLoading
|
||||
? Center(
|
||||
child: SizedBox(
|
||||
width: IconSize.sm,
|
||||
height: IconSize.sm,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
_iconColor(theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
_icon,
|
||||
color: _iconColor(theme),
|
||||
size: IconSize.md,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: Spacing.sm),
|
||||
|
||||
// File info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_fileName,
|
||||
style: AppTypography.bodySmallStyle.copyWith(
|
||||
color: theme.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_isAudio
|
||||
? l10n.audioFileType
|
||||
: _isImage
|
||||
? l10n.imageFileType
|
||||
: l10n.file,
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
if (_fileSize != null) ...[
|
||||
Text(
|
||||
' · ',
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatFileSize(_fileSize),
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Play button for audio
|
||||
if (_isAudio && !isLoading)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.play_circle_fill
|
||||
: Icons.play_circle_filled_rounded,
|
||||
color: _iconColor(theme),
|
||||
size: IconSize.lg,
|
||||
),
|
||||
onPressed: onTap,
|
||||
tooltip: l10n.playAudio,
|
||||
),
|
||||
|
||||
// Delete button
|
||||
if (showDelete && !isLoading)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.xmark_circle_fill
|
||||
: Icons.cancel_rounded,
|
||||
color: theme.textSecondary.withValues(alpha: 0.5),
|
||||
size: IconSize.md,
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onDelete?.call();
|
||||
},
|
||||
tooltip: l10n.removeFile,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A section that displays all file attachments for a note.
|
||||
class NoteFilesSection extends StatelessWidget {
|
||||
/// The list of files attached to the note.
|
||||
final List<Map<String, dynamic>> files;
|
||||
|
||||
/// Called when a file should be played (for audio).
|
||||
final void Function(Map<String, dynamic> file)? onPlayFile;
|
||||
|
||||
/// Called when a file should be deleted.
|
||||
final void Function(Map<String, dynamic> file)? onDeleteFile;
|
||||
|
||||
/// Whether files can be deleted.
|
||||
final bool canDelete;
|
||||
|
||||
const NoteFilesSection({
|
||||
super.key,
|
||||
required this.files,
|
||||
this.onPlayFile,
|
||||
this.onDeleteFile,
|
||||
this.canDelete = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (files.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final theme = context.conduitTheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section header
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: Spacing.xs,
|
||||
bottom: Spacing.xs,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Platform.isIOS
|
||||
? CupertinoIcons.paperclip
|
||||
: Icons.attach_file_rounded,
|
||||
size: IconSize.sm,
|
||||
color: theme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Text(
|
||||
l10n.attachments,
|
||||
style: AppTypography.labelStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.xs),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.xs,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||
),
|
||||
child: Text(
|
||||
'${files.length}',
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: theme.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Files list
|
||||
...files.map(
|
||||
(file) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: Spacing.xs),
|
||||
child: NoteFileAttachment(
|
||||
file: file,
|
||||
showDelete: canDelete,
|
||||
onTap: () => onPlayFile?.call(file),
|
||||
onDelete: () => onDeleteFile?.call(file),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user