import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:mirror_core/mirror_core.dart'; import 'package:mirror_protocol/mirror_protocol.dart'; import 'package:mirror_ui/mirror_ui.dart'; import '../providers/connection_provider.dart'; import '../providers/workout_browse_provider.dart'; import '../providers/programs_provider.dart'; class RemoteWorkout extends ConsumerWidget { const RemoteWorkout({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final playback = ref.watch(mirrorStateProvider.select((s) => s?.playback)); final workout = ref.watch(mirrorStateProvider.select((s) => s?.workout)); final program = ref.watch(mirrorStateProvider.select((s) => s?.program)); // Program playback view if (program != null && program.phase != 'completed') { return _ProgramPlaybackView(program: program, playback: playback); } // If we're playing/resting, show playback controls if (workout != null && (workout.phase == WorkoutPhase.playing || workout.phase == WorkoutPhase.resting)) { return _PlaybackView(workout: workout, playback: playback); } if (workout != null && workout.phase == WorkoutPhase.completed) { return _CompletedView(); } // Otherwise show the browse view with programs toggle return const _WorkoutTabs(); } } class _WorkoutTabs extends ConsumerStatefulWidget { const _WorkoutTabs(); @override ConsumerState<_WorkoutTabs> createState() => _WorkoutTabsState(); } class _WorkoutTabsState extends ConsumerState<_WorkoutTabs> with SingleTickerProviderStateMixin { late TabController _tabController; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); } @override void dispose() { _tabController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ TabBar( controller: _tabController, tabs: const [ Tab(text: 'Videos'), Tab(text: 'Programs'), ], ), Expanded( child: TabBarView( controller: _tabController, children: const [ _BrowseView(), _ProgramsView(), ], ), ), ], ); } } // --- Programs View --- class _ProgramsView extends ConsumerWidget { const _ProgramsView(); @override Widget build(BuildContext context, WidgetRef ref) { final programsAsync = ref.watch(programsProvider); return programsAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: MirrorTheme.danger), const SizedBox(height: 16), Text('Failed to load programs', style: Theme.of(context).textTheme.bodyLarge), const SizedBox(height: 8), Text(err.toString(), style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: 16), FilledButton( onPressed: () => ref.read(programsProvider.notifier).refresh(), child: const Text('Retry'), ), ], ), ), data: (programs) { if (programs.isEmpty) { return const Center( child: Text( 'No programs found', style: TextStyle(color: MirrorTheme.textMuted), ), ); } return ListView.builder( padding: const EdgeInsets.all(16), itemCount: programs.length, itemBuilder: (context, index) { final program = programs[index]; return _ProgramCard(program: program); }, ); }, ); } } class _ProgramCard extends ConsumerWidget { final Program program; const _ProgramCard({required this.program}); @override Widget build(BuildContext context, WidgetRef ref) { final completedCount = program.workouts.where((w) => w.completedAt != null).length; final totalCount = program.workouts.length; final nextWorkout = program.currentWorkoutIndex < totalCount ? program.workouts[program.currentWorkoutIndex] : null; return Card( color: MirrorTheme.surface, margin: const EdgeInsets.only(bottom: 12), child: ExpansionTile( leading: Container( width: 44, height: 44, decoration: BoxDecoration( color: MirrorTheme.accent.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: const Icon(Icons.event_note, color: MirrorTheme.accent), ), title: Text(program.name, style: const TextStyle(fontWeight: FontWeight.w600)), subtitle: Text( '$completedCount/$totalCount workouts completed', style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 13), ), children: [ if (nextWorkout != null) Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () => _startWorkout(ref, program, nextWorkout), icon: const Icon(Icons.play_arrow), label: Text('Start: ${nextWorkout.name}'), ), ), ), const SizedBox(height: 8), ...program.workouts.map((workout) { final isCurrent = workout.position == program.currentWorkoutIndex; final isCompleted = workout.completedAt != null; return ListTile( dense: true, leading: Icon( isCompleted ? Icons.check_circle : isCurrent ? Icons.arrow_right : Icons.circle_outlined, color: isCompleted ? MirrorTheme.success : isCurrent ? MirrorTheme.accent : MirrorTheme.textMuted, size: 20, ), title: Text( workout.name, style: TextStyle( fontSize: 14, color: isCompleted ? MirrorTheme.textMuted : MirrorTheme.textPrimary, decoration: isCompleted ? TextDecoration.lineThrough : null, ), ), subtitle: Text( '${workout.exercises.length} exercises', style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted), ), trailing: isCurrent && !isCompleted ? const Text('NEXT', style: TextStyle(color: MirrorTheme.accent, fontSize: 11, fontWeight: FontWeight.bold)) : null, onTap: () => _startWorkout(ref, program, workout), ); }), const SizedBox(height: 8), ], ), ); } void _startWorkout(WidgetRef ref, Program program, ProgramWorkout workout) { ref.read(connectionProvider.notifier).send( NavigateCommand(target: ScreenType.workout), ); ref.read(connectionProvider.notifier).send( ProgramCommand( action: ProgramAction.playWorkout, payload: { 'programId': program.id, 'workoutId': workout.id, 'exercises': workout.exercises.map((e) => e.toJson()).toList(), }, ), ); } } // --- Program Playback View --- class _ProgramPlaybackView extends ConsumerWidget { final ProgramSessionState program; final PlaybackState? playback; const _ProgramPlaybackView({required this.program, this.playback}); @override Widget build(BuildContext context, WidgetRef ref) { final isResting = program.phase == 'resting'; final isInstruction = program.phase == 'instruction'; final exerciseName = program.currentExerciseIndex < program.exercises.length ? program.exercises[program.currentExerciseIndex] : 'Exercise'; return Padding( padding: const EdgeInsets.all(20), child: Column( children: [ // Program & workout name Text( program.programName, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: MirrorTheme.accent, letterSpacing: 1, ), ), const SizedBox(height: 4), Text( program.workoutName, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), // Exercise progress Text( '${program.currentExerciseIndex + 1} / ${program.exercises.length}', style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 13), ), const Spacer(), if (isResting) ...[ Text( 'REST', style: Theme.of(context).textTheme.labelLarge?.copyWith( color: MirrorTheme.accent, letterSpacing: 6, ), ), const SizedBox(height: 20), TimerDisplay(seconds: program.timerRemaining), const SizedBox(height: 32), FilledButton( onPressed: () => ref.read(connectionProvider.notifier).send( ProgramCommand(action: ProgramAction.skipExercise), ), child: const Text('Skip Rest'), ), ] else if (isInstruction) ...[ Icon(Icons.info_outline, size: 48, color: MirrorTheme.accent), const SizedBox(height: 16), Text( exerciseName, style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ), if (program.timerRemaining > 0) ...[ const SizedBox(height: 16), TimerDisplay(seconds: program.timerRemaining), ], ] else ...[ // Playing Text( exerciseName, style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center, ), const SizedBox(height: 12), // Set counter if (program.totalSets > 1) Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: MirrorTheme.surface, borderRadius: BorderRadius.circular(8), ), child: Text( 'Set ${program.currentSet} of ${program.totalSets}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), ), // Playback progress if (playback != null) ...[ const SizedBox(height: 24), LinearProgressIndicator( value: playback!.duration.inMilliseconds > 0 ? playback!.position.inMilliseconds / playback!.duration.inMilliseconds : 0, backgroundColor: MirrorTheme.surfaceLight, color: MirrorTheme.accent, ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(_fmt(playback!.position), style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)), Text(_fmt(playback!.duration), style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)), ], ), ], // Type indicator const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.play_circle_outline, size: 16, color: MirrorTheme.textMuted), const SizedBox(width: 4), Text( 'VIDEO', style: TextStyle(color: MirrorTheme.textMuted, fontSize: 11, letterSpacing: 2), ), ], ), ], const Spacer(), // Transport controls if (!isResting) Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( iconSize: 40, icon: const Icon(Icons.skip_previous), onPressed: () => ref.read(connectionProvider.notifier).send( ProgramCommand(action: ProgramAction.previousExercise), ), ), const SizedBox(width: 16), IconButton.filled( iconSize: 56, icon: Icon( program.phase == 'playing' ? Icons.pause : Icons.play_arrow, ), onPressed: () => ref.read(connectionProvider.notifier).send( ProgramCommand( action: program.phase == 'playing' ? ProgramAction.pauseExercise : ProgramAction.resumeExercise, ), ), ), const SizedBox(width: 16), IconButton( iconSize: 40, icon: const Icon(Icons.skip_next), onPressed: () => ref.read(connectionProvider.notifier).send( ProgramCommand(action: ProgramAction.skipExercise), ), ), ], ), const SizedBox(height: 20), TextButton.icon( icon: const Icon(Icons.stop, color: MirrorTheme.danger), label: const Text('End Program', style: TextStyle(color: MirrorTheme.danger)), onPressed: () => ref.read(connectionProvider.notifier).send( ProgramCommand(action: ProgramAction.stopProgram), ), ), const SizedBox(height: 20), ], ), ); } String _fmt(Duration d) { final m = d.inMinutes; final s = (d.inSeconds % 60).toString().padLeft(2, '0'); return '$m:$s'; } } // --- Videos Browse View (existing) --- class _BrowseView extends ConsumerWidget { const _BrowseView(); @override Widget build(BuildContext context, WidgetRef ref) { final browseState = ref.watch(workoutBrowseProvider); return browseState.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, _) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.error_outline, size: 48, color: MirrorTheme.danger), const SizedBox(height: 16), Text('Failed to load videos', style: Theme.of(context).textTheme.bodyLarge), const SizedBox(height: 8), Text(err.toString(), style: Theme.of(context).textTheme.bodySmall), const SizedBox(height: 16), FilledButton( onPressed: () => ref.read(workoutBrowseProvider.notifier).browse(''), child: const Text('Retry'), ), ], ), ), data: (data) => _BrowseContent(data: data), ); } } class _BrowseContent extends ConsumerWidget { final WorkoutBrowseState data; const _BrowseContent({required this.data}); @override Widget build(BuildContext context, WidgetRef ref) { final path = data.currentPath; final pathParts = path.isNotEmpty ? path.split('/') : []; final subfolders = _getSubfolders(data.folders, path); final stats = data.stats; // Only show videos directly in this folder (not in subfolders) final videos = data.videos.where((v) { final vFolder = v.folder ?? ''; return vFolder == path; }).toList(); return CustomScrollView( slivers: [ SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Stats if (stats != null) Padding( padding: const EdgeInsets.only(bottom: 16), child: Row( children: [ _StatChip('${stats.total}', 'videos'), const SizedBox(width: 8), _StatChip('${stats.completed}', 'done'), const SizedBox(width: 8), _StatChip('${stats.inProgress}', 'started'), ], ), ), // Breadcrumb _Breadcrumb(pathParts: pathParts, ref: ref), // Filter chips Padding( padding: const EdgeInsets.only(bottom: 16), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: WorkoutFilter.values.map((f) { final active = f == data.filter; return Padding( padding: const EdgeInsets.only(right: 8), child: ChoiceChip( label: Text(f.name), selected: active, onSelected: (_) { ref.read(workoutBrowseProvider.notifier).setFilter(f); }, ), ); }).toList(), ), ), ), // Play all button if (videos.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 16), child: SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () => _playFolder(ref, false), icon: const Icon(Icons.play_arrow), label: Text('Play All (${videos.length})'), ), ), ), ], ), ), ), // Subfolders SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final folder = subfolders[index]; final name = folder.split('/').last; return ListTile( leading: const Icon(Icons.folder, color: MirrorTheme.accent), title: Text(name), trailing: const Icon(Icons.chevron_right), onTap: () { ref.read(workoutBrowseProvider.notifier).browse(folder); }, ); }, childCount: subfolders.length, ), ), // Videos SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final video = videos[index]; final completed = video.percentCompleted >= 95; final thumbUrl = video.thumbnailPath != null ? 'http://192.168.86.172:8091/${video.thumbnailPath}' : null; return ListTile( leading: SizedBox( width: 56, height: 56, child: thumbUrl != null ? ClipRRect( borderRadius: BorderRadius.circular(6), child: Image.network( thumbUrl, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( color: MirrorTheme.surface, child: const Icon(Icons.play_circle_outline, size: 24), ), ), ) : Container( decoration: BoxDecoration( color: MirrorTheme.surface, borderRadius: BorderRadius.circular(6), ), child: const Icon(Icons.play_circle_outline, size: 24), ), ), title: Text( video.title, maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '${_formatSeconds(video.duration)}' '${completed ? ' · Done' : video.percentCompleted > 0 ? ' · ${video.percentCompleted}%' : ''}', ), trailing: ProgressRing( percent: video.percentCompleted.toDouble(), size: 28, ), onTap: () => _playVideo(ref, video), ); }, childCount: videos.length, ), ), if (subfolders.isEmpty && videos.isEmpty) const SliverFillRemaining( child: Center( child: Text('No videos here', style: TextStyle(color: MirrorTheme.textMuted)), ), ), ], ); } void _playVideo(WidgetRef ref, VideoState video) { ref.read(connectionProvider.notifier).send( NavigateCommand(target: ScreenType.workout), ); ref.read(connectionProvider.notifier).send( WorkoutCommand( action: WorkoutAction.playVideo, payload: { 'videoId': video.id, 'title': video.title, 'path': video.path, 'duration': video.duration, 'trimStart': video.trimStart, 'trimEnd': video.trimEnd, 'restSeconds': video.restSeconds, 'lastPosition': video.lastPosition, }, ), ); } void _playFolder(WidgetRef ref, bool unwatchedOnly) { final videos = unwatchedOnly ? data.videos.where((v) => v.percentCompleted < 95).toList() : data.videos; if (videos.isEmpty) return; ref.read(connectionProvider.notifier).send( NavigateCommand(target: ScreenType.workout), ); ref.read(connectionProvider.notifier).send( WorkoutCommand( action: WorkoutAction.playFolder, payload: { 'videos': videos.map((v) => { 'videoId': v.id, 'title': v.title, 'path': v.path, 'duration': v.duration, 'trimStart': v.trimStart, 'trimEnd': v.trimEnd, 'restSeconds': v.restSeconds, 'lastPosition': v.lastPosition, }).toList(), }, ), ); } List _getSubfolders(List allFolders, String currentPath) { if (currentPath.isEmpty) { final top = {}; for (final f in allFolders) { final first = f.split('/').first; if (first.isNotEmpty) top.add(first); } return top.toList()..sort(); } final children = {}; for (final f in allFolders) { if (f.startsWith('$currentPath/')) { final rest = f.substring(currentPath.length + 1); final next = rest.split('/').first; if (next.isNotEmpty) children.add('$currentPath/$next'); } } return children.toList()..sort(); } String _formatSeconds(int s) { final m = s ~/ 60; final sec = (s % 60).toString().padLeft(2, '0'); return '$m:$sec'; } } class _PlaybackView extends ConsumerWidget { final WorkoutSessionState workout; final PlaybackState? playback; const _PlaybackView({required this.workout, this.playback}); @override Widget build(BuildContext context, WidgetRef ref) { final isResting = workout.phase == WorkoutPhase.resting; final currentItem = workout.currentIndex < workout.queue.length ? workout.queue[workout.currentIndex] : null; return Padding( padding: const EdgeInsets.all(20), child: Column( children: [ const Spacer(), if (isResting) ...[ Text( 'REST', style: Theme.of(context).textTheme.labelLarge?.copyWith( color: MirrorTheme.accent, letterSpacing: 6, ), ), const SizedBox(height: 20), TimerDisplay(seconds: workout.restRemaining), const SizedBox(height: 20), if (currentItem != null) Text( 'Next: ${currentItem.title}', style: Theme.of(context).textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: 32), FilledButton( onPressed: () { ref.read(connectionProvider.notifier).send( WorkoutCommand(action: WorkoutAction.skipRest), ); }, child: const Text('Skip Rest'), ), ] else ...[ Text( currentItem?.title ?? playback?.title ?? '', style: Theme.of(context).textTheme.headlineMedium, textAlign: TextAlign.center, ), if (workout.queue.length > 1) Padding( padding: const EdgeInsets.only(top: 8), child: Text( '${workout.currentIndex + 1} of ${workout.queue.length}', style: Theme.of(context).textTheme.bodySmall, ), ), if (playback != null) ...[ const SizedBox(height: 32), LinearProgressIndicator( value: playback!.duration.inMilliseconds > 0 ? playback!.position.inMilliseconds / playback!.duration.inMilliseconds : 0, backgroundColor: MirrorTheme.surfaceLight, color: MirrorTheme.accent, ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(_fmt(playback!.position), style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)), Text(_fmt(playback!.duration), style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)), ], ), ], ], const Spacer(), if (!isResting) Row( mainAxisAlignment: MainAxisAlignment.center, children: [ IconButton( iconSize: 40, icon: const Icon(Icons.skip_previous), onPressed: () => ref.read(connectionProvider.notifier).send( WorkoutCommand(action: WorkoutAction.previousVideo), ), ), const SizedBox(width: 16), IconButton( iconSize: 32, icon: const Icon(Icons.replay_10), onPressed: () { if (playback != null) { final pos = (playback!.position - const Duration(seconds: 10)); ref.read(connectionProvider.notifier).send( PlaybackCommand( action: PlaybackAction.seek, value: pos.inMilliseconds.clamp(0, playback!.duration.inMilliseconds), ), ); } }, ), const SizedBox(width: 16), IconButton.filled( iconSize: 56, icon: Icon(playback?.isPlaying == true ? Icons.pause : Icons.play_arrow), onPressed: () => ref.read(connectionProvider.notifier).send( PlaybackCommand( action: playback?.isPlaying == true ? PlaybackAction.pause : PlaybackAction.resume, ), ), ), const SizedBox(width: 16), IconButton( iconSize: 32, icon: const Icon(Icons.forward_10), onPressed: () { if (playback != null) { final pos = (playback!.position + const Duration(seconds: 10)); ref.read(connectionProvider.notifier).send( PlaybackCommand( action: PlaybackAction.seek, value: pos.inMilliseconds, ), ); } }, ), const SizedBox(width: 16), IconButton( iconSize: 40, icon: const Icon(Icons.skip_next), onPressed: () => ref.read(connectionProvider.notifier).send( WorkoutCommand(action: WorkoutAction.skipVideo), ), ), ], ), const SizedBox(height: 20), TextButton.icon( icon: const Icon(Icons.stop, color: MirrorTheme.danger), label: const Text('End Session', style: TextStyle(color: MirrorTheme.danger)), onPressed: () => ref.read(connectionProvider.notifier).send( WorkoutCommand(action: WorkoutAction.stopSession), ), ), const SizedBox(height: 20), ], ), ); } String _fmt(Duration d) { final m = d.inMinutes; final s = (d.inSeconds % 60).toString().padLeft(2, '0'); return '$m:$s'; } } class _CompletedView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.check_circle, size: 80, color: MirrorTheme.success), const SizedBox(height: 16), Text('Session Complete', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 32), FilledButton( onPressed: () { ref.read(workoutBrowseProvider.notifier).browse(''); }, child: const Text('Back to Browse'), ), ], ), ); } } class _StatChip extends StatelessWidget { final String value; final String label; const _StatChip(this.value, this.label); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: MirrorTheme.surface, borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), const SizedBox(width: 4), Text(label, style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 13)), ], ), ); } } class _Breadcrumb extends StatelessWidget { final List pathParts; final WidgetRef ref; const _Breadcrumb({required this.pathParts, required this.ref}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ GestureDetector( onTap: () => ref.read(workoutBrowseProvider.notifier).browse(''), child: const Text('Home', style: TextStyle(color: MirrorTheme.accent, fontSize: 13)), ), for (int i = 0; i < pathParts.length; i++) ...[ const Text(' / ', style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13)), GestureDetector( onTap: () => ref .read(workoutBrowseProvider.notifier) .browse(pathParts.sublist(0, i + 1).join('/')), child: Text( pathParts[i], style: TextStyle( color: i == pathParts.length - 1 ? MirrorTheme.textPrimary : MirrorTheme.accent, fontSize: 13, ), ), ), ], ], ), ), ); } }