Initial commit
This commit is contained in:
87
lib/presentation/analysis/analysis_controller.dart
Normal file
87
lib/presentation/analysis/analysis_controller.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
30
lib/presentation/analysis/analysis_controller.g.dart
Normal file
30
lib/presentation/analysis/analysis_controller.g.dart
Normal 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
|
||||
232
lib/presentation/analysis/analysis_page.dart
Normal file
232
lib/presentation/analysis/analysis_page.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/presentation/analysis/analysis_state.dart
Normal file
14
lib/presentation/analysis/analysis_state.dart
Normal 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;
|
||||
}
|
||||
244
lib/presentation/analysis/analysis_state.freezed.dart
Normal file
244
lib/presentation/analysis/analysis_state.freezed.dart
Normal 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;
|
||||
}
|
||||
40
lib/presentation/analysis/widgets/analysis_session_list.dart
Normal file
40
lib/presentation/analysis/widgets/analysis_session_list.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
470
lib/presentation/analysis/widgets/analysis_viewer.dart
Normal file
470
lib/presentation/analysis/widgets/analysis_viewer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user