Initial commit

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,470 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:trainhub_flutter/domain/entities/analysis_session.dart';
import 'package:trainhub_flutter/domain/entities/annotation.dart';
import 'package:trainhub_flutter/presentation/analysis/analysis_controller.dart';
import 'package:video_player/video_player.dart';
class AnalysisViewer extends ConsumerStatefulWidget {
final AnalysisSessionEntity session;
final List<AnnotationEntity> annotations;
final VoidCallback onClose;
const AnalysisViewer({
super.key,
required this.session,
required this.annotations,
required this.onClose,
});
@override
ConsumerState<AnalysisViewer> createState() => _AnalysisViewerState();
}
class _AnalysisViewerState extends ConsumerState<AnalysisViewer> {
VideoPlayerController? _videoController;
bool _isPlaying = false;
double _currentPosition = 0.0;
double _totalDuration = 1.0;
// IN/OUT points
double? _inPoint;
double? _outPoint;
bool _isLooping = false;
@override
void initState() {
super.initState();
_initializeVideo();
}
@override
void didUpdateWidget(covariant AnalysisViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.session.videoPath != widget.session.videoPath) {
_initializeVideo();
}
}
Future<void> _initializeVideo() async {
final path = widget.session.videoPath;
if (path == null) return;
_videoController?.dispose();
final file = File(path);
if (await file.exists()) {
_videoController = VideoPlayerController.file(file);
await _videoController!.initialize();
setState(() {
_totalDuration = _videoController!.value.duration.inSeconds.toDouble();
});
_videoController!.addListener(_videoListener);
}
}
void _videoListener() {
if (_videoController == null) return;
final bool isPlaying = _videoController!.value.isPlaying;
final double position =
_videoController!.value.position.inMilliseconds / 1000.0;
// Loop logic
if (_isLooping && _outPoint != null && position >= _outPoint!) {
_seekTo(_inPoint ?? 0.0);
return;
}
if (isPlaying != _isPlaying || (position - _currentPosition).abs() > 0.1) {
setState(() {
_isPlaying = isPlaying;
_currentPosition = position;
});
}
}
@override
void dispose() {
_videoController?.removeListener(_videoListener);
_videoController?.dispose();
super.dispose();
}
void _togglePlay() {
if (_videoController == null) return;
if (_videoController!.value.isPlaying) {
_videoController!.pause();
} else {
if (_isLooping && _outPoint != null && _currentPosition >= _outPoint!) {
_seekTo(_inPoint ?? 0.0);
}
_videoController!.play();
}
}
void _seekTo(double value) {
if (_videoController == null) return;
_videoController!.seekTo(Duration(milliseconds: (value * 1000).toInt()));
}
void _setInPoint() {
setState(() {
_inPoint = _currentPosition;
if (_outPoint != null && _inPoint! > _outPoint!) {
_outPoint = null;
}
});
}
void _setOutPoint() {
setState(() {
_outPoint = _currentPosition;
if (_inPoint != null && _outPoint! < _inPoint!) {
_inPoint = null;
}
});
}
void _clearPoints() {
setState(() {
_inPoint = null;
_outPoint = null;
_isLooping = false;
});
}
void _toggleLoop() {
setState(() {
_isLooping = !_isLooping;
});
}
void _playRange(double start, double end) {
setState(() {
_inPoint = start;
_outPoint = end;
_isLooping = true;
});
_seekTo(start);
_videoController?.play();
}
@override
Widget build(BuildContext context) {
final controller = ref.read(analysisControllerProvider.notifier);
return Column(
children: [
// Video Area
Expanded(
flex: 3,
child: Container(
color: Colors.black,
alignment: Alignment.center,
child:
_videoController != null &&
_videoController!.value.isInitialized
? Stack(
alignment: Alignment.bottomCenter,
children: [
Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
),
_buildTimelineControls(),
],
)
: const Center(child: CircularProgressIndicator()),
),
),
// Annotations List
Expanded(
flex: 2,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Annotations",
style: Theme.of(context).textTheme.titleMedium,
),
ElevatedButton.icon(
onPressed: () {
double start = _inPoint ?? _currentPosition;
double end = _outPoint ?? (_currentPosition + 5.0);
if (end > _totalDuration) end = _totalDuration;
controller.addAnnotation(
name: "New Annotation",
description:
"${_formatDuration(start)} - ${_formatDuration(end)}",
startTime: start,
endTime: end,
color: "red",
);
},
icon: const Icon(Icons.add),
label: const Text("Add from Selection"),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.separated(
separatorBuilder: (context, index) =>
const Divider(height: 1),
itemCount: widget.annotations.length,
itemBuilder: (context, index) {
final note = widget.annotations[index];
final bool isActive =
_currentPosition >= note.startTime &&
_currentPosition <= note.endTime;
return Container(
color: isActive
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.2)
: null,
child: ListTile(
leading: Icon(
Icons.label,
color: _parseColor(note.color ?? 'grey'),
),
title: Text(note.name ?? 'Untitled'),
subtitle: Text(
"${_formatDuration(note.startTime)} - ${_formatDuration(note.endTime)}",
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.play_circle_outline),
onPressed: () =>
_playRange(note.startTime, note.endTime),
tooltip: "Play Range",
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
controller.deleteAnnotation(note.id),
),
],
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: SizedBox(
width: double.infinity,
child: TextButton(
onPressed: widget.onClose,
child: const Text('Back'),
),
),
),
],
),
),
],
);
}
Widget _buildTimelineControls() {
return Container(
color: Colors.black.withOpacity(0.8),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Timeline Visualization
SizedBox(
height: 20,
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
// Annotations
...widget.annotations.map((note) {
final left =
(note.startTime / _totalDuration) *
constraints.maxWidth;
final width =
((note.endTime - note.startTime) / _totalDuration) *
constraints.maxWidth;
return Positioned(
left: left,
width: width,
top: 4,
bottom: 4,
child: Container(
decoration: BoxDecoration(
color: _parseColor(
note.color ?? 'grey',
).withOpacity(0.6),
borderRadius: BorderRadius.circular(2),
),
),
);
}),
// IN Point
if (_inPoint != null)
Positioned(
left:
(_inPoint! / _totalDuration) * constraints.maxWidth,
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.green),
),
// OUT Point
if (_outPoint != null)
Positioned(
left:
(_outPoint! / _totalDuration) *
constraints.maxWidth,
top: 0,
bottom: 0,
child: Container(width: 2, color: Colors.red),
),
],
);
},
),
),
// Slider
SliderTheme(
data: SliderTheme.of(context).copyWith(
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
trackHeight: 2,
),
child: Slider(
value: _currentPosition.clamp(0.0, _totalDuration),
min: 0.0,
max: _totalDuration,
onChanged: (value) => _seekTo(value),
activeColor: Theme.of(context).colorScheme.primary,
inactiveColor: Colors.grey,
),
),
// Controls Row
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_formatDuration(_currentPosition),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(
Icons.keyboard_arrow_left,
color: Colors.white,
),
onPressed: () => _seekTo(_currentPosition - 1),
),
IconButton(
icon: Icon(
_isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
onPressed: _togglePlay,
),
IconButton(
icon: const Icon(
Icons.keyboard_arrow_right,
color: Colors.white,
),
onPressed: () => _seekTo(_currentPosition + 1),
),
const SizedBox(width: 16),
// IN/OUT Controls
IconButton(
icon: const Icon(Icons.login, color: Colors.green),
tooltip: "Set IN Point",
onPressed: _setInPoint,
),
IconButton(
icon: const Icon(Icons.logout, color: Colors.red),
tooltip: "Set OUT Point",
onPressed: _setOutPoint,
),
IconButton(
icon: Icon(
Icons.loop,
color: _isLooping
? Theme.of(context).colorScheme.primary
: Colors.white,
),
tooltip: "Toggle Loop",
onPressed: _toggleLoop,
),
if (_inPoint != null || _outPoint != null)
IconButton(
icon: const Icon(
Icons.cancel_outlined,
color: Colors.white,
),
tooltip: "Clear Points",
onPressed: _clearPoints,
),
const Spacer(),
Text(
_formatDuration(_totalDuration),
style: const TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
],
),
);
}
String _formatDuration(double seconds) {
final duration = Duration(milliseconds: (seconds * 1000).toInt());
final mins = duration.inMinutes;
final secs = duration.inSeconds % 60;
return '$mins:${secs.toString().padLeft(2, '0')}';
}
Color _parseColor(String colorName) {
switch (colorName.toLowerCase()) {
case 'red':
return Colors.red;
case 'green':
return Colors.green;
case 'blue':
return Colors.blue;
case 'yellow':
return Colors.yellow;
default:
return Colors.grey;
}
}
}

