Initial commit

This commit is contained in:
Kazimierz Ciołek
2026-02-19 02:49:29 +01:00
commit 782986a632
148 changed files with 29230 additions and 0 deletions

View File

@@ -0,0 +1,87 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:trainhub_flutter/injection.dart';
import 'package:trainhub_flutter/domain/repositories/analysis_repository.dart';
import 'package:trainhub_flutter/presentation/analysis/analysis_state.dart';
part 'analysis_controller.g.dart';
@riverpod
class AnalysisController extends _$AnalysisController {
late final AnalysisRepository _repo;
@override
Future<AnalysisState> build() async {
_repo = getIt<AnalysisRepository>();
final sessions = await _repo.getAllSessions();
return AnalysisState(sessions: sessions);
}
Future<void> createSession(String name, String videoPath) async {
final session = await _repo.createSession(name, videoPath);
final sessions = await _repo.getAllSessions();
final annotations = await _repo.getAnnotations(session.id);
state = AsyncValue.data(
AnalysisState(
sessions: sessions,
activeSession: session,
annotations: annotations,
),
);
}
Future<void> loadSession(String id) async {
final session = await _repo.getSession(id);
if (session == null) return;
final annotations = await _repo.getAnnotations(id);
final current = state.valueOrNull ?? const AnalysisState();
state = AsyncValue.data(
current.copyWith(
activeSession: session,
annotations: annotations,
),
);
}
Future<void> deleteSession(String id) async {
await _repo.deleteSession(id);
final sessions = await _repo.getAllSessions();
final current = state.valueOrNull ?? const AnalysisState();
state = AsyncValue.data(
current.copyWith(
sessions: sessions,
activeSession:
current.activeSession?.id == id ? null : current.activeSession,
annotations: current.activeSession?.id == id ? [] : current.annotations,
),
);
}
Future<void> addAnnotation({
required String name,
required String description,
required double startTime,
required double endTime,
required String color,
}) async {
final current = state.valueOrNull;
if (current?.activeSession == null) return;
await _repo.addAnnotation(
sessionId: current!.activeSession!.id,
name: name,
description: description,
startTime: startTime,
endTime: endTime,
color: color,
);
final annotations = await _repo.getAnnotations(current.activeSession!.id);
state = AsyncValue.data(current.copyWith(annotations: annotations));
}
Future<void> deleteAnnotation(String id) async {
await _repo.deleteAnnotation(id);
final current = state.valueOrNull;
if (current?.activeSession == null) return;
final annotations = await _repo.getAnnotations(current!.activeSession!.id);
state = AsyncValue.data(current.copyWith(annotations: annotations));
}
}

View File

