Preserve Flutter mirror-os codebase

This commit is contained in:
Chris
2026-07-11 21:51:34 -05:00
commit 27631ed108
142 changed files with 11896 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import 'providers/connection_provider.dart';
import 'screens/connect_screen.dart';
import 'screens/remote_dashboard.dart';
import 'screens/remote_workout.dart';
import 'screens/remote_media.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
class MirrorRemoteApp extends StatelessWidget {
const MirrorRemoteApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mirror Remote',
debugShowCheckedModeBanner: false,
theme: MirrorTheme.dark,
home: const _RemoteShell(),
);
}
}
class _RemoteShell extends ConsumerWidget {
const _RemoteShell();
@override
Widget build(BuildContext context, WidgetRef ref) {
final connection = ref.watch(connectionProvider);
if (!connection.connected) {
return const ConnectScreen();
}
return const _ConnectedRemote();
}
}
class _ConnectedRemote extends ConsumerStatefulWidget {
const _ConnectedRemote();
@override
ConsumerState<_ConnectedRemote> createState() => _ConnectedRemoteState();
}
class _ConnectedRemoteState extends ConsumerState<_ConnectedRemote> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
final activeScreen = ref.watch(
mirrorStateProvider.select((s) => s?.activeScreen),
);
// Sync tab to mirror's active screen
final tabIndex = switch (activeScreen) {
ScreenType.dashboard => 0,
ScreenType.workout => 1,
ScreenType.media => 2,
_ => _currentIndex,
};
return Scaffold(
body: SafeArea(
child: IndexedStack(
index: tabIndex,
children: const [
RemoteDashboard(),
RemoteWorkout(),
RemoteMedia(),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: tabIndex,
onDestinationSelected: (index) {
setState(() => _currentIndex = index);
final target = switch (index) {
0 => ScreenType.dashboard,
1 => ScreenType.workout,
2 => ScreenType.media,
_ => ScreenType.dashboard,
};
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: target),
);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.dashboard), label: 'Home'),
NavigationDestination(icon: Icon(Icons.fitness_center), label: 'Workout'),
NavigationDestination(icon: Icon(Icons.music_note), label: 'Media'),
],
),
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
runApp(
const ProviderScope(child: MirrorRemoteApp()),
);
}
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
final connectionProvider =
NotifierProvider<ConnectionNotifier, ConnectionState>(ConnectionNotifier.new);
final mirrorStateProvider = StateProvider<DisplayState?>((ref) => null);
final dashboardConfigProvider = StateProvider<DashboardLayoutConfig?>((ref) => null);
class ConnectionState {
final bool connected;
final bool connecting;
final String? host;
final String? error;
final bool manualDisconnect;
const ConnectionState({
this.connected = false,
this.connecting = false,
this.host,
this.error,
this.manualDisconnect = false,
});
ConnectionState copyWith({
bool? connected,
bool? connecting,
String? host,
String? error,
bool clearError = false,
bool? manualDisconnect,
}) =>
ConnectionState(
connected: connected ?? this.connected,
connecting: connecting ?? this.connecting,
host: host ?? this.host,
error: clearError ? null : (error ?? this.error),
manualDisconnect: manualDisconnect ?? this.manualDisconnect,
);
}
class ConnectionNotifier extends Notifier<ConnectionState> {
WebSocketChannel? _channel;
Timer? _reconnectTimer;
@override
ConnectionState build() => const ConnectionState();
Future<void> connect(String host, {int port = 9090}) async {
state = state.copyWith(connecting: true, clearError: true, manualDisconnect: false);
try {
final uri = Uri.parse('ws://$host:$port');
_channel = WebSocketChannel.connect(uri);
await _channel!.ready;
state = state.copyWith(connected: true, connecting: false, host: host);
// Send registration
_channel!.sink.add(RegisterMessage(role: 'remote').encode());
// Listen for messages
_channel!.stream.listen(
_handleMessage,
onDone: () => _onDisconnected(),
onError: (e) => _onDisconnected(error: e.toString()),
);
} catch (e) {
state = state.copyWith(
connected: false,
connecting: false,
error: 'Failed to connect: $e',
);
}
}
void _handleMessage(dynamic data) {
try {
final message = MirrorMessage.decode(data as String);
switch (message) {
case StateSync(state: final mirrorState):
ref.read(mirrorStateProvider.notifier).state = mirrorState;
case DashboardConfigResponse(layout: final layout):
ref.read(dashboardConfigProvider.notifier).state = layout;
case StatePatch():
break;
case EventMessage():
break;
default:
break;
}
} catch (e) {
print('Failed to parse message: $e');
}
}
void _onDisconnected({String? error}) {
state = state.copyWith(connected: false, error: error);
_channel = null;
// Only auto-reconnect if not intentionally disconnected
if (state.host != null && error != null) {
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 3), () {
if (state.host != null && !state.connected) {
connect(state.host!);
}
});
}
}
void send(MirrorMessage message) {
_channel?.sink.add(message.encode());
}
void disconnect() {
_reconnectTimer?.cancel();
_channel?.sink.close();
_channel = null;
state = const ConnectionState(manualDisconnect: true);
ref.read(mirrorStateProvider.notifier).state = null;
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'settings_provider.dart';
final haServiceProvider = Provider<HomeAssistantService?>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null || settings.haBaseUrl == null || settings.haToken == null) {
return null;
}
return HomeAssistantService(
baseUrl: settings.haBaseUrl!,
token: settings.haToken!,
);
});
final haEntitiesProvider =
AsyncNotifierProvider<HaEntitiesNotifier, HaEntitiesState>(HaEntitiesNotifier.new);
class HaEntitiesState {
final List<HaEntity> entities;
final HaWeather? weather;
final List<HaCalendarEvent> calendarEvents;
final String? error;
const HaEntitiesState({
this.entities = const [],
this.weather,
this.calendarEvents = const [],
this.error,
});
}
class HaEntitiesNotifier extends AsyncNotifier<HaEntitiesState> {
@override
Future<HaEntitiesState> build() async {
final service = ref.watch(haServiceProvider);
if (service == null) {
return const HaEntitiesState(error: 'Configure Home Assistant in Settings');
}
try {
final entities = await service.getStates();
// Find weather entity
final weatherEntity = entities.firstWhere(
(e) => e.entityId.startsWith('weather.'),
orElse: () => HaEntity(entityId: '', state: '', attributes: {}, lastChanged: null),
);
HaWeather? weather;
if (weatherEntity.entityId.isNotEmpty) {
weather = HaWeather.fromEntity(weatherEntity);
}
return HaEntitiesState(
entities: entities,
weather: weather,
);
} catch (e) {
return HaEntitiesState(error: 'HA connection failed: $e');
}
}
Future<void> toggleEntity(String entityId) async {
final service = ref.read(haServiceProvider);
if (service == null) return;
final entity = state.valueOrNull?.entities.firstWhere(
(e) => e.entityId == entityId,
orElse: () => HaEntity(entityId: '', state: '', attributes: {}, lastChanged: null),
);
if (entity == null || entity.entityId.isEmpty) return;
final isOn = entity.state == 'on';
if (isOn) {
await service.turnOff(entityId);
} else {
await service.turnOn(entityId);
}
// Refresh state
ref.invalidateSelf();
}
Future<void> callScene(String entityId) async {
final service = ref.read(haServiceProvider);
if (service == null) return;
await service.callService('scene', 'turn_on', entityId: entityId);
ref.invalidateSelf();
}
void refresh() => ref.invalidateSelf();
}
class HaWeather {
final double? temperature;
final String? condition;
final double? humidity;
const HaWeather({this.temperature, this.condition, this.humidity});
factory HaWeather.fromEntity(HaEntity entity) {
final attrs = entity.attributes;
return HaWeather(
temperature: (attrs['temperature'] as num?)?.toDouble(),
condition: entity.state,
humidity: (attrs['humidity'] as num?)?.toDouble(),
);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'settings_provider.dart';
final plexServiceProvider = Provider<PlexService?>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null || settings.plexHost == null || settings.plexToken == null) {
return null;
}
return PlexService(
host: settings.plexHost!,
port: settings.plexPort,
token: settings.plexToken!,
);
});
final plexBrowseProvider =
AsyncNotifierProvider<PlexBrowseNotifier, PlexBrowseState>(
PlexBrowseNotifier.new);
class PlexBrowseState {
final List<PlexLibrary> libraries;
final PlexLibrary? currentLibrary;
final List<PlexItem> items;
final List<String> breadcrumb;
final String? error;
const PlexBrowseState({
this.libraries = const [],
this.currentLibrary,
this.items = const [],
this.breadcrumb = const [],
this.error,
});
}
class PlexBrowseNotifier extends AsyncNotifier<PlexBrowseState> {
@override
Future<PlexBrowseState> build() async {
final service = ref.watch(plexServiceProvider);
if (service == null) {
return const PlexBrowseState(error: 'Configure Plex in Settings');
}
final libraries = await service.fetchLibraries();
return PlexBrowseState(libraries: libraries);
}
Future<void> openLibrary(PlexLibrary library) async {
final service = ref.read(plexServiceProvider);
if (service == null) return;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final items = await service.fetchItems(library.id);
return PlexBrowseState(
libraries: state.valueOrNull?.libraries ?? [],
currentLibrary: library,
items: items,
breadcrumb: [library.title],
);
});
}
Future<void> openItem(PlexItem item) async {
final service = ref.read(plexServiceProvider);
if (service == null) return;
if (item.type == 'show' || item.type == 'season' || item.type == 'artist' || item.type == 'album') {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final children = await service.fetchChildren(item.id);
final prev = state.valueOrNull;
return PlexBrowseState(
libraries: prev?.libraries ?? [],
currentLibrary: prev?.currentLibrary,
items: children,
breadcrumb: [...(prev?.breadcrumb ?? []), item.title],
);
});
}
}
Future<void> goBack() async {
final prev = state.valueOrNull;
if (prev == null) return;
if (prev.breadcrumb.length <= 1) {
state = AsyncValue.data(PlexBrowseState(
libraries: prev.libraries,
));
} else {
if (prev.currentLibrary != null) {
await openLibrary(prev.currentLibrary!);
}
}
}
void refresh() {
ref.invalidateSelf();
}
}
@@ -0,0 +1,25 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
final programsApiProvider = Provider<ProgramsApiClient>((ref) {
return ProgramsApiClient(baseUrl: 'http://192.168.86.172:8091');
});
final programsProvider =
AsyncNotifierProvider<ProgramsNotifier, List<Program>>(ProgramsNotifier.new);
class ProgramsNotifier extends AsyncNotifier<List<Program>> {
@override
Future<List<Program>> build() async {
final client = ref.read(programsApiProvider);
return client.fetchPrograms();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final client = ref.read(programsApiProvider);
return client.fetchPrograms();
});
}
}
@@ -0,0 +1,109 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
final settingsProvider = AsyncNotifierProvider<SettingsNotifier, AppSettings>(SettingsNotifier.new);
class AppSettings {
final String mirrorHost;
final int mirrorPort;
final String workoutPlayerUrl;
final String? plexHost;
final int plexPort;
final String? plexToken;
final String? haBaseUrl;
final String? haToken;
final String? spotifyClientId;
final bool spotifyConnected;
const AppSettings({
this.mirrorHost = 'mirror.local',
this.mirrorPort = 9090,
this.workoutPlayerUrl = 'http://192.168.86.172:8091',
this.plexHost,
this.plexPort = 32400,
this.plexToken,
this.haBaseUrl,
this.haToken,
this.spotifyClientId,
this.spotifyConnected = false,
});
AppSettings copyWith({
String? mirrorHost,
int? mirrorPort,
String? workoutPlayerUrl,
String? plexHost,
int? plexPort,
String? plexToken,
String? haBaseUrl,
String? haToken,
String? spotifyClientId,
bool? spotifyConnected,
}) =>
AppSettings(
mirrorHost: mirrorHost ?? this.mirrorHost,
mirrorPort: mirrorPort ?? this.mirrorPort,
workoutPlayerUrl: workoutPlayerUrl ?? this.workoutPlayerUrl,
plexHost: plexHost ?? this.plexHost,
plexPort: plexPort ?? this.plexPort,
plexToken: plexToken ?? this.plexToken,
haBaseUrl: haBaseUrl ?? this.haBaseUrl,
haToken: haToken ?? this.haToken,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyConnected: spotifyConnected ?? this.spotifyConnected,
);
}
class SettingsNotifier extends AsyncNotifier<AppSettings> {
@override
Future<AppSettings> build() async {
final prefs = await SharedPreferences.getInstance();
return AppSettings(
mirrorHost: prefs.getString('mirror_host') ?? 'mirror.local',
mirrorPort: prefs.getInt('mirror_port') ?? 9090,
workoutPlayerUrl: prefs.getString('workout_player_url') ?? 'http://192.168.86.172:8091',
plexHost: prefs.getString('plex_host'),
plexPort: prefs.getInt('plex_port') ?? 32400,
plexToken: prefs.getString('plex_token'),
haBaseUrl: prefs.getString('ha_base_url'),
haToken: prefs.getString('ha_token'),
spotifyClientId: prefs.getString('spotify_client_id'),
spotifyConnected: prefs.getBool('spotify_connected') ?? false,
);
}
Future<void> save(AppSettings settings) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('mirror_host', settings.mirrorHost);
await prefs.setInt('mirror_port', settings.mirrorPort);
await prefs.setString('workout_player_url', settings.workoutPlayerUrl);
if (settings.plexHost != null) {
await prefs.setString('plex_host', settings.plexHost!);
} else {
await prefs.remove('plex_host');
}
await prefs.setInt('plex_port', settings.plexPort);
if (settings.plexToken != null) {
await prefs.setString('plex_token', settings.plexToken!);
} else {
await prefs.remove('plex_token');
}
if (settings.haBaseUrl != null) {
await prefs.setString('ha_base_url', settings.haBaseUrl!);
} else {
await prefs.remove('ha_base_url');
}
if (settings.haToken != null) {
await prefs.setString('ha_token', settings.haToken!);
} else {
await prefs.remove('ha_token');
}
if (settings.spotifyClientId != null) {
await prefs.setString('spotify_client_id', settings.spotifyClientId!);
} else {
await prefs.remove('spotify_client_id');
}
await prefs.setBool('spotify_connected', settings.spotifyConnected);
state = AsyncValue.data(settings);
}
}
@@ -0,0 +1,127 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'settings_provider.dart';
const _currentVersion = 1;
final updateCheckProvider = FutureProvider<UpdateInfo?>((ref) async {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null) return null;
final baseUrl = settings.workoutPlayerUrl;
// Update server runs alongside workout-player
final host = Uri.parse(baseUrl).host;
try {
final response = await http.get(
Uri.parse('http://$host:9091/api/mirror-os/version'),
).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body) as Map<String, dynamic>;
final latestVersion = data['version'] as int? ?? 0;
final downloadUrl = data['downloadUrl'] as String?;
final changelog = data['changelog'] as String?;
if (latestVersion > _currentVersion && downloadUrl != null) {
return UpdateInfo(
currentVersion: _currentVersion,
latestVersion: latestVersion,
downloadUrl: downloadUrl,
changelog: changelog,
);
}
return null;
} catch (_) {
return null;
}
});
class UpdateInfo {
final int currentVersion;
final int latestVersion;
final String downloadUrl;
final String? changelog;
const UpdateInfo({
required this.currentVersion,
required this.latestVersion,
required this.downloadUrl,
this.changelog,
});
}
class UpdateBanner extends ConsumerWidget {
const UpdateBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final update = ref.watch(updateCheckProvider).valueOrNull;
if (update == null) return const SizedBox.shrink();
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E3A5F),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF60A5FA), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.system_update, color: Color(0xFF60A5FA), size: 20),
const SizedBox(width: 8),
Text(
'Update available (v${update.latestVersion})',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: Colors.white,
),
),
],
),
if (update.changelog != null) ...[
const SizedBox(height: 8),
Text(
update.changelog!,
style: const TextStyle(fontSize: 13, color: Colors.white70),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _downloadUpdate(context, update.downloadUrl),
icon: const Icon(Icons.download, size: 18),
label: const Text('Download & Install'),
),
),
],
),
);
}
void _downloadUpdate(BuildContext context, String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Open in browser: $url')),
);
}
}
}
}
@@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import 'settings_provider.dart';
final workoutApiProvider = Provider<WorkoutApiClient>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
final url = settings?.workoutPlayerUrl ?? 'http://192.168.86.172:8091';
return WorkoutApiClient(baseUrl: url);
});
final workoutBrowseProvider =
AsyncNotifierProvider<WorkoutBrowseNotifier, WorkoutBrowseState>(
WorkoutBrowseNotifier.new);
class WorkoutBrowseState {
final List<VideoState> videos;
final List<String> folders;
final WorkoutStats? stats;
final String currentPath;
final WorkoutFilter filter;
const WorkoutBrowseState({
this.videos = const [],
this.folders = const [],
this.stats,
this.currentPath = '',
this.filter = WorkoutFilter.all,
});
WorkoutBrowseState copyWith({
List<VideoState>? videos,
List<String>? folders,
WorkoutStats? stats,
String? currentPath,
WorkoutFilter? filter,
}) =>
WorkoutBrowseState(
videos: videos ?? this.videos,
folders: folders ?? this.folders,
stats: stats ?? this.stats,
currentPath: currentPath ?? this.currentPath,
filter: filter ?? this.filter,
);
}
class WorkoutBrowseNotifier extends AsyncNotifier<WorkoutBrowseState> {
@override
Future<WorkoutBrowseState> build() async {
return _fetch('', WorkoutFilter.all);
}
Future<WorkoutBrowseState> _fetch(String path, WorkoutFilter filter) async {
final client = ref.read(workoutApiProvider);
final result = await client.fetchVideos(folder: path.isEmpty ? null : path, filter: filter);
final stats = await client.fetchStats();
return WorkoutBrowseState(
videos: result.videos,
folders: result.folders,
stats: stats,
currentPath: path,
filter: filter,
);
}
Future<void> browse(String path, {WorkoutFilter? filter}) async {
final currentFilter = filter ?? state.valueOrNull?.filter ?? WorkoutFilter.all;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch(path, currentFilter));
}
Future<void> setFilter(WorkoutFilter filter) async {
final path = state.valueOrNull?.currentPath ?? '';
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch(path, filter));
}
String streamUrl(String videoPath) {
final client = ref.read(workoutApiProvider);
return client.streamUrl(videoPath);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
final youtubeServiceProvider = Provider<YouTubeService>((ref) {
return YouTubeService();
});
final youtubeSearchProvider =
AsyncNotifierProvider<YouTubeSearchNotifier, YouTubeSearchState>(
YouTubeSearchNotifier.new);
class YouTubeSearchState {
final String query;
final List<YouTubeVideo> results;
const YouTubeSearchState({this.query = '', this.results = const []});
}
class YouTubeSearchNotifier extends AsyncNotifier<YouTubeSearchState> {
@override
Future<YouTubeSearchState> build() async {
return const YouTubeSearchState();
}
Future<void> search(String query) async {
if (query.trim().isEmpty) return;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final service = ref.read(youtubeServiceProvider);
final results = await service.search(query, limit: 15);
return YouTubeSearchState(query: query, results: results);
});
}
void clear() {
state = const AsyncValue.data(YouTubeSearchState());
}
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/connection_provider.dart';
class ConnectScreen extends ConsumerStatefulWidget {
const ConnectScreen({super.key});
@override
ConsumerState<ConnectScreen> createState() => _ConnectScreenState();
}
class _ConnectScreenState extends ConsumerState<ConnectScreen> {
final _hostController = TextEditingController();
bool _loaded = false;
@override
void initState() {
super.initState();
_loadSavedHost();
}
Future<void> _loadSavedHost() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('mirror_host') ?? 'mirror.local';
_hostController.text = saved;
setState(() => _loaded = true);
// Auto-connect only if not manually disconnected
final connection = ref.read(connectionProvider);
if (!connection.manualDisconnect && saved.isNotEmpty && saved != 'mirror.local') {
_connect();
}
}
Future<void> _saveHost(String host) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('mirror_host', host);
}
@override
void dispose() {
_hostController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final connection = ref.watch(connectionProvider);
if (!_loaded) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
Icon(
Icons.cast_connected,
size: 64,
color: MirrorTheme.accent,
),
const SizedBox(height: 24),
Text(
'Mirror OS',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Connect to your mirror',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: _hostController,
decoration: InputDecoration(
labelText: 'Mirror address',
hintText: 'mirror.local or 192.168.x.x',
filled: true,
fillColor: MirrorTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(Icons.wifi),
),
keyboardType: TextInputType.url,
onSubmitted: (_) => _connect(),
),
const SizedBox(height: 16),
FilledButton(
onPressed: connection.connecting ? null : _connect,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: connection.connecting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
if (connection.error != null) ...[
const SizedBox(height: 16),
Text(
connection.error!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: MirrorTheme.danger,
),
textAlign: TextAlign.center,
),
],
const Spacer(),
Text(
'Make sure your phone is on the same\nWiFi network as the mirror',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
),
);
}
void _connect() {
final host = _hostController.text.trim();
if (host.isNotEmpty) {
_saveHost(host);
ref.read(connectionProvider.notifier).connect(host);
}
}
}
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/connection_provider.dart';
import 'ha_entity_picker_screen.dart';
import 'weather_picker_screen.dart';
class DashboardEditorScreen extends ConsumerStatefulWidget {
const DashboardEditorScreen({super.key});
@override
ConsumerState<DashboardEditorScreen> createState() => _DashboardEditorScreenState();
}
class _DashboardEditorScreenState extends ConsumerState<DashboardEditorScreen> {
List<DashboardWidgetConfig> _widgets = [];
bool _loading = true;
static const _widgetMeta = <String, ({IconData icon, String label})>{
'clock': (icon: Icons.access_time, label: 'Clock'),
'weather': (icon: Icons.cloud, label: 'Weather'),
'calendar': (icon: Icons.calendar_month, label: 'Calendar'),
'ha_entities': (icon: Icons.home, label: 'HA Entities'),
};
@override
void initState() {
super.initState();
_requestConfig();
}
void _requestConfig() {
final existing = ref.read(dashboardConfigProvider);
if (existing != null) {
setState(() {
_widgets = List.from(existing.widgets);
_loading = false;
});
} else {
// Request from display
ref.read(connectionProvider.notifier).send(DashboardConfigRequest());
// Wait briefly then fall back to default
Future.delayed(const Duration(seconds: 2), () {
if (_loading && mounted) {
setState(() {
_widgets = List.from(DashboardLayoutConfig.defaultLayout().widgets);
_loading = false;
});
}
});
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Listen for config response
ref.listen(dashboardConfigProvider, (prev, next) {
if (next != null && _loading) {
setState(() {
_widgets = List.from(next.widgets);
_loading = false;
});
}
});
}
void _onReorder(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _widgets.removeAt(oldIndex);
_widgets.insert(newIndex, item);
// Update positions
for (var i = 0; i < _widgets.length; i++) {
_widgets[i] = _widgets[i].copyWith(position: i);
}
});
}
void _toggleVisibility(int index) {
setState(() {
_widgets[index] = _widgets[index].copyWith(visible: !_widgets[index].visible);
});
}
Future<void> _openConfig(int index) async {
final widget = _widgets[index];
switch (widget.type) {
case 'ha_entities':
final currentIds =
(widget.config['entity_ids'] as List<dynamic>?)?.cast<String>() ?? [];
final result = await Navigator.of(context).push<List<String>>(
MaterialPageRoute(
builder: (_) => HaEntityPickerScreen(selectedIds: currentIds),
),
);
if (result != null && mounted) {
setState(() {
_widgets[index] = widget.copyWith(
config: {...widget.config, 'entity_ids': result},
);
});
}
case 'weather':
final currentEntity = widget.config['entity_id'] as String?;
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => WeatherPickerScreen(selectedEntityId: currentEntity),
),
);
if (result != null && mounted) {
setState(() {
_widgets[index] = widget.copyWith(
config: {...widget.config, 'entity_id': result},
);
});
}
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('No settings for ${widget.type}')),
);
}
}
void _saveLayout() {
final layout = DashboardLayoutConfig(widgets: _widgets);
ref.read(connectionProvider.notifier).send(DashboardConfigUpdate(layout: layout));
ref.read(dashboardConfigProvider.notifier).state = layout;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Dashboard layout saved')),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard Layout'),
backgroundColor: MirrorTheme.background,
),
backgroundColor: MirrorTheme.background,
body: _loading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Expanded(
child: ReorderableListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _widgets.length,
onReorderItem: (oldIndex, newIndex) => _onReorder(oldIndex, newIndex),
itemBuilder: (context, index) {
final widget = _widgets[index];
final meta = _widgetMeta[widget.type];
final icon = meta?.icon ?? Icons.widgets;
final label = meta?.label ?? widget.type;
final hasConfig =
widget.type == 'ha_entities' || widget.type == 'weather';
return Card(
key: ValueKey(widget.id),
color: MirrorTheme.surface,
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: Icon(icon, color: MirrorTheme.accent),
title: Text(label),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasConfig)
IconButton(
icon: const Icon(Icons.settings, size: 20),
onPressed: () => _openConfig(index),
),
Switch(
value: widget.visible,
onChanged: (_) => _toggleVisibility(index),
activeThumbColor: MirrorTheme.accent,
),
const Icon(Icons.drag_handle),
],
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saveLayout,
icon: const Icon(Icons.save),
label: const Text('Save Layout'),
),
),
),
],
),
);
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/ha_provider.dart';
class HaEntityPickerScreen extends ConsumerStatefulWidget {
final List<String> selectedIds;
const HaEntityPickerScreen({super.key, this.selectedIds = const []});
@override
ConsumerState<HaEntityPickerScreen> createState() => _HaEntityPickerScreenState();
}
class _HaEntityPickerScreenState extends ConsumerState<HaEntityPickerScreen> {
late Set<String> _selected;
String _search = '';
@override
void initState() {
super.initState();
_selected = Set.from(widget.selectedIds);
}
String _domainOf(String entityId) {
final dotIndex = entityId.indexOf('.');
return dotIndex > 0 ? entityId.substring(0, dotIndex) : 'other';
}
@override
Widget build(BuildContext context) {
final entitiesAsync = ref.watch(haEntitiesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Select HA Entities'),
backgroundColor: MirrorTheme.background,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(_selected.toList()),
child: Text('Done (${_selected.length})'),
),
],
),
backgroundColor: MirrorTheme.background,
body: entitiesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (state) {
if (state.error != null) {
return Center(child: Text(state.error!));
}
return _buildList(state.entities);
},
),
);
}
Widget _buildList(List entities) {
// Filter by search
final filtered = entities.where((e) {
final id = e.entityId as String;
final name =
(e.attributes['friendly_name'] as String?) ?? id;
final query = _search.toLowerCase();
return id.toLowerCase().contains(query) || name.toLowerCase().contains(query);
}).toList();
// Group by domain
final grouped = <String, List<dynamic>>{};
for (final entity in filtered) {
final domain = _domainOf(entity.entityId as String);
grouped.putIfAbsent(domain, () => []).add(entity);
}
final sortedDomains = grouped.keys.toList()..sort();
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
decoration: InputDecoration(
hintText: 'Search entities...',
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: MirrorTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (v) => setState(() => _search = v),
),
),
Expanded(
child: ListView.builder(
itemCount: sortedDomains.length,
itemBuilder: (context, domainIndex) {
final domain = sortedDomains[domainIndex];
final items = grouped[domain]!;
return ExpansionTile(
title: Text(
domain,
style: TextStyle(color: MirrorTheme.accent, fontWeight: FontWeight.w600),
),
initiallyExpanded: items.any((e) => _selected.contains(e.entityId)),
children: items.map((entity) {
final id = entity.entityId as String;
final name =
(entity.attributes['friendly_name'] as String?) ?? id;
return CheckboxListTile(
title: Text(name),
subtitle: Text(id, style: const TextStyle(fontSize: 12)),
value: _selected.contains(id),
activeColor: MirrorTheme.accent,
onChanged: (checked) {
setState(() {
if (checked == true) {
_selected.add(id);
} else {
_selected.remove(id);
}
});
},
);
}).toList(),
);
},
),
),
],
);
}
}
@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'package:mirror_ui/mirror_ui.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import '../providers/connection_provider.dart';
import '../providers/ha_provider.dart';
import '../providers/update_provider.dart';
import 'settings_screen.dart';
class RemoteDashboard extends ConsumerWidget {
const RemoteDashboard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final haState = ref.watch(haEntitiesProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Mirror OS', style: Theme.of(context).textTheme.headlineMedium),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
),
],
),
const SizedBox(height: 16),
const UpdateBanner(),
const SizedBox(height: 16),
Text(
'Quick Actions',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: MirrorTheme.textMuted,
),
),
const SizedBox(height: 16),
_ActionCard(
icon: Icons.fitness_center,
title: 'Start Workout',
subtitle: 'Browse your video library',
color: MirrorTheme.accent,
onTap: () {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.workout),
);
ref.read(connectionProvider.notifier).send(
WorkoutCommand(
action: WorkoutAction.browse,
payload: {'path': '', 'filter': 'all'},
),
);
},
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.music_note,
title: 'Media',
subtitle: 'Play music or video',
color: MirrorTheme.success,
onTap: () {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.media),
);
},
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.nightlight_round,
title: 'Standby',
subtitle: 'Turn off the display',
color: MirrorTheme.textMuted,
onTap: () {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.standby),
);
},
),
// --- Home Assistant Device Controls ---
const SizedBox(height: 32),
Text(
'Home Assistant',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: MirrorTheme.textMuted,
),
),
const SizedBox(height: 16),
haState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('HA Error: $e', style: TextStyle(color: MirrorTheme.danger, fontSize: 13)),
data: (data) {
if (data.error != null) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(Icons.home, size: 36, color: MirrorTheme.textMuted),
const SizedBox(height: 8),
Text(data.error!, style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13)),
],
),
);
}
return _HaDeviceControls(entities: data.entities);
},
),
// --- Scenes ---
const SizedBox(height: 24),
Text(
'Scenes',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: MirrorTheme.textMuted,
),
),
const SizedBox(height: 12),
_HaSceneButtons(),
],
),
);
}
}
// --- HA Device Controls ---
class _HaDeviceControls extends ConsumerWidget {
final List<HaEntity> entities;
const _HaDeviceControls({required this.entities});
@override
Widget build(BuildContext context, WidgetRef ref) {
final toggleable = entities.where((e) {
return e.entityId.startsWith('light.') || e.entityId.startsWith('switch.');
}).toList();
if (toggleable.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(Icons.lightbulb_outline, size: 36, color: MirrorTheme.textMuted),
const SizedBox(height: 8),
Text(
'No lights or switches found',
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
],
),
);
}
return Column(
children: toggleable.map((entity) {
final friendlyName = entity.attributes['friendly_name'] as String? ??
entity.entityId.split('.').last;
final isOn = entity.state == 'on';
final isLight = entity.entityId.startsWith('light.');
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
isLight ? Icons.lightbulb : Icons.power_settings_new,
color: isOn ? MirrorTheme.accent : MirrorTheme.textMuted,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(friendlyName, style: const TextStyle(fontSize: 14)),
),
Switch(
value: isOn,
onChanged: (_) {
ref.read(haEntitiesProvider.notifier).toggleEntity(entity.entityId);
},
),
],
),
);
}).toList(),
);
}
}
// --- Scene Buttons ---
class _HaSceneButtons extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final haState = ref.watch(haEntitiesProvider).valueOrNull;
if (haState == null || haState.error != null) return const SizedBox.shrink();
final scenes = haState.entities
.where((e) => e.entityId.startsWith('scene.'))
.toList();
if (scenes.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 8,
children: scenes.map((scene) {
final name = scene.attributes['friendly_name'] as String? ??
scene.entityId.split('.').last.replaceAll('_', ' ');
return ActionChip(
avatar: const Icon(Icons.play_arrow, size: 18),
label: Text(name),
onPressed: () {
ref.read(haEntitiesProvider.notifier).callScene(scene.entityId);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Activating $name...')),
);
},
);
}).toList(),
);
}
}
class _ActionCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final Color color;
final VoidCallback onTap;
const _ActionCard({
required this.icon,
required this.title,
required this.subtitle,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Icon(Icons.chevron_right, color: MirrorTheme.textMuted),
],
),
),
);
}
}
+679
View File
@@ -0,0 +1,679 @@
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/youtube_search_provider.dart';
import '../providers/plex_provider.dart';
class RemoteMedia extends ConsumerStatefulWidget {
const RemoteMedia({super.key});
@override
ConsumerState<RemoteMedia> createState() => _RemoteMediaState();
}
class _RemoteMediaState extends ConsumerState<RemoteMedia>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final playback = ref.watch(mirrorStateProvider.select((s) => s?.playback));
// If media is playing (non-workout), show now-playing overlay on top
if (playback != null && playback.source != MediaSource.workout) {
return Column(
children: [
_NowPlayingBar(playback: playback),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'YouTube'),
Tab(text: 'Plex'),
Tab(text: 'Spotify'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: const [
_YouTubeTab(),
_PlexTab(),
_SpotifyTab(),
],
),
),
],
);
}
return Column(
children: [
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'YouTube'),
Tab(text: 'Plex'),
Tab(text: 'Spotify'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: const [
_YouTubeTab(),
_PlexTab(),
_SpotifyTab(),
],
),
),
],
);
}
}
// --- Now Playing Bar ---
class _NowPlayingBar extends ConsumerWidget {
final PlaybackState playback;
const _NowPlayingBar({required this.playback});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: MirrorTheme.surface,
border: Border(bottom: BorderSide(color: MirrorTheme.surfaceLight)),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playback.title,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
playback.source.toJson().toUpperCase(),
style: TextStyle(color: MirrorTheme.accent, fontSize: 11, letterSpacing: 1),
),
],
),
),
IconButton(
icon: Icon(playback.isPlaying ? Icons.pause : Icons.play_arrow),
iconSize: 28,
onPressed: () => ref.read(connectionProvider.notifier).send(
PlaybackCommand(
action: playback.isPlaying ? PlaybackAction.pause : PlaybackAction.resume,
),
),
),
IconButton(
icon: const Icon(Icons.stop),
iconSize: 24,
onPressed: () => ref.read(connectionProvider.notifier).send(
PlaybackCommand(action: PlaybackAction.stop),
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: playback.duration.inMilliseconds > 0
? playback.position.inMilliseconds / playback.duration.inMilliseconds
: 0,
backgroundColor: MirrorTheme.surfaceLight,
color: MirrorTheme.accent,
minHeight: 2,
),
],
),
);
}
}
// --- YouTube Tab ---
class _YouTubeTab extends ConsumerStatefulWidget {
const _YouTubeTab();
@override
ConsumerState<_YouTubeTab> createState() => _YouTubeTabState();
}
class _YouTubeTabState extends ConsumerState<_YouTubeTab> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final searchState = ref.watch(youtubeSearchProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search YouTube...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref.read(youtubeSearchProvider.notifier).clear();
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: MirrorTheme.surface,
),
textInputAction: TextInputAction.search,
onSubmitted: (query) {
ref.read(youtubeSearchProvider.notifier).search(query);
},
),
),
Expanded(
child: searchState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Text('Search failed: $err', style: const TextStyle(color: MirrorTheme.danger)),
),
data: (data) {
if (data.results.isEmpty && data.query.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.smart_display, size: 64, color: MirrorTheme.textMuted),
const SizedBox(height: 16),
Text(
'Search for videos to play',
style: TextStyle(color: MirrorTheme.textMuted),
),
],
),
);
}
if (data.results.isEmpty) {
return const Center(child: Text('No results found'));
}
return ListView.builder(
itemCount: data.results.length,
itemBuilder: (context, index) {
final video = data.results[index];
return _YouTubeResultTile(video: video);
},
);
},
),
),
],
);
}
}
class _YouTubeResultTile extends ConsumerWidget {
final YouTubeVideo video;
const _YouTubeResultTile({required this.video});
@override
Widget build(BuildContext context, WidgetRef ref) {
final duration = _formatDuration(video.duration);
return ListTile(
leading: SizedBox(
width: 64,
height: 48,
child: video.thumbnailUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
video.thumbnailUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: MirrorTheme.surface,
child: const Icon(Icons.smart_display, size: 24),
),
),
)
: Container(
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.smart_display, size: 24),
),
),
title: Text(
video.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${video.author} · $duration',
style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted),
),
onTap: () {
final url = 'https://www.youtube.com/watch?v=${video.id}';
ref.read(connectionProvider.notifier).send(
MediaCommand(
action: MediaAction.playYouTube,
payload: {'url': url, 'title': video.title},
),
);
},
);
}
String _formatDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes % 60;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
if (h > 0) return '$h:${m.toString().padLeft(2, '0')}:$s';
return '$m:$s';
}
}
// --- Plex Tab ---
class _PlexTab extends ConsumerWidget {
const _PlexTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final plexState = ref.watch(plexBrowseProvider);
return plexState.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('Plex connection failed', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
Text(
'Configure Plex in settings',
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.invalidate(plexBrowseProvider),
child: const Text('Retry'),
),
],
),
),
data: (data) {
if (data.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.movie, size: 48, color: MirrorTheme.textMuted),
const SizedBox(height: 16),
Text(data.error!, style: TextStyle(color: MirrorTheme.textMuted)),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.invalidate(plexBrowseProvider),
child: const Text('Retry'),
),
],
),
);
}
// Show library list
if (data.currentLibrary == null) {
return _PlexLibraryList(libraries: data.libraries);
}
// Show items in library
return _PlexItemList(data: data);
},
);
}
}
class _PlexLibraryList extends ConsumerWidget {
final List<PlexLibrary> libraries;
const _PlexLibraryList({required this.libraries});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (libraries.isEmpty) {
return const Center(
child: Text('No Plex libraries found', style: TextStyle(color: MirrorTheme.textMuted)),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: libraries.length,
itemBuilder: (context, index) {
final lib = libraries[index];
final icon = switch (lib.type) {
'movie' => Icons.movie,
'show' => Icons.tv,
'artist' => Icons.music_note,
_ => Icons.folder,
};
return ListTile(
leading: Icon(icon, color: MirrorTheme.accent),
title: Text(lib.title),
subtitle: Text(lib.type, style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 12)),
trailing: const Icon(Icons.chevron_right),
onTap: () => ref.read(plexBrowseProvider.notifier).openLibrary(lib),
);
},
);
}
}
class _PlexItemList extends ConsumerWidget {
final PlexBrowseState data;
const _PlexItemList({required this.data});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
// Breadcrumb / back
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => ref.read(plexBrowseProvider.notifier).goBack(),
),
Expanded(
child: Text(
data.breadcrumb.join(' / '),
style: const TextStyle(fontSize: 13, color: MirrorTheme.textMuted),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: data.items.length,
itemBuilder: (context, index) {
final item = data.items[index];
final isPlayable = item.partKey != null;
final hasChildren = item.type == 'show' ||
item.type == 'season' ||
item.type == 'artist' ||
item.type == 'album';
return ListTile(
leading: SizedBox(
width: 48,
height: 48,
child: Container(
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
isPlayable ? Icons.play_circle_outline : Icons.folder,
color: MirrorTheme.accent,
),
),
),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
item.type,
if (item.year != null) '${item.year}',
if (item.duration != null) _formatMs(item.duration!),
].join(' · '),
style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted),
),
trailing: hasChildren
? const Icon(Icons.chevron_right)
: isPlayable
? const Icon(Icons.play_arrow, color: MirrorTheme.accent)
: null,
onTap: () {
if (hasChildren) {
ref.read(plexBrowseProvider.notifier).openItem(item);
} else if (isPlayable) {
final plex = ref.read(plexServiceProvider);
if (plex == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Plex not configured')),
);
return;
}
final streamUrl = plex.getStreamUrl(item.partKey!);
ref.read(connectionProvider.notifier).send(
MediaCommand(
action: MediaAction.playPlex,
payload: {'streamUrl': streamUrl, 'title': item.title},
),
);
}
},
);
},
),
),
],
);
}
String _formatMs(int ms) {
final d = Duration(milliseconds: ms);
final h = d.inHours;
final m = d.inMinutes % 60;
if (h > 0) return '${h}h ${m}m';
return '${m}m';
}
}
// --- Spotify Tab ---
class _SpotifyTab extends ConsumerWidget {
const _SpotifyTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final spotify = ref.watch(mirrorStateProvider.select((s) => s?.spotify));
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Now playing section
if (spotify != null && spotify.trackName != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
if (spotify.albumImageUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
spotify.albumImageUrl!,
width: 120,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 120,
height: 120,
color: MirrorTheme.surfaceLight,
child: const Icon(Icons.music_note, size: 48),
),
),
),
const SizedBox(height: 16),
Text(
spotify.trackName ?? '',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (spotify.artistName != null)
Text(
spotify.artistName!,
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
const SizedBox(height: 16),
// Progress
LinearProgressIndicator(
value: spotify.durationMs > 0
? spotify.progressMs / spotify.durationMs
: 0,
backgroundColor: MirrorTheme.surfaceLight,
color: const Color(0xFF1DB954), // Spotify green
minHeight: 3,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_fmtMs(spotify.progressMs),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 11),
),
Text(
_fmtMs(spotify.durationMs),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 11),
),
],
),
const SizedBox(height: 16),
// Transport
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
iconSize: 36,
onPressed: () => ref.read(connectionProvider.notifier).send(
MediaCommand(action: MediaAction.spotifyPrevious),
),
),
const SizedBox(width: 16),
IconButton.filled(
icon: Icon(spotify.isPlaying ? Icons.pause : Icons.play_arrow),
iconSize: 48,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF1DB954),
),
onPressed: () => ref.read(connectionProvider.notifier).send(
MediaCommand(
action: spotify.isPlaying
? MediaAction.spotifyPause
: MediaAction.spotifyPlay,
),
),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.skip_next),
iconSize: 36,
onPressed: () => ref.read(connectionProvider.notifier).send(
MediaCommand(action: MediaAction.spotifyNext),
),
),
],
),
],
),
),
] else ...[
// Not connected / nothing playing
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(Icons.music_note, size: 48, color: MirrorTheme.textMuted),
const SizedBox(height: 16),
Text(
'Spotify',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
spotify == null
? 'Not connected'
: 'Nothing playing',
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.link),
label: const Text('Connect Spotify'),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify OAuth not yet configured')),
);
},
),
],
),
),
],
],
),
);
}
String _fmtMs(int ms) {
final d = Duration(milliseconds: ms);
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}
+950
View File
@@ -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,
),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/settings_provider.dart';
import '../providers/connection_provider.dart';
import 'dashboard_editor_screen.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late TextEditingController _mirrorHostCtrl;
late TextEditingController _mirrorPortCtrl;
late TextEditingController _workoutUrlCtrl;
late TextEditingController _plexHostCtrl;
late TextEditingController _plexPortCtrl;
late TextEditingController _plexTokenCtrl;
late TextEditingController _haUrlCtrl;
late TextEditingController _haTokenCtrl;
late TextEditingController _spotifyClientIdCtrl;
bool _initialized = false;
@override
void initState() {
super.initState();
_mirrorHostCtrl = TextEditingController();
_mirrorPortCtrl = TextEditingController();
_workoutUrlCtrl = TextEditingController();
_plexHostCtrl = TextEditingController();
_plexPortCtrl = TextEditingController();
_plexTokenCtrl = TextEditingController();
_haUrlCtrl = TextEditingController();
_haTokenCtrl = TextEditingController();
_spotifyClientIdCtrl = TextEditingController();
}
void _loadFromSettings(AppSettings settings) {
if (_initialized) return;
_initialized = true;
_mirrorHostCtrl.text = settings.mirrorHost;
_mirrorPortCtrl.text = settings.mirrorPort.toString();
_workoutUrlCtrl.text = settings.workoutPlayerUrl;
_plexHostCtrl.text = settings.plexHost ?? '';
_plexPortCtrl.text = settings.plexPort.toString();
_plexTokenCtrl.text = settings.plexToken ?? '';
_haUrlCtrl.text = settings.haBaseUrl ?? '';
_haTokenCtrl.text = settings.haToken ?? '';
_spotifyClientIdCtrl.text = settings.spotifyClientId ?? '';
}
@override
void dispose() {
_mirrorHostCtrl.dispose();
_mirrorPortCtrl.dispose();
_workoutUrlCtrl.dispose();
_plexHostCtrl.dispose();
_plexPortCtrl.dispose();
_plexTokenCtrl.dispose();
_haUrlCtrl.dispose();
_haTokenCtrl.dispose();
_spotifyClientIdCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settingsAsync = ref.watch(settingsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: MirrorTheme.background,
actions: [
TextButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
backgroundColor: MirrorTheme.background,
body: settingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (settings) {
_loadFromSettings(settings);
return _buildForm(context);
},
),
);
}
Widget _buildForm(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_SectionHeader('Mirror Connection'),
_SettingsField(
controller: _mirrorHostCtrl,
label: 'Host',
hint: 'mirror.local or 192.168.x.x',
icon: Icons.cast_connected,
),
_SettingsField(
controller: _mirrorPortCtrl,
label: 'Port',
hint: '9090',
icon: Icons.numbers,
keyboardType: TextInputType.number,
),
const SizedBox(height: 24),
_SectionHeader('Workout Player'),
_SettingsField(
controller: _workoutUrlCtrl,
label: 'URL',
hint: 'http://192.168.86.172:8091',
icon: Icons.fitness_center,
),
const SizedBox(height: 24),
_SectionHeader('Home Assistant'),
_SettingsField(
controller: _haUrlCtrl,
label: 'URL',
hint: 'http://homeassistant.local:8123',
icon: Icons.home,
),
_SettingsField(
controller: _haTokenCtrl,
label: 'Long-Lived Access Token',
hint: 'eyJ...',
icon: Icons.key,
obscure: true,
),
const SizedBox(height: 24),
_SectionHeader('Dashboard Widgets'),
Card(
color: MirrorTheme.surface,
child: ListTile(
leading: const Icon(Icons.dashboard, color: MirrorTheme.accent),
title: const Text('Dashboard Layout'),
subtitle: const Text('Reorder and configure widgets'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DashboardEditorScreen()),
);
},
),
),
const SizedBox(height: 24),
_SectionHeader('Plex'),
_SettingsField(
controller: _plexHostCtrl,
label: 'Host',
hint: '192.168.86.x',
icon: Icons.movie,
),
_SettingsField(
controller: _plexPortCtrl,
label: 'Port',
hint: '32400',
icon: Icons.numbers,
keyboardType: TextInputType.number,
),
_SettingsField(
controller: _plexTokenCtrl,
label: 'Token',
hint: 'X-Plex-Token',
icon: Icons.key,
obscure: true,
),
const SizedBox(height: 24),
_SectionHeader('Spotify'),
_SettingsField(
controller: _spotifyClientIdCtrl,
label: 'Client ID',
hint: 'From developer.spotify.com',
icon: Icons.music_note,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
// Spotify OAuth flow would go here
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify OAuth not yet implemented')),
);
},
icon: const Icon(Icons.login),
label: const Text('Connect Spotify'),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Save Settings'),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
ref.read(connectionProvider.notifier).disconnect();
Navigator.of(context).pop();
},
icon: const Icon(Icons.link_off, color: MirrorTheme.danger),
label: const Text('Disconnect from Mirror', style: TextStyle(color: MirrorTheme.danger)),
),
),
const SizedBox(height: 40),
],
);
}
void _save() {
final settings = AppSettings(
mirrorHost: _mirrorHostCtrl.text.trim(),
mirrorPort: int.tryParse(_mirrorPortCtrl.text.trim()) ?? 9090,
workoutPlayerUrl: _workoutUrlCtrl.text.trim(),
plexHost: _plexHostCtrl.text.trim().isEmpty ? null : _plexHostCtrl.text.trim(),
plexPort: int.tryParse(_plexPortCtrl.text.trim()) ?? 32400,
plexToken: _plexTokenCtrl.text.trim().isEmpty ? null : _plexTokenCtrl.text.trim(),
haBaseUrl: _haUrlCtrl.text.trim().isEmpty ? null : _haUrlCtrl.text.trim(),
haToken: _haTokenCtrl.text.trim().isEmpty ? null : _haTokenCtrl.text.trim(),
spotifyClientId: _spotifyClientIdCtrl.text.trim().isEmpty ? null : _spotifyClientIdCtrl.text.trim(),
);
ref.read(settingsProvider.notifier).save(settings);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')),
);
Navigator.of(context).pop();
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
title,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: MirrorTheme.accent,
letterSpacing: 1,
),
),
);
}
}
class _SettingsField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final IconData icon;
final bool obscure;
final TextInputType? keyboardType;
const _SettingsField({
required this.controller,
required this.label,
required this.hint,
required this.icon,
this.obscure = false,
this.keyboardType,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
obscureText: obscure,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label,
hintText: hint,
filled: true,
fillColor: MirrorTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(icon, size: 20),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/ha_provider.dart';
class WeatherPickerScreen extends ConsumerStatefulWidget {
final String? selectedEntityId;
const WeatherPickerScreen({super.key, this.selectedEntityId});
@override
ConsumerState<WeatherPickerScreen> createState() => _WeatherPickerScreenState();
}
class _WeatherPickerScreenState extends ConsumerState<WeatherPickerScreen> {
String? _selected;
@override
void initState() {
super.initState();
_selected = widget.selectedEntityId;
}
@override
Widget build(BuildContext context) {
final entitiesAsync = ref.watch(haEntitiesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Select Weather Entity'),
backgroundColor: MirrorTheme.background,
actions: [
TextButton(
onPressed: _selected != null ? () => Navigator.of(context).pop(_selected) : null,
child: const Text('Done'),
),
],
),
backgroundColor: MirrorTheme.background,
body: entitiesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (state) {
if (state.error != null) {
return Center(child: Text(state.error!));
}
final weatherEntities = state.entities
.where((e) => e.entityId.startsWith('weather.'))
.toList();
if (weatherEntities.isEmpty) {
return const Center(
child: Text('No weather entities found in Home Assistant'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: weatherEntities.length,
itemBuilder: (context, index) {
final entity = weatherEntities[index];
final name =
(entity.attributes['friendly_name'] as String?) ?? entity.entityId;
return RadioListTile<String>(
title: Text(name),
subtitle: Text(entity.entityId),
value: entity.entityId,
groupValue: _selected,
activeColor: MirrorTheme.accent,
onChanged: (value) => setState(() => _selected = value),
);
},
);
},
),
);
}
}