View 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());
}
}

View 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

View 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'),
),
],
),
);
}
}

View 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;
}

View 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;
}

View 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,
),
),
],
),
),
),
),
),
);
}
}

View 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(),
],
),
),
);
}
}

View 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?";
}
}

View 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

View 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,
),
),
),
);
}),
);
},
),
),
],
),
);
}
}

View 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;
}

View 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;
}

View 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,
),
),
),
],
),
],
),
),
),
),
);
}
}

View 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,
),
),
),
],
),
],
),
),
),
),
);
}
}

View 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,
),
),
),
],
],
),
),
);
}
}

View 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,
),
],
),
),
),
],
),
),
);
}
}

View 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());
}
}

View 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

View 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,
),
],
),
),
);
}
}

View 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;
}

View 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;
}

View 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));
}
}

View 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

View 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()),
),
);
}
}

View 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;
}

View 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;
}

View 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);
}
},
),
),
],
),
);
}
}

View 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);
},
);
},
),
),
],
);
},
),
);
}
}

View 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),
],
),
);
},
);
}
}

View 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());
}
}

View 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

View 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,
),
],
],
),
),
),
),
);
}
}

View 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;
}

View 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;
}

View 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),
],
);
}
}

View File

@@ -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,
),
],
);
}
}

View File

@@ -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,
);
}
}

View 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));
}
}
}

View File

@@ -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

View 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')}';
}
}

View 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;
}

View File

@@ -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;
}