@@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'analysis_controller.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$analysisControllerHash() =>
r'855d4ab55b8dc398e10c19d0ed245a60f104feed';
/// See also [AnalysisController].
@ProviderFor(AnalysisController)
final analysisControllerProvider =
AutoDisposeAsyncNotifierProvider<
AnalysisController,
AnalysisState
>.internal(
AnalysisController.new,
name: r'analysisControllerProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$analysisControllerHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$AnalysisController = AutoDisposeAsyncNotifier<AnalysisState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -0,0 +1,232 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:trainhub_flutter/core/theme/app_colors.dart';
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
import 'package:trainhub_flutter/presentation/analysis/analysis_controller.dart';
import 'package:trainhub_flutter/presentation/analysis/widgets/analysis_session_list.dart';
import 'package:trainhub_flutter/presentation/analysis/widgets/analysis_viewer.dart';
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
@RoutePage()
class AnalysisPage extends ConsumerWidget {
const AnalysisPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(analysisControllerProvider);
final controller = ref.read(analysisControllerProvider.notifier);
return state.when(
data: (data) {
if (data.activeSession != null) {
return AnalysisViewer(
session: data.activeSession!,
annotations: data.annotations,
onClose: () {
ref.invalidate(analysisControllerProvider);
},
);
}
return Column(
children: [
// Header toolbar
Container(
padding: const EdgeInsets.symmetric(
horizontal: UIConstants.spacing24,
vertical: UIConstants.spacing16,
),
decoration: const BoxDecoration(
color: AppColors.surfaceContainer,
border: Border(
bottom: BorderSide(color: AppColors.border),
),
),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.purpleMuted,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.video_library,
color: AppColors.purple,
size: 20,
),
),
const SizedBox(width: UIConstants.spacing12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Video Analysis',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
Text(
'Analyze and annotate your training videos',
style: TextStyle(
color: AppColors.textMuted,
fontSize: 12,
),
),
],
),
const Spacer(),
FilledButton.icon(
onPressed: () =>
_showAddSessionDialog(context, controller),
icon: const Icon(Icons.add, size: 18),
label: const Text('New Session'),
),
],
),
),
// Session list
Expanded(
child: data.sessions.isEmpty
? AppEmptyState(
icon: Icons.video_library_outlined,
title: 'No analysis sessions yet',
subtitle:
'Create a session to analyze your training videos',
actionLabel: 'Create Session',
onAction: () =>
_showAddSessionDialog(context, controller),
)
: AnalysisSessionList(
sessions: data.sessions,
onSessionSelected: (session) =>
controller.loadSession(session.id),
onDeleteSession: (session) =>
controller.deleteSession(session.id),
),
),
],
);
},
error: (e, s) => Center(
child: Text(
'Error: $e',
style: const TextStyle(color: AppColors.destructive),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
);
}
void _showAddSessionDialog(
BuildContext context,
AnalysisController controller,
) {
final textController = TextEditingController();
String? selectedVideoPath;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('New Analysis Session'),
content: SizedBox(
width: UIConstants.dialogWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: textController,
decoration: const InputDecoration(
labelText: 'Session Name',
hintText: 'e.g. Squat Form Check',
),
autofocus: true,
),
const SizedBox(height: UIConstants.spacing16),
Container(
padding: const EdgeInsets.all(UIConstants.spacing12),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: UIConstants.smallCardBorderRadius,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
Icon(
selectedVideoPath != null
? Icons.videocam
: Icons.videocam_off_outlined,
color: selectedVideoPath != null
? AppColors.success
: AppColors.textMuted,
size: 20,
),
const SizedBox(width: UIConstants.spacing12),
Expanded(
child: Text(
selectedVideoPath != null
? selectedVideoPath!
.split(RegExp(r'[\\/]'))
.last
: 'No video selected',
style: TextStyle(
color: selectedVideoPath != null
? AppColors.textPrimary
: AppColors.textMuted,
fontSize: 13,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: UIConstants.spacing8),
OutlinedButton.icon(
onPressed: () async {
final result = await FilePicker.platform.pickFiles(
type: FileType.video,
allowMultiple: false,
);
if (result != null &&
result.files.single.path != null) {
setState(() {
selectedVideoPath = result.files.single.path;
});
}
},
icon: const Icon(Icons.folder_open, size: 16),
label: const Text('Browse'),
),
],
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () {
if (textController.text.isNotEmpty &&
selectedVideoPath != null) {
controller.createSession(
textController.text,
selectedVideoPath!,
);
Navigator.pop(context);
}
},
child: const Text('Create'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:trainhub_flutter/domain/entities/analysis_session.dart';
import 'package:trainhub_flutter/domain/entities/annotation.dart';
part 'analysis_state.freezed.dart';
@freezed
class AnalysisState with _$AnalysisState {
const factory AnalysisState({
@Default([]) List<AnalysisSessionEntity> sessions,
AnalysisSessionEntity? activeSession,
@Default([]) List<AnnotationEntity> annotations,
}) = _AnalysisState;
}

View File

@@ -0,0 +1,244 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'analysis_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$AnalysisState {
List<AnalysisSessionEntity> get sessions =>
throw _privateConstructorUsedError;
AnalysisSessionEntity? get activeSession =>
throw _privateConstructorUsedError;
List<AnnotationEntity> get annotations => throw _privateConstructorUsedError;
/// Create a copy of AnalysisState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$AnalysisStateCopyWith<AnalysisState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AnalysisStateCopyWith<$Res> {
factory $AnalysisStateCopyWith(
AnalysisState value,
$Res Function(AnalysisState) then,
) = _$AnalysisStateCopyWithImpl<$Res, AnalysisState>;
@useResult
$Res call({
List<AnalysisSessionEntity> sessions,
AnalysisSessionEntity? activeSession,
List<AnnotationEntity> annotations,
});
$AnalysisSessionEntityCopyWith<$Res>? get activeSession;
}
/// @nodoc
class _$AnalysisStateCopyWithImpl<$Res, $Val extends AnalysisState>
implements $AnalysisStateCopyWith<$Res> {
_$AnalysisStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of AnalysisState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? sessions = null,
Object? activeSession = freezed,
Object? annotations = null,
}) {
return _then(
_value.copyWith(
sessions: null == sessions
? _value.sessions
: sessions // ignore: cast_nullable_to_non_nullable
as List<AnalysisSessionEntity>,
activeSession: freezed == activeSession
? _value.activeSession
: activeSession // ignore: cast_nullable_to_non_nullable
as AnalysisSessionEntity?,
annotations: null == annotations
? _value.annotations
: annotations // ignore: cast_nullable_to_non_nullable
as List<AnnotationEntity>,
)
as $Val,
);
}
/// Create a copy of AnalysisState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$AnalysisSessionEntityCopyWith<$Res>? get activeSession {
if (_value.activeSession == null) {
return null;
}
return $AnalysisSessionEntityCopyWith<$Res>(_value.activeSession!, (value) {
return _then(_value.copyWith(activeSession: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$AnalysisStateImplCopyWith<$Res>
implements $AnalysisStateCopyWith<$Res> {
factory _$$AnalysisStateImplCopyWith(
_$AnalysisStateImpl value,
$Res Function(_$AnalysisStateImpl) then,
) = __$$AnalysisStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
List<AnalysisSessionEntity> sessions,
AnalysisSessionEntity? activeSession,
List<AnnotationEntity> annotations,
});
@override
$AnalysisSessionEntityCopyWith<$Res>? get activeSession;
}
/// @nodoc
class __$$AnalysisStateImplCopyWithImpl<$Res>
extends _$AnalysisStateCopyWithImpl<$Res, _$AnalysisStateImpl>
implements _$$AnalysisStateImplCopyWith<$Res> {
__$$AnalysisStateImplCopyWithImpl(
_$AnalysisStateImpl _value,
$Res Function(_$AnalysisStateImpl) _then,
) : super(_value, _then);
/// Create a copy of AnalysisState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? sessions = null,
Object? activeSession = freezed,
Object? annotations = null,
}) {
return _then(
_$AnalysisStateImpl(
sessions: null == sessions
? _value._sessions
: sessions // ignore: cast_nullable_to_non_nullable
as List<AnalysisSessionEntity>,
activeSession: freezed == activeSession
? _value.activeSession
: activeSession // ignore: cast_nullable_to_non_nullable
as AnalysisSessionEntity?,
annotations: null == annotations
? _value._annotations
: annotations // ignore: cast_nullable_to_non_nullable
as List<AnnotationEntity>,
),
);
}
}
/// @nodoc
class _$AnalysisStateImpl implements _AnalysisState {
const _$AnalysisStateImpl({
final List<AnalysisSessionEntity> sessions = const [],
this.activeSession,
final List<AnnotationEntity> annotations = const [],
}) : _sessions = sessions,
_annotations = annotations;
final List<AnalysisSessionEntity> _sessions;
@override
@JsonKey()
List<AnalysisSessionEntity> get sessions {
if (_sessions is EqualUnmodifiableListView) return _sessions;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_sessions);
}
@override
final AnalysisSessionEntity? activeSession;
final List<AnnotationEntity> _annotations;
@override
@JsonKey()
List<AnnotationEntity> get annotations {
if (_annotations is EqualUnmodifiableListView) return _annotations;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_annotations);
}
@override
String toString() {
return 'AnalysisState(sessions: $sessions, activeSession: $activeSession, annotations: $annotations)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AnalysisStateImpl &&
const DeepCollectionEquality().equals(other._sessions, _sessions) &&
(identical(other.activeSession, activeSession) ||
other.activeSession == activeSession) &&
const DeepCollectionEquality().equals(
other._annotations,
_annotations,
));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(_sessions),
activeSession,
const DeepCollectionEquality().hash(_annotations),
);
/// Create a copy of AnalysisState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$AnalysisStateImplCopyWith<_$AnalysisStateImpl> get copyWith =>
__$$AnalysisStateImplCopyWithImpl<_$AnalysisStateImpl>(this, _$identity);
}
abstract class _AnalysisState implements AnalysisState {
const factory _AnalysisState({
final List<AnalysisSessionEntity> sessions,
final AnalysisSessionEntity? activeSession,
final List<AnnotationEntity> annotations,
}) = _$AnalysisStateImpl;
@override
List<AnalysisSessionEntity> get sessions;
@override
AnalysisSessionEntity? get activeSession;
@override
List<AnnotationEntity> get annotations;
/// Create a copy of AnalysisState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$AnalysisStateImplCopyWith<_$AnalysisStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:trainhub_flutter/domain/entities/analysis_session.dart';
class AnalysisSessionList extends StatelessWidget {
final List<AnalysisSessionEntity> sessions;
final Function(AnalysisSessionEntity) onSessionSelected;
final Function(AnalysisSessionEntity) onDeleteSession;
const AnalysisSessionList({
super.key,
required this.sessions,
required this.onSessionSelected,
required this.onDeleteSession,
});
@override
Widget build(BuildContext context) {
if (sessions.isEmpty) {
return const Center(
child: Text('No analysis sessions yet. Tap + to create one.'),
);
}
return ListView.builder(
itemCount: sessions.length,
itemBuilder: (context, index) {
final session = sessions[index];
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.video_library)),
title: Text(session.name),
subtitle: Text(session.date),
trailing: IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () => onDeleteSession(session),
),
onTap: () => onSessionSelected(session),
);
},
);
}
}

