Files
mirror-os-flutter/apps/remote/lib/screens/remote_workout.dart
T
2026-07-11 21:51:34 -05:00

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