472 lines
15 KiB
Dart
472 lines
15 KiB
Dart
|
|
// 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 }
|