View File

@@ -0,0 +1,470 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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';
import 'package:video_player/video_player.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> {
VideoPlayerController? _videoController;
bool _isPlaying = false;
double _currentPosition = 0.0;
double _totalDuration = 1.0;
// IN/OUT points
double? _inPoint;
double? _outPoint;
bool _isLooping = false;
@override
void initState() {
super.initState();
_initializeVideo();
}
@override
void didUpdateWidget(covariant AnalysisViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.session.videoPath != widget.session.videoPath) {
_initializeVideo();
}
}
Future<void> _initializeVideo() async {
final path = widget.session.videoPath;
if (path == null) return;
_videoController?.dispose();
final file = File(path);
if (await file.exists()) {
_videoController = VideoPlayerController.file(file);
await _videoController!.initialize();
setState(() {
_totalDuration = _videoController!.value.duration.inSeconds.toDouble();
});
_videoController!.addListener(_videoListener);
}
}
void _videoListener() {
if (_videoController == null) return;
final bool isPlaying = _videoController!.value.isPlaying;
final double position =
_videoController!.value.position.inMilliseconds / 1000.0;
// Loop logic
if (_isLooping && _outPoint != null && position >= _outPoint!) {
_seekTo(_inPoint ?? 0.0);
return;
}
if (isPlaying != _isPlaying || (position - _currentPosition).abs() > 0.1) {
setState(() {
_isPlaying = isPlaying;
_currentPosition = position;
});
}
}
@override
void dispose() {
_videoController?.removeListener(_videoListener);
_videoController?.dispose();
super.dispose();
}
void _togglePlay() {
if (_videoController == null) return;
if (_videoController!.value.isPlaying) {
_videoController!.pause();
} else {
if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) {
_seekTo(_inPoint ?? 0.0);
}
_videoController!.play();
}
}
void _seekTo(double value) {
if (_videoController == null) return;
_videoController!.seekTo(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);
_videoController?.play();
}
@override
Widget build(BuildContext context) {
final controller = ref.read(analysisControllerProvider.notifier);
return Column(
children: [
// Video Area
Expanded(
flex: 3,
child: Container(
color: Colors.black,
alignment: Alignment.center,
child:
_videoController != null &&
_videoController!.value.isInitialized
? Stack(
alignment: Alignment.bottomCenter,
children: [
Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
),
_buildTimelineControls(),
],
)
: const Center(child: CircularProgressIndicator()),
),
),
// Annotations List
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: () {
double start = _inPoint ?? _currentPosition;
double end = _outPoint ?? (_currentPosition + 5.0);
if (end > _totalDuration) end = _totalDuration;
controller.addAnnotation(
name: "New Annotation",
description:
"${_formatDuration(start)} - ${_formatDuration(end)}",
startTime: start,
endTime: end,
color: "red",
);
},
icon: const Icon(Icons.add),
label: const Text("Add from Selection"),
),
],
),
),
const Divider(height: 1),
Expanded(
child: 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 Container(
color: isActive
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.2)
: null,
child: ListTile(
leading: Icon(
Icons.label,
color: _parseColor(note.color ?? 'grey'),
),
title: Text(note.name ?? 'Untitled'),
subtitle: Text(
"${_formatDuration(note.startTime)} - ${_formatDuration(note.endTime)}",
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_circle_outline),
onPressed: () =>
_playRange(note.startTime, note.endTime),
tooltip: "Play Range",
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
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.withOpacity(0.8),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Timeline Visualization
SizedBox(
height: 20,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
// Annotations
...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,
top: 4,
bottom: 4,
child: Container(
decoration: BoxDecoration(
color: _parseColor(
note.color ?? 'grey',
).withOpacity(0.6),
borderRadius: BorderRadius.circular(2),
),
),
);
}),
// IN Point
if (_inPoint != null)
Positioned(
left:
(_inPoint! / _totalDuration) * constraints.maxWidth,
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.green),
),
// OUT Point
if (_outPoint != null)
Positioned(
left:
(_outPoint! / _totalDuration) *
constraints.maxWidth,
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.red),
),
],
);
},
),
),
// Slider
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: (value) => _seekTo(value),
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Colors.grey,
),
),
// Controls Row
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),
// IN/OUT Controls
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),
),
],
),
),
],
),
);
}
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;
default:
return Colors.grey;
}
}
}