feat(notes): Add audio recording and playback features

This commit is contained in:
cogwheel
2026-01-12 21:48:43 +05:30
parent a7e5bb3704
commit a371556a1c
73 changed files with 2296 additions and 125 deletions

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

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

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