951 lines
33 KiB
Dart
951 lines
33 KiB
Dart
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('/') : <String>[];
|
|
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<String> _getSubfolders(List<String> allFolders, String currentPath) {
|
|
if (currentPath.isEmpty) {
|
|
final top = <String>{};
|
|
for (final f in allFolders) {
|
|
final first = f.split('/').first;
|
|
if (first.isNotEmpty) top.add(first);
|
|
}
|
|
return top.toList()..sort();
|
|
}
|
|
final children = <String>{};
|
|
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<String> 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|