MobilApp/lib/widgets/map_edit_tools/note_audio_widget.dart

472 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 }