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;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
lib/presentation/calendar/calendar_controller.dart
Normal file
125
lib/presentation/calendar/calendar_controller.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/program_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
||||
import 'package:trainhub_flutter/presentation/calendar/calendar_state.dart';
|
||||
|
||||
part 'calendar_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class CalendarController extends _$CalendarController {
|
||||
late final ProgramRepository _programRepo;
|
||||
late final TrainingPlanRepository _planRepo;
|
||||
late final ExerciseRepository _exerciseRepo;
|
||||
|
||||
@override
|
||||
Future<CalendarState> build() async {
|
||||
_programRepo = getIt<ProgramRepository>();
|
||||
_planRepo = getIt<TrainingPlanRepository>();
|
||||
_exerciseRepo = getIt<ExerciseRepository>();
|
||||
final programs = await _programRepo.getAllPrograms();
|
||||
final plans = await _planRepo.getAll();
|
||||
final exercises = await _exerciseRepo.getAll();
|
||||
if (programs.isEmpty) {
|
||||
return CalendarState(plans: plans, exercises: exercises);
|
||||
}
|
||||
final activeProgram = programs.first;
|
||||
final weeks = await _programRepo.getWeeks(activeProgram.id);
|
||||
final workouts = await _programRepo.getWorkouts(activeProgram.id);
|
||||
return CalendarState(
|
||||
programs: programs,
|
||||
activeProgram: activeProgram,
|
||||
weeks: weeks,
|
||||
workouts: workouts,
|
||||
plans: plans,
|
||||
exercises: exercises,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadProgram(String id) async {
|
||||
final program = await _programRepo.getProgram(id);
|
||||
if (program == null) return;
|
||||
final weeks = await _programRepo.getWeeks(id);
|
||||
final workouts = await _programRepo.getWorkouts(id);
|
||||
final current = state.valueOrNull ?? const CalendarState();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
activeProgram: program,
|
||||
weeks: weeks,
|
||||
workouts: workouts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> createProgram(String name) async {
|
||||
final program = await _programRepo.createProgram(name);
|
||||
await _reloadFull();
|
||||
await loadProgram(program.id);
|
||||
}
|
||||
|
||||
Future<void> deleteProgram(String id) async {
|
||||
await _programRepo.deleteProgram(id);
|
||||
state = await AsyncValue.guard(() => build());
|
||||
}
|
||||
|
||||
Future<void> duplicateProgram(String sourceId) async {
|
||||
await _programRepo.duplicateProgram(sourceId);
|
||||
await _reloadFull();
|
||||
}
|
||||
|
||||
Future<void> addWeek() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current?.activeProgram == null) return;
|
||||
final nextPosition =
|
||||
current!.weeks.isEmpty ? 1 : current.weeks.last.position + 1;
|
||||
await _programRepo.addWeek(current.activeProgram!.id, nextPosition);
|
||||
await _reloadProgramDetails();
|
||||
}
|
||||
|
||||
Future<void> deleteWeek(String id) async {
|
||||
await _programRepo.deleteWeek(id);
|
||||
await _reloadProgramDetails();
|
||||
}
|
||||
|
||||
Future<void> updateWeekNote(String weekId, String note) async {
|
||||
await _programRepo.updateWeekNote(weekId, note);
|
||||
await _reloadProgramDetails();
|
||||
}
|
||||
|
||||
Future<void> addWorkout(ProgramWorkoutEntity workout) async {
|
||||
await _programRepo.addWorkout(workout);
|
||||
await _reloadProgramDetails();
|
||||
}
|
||||
|
||||
Future<void> updateWorkout(ProgramWorkoutEntity workout) async {
|
||||
await _programRepo.updateWorkout(workout);
|
||||
await _reloadProgramDetails();
|
||||
}
|
||||
|
||||
Future<void> deleteWorkout(String id) async {
|
||||
await _programRepo.deleteWorkout(id);
|
||||
await _reloadProgramDetails();
|
||||
}
|
||||
|
||||
Future<void> toggleWorkoutComplete(String id, bool currentStatus) async {
|
||||
await _programRepo.toggleWorkoutComplete(id, currentStatus);
|
||||
await _reloadProgramDetails();
|
||||
}
|
||||
|
||||
Future<void> _reloadProgramDetails() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current?.activeProgram == null) return;
|
||||
final weeks = await _programRepo.getWeeks(current!.activeProgram!.id);
|
||||
final workouts =
|
||||
await _programRepo.getWorkouts(current.activeProgram!.id);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(weeks: weeks, workouts: workouts),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _reloadFull() async {
|
||||
state = await AsyncValue.guard(() => build());
|
||||
}
|
||||
}
|
||||
30
lib/presentation/calendar/calendar_controller.g.dart
Normal file
30
lib/presentation/calendar/calendar_controller.g.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'calendar_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$calendarControllerHash() =>
|
||||
r'747a59ba47bf4d1b6a66e3bcc82276e4ad81eb1a';
|
||||
|
||||
/// See also [CalendarController].
|
||||
@ProviderFor(CalendarController)
|
||||
final calendarControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<
|
||||
CalendarController,
|
||||
CalendarState
|
||||
>.internal(
|
||||
CalendarController.new,
|
||||
name: r'calendarControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$calendarControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$CalendarController = AutoDisposeAsyncNotifier<CalendarState>;
|
||||
// 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
|
||||
116
lib/presentation/calendar/calendar_page.dart
Normal file
116
lib/presentation/calendar/calendar_page.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:trainhub_flutter/presentation/calendar/calendar_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/calendar/widgets/program_selector.dart';
|
||||
import 'package:trainhub_flutter/presentation/calendar/widgets/program_week_view.dart';
|
||||
|
||||
@RoutePage()
|
||||
class CalendarPage extends ConsumerWidget {
|
||||
const CalendarPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(calendarControllerProvider);
|
||||
final controller = ref.read(calendarControllerProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Program & Schedule')),
|
||||
body: state.when(
|
||||
data: (data) {
|
||||
if (data.programs.isEmpty) {
|
||||
return Center(
|
||||
child: ElevatedButton(
|
||||
onPressed: () => _showCreateProgramDialog(context, controller),
|
||||
child: const Text('Create First Program'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ProgramSelector(
|
||||
programs: data.programs,
|
||||
activeProgram: data.activeProgram,
|
||||
onProgramSelected: (p) => controller.loadProgram(p.id),
|
||||
onCreateProgram: () =>
|
||||
_showCreateProgramDialog(context, controller),
|
||||
),
|
||||
Expanded(
|
||||
child: data.activeProgram == null
|
||||
? const Center(child: Text("Select a program"))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: data.weeks.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.weeks.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 24.0,
|
||||
),
|
||||
child: Center(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: controller.addWeek,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text("Add Week"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final week = data.weeks[index];
|
||||
final weekWorkouts = data.workouts
|
||||
.where((w) => w.weekId == week.id)
|
||||
.toList();
|
||||
return ProgramWeekView(
|
||||
week: week,
|
||||
workouts: weekWorkouts,
|
||||
availablePlans: data.plans,
|
||||
onAddWorkout: (workout) => controller.addWorkout(
|
||||
workout,
|
||||
), // logic needs refined params
|
||||
onDeleteWorkout: controller.deleteWorkout,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
error: (e, s) => Center(child: Text('Error: $e')),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showCreateProgramDialog(
|
||||
BuildContext context,
|
||||
CalendarController controller,
|
||||
) {
|
||||
final textController = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('New Program'),
|
||||
content: TextField(
|
||||
controller: textController,
|
||||
decoration: const InputDecoration(labelText: 'Program Name'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (textController.text.isNotEmpty) {
|
||||
controller.createProgram(textController.text);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: const Text('Create'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
20
lib/presentation/calendar/calendar_state.dart
Normal file
20
lib/presentation/calendar/calendar_state.dart
Normal file
@@ -0,0 +1,20 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_week.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||
|
||||
part 'calendar_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class CalendarState with _$CalendarState {
|
||||
const factory CalendarState({
|
||||
@Default([]) List<ProgramEntity> programs,
|
||||
ProgramEntity? activeProgram,
|
||||
@Default([]) List<ProgramWeekEntity> weeks,
|
||||
@Default([]) List<ProgramWorkoutEntity> workouts,
|
||||
@Default([]) List<TrainingPlanEntity> plans,
|
||||
@Default([]) List<ExerciseEntity> exercises,
|
||||
}) = _CalendarState;
|
||||
}
|
||||
329
lib/presentation/calendar/calendar_state.freezed.dart
Normal file
329
lib/presentation/calendar/calendar_state.freezed.dart
Normal file
@@ -0,0 +1,329 @@
|
||||
// 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 'calendar_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 _$CalendarState {
|
||||
List<ProgramEntity> get programs => throw _privateConstructorUsedError;
|
||||
ProgramEntity? get activeProgram => throw _privateConstructorUsedError;
|
||||
List<ProgramWeekEntity> get weeks => throw _privateConstructorUsedError;
|
||||
List<ProgramWorkoutEntity> get workouts => throw _privateConstructorUsedError;
|
||||
List<TrainingPlanEntity> get plans => throw _privateConstructorUsedError;
|
||||
List<ExerciseEntity> get exercises => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of CalendarState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$CalendarStateCopyWith<CalendarState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $CalendarStateCopyWith<$Res> {
|
||||
factory $CalendarStateCopyWith(
|
||||
CalendarState value,
|
||||
$Res Function(CalendarState) then,
|
||||
) = _$CalendarStateCopyWithImpl<$Res, CalendarState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ProgramEntity> programs,
|
||||
ProgramEntity? activeProgram,
|
||||
List<ProgramWeekEntity> weeks,
|
||||
List<ProgramWorkoutEntity> workouts,
|
||||
List<TrainingPlanEntity> plans,
|
||||
List<ExerciseEntity> exercises,
|
||||
});
|
||||
|
||||
$ProgramEntityCopyWith<$Res>? get activeProgram;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$CalendarStateCopyWithImpl<$Res, $Val extends CalendarState>
|
||||
implements $CalendarStateCopyWith<$Res> {
|
||||
_$CalendarStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of CalendarState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? programs = null,
|
||||
Object? activeProgram = freezed,
|
||||
Object? weeks = null,
|
||||
Object? workouts = null,
|
||||
Object? plans = null,
|
||||
Object? exercises = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
programs: null == programs
|
||||
? _value.programs
|
||||
: programs // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramEntity>,
|
||||
activeProgram: freezed == activeProgram
|
||||
? _value.activeProgram
|
||||
: activeProgram // ignore: cast_nullable_to_non_nullable
|
||||
as ProgramEntity?,
|
||||
weeks: null == weeks
|
||||
? _value.weeks
|
||||
: weeks // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramWeekEntity>,
|
||||
workouts: null == workouts
|
||||
? _value.workouts
|
||||
: workouts // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramWorkoutEntity>,
|
||||
plans: null == plans
|
||||
? _value.plans
|
||||
: plans // ignore: cast_nullable_to_non_nullable
|
||||
as List<TrainingPlanEntity>,
|
||||
exercises: null == exercises
|
||||
? _value.exercises
|
||||
: exercises // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExerciseEntity>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of CalendarState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$ProgramEntityCopyWith<$Res>? get activeProgram {
|
||||
if (_value.activeProgram == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ProgramEntityCopyWith<$Res>(_value.activeProgram!, (value) {
|
||||
return _then(_value.copyWith(activeProgram: value) as $Val);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$CalendarStateImplCopyWith<$Res>
|
||||
implements $CalendarStateCopyWith<$Res> {
|
||||
factory _$$CalendarStateImplCopyWith(
|
||||
_$CalendarStateImpl value,
|
||||
$Res Function(_$CalendarStateImpl) then,
|
||||
) = __$$CalendarStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ProgramEntity> programs,
|
||||
ProgramEntity? activeProgram,
|
||||
List<ProgramWeekEntity> weeks,
|
||||
List<ProgramWorkoutEntity> workouts,
|
||||
List<TrainingPlanEntity> plans,
|
||||
List<ExerciseEntity> exercises,
|
||||
});
|
||||
|
||||
@override
|
||||
$ProgramEntityCopyWith<$Res>? get activeProgram;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$CalendarStateImplCopyWithImpl<$Res>
|
||||
extends _$CalendarStateCopyWithImpl<$Res, _$CalendarStateImpl>
|
||||
implements _$$CalendarStateImplCopyWith<$Res> {
|
||||
__$$CalendarStateImplCopyWithImpl(
|
||||
_$CalendarStateImpl _value,
|
||||
$Res Function(_$CalendarStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of CalendarState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? programs = null,
|
||||
Object? activeProgram = freezed,
|
||||
Object? weeks = null,
|
||||
Object? workouts = null,
|
||||
Object? plans = null,
|
||||
Object? exercises = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$CalendarStateImpl(
|
||||
programs: null == programs
|
||||
? _value._programs
|
||||
: programs // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramEntity>,
|
||||
activeProgram: freezed == activeProgram
|
||||
? _value.activeProgram
|
||||
: activeProgram // ignore: cast_nullable_to_non_nullable
|
||||
as ProgramEntity?,
|
||||
weeks: null == weeks
|
||||
? _value._weeks
|
||||
: weeks // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramWeekEntity>,
|
||||
workouts: null == workouts
|
||||
? _value._workouts
|
||||
: workouts // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramWorkoutEntity>,
|
||||
plans: null == plans
|
||||
? _value._plans
|
||||
: plans // ignore: cast_nullable_to_non_nullable
|
||||
as List<TrainingPlanEntity>,
|
||||
exercises: null == exercises
|
||||
? _value._exercises
|
||||
: exercises // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExerciseEntity>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$CalendarStateImpl implements _CalendarState {
|
||||
const _$CalendarStateImpl({
|
||||
final List<ProgramEntity> programs = const [],
|
||||
this.activeProgram,
|
||||
final List<ProgramWeekEntity> weeks = const [],
|
||||
final List<ProgramWorkoutEntity> workouts = const [],
|
||||
final List<TrainingPlanEntity> plans = const [],
|
||||
final List<ExerciseEntity> exercises = const [],
|
||||
}) : _programs = programs,
|
||||
_weeks = weeks,
|
||||
_workouts = workouts,
|
||||
_plans = plans,
|
||||
_exercises = exercises;
|
||||
|
||||
final List<ProgramEntity> _programs;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ProgramEntity> get programs {
|
||||
if (_programs is EqualUnmodifiableListView) return _programs;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_programs);
|
||||
}
|
||||
|
||||
@override
|
||||
final ProgramEntity? activeProgram;
|
||||
final List<ProgramWeekEntity> _weeks;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ProgramWeekEntity> get weeks {
|
||||
if (_weeks is EqualUnmodifiableListView) return _weeks;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_weeks);
|
||||
}
|
||||
|
||||
final List<ProgramWorkoutEntity> _workouts;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ProgramWorkoutEntity> get workouts {
|
||||
if (_workouts is EqualUnmodifiableListView) return _workouts;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_workouts);
|
||||
}
|
||||
|
||||
final List<TrainingPlanEntity> _plans;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<TrainingPlanEntity> get plans {
|
||||
if (_plans is EqualUnmodifiableListView) return _plans;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_plans);
|
||||
}
|
||||
|
||||
final List<ExerciseEntity> _exercises;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ExerciseEntity> get exercises {
|
||||
if (_exercises is EqualUnmodifiableListView) return _exercises;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_exercises);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'CalendarState(programs: $programs, activeProgram: $activeProgram, weeks: $weeks, workouts: $workouts, plans: $plans, exercises: $exercises)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$CalendarStateImpl &&
|
||||
const DeepCollectionEquality().equals(other._programs, _programs) &&
|
||||
(identical(other.activeProgram, activeProgram) ||
|
||||
other.activeProgram == activeProgram) &&
|
||||
const DeepCollectionEquality().equals(other._weeks, _weeks) &&
|
||||
const DeepCollectionEquality().equals(other._workouts, _workouts) &&
|
||||
const DeepCollectionEquality().equals(other._plans, _plans) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._exercises,
|
||||
_exercises,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(_programs),
|
||||
activeProgram,
|
||||
const DeepCollectionEquality().hash(_weeks),
|
||||
const DeepCollectionEquality().hash(_workouts),
|
||||
const DeepCollectionEquality().hash(_plans),
|
||||
const DeepCollectionEquality().hash(_exercises),
|
||||
);
|
||||
|
||||
/// Create a copy of CalendarState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$CalendarStateImplCopyWith<_$CalendarStateImpl> get copyWith =>
|
||||
__$$CalendarStateImplCopyWithImpl<_$CalendarStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _CalendarState implements CalendarState {
|
||||
const factory _CalendarState({
|
||||
final List<ProgramEntity> programs,
|
||||
final ProgramEntity? activeProgram,
|
||||
final List<ProgramWeekEntity> weeks,
|
||||
final List<ProgramWorkoutEntity> workouts,
|
||||
final List<TrainingPlanEntity> plans,
|
||||
final List<ExerciseEntity> exercises,
|
||||
}) = _$CalendarStateImpl;
|
||||
|
||||
@override
|
||||
List<ProgramEntity> get programs;
|
||||
@override
|
||||
ProgramEntity? get activeProgram;
|
||||
@override
|
||||
List<ProgramWeekEntity> get weeks;
|
||||
@override
|
||||
List<ProgramWorkoutEntity> get workouts;
|
||||
@override
|
||||
List<TrainingPlanEntity> get plans;
|
||||
@override
|
||||
List<ExerciseEntity> get exercises;
|
||||
|
||||
/// Create a copy of CalendarState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$CalendarStateImplCopyWith<_$CalendarStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
252
lib/presentation/calendar/widgets/program_selector.dart
Normal file
252
lib/presentation/calendar/widgets/program_selector.dart
Normal file
@@ -0,0 +1,252 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program.dart';
|
||||
|
||||
class ProgramSelector extends StatelessWidget {
|
||||
final List<ProgramEntity> programs;
|
||||
final ProgramEntity? activeProgram;
|
||||
final ValueChanged<ProgramEntity> onProgramSelected;
|
||||
final VoidCallback onCreateProgram;
|
||||
final VoidCallback onDuplicateProgram;
|
||||
final VoidCallback onDeleteProgram;
|
||||
|
||||
const ProgramSelector({
|
||||
super.key,
|
||||
required this.programs,
|
||||
required this.activeProgram,
|
||||
required this.onProgramSelected,
|
||||
required this.onCreateProgram,
|
||||
required this.onDuplicateProgram,
|
||||
required this.onDeleteProgram,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.pagePadding,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Program icon
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.calendar_month_rounded,
|
||||
color: AppColors.accent,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
|
||||
// Program dropdown
|
||||
Expanded(
|
||||
child: _ProgramDropdown(
|
||||
programs: programs,
|
||||
activeProgram: activeProgram,
|
||||
onProgramSelected: onProgramSelected,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
|
||||
// Action buttons
|
||||
_ToolbarButton(
|
||||
icon: Icons.add_rounded,
|
||||
label: 'New Program',
|
||||
onPressed: onCreateProgram,
|
||||
isPrimary: true,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
_ToolbarButton(
|
||||
icon: Icons.copy_rounded,
|
||||
label: 'Duplicate',
|
||||
onPressed: activeProgram != null ? onDuplicateProgram : null,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
_ToolbarButton(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
label: 'Delete',
|
||||
onPressed: activeProgram != null ? onDeleteProgram : null,
|
||||
isDestructive: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProgramDropdown extends StatelessWidget {
|
||||
final List<ProgramEntity> programs;
|
||||
final ProgramEntity? activeProgram;
|
||||
final ValueChanged<ProgramEntity> onProgramSelected;
|
||||
|
||||
const _ProgramDropdown({
|
||||
required this.programs,
|
||||
required this.activeProgram,
|
||||
required this.onProgramSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 38,
|
||||
padding: const EdgeInsets.symmetric(horizontal: UIConstants.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.zinc950,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: activeProgram?.id,
|
||||
isExpanded: true,
|
||||
icon: const Icon(
|
||||
Icons.unfold_more_rounded,
|
||||
size: 18,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
dropdownColor: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
hint: Text(
|
||||
'Select a program',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
items: programs.map((p) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: p.id,
|
||||
child: Text(
|
||||
p.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (id) {
|
||||
if (id != null) {
|
||||
final program = programs.firstWhere((p) => p.id == id);
|
||||
onProgramSelected(program);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ToolbarButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isPrimary;
|
||||
final bool isDestructive;
|
||||
|
||||
const _ToolbarButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.isPrimary = false,
|
||||
this.isDestructive = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ToolbarButton> createState() => _ToolbarButtonState();
|
||||
}
|
||||
|
||||
class _ToolbarButtonState extends State<_ToolbarButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDisabled = widget.onPressed == null;
|
||||
|
||||
Color bgColor;
|
||||
Color fgColor;
|
||||
Color borderColor;
|
||||
|
||||
if (isDisabled) {
|
||||
bgColor = Colors.transparent;
|
||||
fgColor = AppColors.zinc600;
|
||||
borderColor = AppColors.border;
|
||||
} else if (widget.isPrimary) {
|
||||
bgColor = _isHovered ? AppColors.accent : AppColors.accentMuted;
|
||||
fgColor = _isHovered ? AppColors.zinc950 : AppColors.accent;
|
||||
borderColor = AppColors.accent.withValues(alpha: 0.3);
|
||||
} else if (widget.isDestructive) {
|
||||
bgColor =
|
||||
_isHovered ? AppColors.destructiveMuted : Colors.transparent;
|
||||
fgColor =
|
||||
_isHovered ? AppColors.destructive : AppColors.textSecondary;
|
||||
borderColor = _isHovered ? AppColors.destructive.withValues(alpha: 0.3) : AppColors.border;
|
||||
} else {
|
||||
bgColor = _isHovered ? AppColors.surfaceContainerHigh : Colors.transparent;
|
||||
fgColor = _isHovered ? AppColors.textPrimary : AppColors.textSecondary;
|
||||
borderColor = AppColors.border;
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: Tooltip(
|
||||
message: widget.label,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onPressed,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
height: 34,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(widget.icon, size: 16, color: fgColor),
|
||||
const SizedBox(width: UIConstants.spacing4),
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: fgColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
244
lib/presentation/calendar/widgets/program_week_view.dart
Normal file
244
lib/presentation/calendar/widgets/program_week_view.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/utils/id_generator.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_week.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
|
||||
class ProgramWeekView extends StatelessWidget {
|
||||
final ProgramWeekEntity week;
|
||||
final List<ProgramWorkoutEntity> workouts;
|
||||
final List<TrainingPlanEntity> availablePlans;
|
||||
final Function(ProgramWorkoutEntity) onAddWorkout;
|
||||
final Function(String) onDeleteWorkout;
|
||||
|
||||
const ProgramWeekView({
|
||||
super.key,
|
||||
required this.week,
|
||||
required this.workouts,
|
||||
required this.availablePlans,
|
||||
required this.onAddWorkout,
|
||||
required this.onDeleteWorkout,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 24),
|
||||
elevation: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"Week ${week.position}",
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const Divider(),
|
||||
SizedBox(
|
||||
height: 500, // Fixed height for the week grid, or make it dynamic
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: List.generate(7, (dayIndex) {
|
||||
final dayNum = dayIndex + 1;
|
||||
final dayWorkouts = workouts
|
||||
.where((w) => w.day == dayNum.toString())
|
||||
.toList();
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: dayIndex < 6
|
||||
? const Border(
|
||||
right: BorderSide(
|
||||
color: Colors.grey,
|
||||
width: 0.5,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
color: dayIndex % 2 == 0
|
||||
? Theme.of(context).colorScheme.surfaceContainerLow
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
border: const Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_getDayName(dayNum),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
_showAddWorkoutSheet(context, dayNum),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(4),
|
||||
child: Column(
|
||||
children: [
|
||||
if (dayWorkouts.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"Rest",
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...dayWorkouts.map(
|
||||
(w) => Card(
|
||||
margin: const EdgeInsets.only(
|
||||
bottom: 4,
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 0,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(
|
||||
w.name ?? 'Untitled',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: InkWell(
|
||||
onTap: () => onDeleteWorkout(w.id),
|
||||
borderRadius: BorderRadius.circular(
|
||||
12,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
// Optional: Edit on tap
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getDayName(int day) {
|
||||
const days = [
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
'Sunday',
|
||||
];
|
||||
if (day >= 1 && day <= 7) return days[day - 1];
|
||||
return 'Day $day';
|
||||
}
|
||||
|
||||
void _showAddWorkoutSheet(BuildContext context, int dayNum) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
vertical: 8.0,
|
||||
),
|
||||
child: Text(
|
||||
"Select Training Plan",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
if (availablePlans.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: Text("No training plans available. Create one first!"),
|
||||
),
|
||||
),
|
||||
...availablePlans
|
||||
.map(
|
||||
(plan) => ListTile(
|
||||
leading: const Icon(Icons.fitness_center),
|
||||
title: Text(plan.name),
|
||||
subtitle: Text("${plan.totalExercises} exercises"),
|
||||
onTap: () {
|
||||
final newWorkout = ProgramWorkoutEntity(
|
||||
id: IdGenerator.generate(),
|
||||
programId: week.programId,
|
||||
weekId: week.id,
|
||||
day: dayNum.toString(),
|
||||
type: 'workout',
|
||||
name: plan.name,
|
||||
refId: plan.id,
|
||||
description: "${plan.sections.length} sections",
|
||||
completed: false,
|
||||
);
|
||||
onAddWorkout(newWorkout);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/presentation/chat/chat_controller.dart
Normal file
109
lib/presentation/chat/chat_controller.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/chat_repository.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
|
||||
part 'chat_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatController extends _$ChatController {
|
||||
late final ChatRepository _repo;
|
||||
|
||||
@override
|
||||
Future<ChatState> build() async {
|
||||
_repo = getIt<ChatRepository>();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
return ChatState(sessions: sessions);
|
||||
}
|
||||
|
||||
Future<void> createSession() async {
|
||||
final session = await _repo.createSession();
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
ChatState(sessions: sessions, activeSession: session),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadSession(String id) async {
|
||||
final session = await _repo.getSession(id);
|
||||
if (session == null) return;
|
||||
final messages = await _repo.getMessages(id);
|
||||
final current = state.valueOrNull ?? const ChatState();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(activeSession: session, messages: messages),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteSession(String id) async {
|
||||
await _repo.deleteSession(id);
|
||||
final sessions = await _repo.getAllSessions();
|
||||
final current = state.valueOrNull ?? const ChatState();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
sessions: sessions,
|
||||
activeSession:
|
||||
current.activeSession?.id == id ? null : current.activeSession,
|
||||
messages: current.activeSession?.id == id ? [] : current.messages,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
String sessionId;
|
||||
if (current.activeSession == null) {
|
||||
final session = await _repo.createSession();
|
||||
sessionId = session.id;
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(sessions: sessions, activeSession: session),
|
||||
);
|
||||
} else {
|
||||
sessionId = current.activeSession!.id;
|
||||
}
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'user',
|
||||
content: content,
|
||||
);
|
||||
final messagesAfterUser = await _repo.getMessages(sessionId);
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(messages: messagesAfterUser, isTyping: true),
|
||||
);
|
||||
await Future<void>.delayed(const Duration(seconds: 1));
|
||||
final String response = _getMockResponse(content);
|
||||
await _repo.addMessage(
|
||||
sessionId: sessionId,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
);
|
||||
final messagesAfterAi = await _repo.getMessages(sessionId);
|
||||
if (messagesAfterAi.length <= 2) {
|
||||
final title = content.length > 30
|
||||
? '${content.substring(0, 30)}...'
|
||||
: content;
|
||||
await _repo.updateSessionTitle(sessionId, title);
|
||||
}
|
||||
final sessions = await _repo.getAllSessions();
|
||||
state = AsyncValue.data(
|
||||
state.valueOrNull!.copyWith(
|
||||
messages: messagesAfterAi,
|
||||
isTyping: false,
|
||||
sessions: sessions,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMockResponse(String input) {
|
||||
final String lower = input.toLowerCase();
|
||||
if (lower.contains('plan') || lower.contains('program')) {
|
||||
return "I can help you design a training plan! What are your goals? Strength, hypertrophy, or endurance?";
|
||||
} else if (lower.contains('squat') || lower.contains('bench')) {
|
||||
return "Compound movements are great. Remember to maintain proper form. For squats, keep your chest up and knees tracking over toes.";
|
||||
} else if (lower.contains('nutrition') || lower.contains('eat')) {
|
||||
return "Nutrition is key. Aim for 1.6-2.2g of protein per kg of bodyweight if you're training hard.";
|
||||
}
|
||||
return "I'm your AI training assistant. How can I help you today?";
|
||||
}
|
||||
}
|
||||
26
lib/presentation/chat/chat_controller.g.dart
Normal file
26
lib/presentation/chat/chat_controller.g.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatControllerHash() => r'44a3d0e906eaad16f7a9c292fe847b8bd144c835';
|
||||
|
||||
/// See also [ChatController].
|
||||
@ProviderFor(ChatController)
|
||||
final chatControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<ChatController, ChatState>.internal(
|
||||
ChatController.new,
|
||||
name: r'chatControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$ChatController = AutoDisposeAsyncNotifier<ChatState>;
|
||||
// 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
|
||||
766
lib/presentation/chat/chat_page.dart
Normal file
766
lib/presentation/chat/chat_page.dart
Normal file
@@ -0,0 +1,766 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/chat/chat_state.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ChatPage extends ConsumerStatefulWidget {
|
||||
const ChatPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ChatPage> createState() => _ChatPageState();
|
||||
}
|
||||
|
||||
class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final FocusNode _inputFocusNode = FocusNode();
|
||||
String? _hoveredSessionId;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_inputController.dispose();
|
||||
_scrollController.dispose();
|
||||
_inputFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToBottom() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _sendMessage(ChatController controller) {
|
||||
final text = _inputController.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
controller.sendMessage(text);
|
||||
_inputController.clear();
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
String _formatTimestamp(String timestamp) {
|
||||
try {
|
||||
final dt = DateTime.parse(timestamp);
|
||||
final now = DateTime.now();
|
||||
final hour = dt.hour.toString().padLeft(2, '0');
|
||||
final minute = dt.minute.toString().padLeft(2, '0');
|
||||
if (dt.year == now.year &&
|
||||
dt.month == now.month &&
|
||||
dt.day == now.day) {
|
||||
return '$hour:$minute';
|
||||
}
|
||||
return '${dt.day}/${dt.month} $hour:$minute';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(chatControllerProvider);
|
||||
final controller = ref.read(chatControllerProvider.notifier);
|
||||
|
||||
ref.listen(chatControllerProvider, (prev, next) {
|
||||
if (next.hasValue &&
|
||||
(prev?.value?.messages.length ?? 0) <
|
||||
next.value!.messages.length) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
if (next.hasValue && next.value!.isTyping && !(prev?.value?.isTyping ?? false)) {
|
||||
_scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.surface,
|
||||
body: Row(
|
||||
children: [
|
||||
// --- Side Panel ---
|
||||
_buildSidePanel(state, controller),
|
||||
|
||||
// --- Main Chat Area ---
|
||||
Expanded(
|
||||
child: _buildChatArea(state, controller),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side Panel
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildSidePanel(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
) {
|
||||
return Container(
|
||||
width: 250,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
border: Border(
|
||||
right: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// New Chat button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _NewChatButton(onPressed: controller.createSession),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1, color: AppColors.border),
|
||||
|
||||
// Session list
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
data: (data) {
|
||||
if (data.sessions.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing24),
|
||||
child: Text(
|
||||
'No conversations yet',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
itemCount: data.sessions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final session = data.sessions[index];
|
||||
final isActive =
|
||||
session.id == data.activeSession?.id;
|
||||
return _buildSessionTile(
|
||||
session: session,
|
||||
isActive: isActive,
|
||||
controller: controller,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (_, __) => Center(
|
||||
child: Text(
|
||||
'Error loading sessions',
|
||||
style: TextStyle(color: AppColors.textMuted, fontSize: 13),
|
||||
),
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSessionTile({
|
||||
required ChatSessionEntity session,
|
||||
required bool isActive,
|
||||
required ChatController controller,
|
||||
}) {
|
||||
final isHovered = _hoveredSessionId == session.id;
|
||||
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _hoveredSessionId = session.id),
|
||||
onExit: (_) => setState(() {
|
||||
if (_hoveredSessionId == session.id) {
|
||||
_hoveredSessionId = null;
|
||||
}
|
||||
}),
|
||||
child: GestureDetector(
|
||||
onTap: () => controller.loadSession(session.id),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing8,
|
||||
vertical: 2,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? AppColors.zinc700.withValues(alpha: 0.7)
|
||||
: isHovered
|
||||
? AppColors.zinc800.withValues(alpha: 0.6)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: isActive
|
||||
? Border.all(
|
||||
color: AppColors.accent.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 14,
|
||||
color: isActive ? AppColors.accent : AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
session.title ?? 'New Chat',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: isActive
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight:
|
||||
isActive ? FontWeight.w500 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Delete button appears on hover
|
||||
AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
opacity: isHovered ? 1.0 : 0.0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isHovered,
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 14,
|
||||
splashRadius: 14,
|
||||
icon: const Icon(
|
||||
Icons.delete_outline_rounded,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
onPressed: () => controller.deleteSession(session.id),
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat Area
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildChatArea(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
) {
|
||||
return Column(
|
||||
children: [
|
||||
// Messages
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
data: (data) {
|
||||
if (data.messages.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing16,
|
||||
),
|
||||
itemCount:
|
||||
data.messages.length + (data.isTyping ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.messages.length) {
|
||||
return const _TypingIndicator();
|
||||
}
|
||||
final msg = data.messages[index];
|
||||
return _MessageBubble(
|
||||
message: msg,
|
||||
formattedTime: _formatTimestamp(msg.createdAt),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
error: (e, _) => Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Input area
|
||||
_buildInputBar(asyncState, controller),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_outlined,
|
||||
size: 32,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
const Text(
|
||||
'Ask me anything about your training!',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
const Text(
|
||||
'Start a conversation to get personalized advice.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input Bar
|
||||
// ---------------------------------------------------------------------------
|
||||
Widget _buildInputBar(
|
||||
AsyncValue<ChatState> asyncState,
|
||||
ChatController controller,
|
||||
) {
|
||||
final isTyping = asyncState.valueOrNull?.isTyping ?? false;
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.border, width: 1),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 4,
|
||||
minLines: 1,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Type a message...',
|
||||
hintStyle: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(controller),
|
||||
textInputAction: TextInputAction.send,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Material(
|
||||
color: isTyping ? AppColors.zinc700 : AppColors.accent,
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.borderRadius),
|
||||
onTap: isTyping ? null : () => _sendMessage(controller),
|
||||
child: Icon(
|
||||
Icons.arrow_upward_rounded,
|
||||
color: isTyping ? AppColors.textMuted : AppColors.zinc950,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// New Chat Button
|
||||
// =============================================================================
|
||||
class _NewChatButton extends StatefulWidget {
|
||||
const _NewChatButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
State<_NewChatButton> createState() => _NewChatButtonState();
|
||||
}
|
||||
|
||||
class _NewChatButtonState extends State<_NewChatButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius:
|
||||
BorderRadius.circular(UIConstants.smallBorderRadius),
|
||||
onTap: widget.onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(
|
||||
Icons.add_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
'New Chat',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Message Bubble
|
||||
// =============================================================================
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.formattedTime,
|
||||
});
|
||||
|
||||
final ChatMessageEntity message;
|
||||
final String formattedTime;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isUser = message.isUser;
|
||||
final maxWidth = MediaQuery.of(context).size.width * 0.55;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser) ...[
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
],
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? AppColors.zinc700
|
||||
: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft:
|
||||
isUser ? const Radius.circular(16) : const Radius.circular(4),
|
||||
bottomRight:
|
||||
isUser ? const Radius.circular(4) : const Radius.circular(16),
|
||||
),
|
||||
border: isUser
|
||||
? null
|
||||
: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: SelectableText(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
formattedTime,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isUser) ...[
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Typing Indicator (3 animated bouncing dots)
|
||||
// =============================================================================
|
||||
class _TypingIndicator extends StatefulWidget {
|
||||
const _TypingIndicator();
|
||||
|
||||
@override
|
||||
State<_TypingIndicator> createState() => _TypingIndicatorState();
|
||||
}
|
||||
|
||||
class _TypingIndicatorState extends State<_TypingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UIConstants.spacing12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.auto_awesome_rounded,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
bottomLeft: Radius.circular(4),
|
||||
bottomRight: Radius.circular(16),
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(3, (index) {
|
||||
// Stagger each dot by 0.2 of the animation cycle
|
||||
final delay = index * 0.2;
|
||||
final t = (_controller.value - delay) % 1.0;
|
||||
// Bounce: use a sin curve over the first half, rest at 0
|
||||
final bounce =
|
||||
t < 0.5 ? math.sin(t * math.pi * 2) * 4.0 : 0.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: index == 0 ? 0 : 4,
|
||||
),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -bounce.abs()),
|
||||
child: Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textMuted,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/presentation/chat/chat_state.dart
Normal file
15
lib/presentation/chat/chat_state.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_session.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/chat_message.dart';
|
||||
|
||||
part 'chat_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class ChatState with _$ChatState {
|
||||
const factory ChatState({
|
||||
@Default([]) List<ChatSessionEntity> sessions,
|
||||
ChatSessionEntity? activeSession,
|
||||
@Default([]) List<ChatMessageEntity> messages,
|
||||
@Default(false) bool isTyping,
|
||||
}) = _ChatState;
|
||||
}
|
||||
261
lib/presentation/chat/chat_state.freezed.dart
Normal file
261
lib/presentation/chat/chat_state.freezed.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
// 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 'chat_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 _$ChatState {
|
||||
List<ChatSessionEntity> get sessions => throw _privateConstructorUsedError;
|
||||
ChatSessionEntity? get activeSession => throw _privateConstructorUsedError;
|
||||
List<ChatMessageEntity> get messages => throw _privateConstructorUsedError;
|
||||
bool get isTyping => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$ChatStateCopyWith<ChatState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $ChatStateCopyWith<$Res> {
|
||||
factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) then) =
|
||||
_$ChatStateCopyWithImpl<$Res, ChatState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ChatSessionEntity> sessions,
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
});
|
||||
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$ChatStateCopyWithImpl<$Res, $Val extends ChatState>
|
||||
implements $ChatStateCopyWith<$Res> {
|
||||
_$ChatStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// 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? messages = null,
|
||||
Object? isTyping = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
sessions: null == sessions
|
||||
? _value.sessions
|
||||
: sessions // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatSessionEntity>,
|
||||
activeSession: freezed == activeSession
|
||||
? _value.activeSession
|
||||
: activeSession // ignore: cast_nullable_to_non_nullable
|
||||
as ChatSessionEntity?,
|
||||
messages: null == messages
|
||||
? _value.messages
|
||||
: messages // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageEntity>,
|
||||
isTyping: null == isTyping
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession {
|
||||
if (_value.activeSession == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $ChatSessionEntityCopyWith<$Res>(_value.activeSession!, (value) {
|
||||
return _then(_value.copyWith(activeSession: value) as $Val);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$ChatStateImplCopyWith<$Res>
|
||||
implements $ChatStateCopyWith<$Res> {
|
||||
factory _$$ChatStateImplCopyWith(
|
||||
_$ChatStateImpl value,
|
||||
$Res Function(_$ChatStateImpl) then,
|
||||
) = __$$ChatStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
List<ChatSessionEntity> sessions,
|
||||
ChatSessionEntity? activeSession,
|
||||
List<ChatMessageEntity> messages,
|
||||
bool isTyping,
|
||||
});
|
||||
|
||||
@override
|
||||
$ChatSessionEntityCopyWith<$Res>? get activeSession;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$ChatStateImplCopyWithImpl<$Res>
|
||||
extends _$ChatStateCopyWithImpl<$Res, _$ChatStateImpl>
|
||||
implements _$$ChatStateImplCopyWith<$Res> {
|
||||
__$$ChatStateImplCopyWithImpl(
|
||||
_$ChatStateImpl _value,
|
||||
$Res Function(_$ChatStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// 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? messages = null,
|
||||
Object? isTyping = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$ChatStateImpl(
|
||||
sessions: null == sessions
|
||||
? _value._sessions
|
||||
: sessions // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatSessionEntity>,
|
||||
activeSession: freezed == activeSession
|
||||
? _value.activeSession
|
||||
: activeSession // ignore: cast_nullable_to_non_nullable
|
||||
as ChatSessionEntity?,
|
||||
messages: null == messages
|
||||
? _value._messages
|
||||
: messages // ignore: cast_nullable_to_non_nullable
|
||||
as List<ChatMessageEntity>,
|
||||
isTyping: null == isTyping
|
||||
? _value.isTyping
|
||||
: isTyping // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$ChatStateImpl implements _ChatState {
|
||||
const _$ChatStateImpl({
|
||||
final List<ChatSessionEntity> sessions = const [],
|
||||
this.activeSession,
|
||||
final List<ChatMessageEntity> messages = const [],
|
||||
this.isTyping = false,
|
||||
}) : _sessions = sessions,
|
||||
_messages = messages;
|
||||
|
||||
final List<ChatSessionEntity> _sessions;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ChatSessionEntity> get sessions {
|
||||
if (_sessions is EqualUnmodifiableListView) return _sessions;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_sessions);
|
||||
}
|
||||
|
||||
@override
|
||||
final ChatSessionEntity? activeSession;
|
||||
final List<ChatMessageEntity> _messages;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ChatMessageEntity> get messages {
|
||||
if (_messages is EqualUnmodifiableListView) return _messages;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_messages);
|
||||
}
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isTyping;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatState(sessions: $sessions, activeSession: $activeSession, messages: $messages, isTyping: $isTyping)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$ChatStateImpl &&
|
||||
const DeepCollectionEquality().equals(other._sessions, _sessions) &&
|
||||
(identical(other.activeSession, activeSession) ||
|
||||
other.activeSession == activeSession) &&
|
||||
const DeepCollectionEquality().equals(other._messages, _messages) &&
|
||||
(identical(other.isTyping, isTyping) ||
|
||||
other.isTyping == isTyping));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(_sessions),
|
||||
activeSession,
|
||||
const DeepCollectionEquality().hash(_messages),
|
||||
isTyping,
|
||||
);
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$ChatStateImplCopyWith<_$ChatStateImpl> get copyWith =>
|
||||
__$$ChatStateImplCopyWithImpl<_$ChatStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _ChatState implements ChatState {
|
||||
const factory _ChatState({
|
||||
final List<ChatSessionEntity> sessions,
|
||||
final ChatSessionEntity? activeSession,
|
||||
final List<ChatMessageEntity> messages,
|
||||
final bool isTyping,
|
||||
}) = _$ChatStateImpl;
|
||||
|
||||
@override
|
||||
List<ChatSessionEntity> get sessions;
|
||||
@override
|
||||
ChatSessionEntity? get activeSession;
|
||||
@override
|
||||
List<ChatMessageEntity> get messages;
|
||||
@override
|
||||
bool get isTyping;
|
||||
|
||||
/// Create a copy of ChatState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$ChatStateImplCopyWith<_$ChatStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
105
lib/presentation/common/dialogs/confirm_dialog.dart
Normal file
105
lib/presentation/common/dialogs/confirm_dialog.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class ConfirmDialog {
|
||||
ConfirmDialog._();
|
||||
|
||||
static Future<bool?> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required String message,
|
||||
String confirmLabel = 'Delete',
|
||||
Color? confirmColor,
|
||||
}) {
|
||||
final color = confirmColor ?? AppColors.destructive;
|
||||
|
||||
return showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: AppColors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: UIConstants.dialogWidth),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
message,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: AppColors.zinc50,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
confirmLabel,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
lib/presentation/common/dialogs/text_input_dialog.dart
Normal file
143
lib/presentation/common/dialogs/text_input_dialog.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class TextInputDialog {
|
||||
TextInputDialog._();
|
||||
|
||||
static Future<String?> show(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
String? hintText,
|
||||
String? initialValue,
|
||||
String confirmLabel = 'Create',
|
||||
}) {
|
||||
final controller = TextEditingController(text: initialValue);
|
||||
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
backgroundColor: AppColors.surfaceContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: UIConstants.dialogWidth),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
cursorColor: AppColors.accent,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.zinc950,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
borderSide: const BorderSide(color: AppColors.accent),
|
||||
),
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
final text = value.trim();
|
||||
if (text.isNotEmpty) {
|
||||
Navigator.of(context).pop(text);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textSecondary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final text = controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
Navigator.of(context).pop(text);
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: AppColors.zinc950,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
confirmLabel,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/presentation/common/widgets/app_empty_state.dart
Normal file
94
lib/presentation/common/widgets/app_empty_state.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
const AppEmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 28,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
FilledButton(
|
||||
onPressed: onAction,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.accent,
|
||||
foregroundColor: AppColors.zinc950,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
actionLabel!,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
85
lib/presentation/common/widgets/app_stat_card.dart
Normal file
85
lib/presentation/common/widgets/app_stat_card.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
|
||||
class AppStatCard extends StatelessWidget {
|
||||
const AppStatCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.accentColor,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String value;
|
||||
final Color accentColor;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 72,
|
||||
color: accentColor,
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.cardPadding,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Text(
|
||||
value,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (icon != null)
|
||||
Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: accentColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
lib/presentation/home/home_controller.dart
Normal file
32
lib/presentation/home/home_controller.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/program_repository.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/home_state.dart';
|
||||
|
||||
part 'home_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class HomeController extends _$HomeController {
|
||||
@override
|
||||
Future<HomeState> build() async {
|
||||
final ProgramRepository programRepo = getIt<ProgramRepository>();
|
||||
final programs = await programRepo.getAllPrograms();
|
||||
if (programs.isEmpty) return const HomeState();
|
||||
final activeProgram = programs.first;
|
||||
final workouts = await programRepo.getWorkouts(activeProgram.id);
|
||||
final completed = workouts.where((w) => w.completed).toList();
|
||||
final next = workouts.where((w) => !w.completed).firstOrNull;
|
||||
return HomeState(
|
||||
activeProgramName: activeProgram.name,
|
||||
completedWorkouts: completed.length,
|
||||
totalWorkouts: workouts.length,
|
||||
nextWorkoutName: next?.name,
|
||||
recentActivity: completed.reversed.take(5).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
state = const AsyncValue.loading();
|
||||
state = await AsyncValue.guard(() => build());
|
||||
}
|
||||
}
|
||||
26
lib/presentation/home/home_controller.g.dart
Normal file
26
lib/presentation/home/home_controller.g.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'home_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$homeControllerHash() => r'9704c0237cda64f1c13b4fd6db4fbc6eca9988f8';
|
||||
|
||||
/// See also [HomeController].
|
||||
@ProviderFor(HomeController)
|
||||
final homeControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<HomeController, HomeState>.internal(
|
||||
HomeController.new,
|
||||
name: r'homeControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$homeControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$HomeController = AutoDisposeAsyncNotifier<HomeState>;
|
||||
// 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
|
||||
699
lib/presentation/home/home_page.dart
Normal file
699
lib/presentation/home/home_page.dart
Normal file
@@ -0,0 +1,699 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/widgets/app_stat_card.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/home_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/home/home_state.dart';
|
||||
|
||||
@RoutePage()
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncState = ref.watch(homeControllerProvider);
|
||||
|
||||
return asyncState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 48,
|
||||
color: AppColors.destructive,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
Text(
|
||||
'Something went wrong',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'$e',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
FilledButton.icon(
|
||||
onPressed: () =>
|
||||
ref.read(homeControllerProvider.notifier).refresh(),
|
||||
icon: const Icon(Icons.refresh, size: 18),
|
||||
label: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (data) {
|
||||
if (data.activeProgramName == null) {
|
||||
return AppEmptyState(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
title: 'No active program',
|
||||
subtitle:
|
||||
'Head to Calendar to create or select a training program to get started.',
|
||||
actionLabel: 'Go to Calendar',
|
||||
onAction: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(3);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _HomeContent(data: data);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeContent extends StatelessWidget {
|
||||
final HomeState data;
|
||||
|
||||
const _HomeContent({required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UIConstants.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// -- Welcome header --
|
||||
_WelcomeHeader(programName: data.activeProgramName!),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
// -- Stat cards row --
|
||||
_StatCardsRow(
|
||||
completed: data.completedWorkouts,
|
||||
total: data.totalWorkouts,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
// -- Next workout banner --
|
||||
if (data.nextWorkoutName != null) ...[
|
||||
_NextWorkoutBanner(workoutName: data.nextWorkoutName!),
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
],
|
||||
|
||||
// -- Quick actions --
|
||||
_QuickActionsRow(),
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// -- Recent activity --
|
||||
_RecentActivitySection(activity: data.recentActivity),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Welcome header
|
||||
// ---------------------------------------------------------------------------
|
||||
class _WelcomeHeader extends StatelessWidget {
|
||||
final String programName;
|
||||
|
||||
const _WelcomeHeader({required this.programName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome back',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
programName,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.fitness_center,
|
||||
size: 14,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Active Program',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stat cards row
|
||||
// ---------------------------------------------------------------------------
|
||||
class _StatCardsRow extends StatelessWidget {
|
||||
final int completed;
|
||||
final int total;
|
||||
|
||||
const _StatCardsRow({required this.completed, required this.total});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = total == 0 ? 0 : (completed / total * 100).round();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Completed',
|
||||
value: '$completed',
|
||||
icon: Icons.check_circle_outline,
|
||||
accentColor: AppColors.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing16),
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Total Workouts',
|
||||
value: '$total',
|
||||
icon: Icons.list_alt,
|
||||
accentColor: AppColors.info,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing16),
|
||||
Expanded(
|
||||
child: AppStatCard(
|
||||
title: 'Progress',
|
||||
value: '$progress%',
|
||||
icon: Icons.trending_up,
|
||||
accentColor: AppColors.purple,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Next workout banner
|
||||
// ---------------------------------------------------------------------------
|
||||
class _NextWorkoutBanner extends StatelessWidget {
|
||||
final String workoutName;
|
||||
|
||||
const _NextWorkoutBanner({required this.workoutName});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UIConstants.cardPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.play_arrow_rounded,
|
||||
color: AppColors.accent,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Up Next',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
workoutName,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textMuted,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quick actions row
|
||||
// ---------------------------------------------------------------------------
|
||||
class _QuickActionsRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Quick Actions',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Row(
|
||||
children: [
|
||||
_QuickActionButton(
|
||||
icon: Icons.play_arrow_rounded,
|
||||
label: 'New Workout',
|
||||
color: AppColors.accent,
|
||||
onTap: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(1);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_QuickActionButton(
|
||||
icon: Icons.description_outlined,
|
||||
label: 'View Plans',
|
||||
color: AppColors.info,
|
||||
onTap: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(1);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
_QuickActionButton(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
label: 'AI Chat',
|
||||
color: AppColors.purple,
|
||||
onTap: () {
|
||||
AutoTabsRouter.of(context).setActiveIndex(4);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickActionButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QuickActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_QuickActionButton> createState() => _QuickActionButtonState();
|
||||
}
|
||||
|
||||
class _QuickActionButtonState extends State<_QuickActionButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
decoration: BoxDecoration(
|
||||
color: _isHovered
|
||||
? widget.color.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
border: Border.all(
|
||||
color: _isHovered ? widget.color.withValues(alpha: 0.4) : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
widget.icon,
|
||||
size: 18,
|
||||
color: widget.color,
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Text(
|
||||
widget.label,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _isHovered
|
||||
? widget.color
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recent activity section
|
||||
// ---------------------------------------------------------------------------
|
||||
class _RecentActivitySection extends StatelessWidget {
|
||||
final List<ProgramWorkoutEntity> activity;
|
||||
|
||||
const _RecentActivitySection({required this.activity});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Recent Activity',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (activity.isNotEmpty)
|
||||
Text(
|
||||
'${activity.length} workout${activity.length == 1 ? '' : 's'}',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
if (activity.isEmpty)
|
||||
_EmptyActivity()
|
||||
else
|
||||
_ActivityList(activity: activity),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyActivity extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 40,
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.history,
|
||||
size: 32,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
Text(
|
||||
'No completed workouts yet',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Text(
|
||||
'Your recent workout history will appear here.',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityList extends StatelessWidget {
|
||||
final List<ProgramWorkoutEntity> activity;
|
||||
|
||||
const _ActivityList({required this.activity});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < activity.length; i++) ...[
|
||||
if (i > 0)
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: AppColors.border,
|
||||
),
|
||||
_ActivityItem(workout: activity[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActivityItem extends StatefulWidget {
|
||||
final ProgramWorkoutEntity workout;
|
||||
|
||||
const _ActivityItem({required this.workout});
|
||||
|
||||
@override
|
||||
State<_ActivityItem> createState() => _ActivityItemState();
|
||||
}
|
||||
|
||||
class _ActivityItemState extends State<_ActivityItem> {
|
||||
bool _isHovered = false;
|
||||
|
||||
Color get _typeColor {
|
||||
switch (widget.workout.type.toLowerCase()) {
|
||||
case 'strength':
|
||||
return AppColors.accent;
|
||||
case 'cardio':
|
||||
return AppColors.info;
|
||||
case 'flexibility':
|
||||
case 'mobility':
|
||||
return AppColors.purple;
|
||||
case 'rest':
|
||||
return AppColors.textMuted;
|
||||
default:
|
||||
return AppColors.success;
|
||||
}
|
||||
}
|
||||
|
||||
IconData get _typeIcon {
|
||||
switch (widget.workout.type.toLowerCase()) {
|
||||
case 'strength':
|
||||
return Icons.fitness_center;
|
||||
case 'cardio':
|
||||
return Icons.directions_run;
|
||||
case 'flexibility':
|
||||
case 'mobility':
|
||||
return Icons.self_improvement;
|
||||
case 'rest':
|
||||
return Icons.bedtime_outlined;
|
||||
default:
|
||||
return Icons.check_circle;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: AnimatedContainer(
|
||||
duration: UIConstants.animationDuration,
|
||||
color: _isHovered
|
||||
? AppColors.surfaceContainerHigh.withValues(alpha: 0.5)
|
||||
: Colors.transparent,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.cardPadding,
|
||||
vertical: UIConstants.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Leading icon with color coding
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.workout.completed
|
||||
? _typeColor.withValues(alpha: 0.15)
|
||||
: AppColors.zinc800,
|
||||
borderRadius: UIConstants.smallCardBorderRadius,
|
||||
),
|
||||
child: Icon(
|
||||
widget.workout.completed ? _typeIcon : Icons.circle_outlined,
|
||||
size: 18,
|
||||
color: widget.workout.completed
|
||||
? _typeColor
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
|
||||
// Workout info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.workout.name ?? 'Workout',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Week ${widget.workout.weekId} · Day ${widget.workout.day}',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Type badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _typeColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
widget.workout.type,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _typeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
|
||||
// Status indicator
|
||||
if (widget.workout.completed)
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: AppColors.success,
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
Icons.radio_button_unchecked,
|
||||
size: 18,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/presentation/home/home_state.dart
Normal file
15
lib/presentation/home/home_state.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/program_workout.dart';
|
||||
|
||||
part 'home_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class HomeState with _$HomeState {
|
||||
const factory HomeState({
|
||||
String? activeProgramName,
|
||||
@Default(0) int completedWorkouts,
|
||||
@Default(0) int totalWorkouts,
|
||||
String? nextWorkoutName,
|
||||
@Default([]) List<ProgramWorkoutEntity> recentActivity,
|
||||
}) = _HomeState;
|
||||
}
|
||||
261
lib/presentation/home/home_state.freezed.dart
Normal file
261
lib/presentation/home/home_state.freezed.dart
Normal file
@@ -0,0 +1,261 @@
|
||||
// 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 'home_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 _$HomeState {
|
||||
String? get activeProgramName => throw _privateConstructorUsedError;
|
||||
int get completedWorkouts => throw _privateConstructorUsedError;
|
||||
int get totalWorkouts => throw _privateConstructorUsedError;
|
||||
String? get nextWorkoutName => throw _privateConstructorUsedError;
|
||||
List<ProgramWorkoutEntity> get recentActivity =>
|
||||
throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of HomeState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$HomeStateCopyWith<HomeState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $HomeStateCopyWith<$Res> {
|
||||
factory $HomeStateCopyWith(HomeState value, $Res Function(HomeState) then) =
|
||||
_$HomeStateCopyWithImpl<$Res, HomeState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
String? activeProgramName,
|
||||
int completedWorkouts,
|
||||
int totalWorkouts,
|
||||
String? nextWorkoutName,
|
||||
List<ProgramWorkoutEntity> recentActivity,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$HomeStateCopyWithImpl<$Res, $Val extends HomeState>
|
||||
implements $HomeStateCopyWith<$Res> {
|
||||
_$HomeStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of HomeState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activeProgramName = freezed,
|
||||
Object? completedWorkouts = null,
|
||||
Object? totalWorkouts = null,
|
||||
Object? nextWorkoutName = freezed,
|
||||
Object? recentActivity = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
activeProgramName: freezed == activeProgramName
|
||||
? _value.activeProgramName
|
||||
: activeProgramName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
completedWorkouts: null == completedWorkouts
|
||||
? _value.completedWorkouts
|
||||
: completedWorkouts // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
totalWorkouts: null == totalWorkouts
|
||||
? _value.totalWorkouts
|
||||
: totalWorkouts // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
nextWorkoutName: freezed == nextWorkoutName
|
||||
? _value.nextWorkoutName
|
||||
: nextWorkoutName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
recentActivity: null == recentActivity
|
||||
? _value.recentActivity
|
||||
: recentActivity // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramWorkoutEntity>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$HomeStateImplCopyWith<$Res>
|
||||
implements $HomeStateCopyWith<$Res> {
|
||||
factory _$$HomeStateImplCopyWith(
|
||||
_$HomeStateImpl value,
|
||||
$Res Function(_$HomeStateImpl) then,
|
||||
) = __$$HomeStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
String? activeProgramName,
|
||||
int completedWorkouts,
|
||||
int totalWorkouts,
|
||||
String? nextWorkoutName,
|
||||
List<ProgramWorkoutEntity> recentActivity,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$HomeStateImplCopyWithImpl<$Res>
|
||||
extends _$HomeStateCopyWithImpl<$Res, _$HomeStateImpl>
|
||||
implements _$$HomeStateImplCopyWith<$Res> {
|
||||
__$$HomeStateImplCopyWithImpl(
|
||||
_$HomeStateImpl _value,
|
||||
$Res Function(_$HomeStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of HomeState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activeProgramName = freezed,
|
||||
Object? completedWorkouts = null,
|
||||
Object? totalWorkouts = null,
|
||||
Object? nextWorkoutName = freezed,
|
||||
Object? recentActivity = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$HomeStateImpl(
|
||||
activeProgramName: freezed == activeProgramName
|
||||
? _value.activeProgramName
|
||||
: activeProgramName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
completedWorkouts: null == completedWorkouts
|
||||
? _value.completedWorkouts
|
||||
: completedWorkouts // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
totalWorkouts: null == totalWorkouts
|
||||
? _value.totalWorkouts
|
||||
: totalWorkouts // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
nextWorkoutName: freezed == nextWorkoutName
|
||||
? _value.nextWorkoutName
|
||||
: nextWorkoutName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
recentActivity: null == recentActivity
|
||||
? _value._recentActivity
|
||||
: recentActivity // ignore: cast_nullable_to_non_nullable
|
||||
as List<ProgramWorkoutEntity>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$HomeStateImpl implements _HomeState {
|
||||
const _$HomeStateImpl({
|
||||
this.activeProgramName,
|
||||
this.completedWorkouts = 0,
|
||||
this.totalWorkouts = 0,
|
||||
this.nextWorkoutName,
|
||||
final List<ProgramWorkoutEntity> recentActivity = const [],
|
||||
}) : _recentActivity = recentActivity;
|
||||
|
||||
@override
|
||||
final String? activeProgramName;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int completedWorkouts;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int totalWorkouts;
|
||||
@override
|
||||
final String? nextWorkoutName;
|
||||
final List<ProgramWorkoutEntity> _recentActivity;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ProgramWorkoutEntity> get recentActivity {
|
||||
if (_recentActivity is EqualUnmodifiableListView) return _recentActivity;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_recentActivity);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'HomeState(activeProgramName: $activeProgramName, completedWorkouts: $completedWorkouts, totalWorkouts: $totalWorkouts, nextWorkoutName: $nextWorkoutName, recentActivity: $recentActivity)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$HomeStateImpl &&
|
||||
(identical(other.activeProgramName, activeProgramName) ||
|
||||
other.activeProgramName == activeProgramName) &&
|
||||
(identical(other.completedWorkouts, completedWorkouts) ||
|
||||
other.completedWorkouts == completedWorkouts) &&
|
||||
(identical(other.totalWorkouts, totalWorkouts) ||
|
||||
other.totalWorkouts == totalWorkouts) &&
|
||||
(identical(other.nextWorkoutName, nextWorkoutName) ||
|
||||
other.nextWorkoutName == nextWorkoutName) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._recentActivity,
|
||||
_recentActivity,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
activeProgramName,
|
||||
completedWorkouts,
|
||||
totalWorkouts,
|
||||
nextWorkoutName,
|
||||
const DeepCollectionEquality().hash(_recentActivity),
|
||||
);
|
||||
|
||||
/// Create a copy of HomeState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$HomeStateImplCopyWith<_$HomeStateImpl> get copyWith =>
|
||||
__$$HomeStateImplCopyWithImpl<_$HomeStateImpl>(this, _$identity);
|
||||
}
|
||||
|
||||
abstract class _HomeState implements HomeState {
|
||||
const factory _HomeState({
|
||||
final String? activeProgramName,
|
||||
final int completedWorkouts,
|
||||
final int totalWorkouts,
|
||||
final String? nextWorkoutName,
|
||||
final List<ProgramWorkoutEntity> recentActivity,
|
||||
}) = _$HomeStateImpl;
|
||||
|
||||
@override
|
||||
String? get activeProgramName;
|
||||
@override
|
||||
int get completedWorkouts;
|
||||
@override
|
||||
int get totalWorkouts;
|
||||
@override
|
||||
String? get nextWorkoutName;
|
||||
@override
|
||||
List<ProgramWorkoutEntity> get recentActivity;
|
||||
|
||||
/// Create a copy of HomeState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$HomeStateImplCopyWith<_$HomeStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
177
lib/presentation/plan_editor/plan_editor_controller.dart
Normal file
177
lib/presentation/plan_editor/plan_editor_controller.dart
Normal file
@@ -0,0 +1,177 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/core/utils/id_generator.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_section.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
|
||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_state.dart';
|
||||
|
||||
part 'plan_editor_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class PlanEditorController extends _$PlanEditorController {
|
||||
late final TrainingPlanRepository _planRepo;
|
||||
|
||||
@override
|
||||
Future<PlanEditorState> build(String planId) async {
|
||||
_planRepo = getIt<TrainingPlanRepository>();
|
||||
final ExerciseRepository exerciseRepo = getIt<ExerciseRepository>();
|
||||
|
||||
final plan = await _planRepo.getById(planId);
|
||||
final exercises = await exerciseRepo.getAll();
|
||||
|
||||
return PlanEditorState(plan: plan, availableExercises: exercises);
|
||||
}
|
||||
|
||||
void updatePlanName(String name) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(plan: current.plan.copyWith(name: name), isDirty: true),
|
||||
);
|
||||
}
|
||||
|
||||
void addSection() {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final newSection = TrainingSectionEntity(
|
||||
id: IdGenerator.generate(),
|
||||
name: 'New Section',
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
plan: current.plan.copyWith(
|
||||
sections: [...current.plan.sections, newSection],
|
||||
),
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void deleteSection(int index) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||
sections.removeAt(index);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
plan: current.plan.copyWith(sections: sections),
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateSectionName(int sectionIndex, String name) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||
sections[sectionIndex] = sections[sectionIndex].copyWith(name: name);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
plan: current.plan.copyWith(sections: sections),
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void addExerciseToSection(int sectionIndex, ExerciseEntity exercise) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||
final newExercise = TrainingExerciseEntity(
|
||||
instanceId: IdGenerator.generate(),
|
||||
exerciseId: exercise.id,
|
||||
name: exercise.name,
|
||||
);
|
||||
sections[sectionIndex] = sections[sectionIndex].copyWith(
|
||||
exercises: [...sections[sectionIndex].exercises, newExercise],
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
plan: current.plan.copyWith(sections: sections),
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void removeExerciseFromSection(int sectionIndex, int exerciseIndex) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||
final exercises = List<TrainingExerciseEntity>.from(
|
||||
sections[sectionIndex].exercises,
|
||||
);
|
||||
exercises.removeAt(exerciseIndex);
|
||||
sections[sectionIndex] = sections[sectionIndex].copyWith(
|
||||
exercises: exercises,
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
plan: current.plan.copyWith(sections: sections),
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void updateExerciseParams(
|
||||
int sectionIndex,
|
||||
int exerciseIndex, {
|
||||
int? sets,
|
||||
int? value,
|
||||
bool? isTime,
|
||||
int? rest,
|
||||
}) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||
final exercises = List<TrainingExerciseEntity>.from(
|
||||
sections[sectionIndex].exercises,
|
||||
);
|
||||
exercises[exerciseIndex] = exercises[exerciseIndex].copyWith(
|
||||
sets: sets ?? exercises[exerciseIndex].sets,
|
||||
value: value ?? exercises[exerciseIndex].value,
|
||||
isTime: isTime ?? exercises[exerciseIndex].isTime,
|
||||
rest: rest ?? exercises[exerciseIndex].rest,
|
||||
);
|
||||
sections[sectionIndex] = sections[sectionIndex].copyWith(
|
||||
exercises: exercises,
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
plan: current.plan.copyWith(sections: sections),
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void reorderExercise(int sectionIndex, int oldIndex, int newIndex) {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
final sections = List<TrainingSectionEntity>.from(current.plan.sections);
|
||||
final exercises = List<TrainingExerciseEntity>.from(
|
||||
sections[sectionIndex].exercises,
|
||||
);
|
||||
if (oldIndex < newIndex) newIndex -= 1;
|
||||
final item = exercises.removeAt(oldIndex);
|
||||
exercises.insert(newIndex, item);
|
||||
sections[sectionIndex] = sections[sectionIndex].copyWith(
|
||||
exercises: exercises,
|
||||
);
|
||||
state = AsyncValue.data(
|
||||
current.copyWith(
|
||||
plan: current.plan.copyWith(sections: sections),
|
||||
isDirty: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
final current = state.valueOrNull;
|
||||
if (current == null) return;
|
||||
await _planRepo.update(current.plan);
|
||||
state = AsyncValue.data(current.copyWith(isDirty: false));
|
||||
}
|
||||
}
|
||||
175
lib/presentation/plan_editor/plan_editor_controller.g.dart
Normal file
175
lib/presentation/plan_editor/plan_editor_controller.g.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plan_editor_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$planEditorControllerHash() =>
|
||||
r'4045493829126f28b3a58695b68ade53519c1412';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$PlanEditorController
|
||||
extends BuildlessAutoDisposeAsyncNotifier<PlanEditorState> {
|
||||
late final String planId;
|
||||
|
||||
FutureOr<PlanEditorState> build(String planId);
|
||||
}
|
||||
|
||||
/// See also [PlanEditorController].
|
||||
@ProviderFor(PlanEditorController)
|
||||
const planEditorControllerProvider = PlanEditorControllerFamily();
|
||||
|
||||
/// See also [PlanEditorController].
|
||||
class PlanEditorControllerFamily extends Family<AsyncValue<PlanEditorState>> {
|
||||
/// See also [PlanEditorController].
|
||||
const PlanEditorControllerFamily();
|
||||
|
||||
/// See also [PlanEditorController].
|
||||
PlanEditorControllerProvider call(String planId) {
|
||||
return PlanEditorControllerProvider(planId);
|
||||
}
|
||||
|
||||
@override
|
||||
PlanEditorControllerProvider getProviderOverride(
|
||||
covariant PlanEditorControllerProvider provider,
|
||||
) {
|
||||
return call(provider.planId);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'planEditorControllerProvider';
|
||||
}
|
||||
|
||||
/// See also [PlanEditorController].
|
||||
class PlanEditorControllerProvider
|
||||
extends
|
||||
AutoDisposeAsyncNotifierProviderImpl<
|
||||
PlanEditorController,
|
||||
PlanEditorState
|
||||
> {
|
||||
/// See also [PlanEditorController].
|
||||
PlanEditorControllerProvider(String planId)
|
||||
: this._internal(
|
||||
() => PlanEditorController()..planId = planId,
|
||||
from: planEditorControllerProvider,
|
||||
name: r'planEditorControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$planEditorControllerHash,
|
||||
dependencies: PlanEditorControllerFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
PlanEditorControllerFamily._allTransitiveDependencies,
|
||||
planId: planId,
|
||||
);
|
||||
|
||||
PlanEditorControllerProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.planId,
|
||||
}) : super.internal();
|
||||
|
||||
final String planId;
|
||||
|
||||
@override
|
||||
FutureOr<PlanEditorState> runNotifierBuild(
|
||||
covariant PlanEditorController notifier,
|
||||
) {
|
||||
return notifier.build(planId);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(PlanEditorController Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: PlanEditorControllerProvider._internal(
|
||||
() => create()..planId = planId,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
planId: planId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeAsyncNotifierProviderElement<PlanEditorController, PlanEditorState>
|
||||
createElement() {
|
||||
return _PlanEditorControllerProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is PlanEditorControllerProvider && other.planId == planId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, planId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin PlanEditorControllerRef
|
||||
on AutoDisposeAsyncNotifierProviderRef<PlanEditorState> {
|
||||
/// The parameter `planId` of this provider.
|
||||
String get planId;
|
||||
}
|
||||
|
||||
class _PlanEditorControllerProviderElement
|
||||
extends
|
||||
AutoDisposeAsyncNotifierProviderElement<
|
||||
PlanEditorController,
|
||||
PlanEditorState
|
||||
>
|
||||
with PlanEditorControllerRef {
|
||||
_PlanEditorControllerProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get planId => (origin as PlanEditorControllerProvider).planId;
|
||||
}
|
||||
|
||||
// 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
|
||||
80
lib/presentation/plan_editor/plan_editor_page.dart
Normal file
80
lib/presentation/plan_editor/plan_editor_page.dart
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_section_card.dart';
|
||||
|
||||
@RoutePage()
|
||||
class PlanEditorPage extends ConsumerWidget {
|
||||
final String planId;
|
||||
|
||||
const PlanEditorPage({super.key, @PathParam('planId') required this.planId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(planEditorControllerProvider(planId));
|
||||
final controller = ref.read(planEditorControllerProvider(planId).notifier);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: state.when(
|
||||
data: (data) => TextField(
|
||||
controller: TextEditingController(text: data.plan.name)
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: data.plan.name.length),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Plan Name',
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
onChanged: controller.updatePlanName,
|
||||
),
|
||||
error: (_, __) => const Text('Error'),
|
||||
loading: () => const Text('Loading...'),
|
||||
),
|
||||
actions: [
|
||||
state.maybeWhen(
|
||||
data: (data) => IconButton(
|
||||
icon: Icon(
|
||||
Icons.save,
|
||||
color: data.isDirty ? Theme.of(context).primaryColor : null,
|
||||
),
|
||||
onPressed: data.isDirty ? () => controller.save() : null,
|
||||
),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: state.when(
|
||||
data: (data) => ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: data.plan.sections.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == data.plan.sections.length) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: controller.addSection,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Section'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final section = data.plan.sections[index];
|
||||
return PlanSectionCard(
|
||||
section: section,
|
||||
sectionIndex: index,
|
||||
plan: data.plan,
|
||||
availableExercises: data.availableExercises,
|
||||
);
|
||||
},
|
||||
),
|
||||
error: (e, s) => Center(child: Text('Error: $e')),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
14
lib/presentation/plan_editor/plan_editor_state.dart
Normal file
14
lib/presentation/plan_editor/plan_editor_state.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||
|
||||
part 'plan_editor_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class PlanEditorState with _$PlanEditorState {
|
||||
const factory PlanEditorState({
|
||||
required TrainingPlanEntity plan,
|
||||
@Default(false) bool isDirty,
|
||||
@Default([]) List<ExerciseEntity> availableExercises,
|
||||
}) = _PlanEditorState;
|
||||
}
|
||||
235
lib/presentation/plan_editor/plan_editor_state.freezed.dart
Normal file
235
lib/presentation/plan_editor/plan_editor_state.freezed.dart
Normal file
@@ -0,0 +1,235 @@
|
||||
// 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 'plan_editor_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 _$PlanEditorState {
|
||||
TrainingPlanEntity get plan => throw _privateConstructorUsedError;
|
||||
bool get isDirty => throw _privateConstructorUsedError;
|
||||
List<ExerciseEntity> get availableExercises =>
|
||||
throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of PlanEditorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$PlanEditorStateCopyWith<PlanEditorState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $PlanEditorStateCopyWith<$Res> {
|
||||
factory $PlanEditorStateCopyWith(
|
||||
PlanEditorState value,
|
||||
$Res Function(PlanEditorState) then,
|
||||
) = _$PlanEditorStateCopyWithImpl<$Res, PlanEditorState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
TrainingPlanEntity plan,
|
||||
bool isDirty,
|
||||
List<ExerciseEntity> availableExercises,
|
||||
});
|
||||
|
||||
$TrainingPlanEntityCopyWith<$Res> get plan;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$PlanEditorStateCopyWithImpl<$Res, $Val extends PlanEditorState>
|
||||
implements $PlanEditorStateCopyWith<$Res> {
|
||||
_$PlanEditorStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of PlanEditorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? plan = null,
|
||||
Object? isDirty = null,
|
||||
Object? availableExercises = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
plan: null == plan
|
||||
? _value.plan
|
||||
: plan // ignore: cast_nullable_to_non_nullable
|
||||
as TrainingPlanEntity,
|
||||
isDirty: null == isDirty
|
||||
? _value.isDirty
|
||||
: isDirty // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
availableExercises: null == availableExercises
|
||||
? _value.availableExercises
|
||||
: availableExercises // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExerciseEntity>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a copy of PlanEditorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$TrainingPlanEntityCopyWith<$Res> get plan {
|
||||
return $TrainingPlanEntityCopyWith<$Res>(_value.plan, (value) {
|
||||
return _then(_value.copyWith(plan: value) as $Val);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$PlanEditorStateImplCopyWith<$Res>
|
||||
implements $PlanEditorStateCopyWith<$Res> {
|
||||
factory _$$PlanEditorStateImplCopyWith(
|
||||
_$PlanEditorStateImpl value,
|
||||
$Res Function(_$PlanEditorStateImpl) then,
|
||||
) = __$$PlanEditorStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
TrainingPlanEntity plan,
|
||||
bool isDirty,
|
||||
List<ExerciseEntity> availableExercises,
|
||||
});
|
||||
|
||||
@override
|
||||
$TrainingPlanEntityCopyWith<$Res> get plan;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$PlanEditorStateImplCopyWithImpl<$Res>
|
||||
extends _$PlanEditorStateCopyWithImpl<$Res, _$PlanEditorStateImpl>
|
||||
implements _$$PlanEditorStateImplCopyWith<$Res> {
|
||||
__$$PlanEditorStateImplCopyWithImpl(
|
||||
_$PlanEditorStateImpl _value,
|
||||
$Res Function(_$PlanEditorStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of PlanEditorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? plan = null,
|
||||
Object? isDirty = null,
|
||||
Object? availableExercises = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$PlanEditorStateImpl(
|
||||
plan: null == plan
|
||||
? _value.plan
|
||||
: plan // ignore: cast_nullable_to_non_nullable
|
||||
as TrainingPlanEntity,
|
||||
isDirty: null == isDirty
|
||||
? _value.isDirty
|
||||
: isDirty // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
availableExercises: null == availableExercises
|
||||
? _value._availableExercises
|
||||
: availableExercises // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExerciseEntity>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$PlanEditorStateImpl implements _PlanEditorState {
|
||||
const _$PlanEditorStateImpl({
|
||||
required this.plan,
|
||||
this.isDirty = false,
|
||||
final List<ExerciseEntity> availableExercises = const [],
|
||||
}) : _availableExercises = availableExercises;
|
||||
|
||||
@override
|
||||
final TrainingPlanEntity plan;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isDirty;
|
||||
final List<ExerciseEntity> _availableExercises;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ExerciseEntity> get availableExercises {
|
||||
if (_availableExercises is EqualUnmodifiableListView)
|
||||
return _availableExercises;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_availableExercises);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PlanEditorState(plan: $plan, isDirty: $isDirty, availableExercises: $availableExercises)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$PlanEditorStateImpl &&
|
||||
(identical(other.plan, plan) || other.plan == plan) &&
|
||||
(identical(other.isDirty, isDirty) || other.isDirty == isDirty) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._availableExercises,
|
||||
_availableExercises,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
plan,
|
||||
isDirty,
|
||||
const DeepCollectionEquality().hash(_availableExercises),
|
||||
);
|
||||
|
||||
/// Create a copy of PlanEditorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$PlanEditorStateImplCopyWith<_$PlanEditorStateImpl> get copyWith =>
|
||||
__$$PlanEditorStateImplCopyWithImpl<_$PlanEditorStateImpl>(
|
||||
this,
|
||||
_$identity,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class _PlanEditorState implements PlanEditorState {
|
||||
const factory _PlanEditorState({
|
||||
required final TrainingPlanEntity plan,
|
||||
final bool isDirty,
|
||||
final List<ExerciseEntity> availableExercises,
|
||||
}) = _$PlanEditorStateImpl;
|
||||
|
||||
@override
|
||||
TrainingPlanEntity get plan;
|
||||
@override
|
||||
bool get isDirty;
|
||||
@override
|
||||
List<ExerciseEntity> get availableExercises;
|
||||
|
||||
/// Create a copy of PlanEditorState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$PlanEditorStateImplCopyWith<_$PlanEditorStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
165
lib/presentation/plan_editor/widgets/plan_exercise_tile.dart
Normal file
165
lib/presentation/plan_editor/widgets/plan_exercise_tile.dart
Normal file
@@ -0,0 +1,165 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_exercise.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
||||
|
||||
class PlanExerciseTile extends ConsumerWidget {
|
||||
final TrainingExerciseEntity exercise;
|
||||
final int sectionIndex;
|
||||
final int exerciseIndex;
|
||||
final TrainingPlanEntity plan;
|
||||
|
||||
const PlanExerciseTile({
|
||||
super.key,
|
||||
required this.exercise,
|
||||
required this.sectionIndex,
|
||||
required this.exerciseIndex,
|
||||
required this.plan,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
elevation: 0,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
exercise.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () => controller.removeExerciseFromSection(
|
||||
sectionIndex,
|
||||
exerciseIndex,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_buildNumberInput(
|
||||
context,
|
||||
label: 'Sets',
|
||||
value: exercise.sets,
|
||||
onChanged: (val) => controller.updateExerciseParams(
|
||||
sectionIndex,
|
||||
exerciseIndex,
|
||||
sets: val,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildNumberInput(
|
||||
context,
|
||||
label: exercise.isTime ? 'Secs' : 'Reps',
|
||||
value: exercise.value,
|
||||
onChanged: (val) => controller.updateExerciseParams(
|
||||
sectionIndex,
|
||||
exerciseIndex,
|
||||
value: val,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildNumberInput(
|
||||
context,
|
||||
label: 'Rest(s)',
|
||||
value: exercise.rest,
|
||||
onChanged: (val) => controller.updateExerciseParams(
|
||||
sectionIndex,
|
||||
exerciseIndex,
|
||||
rest: val,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Toggle Time/Reps
|
||||
InkWell(
|
||||
onTap: () => controller.updateExerciseParams(
|
||||
sectionIndex,
|
||||
exerciseIndex,
|
||||
isTime: !exercise.isTime,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
color: exercise.isTime
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: null,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.timer,
|
||||
size: 16,
|
||||
color: exercise.isTime
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumberInput(
|
||||
BuildContext context, {
|
||||
required String label,
|
||||
required int value,
|
||||
required Function(int) onChanged,
|
||||
}) {
|
||||
return Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
||||
SizedBox(
|
||||
height: 40,
|
||||
child: TextField(
|
||||
controller: TextEditingController(text: value.toString())
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: value.toString().length),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 0,
|
||||
),
|
||||
),
|
||||
onChanged: (val) {
|
||||
final intVal = int.tryParse(val);
|
||||
if (intVal != null) {
|
||||
onChanged(intVal);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
140
lib/presentation/plan_editor/widgets/plan_section_card.dart
Normal file
140
lib/presentation/plan_editor/widgets/plan_section_card.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_section.dart';
|
||||
import 'package:trainhub_flutter/presentation/plan_editor/plan_editor_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/plan_editor/widgets/plan_exercise_tile.dart';
|
||||
|
||||
class PlanSectionCard extends ConsumerWidget {
|
||||
final TrainingSectionEntity section;
|
||||
final int sectionIndex;
|
||||
final TrainingPlanEntity plan;
|
||||
final List<ExerciseEntity> availableExercises;
|
||||
|
||||
const PlanSectionCard({
|
||||
super.key,
|
||||
required this.section,
|
||||
required this.sectionIndex,
|
||||
required this.plan,
|
||||
required this.availableExercises,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = ref.read(planEditorControllerProvider(plan.id).notifier);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: TextEditingController(text: section.name)
|
||||
..selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: section.name.length),
|
||||
),
|
||||
decoration: const InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'Section Name',
|
||||
isDense: true,
|
||||
),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
onChanged: (val) =>
|
||||
controller.updateSectionName(sectionIndex, val),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: () => controller.deleteSection(sectionIndex),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: section.exercises.length,
|
||||
onReorder: (oldIndex, newIndex) =>
|
||||
controller.reorderExercise(sectionIndex, oldIndex, newIndex),
|
||||
itemBuilder: (context, index) {
|
||||
final exercise = section.exercises[index];
|
||||
return PlanExerciseTile(
|
||||
key: ValueKey(exercise.instanceId),
|
||||
exercise: exercise,
|
||||
sectionIndex: sectionIndex,
|
||||
exerciseIndex: index,
|
||||
plan: plan,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
_showExercisePicker(context, (exercise) {
|
||||
controller.addExerciseToSection(sectionIndex, exercise);
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Add Exercise'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showExercisePicker(
|
||||
BuildContext context,
|
||||
Function(ExerciseEntity) onSelect,
|
||||
) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (context) => DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, scrollController) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Select Exercise',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: availableExercises.length,
|
||||
itemBuilder: (context, index) {
|
||||
final exercise = availableExercises[index];
|
||||
return ListTile(
|
||||
title: Text(exercise.name),
|
||||
subtitle: Text(exercise.muscleGroup ?? ''),
|
||||
onTap: () {
|
||||
onSelect(exercise);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/presentation/shell/shell_page.dart
Normal file
76
lib/presentation/shell/shell_page.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
|
||||
@RoutePage()
|
||||
class ShellPage extends StatelessWidget {
|
||||
const ShellPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AutoTabsRouter(
|
||||
routes: const [
|
||||
HomeRoute(),
|
||||
TrainingsRoute(),
|
||||
AnalysisRoute(),
|
||||
CalendarRoute(),
|
||||
ChatRoute(),
|
||||
],
|
||||
builder: (context, child) {
|
||||
final tabsRouter = AutoTabsRouter.of(context);
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
// NavigationRail with logo area at top
|
||||
NavigationRail(
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
onDestinationSelected: tabsRouter.setActiveIndex,
|
||||
labelType: NavigationRailLabelType.all,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.fitness_center, color: AppColors.accent, size: 28),
|
||||
const SizedBox(height: 4),
|
||||
Text('TrainHub', style: TextStyle(color: AppColors.textPrimary, fontSize: 10, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
destinations: const [
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: Text('Home'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.fitness_center_outlined),
|
||||
selectedIcon: Icon(Icons.fitness_center),
|
||||
label: Text('Trainings'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.video_library_outlined),
|
||||
selectedIcon: Icon(Icons.video_library),
|
||||
label: Text('Analysis'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.calendar_today_outlined),
|
||||
selectedIcon: Icon(Icons.calendar_today),
|
||||
label: Text('Calendar'),
|
||||
),
|
||||
NavigationRailDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: Text('AI Chat'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const VerticalDivider(thickness: 1, width: 1),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
64
lib/presentation/trainings/trainings_controller.dart
Normal file
64
lib/presentation/trainings/trainings_controller.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/exercise_repository.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
|
||||
import 'package:trainhub_flutter/presentation/trainings/trainings_state.dart';
|
||||
|
||||
part 'trainings_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class TrainingsController extends _$TrainingsController {
|
||||
late final TrainingPlanRepository _planRepo;
|
||||
late final ExerciseRepository _exerciseRepo;
|
||||
|
||||
@override
|
||||
Future<TrainingsState> build() async {
|
||||
_planRepo = getIt<TrainingPlanRepository>();
|
||||
_exerciseRepo = getIt<ExerciseRepository>();
|
||||
final plans = await _planRepo.getAll();
|
||||
final exercises = await _exerciseRepo.getAll();
|
||||
return TrainingsState(plans: plans, exercises: exercises);
|
||||
}
|
||||
|
||||
Future<TrainingPlanEntity> createPlan(String name) async {
|
||||
final plan = await _planRepo.create(name);
|
||||
await _reload();
|
||||
return plan;
|
||||
}
|
||||
|
||||
Future<void> deletePlan(String id) async {
|
||||
await _planRepo.delete(id);
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> addExercise({
|
||||
required String name,
|
||||
String? instructions,
|
||||
String? tags,
|
||||
String? videoUrl,
|
||||
}) async {
|
||||
await _exerciseRepo.create(
|
||||
name: name,
|
||||
instructions: instructions,
|
||||
tags: tags,
|
||||
videoUrl: videoUrl,
|
||||
);
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> updateExercise(ExerciseEntity exercise) async {
|
||||
await _exerciseRepo.update(exercise);
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> deleteExercise(String id) async {
|
||||
await _exerciseRepo.delete(id);
|
||||
await _reload();
|
||||
}
|
||||
|
||||
Future<void> _reload() async {
|
||||
state = await AsyncValue.guard(() => build());
|
||||
}
|
||||
}
|
||||
30
lib/presentation/trainings/trainings_controller.g.dart
Normal file
30
lib/presentation/trainings/trainings_controller.g.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'trainings_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$trainingsControllerHash() =>
|
||||
r'15c54eb8211e3b2549af6ef25a9cb451a7a9988a';
|
||||
|
||||
/// See also [TrainingsController].
|
||||
@ProviderFor(TrainingsController)
|
||||
final trainingsControllerProvider =
|
||||
AutoDisposeAsyncNotifierProvider<
|
||||
TrainingsController,
|
||||
TrainingsState
|
||||
>.internal(
|
||||
TrainingsController.new,
|
||||
name: r'trainingsControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$trainingsControllerHash,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
typedef _$TrainingsController = AutoDisposeAsyncNotifier<TrainingsState>;
|
||||
// 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
|
||||
493
lib/presentation/trainings/trainings_page.dart
Normal file
493
lib/presentation/trainings/trainings_page.dart
Normal file
@@ -0,0 +1,493 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/router/app_router.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||
import 'package:trainhub_flutter/presentation/trainings/trainings_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/widgets/app_empty_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/dialogs/text_input_dialog.dart';
|
||||
import 'package:trainhub_flutter/presentation/common/dialogs/confirm_dialog.dart';
|
||||
|
||||
@RoutePage()
|
||||
class TrainingsPage extends ConsumerWidget {
|
||||
const TrainingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncState = ref.watch(trainingsControllerProvider);
|
||||
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer,
|
||||
border: const Border(bottom: BorderSide(color: AppColors.border)),
|
||||
),
|
||||
child: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Training Plans'),
|
||||
Tab(text: 'Exercises'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: asyncState.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (err, _) => Center(child: Text('Error: $err')),
|
||||
data: (state) => TabBarView(
|
||||
children: [
|
||||
_PlansTab(plans: state.plans, ref: ref),
|
||||
_ExercisesTab(exercises: state.exercises, ref: ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlansTab extends StatelessWidget {
|
||||
final List<TrainingPlanEntity> plans;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _PlansTab({required this.plans, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (plans.isEmpty) {
|
||||
return AppEmptyState(
|
||||
icon: Icons.fitness_center,
|
||||
title: 'No training plans yet',
|
||||
subtitle: 'Create your first training plan to get started',
|
||||
actionLabel: 'Create Plan',
|
||||
onAction: () => _createPlan(context),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: () => _createPlan(context),
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('New Plan'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
),
|
||||
itemCount: plans.length,
|
||||
itemBuilder: (context, index) {
|
||||
final plan = plans[index];
|
||||
return _PlanListItem(
|
||||
plan: plan,
|
||||
onEdit: () {
|
||||
context.router.push(PlanEditorRoute(planId: plan.id));
|
||||
},
|
||||
onStart: () {
|
||||
context.router.push(WorkoutSessionRoute(planId: plan.id));
|
||||
},
|
||||
onDelete: () => _deletePlan(context, plan),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _createPlan(BuildContext context) async {
|
||||
final name = await TextInputDialog.show(
|
||||
context,
|
||||
title: 'New Plan Name',
|
||||
hintText: 'e.g. Push Pull Legs',
|
||||
);
|
||||
if (name != null && name.isNotEmpty) {
|
||||
final plan = await ref
|
||||
.read(trainingsControllerProvider.notifier)
|
||||
.createPlan(name);
|
||||
if (context.mounted) {
|
||||
context.router.push(PlanEditorRoute(planId: plan.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePlan(
|
||||
BuildContext context,
|
||||
TrainingPlanEntity plan,
|
||||
) async {
|
||||
final confirmed = await ConfirmDialog.show(
|
||||
context,
|
||||
title: 'Delete Plan?',
|
||||
message: 'Are you sure you want to delete "${plan.name}"?',
|
||||
);
|
||||
if (confirmed == true) {
|
||||
ref.read(trainingsControllerProvider.notifier).deletePlan(plan.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanListItem extends StatefulWidget {
|
||||
final TrainingPlanEntity plan;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onStart;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _PlanListItem({
|
||||
required this.plan,
|
||||
required this.onEdit,
|
||||
required this.onStart,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_PlanListItem> createState() => _PlanListItemState();
|
||||
}
|
||||
|
||||
class _PlanListItemState extends State<_PlanListItem> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: UIConstants.spacing8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accentMuted,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description_outlined,
|
||||
color: AppColors.accent,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.plan.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${widget.plan.sections.length} sections, ${widget.plan.totalExercises} exercises',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_isHovered) ...[
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: AppColors.destructive,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: widget.onDelete,
|
||||
tooltip: 'Delete',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
OutlinedButton(
|
||||
onPressed: widget.onEdit,
|
||||
child: const Text('Edit'),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
FilledButton.icon(
|
||||
onPressed: widget.onStart,
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('Start'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExercisesTab extends StatelessWidget {
|
||||
final List<ExerciseEntity> exercises;
|
||||
final WidgetRef ref;
|
||||
|
||||
const _ExercisesTab({required this.exercises, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showExerciseDialog(context),
|
||||
backgroundColor: AppColors.zinc50,
|
||||
foregroundColor: AppColors.zinc950,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: exercises.isEmpty
|
||||
? const AppEmptyState(
|
||||
icon: Icons.fitness_center,
|
||||
title: 'No exercises yet',
|
||||
subtitle: 'Add exercises to use in your training plans',
|
||||
)
|
||||
: GridView.builder(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 3.0,
|
||||
crossAxisSpacing: UIConstants.spacing12,
|
||||
mainAxisSpacing: UIConstants.spacing12,
|
||||
),
|
||||
itemCount: exercises.length,
|
||||
itemBuilder: (context, index) {
|
||||
final exercise = exercises[index];
|
||||
return _ExerciseCard(
|
||||
exercise: exercise,
|
||||
onEdit: () =>
|
||||
_showExerciseDialog(context, exercise: exercise),
|
||||
onDelete: () => ref
|
||||
.read(trainingsControllerProvider.notifier)
|
||||
.deleteExercise(exercise.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showExerciseDialog(BuildContext context, {ExerciseEntity? exercise}) {
|
||||
final nameCtrl = TextEditingController(text: exercise?.name);
|
||||
final instructionsCtrl = TextEditingController(
|
||||
text: exercise?.instructions,
|
||||
);
|
||||
final tagsCtrl = TextEditingController(text: exercise?.tags);
|
||||
final videoUrlCtrl = TextEditingController(text: exercise?.videoUrl);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(exercise == null ? 'New Exercise' : 'Edit Exercise'),
|
||||
content: SizedBox(
|
||||
width: UIConstants.dialogWidth,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Name'),
|
||||
autofocus: true,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
TextField(
|
||||
controller: instructionsCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Instructions'),
|
||||
maxLines: 2,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
TextField(
|
||||
controller: tagsCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Tags (comma separated)',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing12),
|
||||
TextField(
|
||||
controller: videoUrlCtrl,
|
||||
decoration: const InputDecoration(labelText: 'Video URL'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
if (nameCtrl.text.isEmpty) return;
|
||||
if (exercise == null) {
|
||||
ref
|
||||
.read(trainingsControllerProvider.notifier)
|
||||
.addExercise(
|
||||
name: nameCtrl.text,
|
||||
instructions: instructionsCtrl.text,
|
||||
tags: tagsCtrl.text,
|
||||
videoUrl: videoUrlCtrl.text,
|
||||
);
|
||||
} else {
|
||||
ref
|
||||
.read(trainingsControllerProvider.notifier)
|
||||
.updateExercise(
|
||||
exercise.copyWith(
|
||||
name: nameCtrl.text,
|
||||
instructions: instructionsCtrl.text,
|
||||
tags: tagsCtrl.text,
|
||||
videoUrl: videoUrlCtrl.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Save'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ExerciseCard extends StatefulWidget {
|
||||
final ExerciseEntity exercise;
|
||||
final VoidCallback onEdit;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _ExerciseCard({
|
||||
required this.exercise,
|
||||
required this.onEdit,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ExerciseCard> createState() => _ExerciseCardState();
|
||||
}
|
||||
|
||||
class _ExerciseCardState extends State<_ExerciseCard> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: Card(
|
||||
child: InkWell(
|
||||
onTap: widget.onEdit,
|
||||
borderRadius: UIConstants.cardBorderRadius,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UIConstants.spacing12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
widget.exercise.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (widget.exercise.instructions != null &&
|
||||
widget.exercise.instructions!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.exercise.instructions!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (widget.exercise.tags != null &&
|
||||
widget.exercise.tags!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
children: widget.exercise.tags!
|
||||
.split(',')
|
||||
.take(3)
|
||||
.map(
|
||||
(tag) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.zinc800,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
tag.trim(),
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (widget.exercise.videoUrl != null &&
|
||||
widget.exercise.videoUrl!.isNotEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.videocam,
|
||||
size: 16,
|
||||
color: AppColors.info,
|
||||
),
|
||||
),
|
||||
if (_isHovered) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, size: 16),
|
||||
onPressed: widget.onEdit,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 16,
|
||||
color: AppColors.destructive,
|
||||
),
|
||||
onPressed: widget.onDelete,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
lib/presentation/trainings/trainings_state.dart
Normal file
13
lib/presentation/trainings/trainings_state.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/exercise.dart';
|
||||
|
||||
part 'trainings_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class TrainingsState with _$TrainingsState {
|
||||
const factory TrainingsState({
|
||||
@Default([]) List<TrainingPlanEntity> plans,
|
||||
@Default([]) List<ExerciseEntity> exercises,
|
||||
}) = _TrainingsState;
|
||||
}
|
||||
192
lib/presentation/trainings/trainings_state.freezed.dart
Normal file
192
lib/presentation/trainings/trainings_state.freezed.dart
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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 'trainings_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 _$TrainingsState {
|
||||
List<TrainingPlanEntity> get plans => throw _privateConstructorUsedError;
|
||||
List<ExerciseEntity> get exercises => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of TrainingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$TrainingsStateCopyWith<TrainingsState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $TrainingsStateCopyWith<$Res> {
|
||||
factory $TrainingsStateCopyWith(
|
||||
TrainingsState value,
|
||||
$Res Function(TrainingsState) then,
|
||||
) = _$TrainingsStateCopyWithImpl<$Res, TrainingsState>;
|
||||
@useResult
|
||||
$Res call({List<TrainingPlanEntity> plans, List<ExerciseEntity> exercises});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$TrainingsStateCopyWithImpl<$Res, $Val extends TrainingsState>
|
||||
implements $TrainingsStateCopyWith<$Res> {
|
||||
_$TrainingsStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of TrainingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({Object? plans = null, Object? exercises = null}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
plans: null == plans
|
||||
? _value.plans
|
||||
: plans // ignore: cast_nullable_to_non_nullable
|
||||
as List<TrainingPlanEntity>,
|
||||
exercises: null == exercises
|
||||
? _value.exercises
|
||||
: exercises // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExerciseEntity>,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$TrainingsStateImplCopyWith<$Res>
|
||||
implements $TrainingsStateCopyWith<$Res> {
|
||||
factory _$$TrainingsStateImplCopyWith(
|
||||
_$TrainingsStateImpl value,
|
||||
$Res Function(_$TrainingsStateImpl) then,
|
||||
) = __$$TrainingsStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({List<TrainingPlanEntity> plans, List<ExerciseEntity> exercises});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$TrainingsStateImplCopyWithImpl<$Res>
|
||||
extends _$TrainingsStateCopyWithImpl<$Res, _$TrainingsStateImpl>
|
||||
implements _$$TrainingsStateImplCopyWith<$Res> {
|
||||
__$$TrainingsStateImplCopyWithImpl(
|
||||
_$TrainingsStateImpl _value,
|
||||
$Res Function(_$TrainingsStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of TrainingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({Object? plans = null, Object? exercises = null}) {
|
||||
return _then(
|
||||
_$TrainingsStateImpl(
|
||||
plans: null == plans
|
||||
? _value._plans
|
||||
: plans // ignore: cast_nullable_to_non_nullable
|
||||
as List<TrainingPlanEntity>,
|
||||
exercises: null == exercises
|
||||
? _value._exercises
|
||||
: exercises // ignore: cast_nullable_to_non_nullable
|
||||
as List<ExerciseEntity>,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$TrainingsStateImpl implements _TrainingsState {
|
||||
const _$TrainingsStateImpl({
|
||||
final List<TrainingPlanEntity> plans = const [],
|
||||
final List<ExerciseEntity> exercises = const [],
|
||||
}) : _plans = plans,
|
||||
_exercises = exercises;
|
||||
|
||||
final List<TrainingPlanEntity> _plans;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<TrainingPlanEntity> get plans {
|
||||
if (_plans is EqualUnmodifiableListView) return _plans;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_plans);
|
||||
}
|
||||
|
||||
final List<ExerciseEntity> _exercises;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<ExerciseEntity> get exercises {
|
||||
if (_exercises is EqualUnmodifiableListView) return _exercises;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_exercises);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'TrainingsState(plans: $plans, exercises: $exercises)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$TrainingsStateImpl &&
|
||||
const DeepCollectionEquality().equals(other._plans, _plans) &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._exercises,
|
||||
_exercises,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(_plans),
|
||||
const DeepCollectionEquality().hash(_exercises),
|
||||
);
|
||||
|
||||
/// Create a copy of TrainingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$TrainingsStateImplCopyWith<_$TrainingsStateImpl> get copyWith =>
|
||||
__$$TrainingsStateImplCopyWithImpl<_$TrainingsStateImpl>(
|
||||
this,
|
||||
_$identity,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class _TrainingsState implements TrainingsState {
|
||||
const factory _TrainingsState({
|
||||
final List<TrainingPlanEntity> plans,
|
||||
final List<ExerciseEntity> exercises,
|
||||
}) = _$TrainingsStateImpl;
|
||||
|
||||
@override
|
||||
List<TrainingPlanEntity> get plans;
|
||||
@override
|
||||
List<ExerciseEntity> get exercises;
|
||||
|
||||
/// Create a copy of TrainingsState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$TrainingsStateImplCopyWith<_$TrainingsStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
72
lib/presentation/workout_session/widgets/activity_card.dart
Normal file
72
lib/presentation/workout_session/widgets/activity_card.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
|
||||
|
||||
class ActivityCard extends StatelessWidget {
|
||||
final WorkoutActivityEntity activity;
|
||||
|
||||
const ActivityCard({super.key, required this.activity});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isRest = activity.type == 'rest';
|
||||
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
activity.name,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (!isRest) ...[
|
||||
Text(
|
||||
"${activity.sectionName} • Set ${activity.setIndex}/${activity.totalSets}",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (activity.originalExercise != null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildInfo(
|
||||
context,
|
||||
"Sets",
|
||||
"${activity.originalExercise!.sets}",
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
_buildInfo(
|
||||
context,
|
||||
activity.originalExercise!.isTime ? "Secs" : "Reps",
|
||||
"${activity.originalExercise!.value}",
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
"Resting...",
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfo(BuildContext context, String label, String value) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(value, style: Theme.of(context).textTheme.headlineSmall),
|
||||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SessionControls extends StatelessWidget {
|
||||
final bool isRunning;
|
||||
final bool isFinished;
|
||||
final VoidCallback onPause;
|
||||
final VoidCallback onPlay;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onPrevious;
|
||||
|
||||
const SessionControls({
|
||||
super.key,
|
||||
required this.isRunning,
|
||||
required this.isFinished,
|
||||
required this.onPause,
|
||||
required this.onPlay,
|
||||
required this.onNext,
|
||||
required this.onPrevious,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isFinished) {
|
||||
return ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Finish Workout'),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton.filledTonal(
|
||||
onPressed: onPrevious,
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
iconSize: 32,
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
IconButton.filled(
|
||||
onPressed: isRunning ? onPause : onPlay,
|
||||
icon: Icon(isRunning ? Icons.pause : Icons.play_arrow),
|
||||
iconSize: 48,
|
||||
style: IconButton.styleFrom(padding: const EdgeInsets.all(16)),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
IconButton.filledTonal(
|
||||
onPressed: onNext,
|
||||
icon: const Icon(Icons.skip_next),
|
||||
iconSize: 32,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SessionProgressBar extends StatelessWidget {
|
||||
final double progress;
|
||||
|
||||
const SessionProgressBar({super.key, required this.progress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 8,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
}
|
||||
}
|
||||
182
lib/presentation/workout_session/workout_session_controller.dart
Normal file
182
lib/presentation/workout_session/workout_session_controller.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'dart:async';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/training_plan.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
|
||||
import 'package:trainhub_flutter/presentation/workout_session/workout_session_state.dart';
|
||||
import 'package:trainhub_flutter/injection.dart';
|
||||
import 'package:trainhub_flutter/domain/repositories/training_plan_repository.dart';
|
||||
|
||||
part 'workout_session_controller.g.dart';
|
||||
|
||||
@riverpod
|
||||
class WorkoutSessionController extends _$WorkoutSessionController {
|
||||
Timer? _timer;
|
||||
|
||||
@override
|
||||
Future<WorkoutSessionState> build(String planId) async {
|
||||
final planRepo = getIt<TrainingPlanRepository>();
|
||||
final plan = await planRepo.getById(planId);
|
||||
|
||||
final activities = _buildSequence(plan);
|
||||
ref.onDispose(() => _timer?.cancel());
|
||||
|
||||
final initialState = WorkoutSessionState(activities: activities);
|
||||
|
||||
if (activities.isNotEmpty) {
|
||||
final first = activities.first;
|
||||
return initialState.copyWith(timeRemaining: first.duration);
|
||||
}
|
||||
return initialState;
|
||||
}
|
||||
|
||||
List<WorkoutActivityEntity> _buildSequence(TrainingPlanEntity plan) {
|
||||
final List<WorkoutActivityEntity> seq = [];
|
||||
for (final section in plan.sections) {
|
||||
for (final ex in section.exercises) {
|
||||
for (int s = 1; s <= ex.sets; s++) {
|
||||
seq.add(
|
||||
WorkoutActivityEntity(
|
||||
id: '${ex.instanceId}-s$s-work',
|
||||
name: ex.name,
|
||||
type: 'work',
|
||||
duration: ex.isTime ? ex.value : 0,
|
||||
originalExercise: ex,
|
||||
sectionName: section.name,
|
||||
setIndex: s,
|
||||
totalSets: ex.sets,
|
||||
),
|
||||
);
|
||||
final bool isLastOfWorkout =
|
||||
s == ex.sets &&
|
||||
section.exercises.last == ex &&
|
||||
plan.sections.last == section;
|
||||
if (ex.rest > 0 && !isLastOfWorkout) {
|
||||
seq.add(
|
||||
WorkoutActivityEntity(
|
||||
id: '${ex.instanceId}-s$s-rest',
|
||||
name: 'Rest',
|
||||
type: 'rest',
|
||||
duration: ex.rest,
|
||||
sectionName: section.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return seq;
|
||||
}
|
||||
|
||||
void startTimer() {
|
||||
if (_timer != null && _timer!.isActive) return;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), _tick);
|
||||
final currentState = state.value;
|
||||
if (currentState != null) {
|
||||
state = AsyncValue.data(currentState.copyWith(isRunning: true));
|
||||
}
|
||||
}
|
||||
|
||||
void pauseTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
final currentState = state.value;
|
||||
if (currentState != null) {
|
||||
state = AsyncValue.data(currentState.copyWith(isRunning: false));
|
||||
}
|
||||
}
|
||||
|
||||
void _tick(Timer timer) {
|
||||
if (state.value?.isFinished ?? true) return;
|
||||
var currentState = state.value!;
|
||||
|
||||
var newState = currentState.copyWith(
|
||||
totalTimeElapsed: currentState.totalTimeElapsed + 1,
|
||||
);
|
||||
final activity = newState.currentActivity;
|
||||
|
||||
if (activity != null && activity.duration > 0 && newState.isRunning) {
|
||||
if (newState.timeRemaining > 0) {
|
||||
newState = newState.copyWith(timeRemaining: newState.timeRemaining - 1);
|
||||
} else {
|
||||
state = AsyncValue.data(newState); // update interim state before next
|
||||
_goNext(newState);
|
||||
return;
|
||||
}
|
||||
}
|
||||
state = AsyncValue.data(newState);
|
||||
}
|
||||
|
||||
void next() {
|
||||
final currentState = state.value;
|
||||
if (currentState != null) _goNext(currentState);
|
||||
}
|
||||
|
||||
void _goNext(WorkoutSessionState currentState) {
|
||||
if (currentState.currentIndex < currentState.activities.length - 1) {
|
||||
final nextIndex = currentState.currentIndex + 1;
|
||||
final nextActivity = currentState.activities[nextIndex];
|
||||
|
||||
final newState = currentState.copyWith(
|
||||
currentIndex: nextIndex,
|
||||
timeRemaining: nextActivity.duration,
|
||||
);
|
||||
|
||||
state = AsyncValue.data(newState);
|
||||
|
||||
if (nextActivity.isRest) {
|
||||
startTimer();
|
||||
} else {
|
||||
pauseTimer();
|
||||
}
|
||||
} else {
|
||||
_finish();
|
||||
}
|
||||
}
|
||||
|
||||
void previous() {
|
||||
final currentState = state.value;
|
||||
if (currentState != null && currentState.currentIndex > 0) {
|
||||
final prevIndex = currentState.currentIndex - 1;
|
||||
final prevActivity = currentState.activities[prevIndex];
|
||||
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(
|
||||
currentIndex: prevIndex,
|
||||
timeRemaining: prevActivity.duration,
|
||||
),
|
||||
);
|
||||
|
||||
pauseTimer();
|
||||
}
|
||||
}
|
||||
|
||||
void jumpTo(int index) {
|
||||
final currentState = state.value;
|
||||
if (currentState != null &&
|
||||
index >= 0 &&
|
||||
index < currentState.activities.length) {
|
||||
final activity = currentState.activities[index];
|
||||
|
||||
state = AsyncValue.data(
|
||||
currentState.copyWith(
|
||||
currentIndex: index,
|
||||
timeRemaining: activity.duration,
|
||||
),
|
||||
);
|
||||
|
||||
if (activity.isRest) {
|
||||
startTimer();
|
||||
} else {
|
||||
pauseTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _finish() {
|
||||
pauseTimer();
|
||||
final currentState = state.value;
|
||||
if (currentState != null) {
|
||||
state = AsyncValue.data(currentState.copyWith(isFinished: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'workout_session_controller.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$workoutSessionControllerHash() =>
|
||||
r'd3f53d72c80963634c6edaeb44aa5b04c9ffba6d';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$WorkoutSessionController
|
||||
extends BuildlessAutoDisposeAsyncNotifier<WorkoutSessionState> {
|
||||
late final String planId;
|
||||
|
||||
FutureOr<WorkoutSessionState> build(String planId);
|
||||
}
|
||||
|
||||
/// See also [WorkoutSessionController].
|
||||
@ProviderFor(WorkoutSessionController)
|
||||
const workoutSessionControllerProvider = WorkoutSessionControllerFamily();
|
||||
|
||||
/// See also [WorkoutSessionController].
|
||||
class WorkoutSessionControllerFamily
|
||||
extends Family<AsyncValue<WorkoutSessionState>> {
|
||||
/// See also [WorkoutSessionController].
|
||||
const WorkoutSessionControllerFamily();
|
||||
|
||||
/// See also [WorkoutSessionController].
|
||||
WorkoutSessionControllerProvider call(String planId) {
|
||||
return WorkoutSessionControllerProvider(planId);
|
||||
}
|
||||
|
||||
@override
|
||||
WorkoutSessionControllerProvider getProviderOverride(
|
||||
covariant WorkoutSessionControllerProvider provider,
|
||||
) {
|
||||
return call(provider.planId);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'workoutSessionControllerProvider';
|
||||
}
|
||||
|
||||
/// See also [WorkoutSessionController].
|
||||
class WorkoutSessionControllerProvider
|
||||
extends
|
||||
AutoDisposeAsyncNotifierProviderImpl<
|
||||
WorkoutSessionController,
|
||||
WorkoutSessionState
|
||||
> {
|
||||
/// See also [WorkoutSessionController].
|
||||
WorkoutSessionControllerProvider(String planId)
|
||||
: this._internal(
|
||||
() => WorkoutSessionController()..planId = planId,
|
||||
from: workoutSessionControllerProvider,
|
||||
name: r'workoutSessionControllerProvider',
|
||||
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$workoutSessionControllerHash,
|
||||
dependencies: WorkoutSessionControllerFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
WorkoutSessionControllerFamily._allTransitiveDependencies,
|
||||
planId: planId,
|
||||
);
|
||||
|
||||
WorkoutSessionControllerProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.planId,
|
||||
}) : super.internal();
|
||||
|
||||
final String planId;
|
||||
|
||||
@override
|
||||
FutureOr<WorkoutSessionState> runNotifierBuild(
|
||||
covariant WorkoutSessionController notifier,
|
||||
) {
|
||||
return notifier.build(planId);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(WorkoutSessionController Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: WorkoutSessionControllerProvider._internal(
|
||||
() => create()..planId = planId,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
planId: planId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeAsyncNotifierProviderElement<
|
||||
WorkoutSessionController,
|
||||
WorkoutSessionState
|
||||
>
|
||||
createElement() {
|
||||
return _WorkoutSessionControllerProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is WorkoutSessionControllerProvider && other.planId == planId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, planId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin WorkoutSessionControllerRef
|
||||
on AutoDisposeAsyncNotifierProviderRef<WorkoutSessionState> {
|
||||
/// The parameter `planId` of this provider.
|
||||
String get planId;
|
||||
}
|
||||
|
||||
class _WorkoutSessionControllerProviderElement
|
||||
extends
|
||||
AutoDisposeAsyncNotifierProviderElement<
|
||||
WorkoutSessionController,
|
||||
WorkoutSessionState
|
||||
>
|
||||
with WorkoutSessionControllerRef {
|
||||
_WorkoutSessionControllerProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get planId => (origin as WorkoutSessionControllerProvider).planId;
|
||||
}
|
||||
|
||||
// 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
|
||||
595
lib/presentation/workout_session/workout_session_page.dart
Normal file
595
lib/presentation/workout_session/workout_session_page.dart
Normal file
@@ -0,0 +1,595 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:trainhub_flutter/core/constants/ui_constants.dart';
|
||||
import 'package:trainhub_flutter/core/theme/app_colors.dart';
|
||||
import 'package:trainhub_flutter/presentation/workout_session/workout_session_controller.dart';
|
||||
import 'package:trainhub_flutter/presentation/workout_session/workout_session_state.dart';
|
||||
import 'package:trainhub_flutter/presentation/workout_session/widgets/activity_card.dart';
|
||||
import 'package:trainhub_flutter/presentation/workout_session/widgets/session_controls.dart';
|
||||
import 'package:trainhub_flutter/presentation/workout_session/widgets/session_progress_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
class WorkoutSessionPage extends ConsumerWidget {
|
||||
final String planId;
|
||||
|
||||
const WorkoutSessionPage({
|
||||
super.key,
|
||||
@PathParam('planId') required this.planId,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final asyncState = ref.watch(workoutSessionControllerProvider(planId));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.zinc950,
|
||||
body: asyncState.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.accent,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
error: (err, stack) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: AppColors.destructive,
|
||||
size: 48,
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing16),
|
||||
Text(
|
||||
'Failed to load workout',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
Text(
|
||||
'$err',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
data: (state) {
|
||||
final controller = ref.read(
|
||||
workoutSessionControllerProvider(planId).notifier,
|
||||
);
|
||||
|
||||
if (state.isFinished) {
|
||||
return _CompletionScreen(
|
||||
totalTimeElapsed: state.totalTimeElapsed,
|
||||
);
|
||||
}
|
||||
|
||||
final isRest = state.currentActivity?.isRest ?? false;
|
||||
|
||||
return _ActiveSessionView(
|
||||
state: state,
|
||||
isRest: isRest,
|
||||
controller: controller,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active session view (gradient background + timer + controls)
|
||||
// ---------------------------------------------------------------------------
|
||||
class _ActiveSessionView extends StatelessWidget {
|
||||
final WorkoutSessionState state;
|
||||
final bool isRest;
|
||||
final WorkoutSessionController controller;
|
||||
|
||||
const _ActiveSessionView({
|
||||
required this.state,
|
||||
required this.isRest,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Compute the time progress for the circular ring.
|
||||
final activity = state.currentActivity;
|
||||
final double timeProgress;
|
||||
if (activity != null && activity.duration > 0) {
|
||||
timeProgress = 1.0 - (state.timeRemaining / activity.duration);
|
||||
} else {
|
||||
timeProgress = 0.0;
|
||||
}
|
||||
|
||||
final accentTint = isRest
|
||||
? AppColors.info.withValues(alpha: 0.06)
|
||||
: AppColors.accent.withValues(alpha: 0.06);
|
||||
final ringColor = isRest ? AppColors.info : AppColors.accent;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.zinc950,
|
||||
accentTint,
|
||||
AppColors.zinc950,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// -- Top progress bar --
|
||||
SessionProgressBar(progress: state.progress),
|
||||
|
||||
// -- Elapsed time badge --
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: UIConstants.spacing16,
|
||||
right: UIConstants.spacing24,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing12,
|
||||
vertical: UIConstants.spacing4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.zinc800.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.border.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.timer_outlined,
|
||||
size: 14,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_formatDuration(state.totalTimeElapsed),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// -- Central content --
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Activity info card
|
||||
if (activity != null)
|
||||
ActivityCard(activity: activity),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// Circular progress ring + timer
|
||||
_CircularTimerDisplay(
|
||||
timeRemaining: state.timeRemaining,
|
||||
progress: timeProgress,
|
||||
ringColor: ringColor,
|
||||
isRunning: state.isRunning,
|
||||
isTimeBased: activity?.isTimeBased ?? false,
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
// "Up next" pill
|
||||
if (state.nextActivity != null)
|
||||
_UpNextPill(
|
||||
nextActivityName: state.nextActivity!.name,
|
||||
isNextRest: state.nextActivity!.isRest,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// -- Bottom controls --
|
||||
SessionControls(
|
||||
isRunning: state.isRunning,
|
||||
isFinished: state.isFinished,
|
||||
onPause: controller.pauseTimer,
|
||||
onPlay: controller.startTimer,
|
||||
onNext: controller.next,
|
||||
onPrevious: controller.previous,
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Circular timer with arc progress ring
|
||||
// ---------------------------------------------------------------------------
|
||||
class _CircularTimerDisplay extends StatelessWidget {
|
||||
final int timeRemaining;
|
||||
final double progress;
|
||||
final Color ringColor;
|
||||
final bool isRunning;
|
||||
final bool isTimeBased;
|
||||
|
||||
const _CircularTimerDisplay({
|
||||
required this.timeRemaining,
|
||||
required this.progress,
|
||||
required this.ringColor,
|
||||
required this.isRunning,
|
||||
required this.isTimeBased,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const double size = 220;
|
||||
const double strokeWidth = 6.0;
|
||||
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// Background track
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
value: 1.0,
|
||||
strokeWidth: strokeWidth,
|
||||
color: AppColors.zinc800.withValues(alpha: 0.5),
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
),
|
||||
|
||||
// Progress arc
|
||||
if (isTimeBased)
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress.clamp(0.0, 1.0),
|
||||
strokeWidth: strokeWidth,
|
||||
color: ringColor,
|
||||
backgroundColor: Colors.transparent,
|
||||
strokeCap: StrokeCap.round,
|
||||
),
|
||||
),
|
||||
|
||||
// Glow behind the timer text
|
||||
Container(
|
||||
width: size * 0.7,
|
||||
height: size * 0.7,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: ringColor.withValues(alpha: 0.08),
|
||||
blurRadius: 40,
|
||||
spreadRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Timer text
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(timeRemaining),
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 52,
|
||||
fontWeight: FontWeight.w300,
|
||||
letterSpacing: 2,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
if (!isTimeBased)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'UNTIMED',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isTimeBased && !isRunning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'PAUSED',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(int seconds) {
|
||||
final m = seconds ~/ 60;
|
||||
final s = seconds % 60;
|
||||
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// "Up next" pill
|
||||
// ---------------------------------------------------------------------------
|
||||
class _UpNextPill extends StatelessWidget {
|
||||
final String nextActivityName;
|
||||
final bool isNextRest;
|
||||
|
||||
const _UpNextPill({
|
||||
required this.nextActivityName,
|
||||
required this.isNextRest,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pillColor = isNextRest
|
||||
? AppColors.info.withValues(alpha: 0.12)
|
||||
: AppColors.accent.withValues(alpha: 0.12);
|
||||
final pillBorderColor = isNextRest
|
||||
? AppColors.info.withValues(alpha: 0.25)
|
||||
: AppColors.accent.withValues(alpha: 0.25);
|
||||
final labelColor = isNextRest ? AppColors.info : AppColors.accent;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing16,
|
||||
vertical: UIConstants.spacing8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: pillColor,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: pillBorderColor),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'UP NEXT',
|
||||
style: TextStyle(
|
||||
color: labelColor,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Container(
|
||||
width: 3,
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.textMuted,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UIConstants.spacing8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
nextActivityName,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Completion screen
|
||||
// ---------------------------------------------------------------------------
|
||||
class _CompletionScreen extends StatelessWidget {
|
||||
final int totalTimeElapsed;
|
||||
|
||||
const _CompletionScreen({required this.totalTimeElapsed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.zinc950,
|
||||
AppColors.success.withValues(alpha: 0.06),
|
||||
AppColors.zinc950,
|
||||
],
|
||||
stops: const [0.0, 0.45, 1.0],
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Checkmark circle
|
||||
Container(
|
||||
width: 96,
|
||||
height: 96,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.success.withValues(alpha: 0.12),
|
||||
border: Border.all(
|
||||
color: AppColors.success.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.success.withValues(alpha: 0.15),
|
||||
blurRadius: 40,
|
||||
spreadRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_rounded,
|
||||
color: AppColors.success,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing24),
|
||||
|
||||
const Text(
|
||||
'Workout Complete',
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing8),
|
||||
|
||||
Text(
|
||||
'Great job! You crushed it.',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// Total time card
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UIConstants.spacing24,
|
||||
vertical: UIConstants.spacing16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceContainer.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(UIConstants.borderRadius),
|
||||
border: Border.all(
|
||||
color: AppColors.border.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'TOTAL TIME',
|
||||
style: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UIConstants.spacing4),
|
||||
Text(
|
||||
_formatDuration(totalTimeElapsed),
|
||||
style: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w300,
|
||||
fontFeatures: const [FontFeature.tabularFigures()],
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UIConstants.spacing32),
|
||||
|
||||
// Finish button
|
||||
SizedBox(
|
||||
width: 200,
|
||||
height: 48,
|
||||
child: FilledButton(
|
||||
onPressed: () => context.router.maybePop(),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.success,
|
||||
foregroundColor: AppColors.zinc950,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: const Text('Finish'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(int seconds) {
|
||||
final h = seconds ~/ 3600;
|
||||
final m = (seconds % 3600) ~/ 60;
|
||||
final s = seconds % 60;
|
||||
if (h > 0) {
|
||||
return '${h.toString().padLeft(2, '0')}:${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
29
lib/presentation/workout_session/workout_session_state.dart
Normal file
29
lib/presentation/workout_session/workout_session_state.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:trainhub_flutter/domain/entities/workout_activity.dart';
|
||||
|
||||
part 'workout_session_state.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class WorkoutSessionState with _$WorkoutSessionState {
|
||||
const factory WorkoutSessionState({
|
||||
required List<WorkoutActivityEntity> activities,
|
||||
@Default(0) int currentIndex,
|
||||
@Default(0) int timeRemaining,
|
||||
@Default(0) int totalTimeElapsed,
|
||||
@Default(false) bool isRunning,
|
||||
@Default(false) bool isFinished,
|
||||
}) = _WorkoutSessionState;
|
||||
|
||||
const WorkoutSessionState._();
|
||||
|
||||
WorkoutActivityEntity? get currentActivity =>
|
||||
currentIndex < activities.length ? activities[currentIndex] : null;
|
||||
|
||||
WorkoutActivityEntity? get nextActivity =>
|
||||
currentIndex + 1 < activities.length
|
||||
? activities[currentIndex + 1]
|
||||
: null;
|
||||
|
||||
double get progress =>
|
||||
activities.isEmpty ? 0.0 : currentIndex / activities.length;
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
// 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 'workout_session_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 _$WorkoutSessionState {
|
||||
List<WorkoutActivityEntity> get activities =>
|
||||
throw _privateConstructorUsedError;
|
||||
int get currentIndex => throw _privateConstructorUsedError;
|
||||
int get timeRemaining => throw _privateConstructorUsedError;
|
||||
int get totalTimeElapsed => throw _privateConstructorUsedError;
|
||||
bool get isRunning => throw _privateConstructorUsedError;
|
||||
bool get isFinished => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of WorkoutSessionState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$WorkoutSessionStateCopyWith<WorkoutSessionState> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $WorkoutSessionStateCopyWith<$Res> {
|
||||
factory $WorkoutSessionStateCopyWith(
|
||||
WorkoutSessionState value,
|
||||
$Res Function(WorkoutSessionState) then,
|
||||
) = _$WorkoutSessionStateCopyWithImpl<$Res, WorkoutSessionState>;
|
||||
@useResult
|
||||
$Res call({
|
||||
List<WorkoutActivityEntity> activities,
|
||||
int currentIndex,
|
||||
int timeRemaining,
|
||||
int totalTimeElapsed,
|
||||
bool isRunning,
|
||||
bool isFinished,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$WorkoutSessionStateCopyWithImpl<$Res, $Val extends WorkoutSessionState>
|
||||
implements $WorkoutSessionStateCopyWith<$Res> {
|
||||
_$WorkoutSessionStateCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of WorkoutSessionState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activities = null,
|
||||
Object? currentIndex = null,
|
||||
Object? timeRemaining = null,
|
||||
Object? totalTimeElapsed = null,
|
||||
Object? isRunning = null,
|
||||
Object? isFinished = null,
|
||||
}) {
|
||||
return _then(
|
||||
_value.copyWith(
|
||||
activities: null == activities
|
||||
? _value.activities
|
||||
: activities // ignore: cast_nullable_to_non_nullable
|
||||
as List<WorkoutActivityEntity>,
|
||||
currentIndex: null == currentIndex
|
||||
? _value.currentIndex
|
||||
: currentIndex // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
timeRemaining: null == timeRemaining
|
||||
? _value.timeRemaining
|
||||
: timeRemaining // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
totalTimeElapsed: null == totalTimeElapsed
|
||||
? _value.totalTimeElapsed
|
||||
: totalTimeElapsed // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
isRunning: null == isRunning
|
||||
? _value.isRunning
|
||||
: isRunning // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
isFinished: null == isFinished
|
||||
? _value.isFinished
|
||||
: isFinished // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
)
|
||||
as $Val,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$WorkoutSessionStateImplCopyWith<$Res>
|
||||
implements $WorkoutSessionStateCopyWith<$Res> {
|
||||
factory _$$WorkoutSessionStateImplCopyWith(
|
||||
_$WorkoutSessionStateImpl value,
|
||||
$Res Function(_$WorkoutSessionStateImpl) then,
|
||||
) = __$$WorkoutSessionStateImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call({
|
||||
List<WorkoutActivityEntity> activities,
|
||||
int currentIndex,
|
||||
int timeRemaining,
|
||||
int totalTimeElapsed,
|
||||
bool isRunning,
|
||||
bool isFinished,
|
||||
});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$WorkoutSessionStateImplCopyWithImpl<$Res>
|
||||
extends _$WorkoutSessionStateCopyWithImpl<$Res, _$WorkoutSessionStateImpl>
|
||||
implements _$$WorkoutSessionStateImplCopyWith<$Res> {
|
||||
__$$WorkoutSessionStateImplCopyWithImpl(
|
||||
_$WorkoutSessionStateImpl _value,
|
||||
$Res Function(_$WorkoutSessionStateImpl) _then,
|
||||
) : super(_value, _then);
|
||||
|
||||
/// Create a copy of WorkoutSessionState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? activities = null,
|
||||
Object? currentIndex = null,
|
||||
Object? timeRemaining = null,
|
||||
Object? totalTimeElapsed = null,
|
||||
Object? isRunning = null,
|
||||
Object? isFinished = null,
|
||||
}) {
|
||||
return _then(
|
||||
_$WorkoutSessionStateImpl(
|
||||
activities: null == activities
|
||||
? _value._activities
|
||||
: activities // ignore: cast_nullable_to_non_nullable
|
||||
as List<WorkoutActivityEntity>,
|
||||
currentIndex: null == currentIndex
|
||||
? _value.currentIndex
|
||||
: currentIndex // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
timeRemaining: null == timeRemaining
|
||||
? _value.timeRemaining
|
||||
: timeRemaining // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
totalTimeElapsed: null == totalTimeElapsed
|
||||
? _value.totalTimeElapsed
|
||||
: totalTimeElapsed // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
isRunning: null == isRunning
|
||||
? _value.isRunning
|
||||
: isRunning // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
isFinished: null == isFinished
|
||||
? _value.isFinished
|
||||
: isFinished // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
class _$WorkoutSessionStateImpl extends _WorkoutSessionState {
|
||||
const _$WorkoutSessionStateImpl({
|
||||
required final List<WorkoutActivityEntity> activities,
|
||||
this.currentIndex = 0,
|
||||
this.timeRemaining = 0,
|
||||
this.totalTimeElapsed = 0,
|
||||
this.isRunning = false,
|
||||
this.isFinished = false,
|
||||
}) : _activities = activities,
|
||||
super._();
|
||||
|
||||
final List<WorkoutActivityEntity> _activities;
|
||||
@override
|
||||
List<WorkoutActivityEntity> get activities {
|
||||
if (_activities is EqualUnmodifiableListView) return _activities;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_activities);
|
||||
}
|
||||
|
||||
@override
|
||||
@JsonKey()
|
||||
final int currentIndex;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int timeRemaining;
|
||||
@override
|
||||
@JsonKey()
|
||||
final int totalTimeElapsed;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isRunning;
|
||||
@override
|
||||
@JsonKey()
|
||||
final bool isFinished;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'WorkoutSessionState(activities: $activities, currentIndex: $currentIndex, timeRemaining: $timeRemaining, totalTimeElapsed: $totalTimeElapsed, isRunning: $isRunning, isFinished: $isFinished)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$WorkoutSessionStateImpl &&
|
||||
const DeepCollectionEquality().equals(
|
||||
other._activities,
|
||||
_activities,
|
||||
) &&
|
||||
(identical(other.currentIndex, currentIndex) ||
|
||||
other.currentIndex == currentIndex) &&
|
||||
(identical(other.timeRemaining, timeRemaining) ||
|
||||
other.timeRemaining == timeRemaining) &&
|
||||
(identical(other.totalTimeElapsed, totalTimeElapsed) ||
|
||||
other.totalTimeElapsed == totalTimeElapsed) &&
|
||||
(identical(other.isRunning, isRunning) ||
|
||||
other.isRunning == isRunning) &&
|
||||
(identical(other.isFinished, isFinished) ||
|
||||
other.isFinished == isFinished));
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
const DeepCollectionEquality().hash(_activities),
|
||||
currentIndex,
|
||||
timeRemaining,
|
||||
totalTimeElapsed,
|
||||
isRunning,
|
||||
isFinished,
|
||||
);
|
||||
|
||||
/// Create a copy of WorkoutSessionState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$WorkoutSessionStateImplCopyWith<_$WorkoutSessionStateImpl> get copyWith =>
|
||||
__$$WorkoutSessionStateImplCopyWithImpl<_$WorkoutSessionStateImpl>(
|
||||
this,
|
||||
_$identity,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class _WorkoutSessionState extends WorkoutSessionState {
|
||||
const factory _WorkoutSessionState({
|
||||
required final List<WorkoutActivityEntity> activities,
|
||||
final int currentIndex,
|
||||
final int timeRemaining,
|
||||
final int totalTimeElapsed,
|
||||
final bool isRunning,
|
||||
final bool isFinished,
|
||||
}) = _$WorkoutSessionStateImpl;
|
||||
const _WorkoutSessionState._() : super._();
|
||||
|
||||
@override
|
||||
List<WorkoutActivityEntity> get activities;
|
||||
@override
|
||||
int get currentIndex;
|
||||
@override
|
||||
int get timeRemaining;
|
||||
@override
|
||||
int get totalTimeElapsed;
|
||||
@override
|
||||
bool get isRunning;
|
||||
@override
|
||||
bool get isFinished;
|
||||
|
||||
/// Create a copy of WorkoutSessionState
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$WorkoutSessionStateImplCopyWith<_$WorkoutSessionStateImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
Reference in New Issue
Block a user