866 lines
26 KiB
Dart
866 lines
26 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:media_kit/media_kit.dart';
|
|
import 'package:media_kit_video/media_kit_video.dart';
|
|
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
|
import 'package:trainhub_flutter/domain/entities/analysis_session.dart';
|
|
import 'package:trainhub_flutter/domain/entities/annotation.dart';
|
|
import 'package:trainhub_flutter/presentation/analysis/analysis_controller.dart';
|
|
|
|
class AnalysisViewer extends ConsumerStatefulWidget {
|
|
final AnalysisSessionEntity session;
|
|
final List<AnnotationEntity> annotations;
|
|
final VoidCallback onClose;
|
|
|
|
const AnalysisViewer({
|
|
super.key,
|
|
required this.session,
|
|
required this.annotations,
|
|
required this.onClose,
|
|
});
|
|
|
|
@override
|
|
ConsumerState<AnalysisViewer> createState() => _AnalysisViewerState();
|
|
}
|
|
|
|
class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
|
|
late final Player _player;
|
|
late final VideoController _videoController;
|
|
|
|
bool _isPlaying = false;
|
|
double _currentPosition = 0.0;
|
|
double _totalDuration = 1.0;
|
|
bool _isInitialized = false;
|
|
bool _hasError = false;
|
|
|
|
double? _inPoint;
|
|
double? _outPoint;
|
|
bool _isLooping = false;
|
|
|
|
late StreamSubscription<Duration> _positionSubscription;
|
|
late StreamSubscription<Duration> _durationSubscription;
|
|
late StreamSubscription<bool> _playingSubscription;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_player = Player();
|
|
_videoController = VideoController(
|
|
_player,
|
|
configuration: const VideoControllerConfiguration(
|
|
enableHardwareAcceleration: false,
|
|
),
|
|
);
|
|
_setupStreams();
|
|
_initializeVideo();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant AnalysisViewer oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.session.videoPath != widget.session.videoPath) {
|
|
_initializeVideo();
|
|
}
|
|
}
|
|
|
|
void _setupStreams() {
|
|
_positionSubscription = _player.stream.position.listen((position) {
|
|
final posSeconds = position.inMilliseconds / 1000.0;
|
|
if (_isLooping && _outPoint != null && posSeconds >= _outPoint!) {
|
|
_seekTo(_inPoint ?? 0.0);
|
|
return;
|
|
}
|
|
setState(() => _currentPosition = posSeconds);
|
|
});
|
|
|
|
_durationSubscription = _player.stream.duration.listen((duration) {
|
|
final total = duration.inMilliseconds / 1000.0;
|
|
setState(() => _totalDuration = total > 0 ? total : 1.0);
|
|
});
|
|
|
|
_playingSubscription = _player.stream.playing.listen((isPlaying) {
|
|
setState(() => _isPlaying = isPlaying);
|
|
});
|
|
|
|
_player.stream.completed.listen((_) {
|
|
setState(() => _isPlaying = false);
|
|
});
|
|
|
|
_player.stream.error.listen((error) {
|
|
debugPrint('Video player error: $error');
|
|
if (mounted) setState(() => _hasError = true);
|
|
});
|
|
}
|
|
|
|
Future<void> _initializeVideo() async {
|
|
final path = widget.session.videoPath;
|
|
if (path == null) return;
|
|
if (mounted) setState(() { _isInitialized = false; _hasError = false; });
|
|
await _player.open(Media(path), play: false);
|
|
// Seek to zero so MPV renders the first frame before the user presses play.
|
|
await _player.seek(Duration.zero);
|
|
if (mounted) setState(() => _isInitialized = true);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_positionSubscription.cancel();
|
|
_durationSubscription.cancel();
|
|
_playingSubscription.cancel();
|
|
_player.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _togglePlay() {
|
|
if (_isPlaying) {
|
|
_player.pause();
|
|
} else {
|
|
if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) {
|
|
_seekTo(_inPoint ?? 0.0);
|
|
}
|
|
_player.play();
|
|
}
|
|
}
|
|
|
|
void _seekTo(double value) {
|
|
_player.seek(Duration(milliseconds: (value * 1000).toInt()));
|
|
}
|
|
|
|
void _setInPoint() {
|
|
setState(() {
|
|
_inPoint = _currentPosition;
|
|
if (_outPoint != null && _inPoint! > _outPoint!) {
|
|
_outPoint = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _setOutPoint() {
|
|
setState(() {
|
|
_outPoint = _currentPosition;
|
|
if (_inPoint != null && _outPoint! < _inPoint!) {
|
|
_inPoint = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
void _clearPoints() {
|
|
setState(() {
|
|
_inPoint = null;
|
|
_outPoint = null;
|
|
_isLooping = false;
|
|
});
|
|
}
|
|
|
|
void _toggleLoop() {
|
|
setState(() => _isLooping = !_isLooping);
|
|
}
|
|
|
|
void _playRange(double start, double end) {
|
|
setState(() {
|
|
_inPoint = start;
|
|
_outPoint = end;
|
|
_isLooping = true;
|
|
});
|
|
_seekTo(start);
|
|
_player.play();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final controller = ref.read(analysisControllerProvider.notifier);
|
|
|
|
return Column(
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: Container(
|
|
color: Colors.black,
|
|
alignment: Alignment.center,
|
|
child: _hasError
|
|
? const Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.videocam_off, color: Colors.grey, size: 40),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
'Failed to load video',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
: _isInitialized
|
|
? Stack(
|
|
alignment: Alignment.bottomCenter,
|
|
children: [
|
|
Video(
|
|
controller: _videoController,
|
|
controls: NoVideoControls,
|
|
fit: BoxFit.contain,
|
|
),
|
|
_buildTimelineControls(),
|
|
],
|
|
)
|
|
: const Center(child: CircularProgressIndicator()),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16.0,
|
|
vertical: 8,
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Annotations',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
ElevatedButton.icon(
|
|
onPressed: () => _showAddAnnotationDialog(controller),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Add from Selection'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
Expanded(
|
|
child: widget.annotations.isEmpty
|
|
? const Center(
|
|
child: Text(
|
|
'No annotations yet.\nSet IN/OUT points and click "Add from Selection".',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
)
|
|
: ListView.separated(
|
|
separatorBuilder: (context, index) =>
|
|
const Divider(height: 1),
|
|
itemCount: widget.annotations.length,
|
|
itemBuilder: (context, index) {
|
|
final note = widget.annotations[index];
|
|
final bool isActive =
|
|
_currentPosition >= note.startTime &&
|
|
_currentPosition <= note.endTime;
|
|
|
|
return _AnnotationListItem(
|
|
annotation: note,
|
|
isActive: isActive,
|
|
onPlay: () =>
|
|
_playRange(note.startTime, note.endTime),
|
|
onEdit: () =>
|
|
_showEditAnnotationDialog(controller, note),
|
|
onDelete: () => controller.deleteAnnotation(note.id),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: TextButton(
|
|
onPressed: widget.onClose,
|
|
child: const Text('Back'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildTimelineControls() {
|
|
return Container(
|
|
color: Colors.black.withValues(alpha: 0.8),
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildAnnotationTimeline(),
|
|
_buildSeekSlider(),
|
|
_buildControlsRow(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAnnotationTimeline() {
|
|
return SizedBox(
|
|
height: 20,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return Stack(
|
|
children: [
|
|
...widget.annotations.map((note) {
|
|
final left =
|
|
(note.startTime / _totalDuration) * constraints.maxWidth;
|
|
final width =
|
|
((note.endTime - note.startTime) / _totalDuration) *
|
|
constraints.maxWidth;
|
|
return Positioned(
|
|
left: left,
|
|
width: width.clamp(2.0, constraints.maxWidth),
|
|
top: 4,
|
|
bottom: 4,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: _parseColor(
|
|
note.color ?? 'grey',
|
|
).withValues(alpha: 0.6),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
if (_inPoint != null)
|
|
Positioned(
|
|
left: (_inPoint! / _totalDuration) * constraints.maxWidth,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Container(width: 2, color: Colors.green),
|
|
),
|
|
if (_outPoint != null)
|
|
Positioned(
|
|
left: (_outPoint! / _totalDuration) * constraints.maxWidth,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Container(width: 2, color: Colors.red),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSeekSlider() {
|
|
return SliderTheme(
|
|
data: SliderTheme.of(context).copyWith(
|
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
|
|
trackHeight: 2,
|
|
),
|
|
child: Slider(
|
|
value: _currentPosition.clamp(0.0, _totalDuration),
|
|
min: 0.0,
|
|
max: _totalDuration,
|
|
onChanged: _seekTo,
|
|
activeColor: Theme.of(context).colorScheme.primary,
|
|
inactiveColor: Colors.grey,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildControlsRow() {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Text(
|
|
_formatDuration(_currentPosition),
|
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
),
|
|
const SizedBox(width: 16),
|
|
IconButton(
|
|
icon: const Icon(Icons.keyboard_arrow_left, color: Colors.white),
|
|
onPressed: () => _seekTo(_currentPosition - 1),
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
_isPlaying ? Icons.pause : Icons.play_arrow,
|
|
color: Colors.white,
|
|
),
|
|
onPressed: _togglePlay,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.keyboard_arrow_right, color: Colors.white),
|
|
onPressed: () => _seekTo(_currentPosition + 1),
|
|
),
|
|
const SizedBox(width: 16),
|
|
IconButton(
|
|
icon: const Icon(Icons.login, color: Colors.green),
|
|
tooltip: 'Set IN Point',
|
|
onPressed: _setInPoint,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.logout, color: Colors.red),
|
|
tooltip: 'Set OUT Point',
|
|
onPressed: _setOutPoint,
|
|
),
|
|
IconButton(
|
|
icon: Icon(
|
|
Icons.loop,
|
|
color: _isLooping
|
|
? Theme.of(context).colorScheme.primary
|
|
: Colors.white,
|
|
),
|
|
tooltip: 'Toggle Loop',
|
|
onPressed: _toggleLoop,
|
|
),
|
|
if (_inPoint != null || _outPoint != null)
|
|
IconButton(
|
|
icon: const Icon(Icons.cancel_outlined, color: Colors.white),
|
|
tooltip: 'Clear Points',
|
|
onPressed: _clearPoints,
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
_formatDuration(_totalDuration),
|
|
style: const TextStyle(color: Colors.white, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showAddAnnotationDialog(AnalysisController controller) {
|
|
final double start = _inPoint ?? _currentPosition;
|
|
double end = _outPoint ?? (_currentPosition + 5.0);
|
|
if (end > _totalDuration) end = _totalDuration;
|
|
final videoPath = widget.session.videoPath ?? '';
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => _AnnotationDialog(
|
|
initialName: '',
|
|
initialDescription: '',
|
|
initialColor: 'red',
|
|
startTime: start,
|
|
endTime: end,
|
|
videoPath: videoPath,
|
|
onSave: ({
|
|
required String name,
|
|
required String description,
|
|
required String color,
|
|
required bool createExercise,
|
|
required String exerciseName,
|
|
required String exerciseInstructions,
|
|
}) async {
|
|
await controller.addAnnotation(
|
|
name: name,
|
|
description: description,
|
|
startTime: start,
|
|
endTime: end,
|
|
color: color,
|
|
);
|
|
if (createExercise && exerciseName.isNotEmpty) {
|
|
await controller.createExerciseFromAnnotation(
|
|
name: exerciseName,
|
|
instructions: exerciseInstructions,
|
|
videoPath: videoPath,
|
|
startTime: start,
|
|
endTime: end,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showEditAnnotationDialog(
|
|
AnalysisController controller,
|
|
AnnotationEntity annotation,
|
|
) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (dialogContext) => _AnnotationDialog(
|
|
initialName: annotation.name ?? '',
|
|
initialDescription: annotation.description ?? '',
|
|
initialColor: annotation.color ?? 'red',
|
|
startTime: annotation.startTime,
|
|
endTime: annotation.endTime,
|
|
videoPath: widget.session.videoPath ?? '',
|
|
isEditing: true,
|
|
onSave: ({
|
|
required String name,
|
|
required String description,
|
|
required String color,
|
|
required bool createExercise,
|
|
required String exerciseName,
|
|
required String exerciseInstructions,
|
|
}) async {
|
|
await controller.updateAnnotation(
|
|
annotation.copyWith(
|
|
name: name,
|
|
description: description,
|
|
color: color,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDuration(double seconds) {
|
|
final duration = Duration(milliseconds: (seconds * 1000).toInt());
|
|
final mins = duration.inMinutes;
|
|
final secs = duration.inSeconds % 60;
|
|
return '$mins:${secs.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
Color _parseColor(String colorName) {
|
|
switch (colorName.toLowerCase()) {
|
|
case 'red':
|
|
return Colors.red;
|
|
case 'green':
|
|
return Colors.green;
|
|
case 'blue':
|
|
return Colors.blue;
|
|
case 'yellow':
|
|
return Colors.yellow;
|
|
case 'orange':
|
|
return Colors.orange;
|
|
case 'purple':
|
|
return Colors.purple;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
}
|
|
|
|
class _AnnotationListItem extends StatelessWidget {
|
|
final AnnotationEntity annotation;
|
|
final bool isActive;
|
|
final VoidCallback onPlay;
|
|
final VoidCallback onEdit;
|
|
final VoidCallback onDelete;
|
|
|
|
const _AnnotationListItem({
|
|
required this.annotation,
|
|
required this.isActive,
|
|
required this.onPlay,
|
|
required this.onEdit,
|
|
required this.onDelete,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: isActive
|
|
? Theme.of(context).colorScheme.primaryContainer.withValues(alpha: 0.2)
|
|
: null,
|
|
child: ListTile(
|
|
leading: Icon(
|
|
Icons.label,
|
|
color: _parseColor(annotation.color ?? 'grey'),
|
|
),
|
|
title: Text(annotation.name ?? 'Untitled'),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${_formatDuration(annotation.startTime)} - ${_formatDuration(annotation.endTime)}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
if (annotation.description != null &&
|
|
annotation.description!.isNotEmpty)
|
|
Text(
|
|
annotation.description!,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Colors.grey,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.play_circle_outline),
|
|
onPressed: onPlay,
|
|
tooltip: 'Play Range',
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.edit_outlined),
|
|
onPressed: onEdit,
|
|
tooltip: 'Edit',
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
onPressed: onDelete,
|
|
tooltip: 'Delete',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDuration(double seconds) {
|
|
final duration = Duration(milliseconds: (seconds * 1000).toInt());
|
|
final mins = duration.inMinutes;
|
|
final secs = duration.inSeconds % 60;
|
|
return '$mins:${secs.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
Color _parseColor(String colorName) {
|
|
switch (colorName.toLowerCase()) {
|
|
case 'red':
|
|
return Colors.red;
|
|
case 'green':
|
|
return Colors.green;
|
|
case 'blue':
|
|
return Colors.blue;
|
|
case 'yellow':
|
|
return Colors.yellow;
|
|
case 'orange':
|
|
return Colors.orange;
|
|
case 'purple':
|
|
return Colors.purple;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
}
|
|
|
|
typedef AnnotationSaveCallback = Future<void> Function({
|
|
required String name,
|
|
required String description,
|
|
required String color,
|
|
required bool createExercise,
|
|
required String exerciseName,
|
|
required String exerciseInstructions,
|
|
});
|
|
|
|
class _AnnotationDialog extends StatefulWidget {
|
|
final String initialName;
|
|
final String initialDescription;
|
|
final String initialColor;
|
|
final double startTime;
|
|
final double endTime;
|
|
final String videoPath;
|
|
final bool isEditing;
|
|
final AnnotationSaveCallback onSave;
|
|
|
|
const _AnnotationDialog({
|
|
required this.initialName,
|
|
required this.initialDescription,
|
|
required this.initialColor,
|
|
required this.startTime,
|
|
required this.endTime,
|
|
required this.videoPath,
|
|
required this.onSave,
|
|
this.isEditing = false,
|
|
});
|
|
|
|
@override
|
|
State<_AnnotationDialog> createState() => _AnnotationDialogState();
|
|
}
|
|
|
|
class _AnnotationDialogState extends State<_AnnotationDialog> {
|
|
late final TextEditingController _nameController;
|
|
late final TextEditingController _descriptionController;
|
|
late final TextEditingController _exerciseNameController;
|
|
late final TextEditingController _exerciseInstructionsController;
|
|
late String _selectedColor;
|
|
bool _createExercise = false;
|
|
|
|
static const List<String> _colorOptions = [
|
|
'red',
|
|
'green',
|
|
'blue',
|
|
'yellow',
|
|
'orange',
|
|
'purple',
|
|
'grey',
|
|
];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_nameController = TextEditingController(text: widget.initialName);
|
|
_descriptionController = TextEditingController(
|
|
text: widget.initialDescription,
|
|
);
|
|
_exerciseNameController = TextEditingController(text: widget.initialName);
|
|
_exerciseInstructionsController = TextEditingController();
|
|
_selectedColor = widget.initialColor;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_descriptionController.dispose();
|
|
_exerciseNameController.dispose();
|
|
_exerciseInstructionsController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: Text(widget.isEditing ? 'Edit Annotation' : 'New Annotation'),
|
|
content: SizedBox(
|
|
width: UIConstants.dialogWidth,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${_formatDuration(widget.startTime)} — ${_formatDuration(widget.endTime)}',
|
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
),
|
|
const SizedBox(height: UIConstants.spacing12),
|
|
TextField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Name *',
|
|
hintText: 'e.g. Knee cave on squat',
|
|
),
|
|
autofocus: true,
|
|
),
|
|
const SizedBox(height: UIConstants.spacing12),
|
|
TextField(
|
|
controller: _descriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
hintText: 'Additional notes...',
|
|
),
|
|
maxLines: 2,
|
|
),
|
|
const SizedBox(height: UIConstants.spacing12),
|
|
const Text(
|
|
'Color',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
const SizedBox(height: 6),
|
|
_buildColorPicker(),
|
|
if (!widget.isEditing) ...[
|
|
const SizedBox(height: UIConstants.spacing16),
|
|
const Divider(),
|
|
const SizedBox(height: UIConstants.spacing8),
|
|
CheckboxListTile(
|
|
value: _createExercise,
|
|
onChanged: (value) =>
|
|
setState(() => _createExercise = value ?? false),
|
|
title: const Text('Also create an exercise from this clip'),
|
|
subtitle: const Text(
|
|
'Saves this video segment as an exercise',
|
|
style: TextStyle(fontSize: 11),
|
|
),
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
if (_createExercise) ...[
|
|
const SizedBox(height: UIConstants.spacing8),
|
|
TextField(
|
|
controller: _exerciseNameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Exercise Name *',
|
|
),
|
|
),
|
|
const SizedBox(height: UIConstants.spacing8),
|
|
TextField(
|
|
controller: _exerciseInstructionsController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Exercise Instructions',
|
|
),
|
|
maxLines: 2,
|
|
),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: _handleSave,
|
|
child: Text(widget.isEditing ? 'Update' : 'Save'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildColorPicker() {
|
|
return Wrap(
|
|
spacing: 8,
|
|
children: _colorOptions.map((colorName) {
|
|
final color = _colorFromName(colorName);
|
|
final isSelected = _selectedColor == colorName;
|
|
return GestureDetector(
|
|
onTap: () => setState(() => _selectedColor = colorName),
|
|
child: Container(
|
|
width: 28,
|
|
height: 28,
|
|
decoration: BoxDecoration(
|
|
color: color,
|
|
shape: BoxShape.circle,
|
|
border: isSelected
|
|
? Border.all(color: Colors.white, width: 2)
|
|
: null,
|
|
boxShadow: isSelected
|
|
? [
|
|
BoxShadow(
|
|
color: color.withValues(alpha: 0.5),
|
|
blurRadius: 4,
|
|
spreadRadius: 1,
|
|
),
|
|
]
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleSave() async {
|
|
if (_nameController.text.isEmpty) return;
|
|
Navigator.pop(context);
|
|
await widget.onSave(
|
|
name: _nameController.text,
|
|
description: _descriptionController.text,
|
|
color: _selectedColor,
|
|
createExercise: _createExercise,
|
|
exerciseName: _exerciseNameController.text,
|
|
exerciseInstructions: _exerciseInstructionsController.text,
|
|
);
|
|
}
|
|
|
|
String _formatDuration(double seconds) {
|
|
final duration = Duration(milliseconds: (seconds * 1000).toInt());
|
|
final mins = duration.inMinutes;
|
|
final secs = duration.inSeconds % 60;
|
|
return '$mins:${secs.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
Color _colorFromName(String name) {
|
|
switch (name) {
|
|
case 'red':
|
|
return Colors.red;
|
|
case 'green':
|
|
return Colors.green;
|
|
case 'blue':
|
|
return Colors.blue;
|
|
case 'yellow':
|
|
return Colors.yellow;
|
|
case 'orange':
|
|
return Colors.orange;
|
|
case 'purple':
|
|
return Colors.purple;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
}
|