MobilApp/lib/widgets/map_edit_tools/note_audio_widget.dart

472 lines
15 KiB
Dart
Raw Permalink Normal View History

// Hangjegyzet widget a MapFeatureSaveSheet-ben:
// - Felvétel gomb animált mikrofonnal
// - Felvett klipek listája play/pause/delete gombokkal
// - Haladásjelző csúszka lejátszás közben
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../models/note_item_audio.dart';
import '../../services/note_audio_service.dart';
class NoteAudioWidget extends StatefulWidget {
final int? noteItemId;
const NoteAudioWidget({super.key, required this.noteItemId});
@override
State<NoteAudioWidget> createState() => _NoteAudioWidgetState();
}
class _NoteAudioWidgetState extends State<NoteAudioWidget> {
List<NoteItemAudio> _audios = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (widget.noteItemId == null) return;
final list = await NoteAudioService.to.loadAudios(widget.noteItemId!);
if (mounted) setState(() => _audios = list);
}
@override
Widget build(BuildContext context) {
if (widget.noteItemId == null) {
return const _DisabledAudio();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Fejléc sor
Row(children: [
Text('Hangjegyzetek',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
const SizedBox(width: 6),
if (_audios.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(10),
),
child: Text('${_audios.length}',
style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.w600)),
),
]),
const SizedBox(height: 10),
// Felvétel gomb
_RecordButton(
noteItemId: widget.noteItemId!,
onRecorded: (audio) {
setState(() => _audios.add(audio));
},
),
// Felvett klipek listája
if (_audios.isNotEmpty) ...[
const SizedBox(height: 10),
..._audios.map((audio) => _AudioClipTile(
audio: audio,
onDelete: () async {
await NoteAudioService.to.deleteAudio(audio);
setState(() => _audios.removeWhere((a) => a.id == audio.id));
},
)),
],
],
);
}
}
// ─── Felvétel gomb ───────────────────────────────────────────────────────────
class _RecordButton extends StatelessWidget {
final int noteItemId;
final ValueChanged<NoteItemAudio> onRecorded;
const _RecordButton({
required this.noteItemId,
required this.onRecorded,
});
@override
Widget build(BuildContext context) {
final svc = NoteAudioService.to;
return Obx(() {
final isRecording = svc.recordState.value == AudioRecordState.recording;
final durationMs = svc.recordDurationMs.value;
return Row(children: [
// Mikrofon gomb
GestureDetector(
onTap: () => isRecording ? _stopRecording(svc) : _startRecording(svc),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 52,
height: 52,
decoration: BoxDecoration(
color: isRecording
? Colors.red
: Theme.of(context).colorScheme.primaryContainer,
shape: BoxShape.circle,
boxShadow: isRecording
? [
BoxShadow(
color: Colors.red.withOpacity(0.4),
blurRadius: 12,
spreadRadius: 2,
)
]
: null,
),
child: Icon(
isRecording ? Icons.stop : Icons.mic,
color: isRecording
? Colors.white
: Theme.of(context).colorScheme.primary,
size: 24,
),
),
),
const SizedBox(width: 12),
Expanded(
child: isRecording
// Felvétel közben: időmérő + animált hullám
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(children: [
_PulsingDot(),
const SizedBox(width: 8),
Text(
'Felvétel: ${svc.formatMs(durationMs)}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.red,
fontSize: 14,
),
),
]),
const SizedBox(height: 4),
Text(
'Megállításhoz nyomd meg a gombot',
style:
TextStyle(fontSize: 11, color: Colors.grey.shade500),
),
],
)
// Alap állapot
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Hangjegyzet rögzítése',
style: TextStyle(
fontWeight: FontWeight.w500, fontSize: 14)),
Text('Nyomd meg a mikrofon gombot',
style: TextStyle(
fontSize: 11, color: Colors.grey.shade500)),
],
),
),
// Mégse — csak felvétel közben
if (isRecording)
TextButton(
onPressed: () async {
await svc.cancelRecording();
},
child: const Text('Mégse', style: TextStyle(color: Colors.grey)),
),
]);
});
}
Future<void> _startRecording(NoteAudioService svc) async {
await svc.startRecording(noteItemId);
}
Future<void> _stopRecording(NoteAudioService svc) async {
final audio = await svc.stopRecording(noteItemId);
if (audio != null) onRecorded(audio);
}
}
// ─── Egy felvett klip sor ────────────────────────────────────────────────────
class _AudioClipTile extends StatelessWidget {
final NoteItemAudio audio;
final VoidCallback onDelete;
const _AudioClipTile({
required this.audio,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final svc = NoteAudioService.to;
return Obx(() {
final isThisPlaying = svc.playingAudioId.value == audio.id;
final pState = svc.playState.value;
final posMs = svc.playPositionMs.value;
final totalMs = audio.durationSeconds * 1000;
// Haladás 0.0 1.0
final progress = (isThisPlaying && totalMs > 0)
? (posMs / totalMs).clamp(0.0, 1.0)
: 0.0;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isThisPlaying
? Theme.of(context).colorScheme.primaryContainer.withOpacity(0.4)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
border: isThisPlaying
? Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.4))
: null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(children: [
// Play / Pause gomb
GestureDetector(
onTap: () => svc.playAudio(audio),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
shape: BoxShape.circle,
),
child: Icon(
isThisPlaying && pState == AudioPlayState.playing
? Icons.pause
: Icons.play_arrow,
color: Colors.white,
size: 20,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Felirat vagy dátum
if (audio.caption.isNotEmpty)
Text(audio.caption,
style: const TextStyle(
fontSize: 13, fontWeight: FontWeight.w500),
maxLines: 1,
overflow: TextOverflow.ellipsis)
else
Text(
_formatDate(audio.createdAt),
style: TextStyle(
fontSize: 12, color: Colors.grey.shade600),
),
const SizedBox(height: 4),
// Haladásjelző csúszka
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: isThisPlaying && totalMs > 0
? (posMs / totalMs).clamp(0.0, 1.0)
: 0.0,
minHeight: 3,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 2),
// Időtartam
Text(
isThisPlaying
? '${svc.formatMs(posMs)} / '
'${audio.durationFormatted}'
: audio.durationFormatted,
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade500,
fontFeatures: const [FontFeature.tabularFigures()]),
),
],
),
),
const SizedBox(width: 4),
// Menü: felirat / törlés
PopupMenuButton<_AudioAction>(
icon: Icon(Icons.more_vert,
size: 18, color: Colors.grey.shade500),
onSelected: (a) => _onAction(a, context),
itemBuilder: (_) => [
const PopupMenuItem(
value: _AudioAction.caption,
child: ListTile(
leading: Icon(Icons.edit_outlined),
title: Text('Felirat'),
dense: true,
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: _AudioAction.delete,
child: ListTile(
leading: Icon(Icons.delete_outline, color: Colors.red),
title:
Text('Törlés', style: TextStyle(color: Colors.red)),
dense: true,
),
),
],
),
]),
],
),
);
});
}
void _onAction(_AudioAction action, BuildContext context) {
switch (action) {
case _AudioAction.caption:
_editCaption();
case _AudioAction.delete:
_confirmDelete();
}
}
Future<void> _editCaption() async {
final ctrl = TextEditingController(text: audio.caption);
await Get.dialog(AlertDialog(
title: const Text('Felirat'),
content: TextField(
controller: ctrl,
decoration: const InputDecoration(hintText: 'Hangjegyzet leírása...'),
autofocus: true,
),
actions: [
TextButton(onPressed: Get.back, child: const Text('Mégse')),
FilledButton(
onPressed: () async {
Get.back();
await NoteAudioService.to.updateCaption(audio, ctrl.text.trim());
},
child: const Text('Mentés'),
),
],
));
}
Future<void> _confirmDelete() async {
final ok = await Get.dialog<bool>(AlertDialog(
title: const Text('Hangjegyzet törlése'),
content: const Text('Ez a felvétel véglegesen törlődik.'),
actions: [
TextButton(onPressed: Get.back, child: const Text('Mégse')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Get.back(result: true),
child: const Text('Törlés'),
),
],
));
if (ok == true) onDelete();
}
String _formatDate(DateTime dt) =>
'${dt.year}.${dt.month.toString().padLeft(2, '0')}.'
'${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:'
'${dt.minute.toString().padLeft(2, '0')}';
}
// ─── Segéd widgetek ──────────────────────────────────────────────────────────
class _DisabledAudio extends StatelessWidget {
const _DisabledAudio();
@override
Widget build(BuildContext context) => Container(
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: Text('Mentés után adható hangjegyzet',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500)),
);
}
class _PulsingDot extends StatefulWidget {
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
late Animation<double> _anim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 600))
..repeat(reverse: true);
_anim = Tween(begin: 0.3, end: 1.0).animate(_ctrl);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => AnimatedBuilder(
animation: _anim,
builder: (_, __) => Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.red.withOpacity(_anim.value),
shape: BoxShape.circle,
),
),
);
}
enum _AudioAction { caption, delete }