Preserve Flutter mirror-os codebase
This commit is contained in:
@@ -0,0 +1,950 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user