commit 27631ed1084d9eec0dc6bc8e1ceb0d7d8e54b829 Author: Chris Date: Sat Jul 11 21:51:34 2026 -0500 Preserve Flutter mirror-os codebase diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a7eeee --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Dependencies +.dart_tool/ +.packages +.pub/ +build/ +pubspec.lock + +# Flutter +.flutter-plugins +.flutter-plugins-dependencies + +# IDE +.idea/ +*.iml +.vscode/ +*.swp +*.swo + +# macOS +.DS_Store +macos/Flutter/ephemeral/ + +# iOS +ios/Pods/ +ios/.symlinks/ + +# Android +android/.gradle/ +android/local.properties +android/captures/ +android/gradlew +android/gradlew.bat + +# Generated +*.g.dart +*.freezed.dart +*.mocks.dart + +# Environment +.env +.env.* + +# Python sidecars +services/voice/__pycache__/ +services/motion/__pycache__/ +services/voice/venv/ +services/motion/venv/ +*.pyc diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..85053e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# Mirror OS + +Smart mirror app platform built with Flutter for a hacked Lululemon Mirror (BOE panel + Vizio driver board + Raspberry Pi 4). + +## Architecture + +Melos mono-repo with two apps sharing packages: + +- `apps/display` — Flutter Linux desktop app, runs fullscreen on Pi 4 (1080x1920 portrait) +- `apps/remote` — Flutter mobile app (iOS/Android), phone remote control +- `packages/mirror_protocol` — WebSocket message types and serialization +- `packages/mirror_core` — Shared business logic, API clients (workout-player, YouTube) +- `packages/mirror_ui` — Dark theme, shared widgets, animations +- `services/` — Python sidecars for voice (OpenWakeWord + Whisper) and motion (PIR/GPIO) +- `deploy/` — Pi setup scripts, systemd units, labwc compositor config + +## Prerequisites + +- Flutter SDK 3.24+ (stable channel) +- Dart SDK 3.5+ +- Melos: `dart pub global activate melos` + +## Setup + +```bash +melos bootstrap +``` + +## Development + +```bash +# Run display app (macOS/Linux desktop for development) +cd apps/display && flutter run -d macos + +# Run remote app on iOS simulator +cd apps/remote && flutter run + +# Run both and test WebSocket communication: +# 1. Start display app (starts WS server on :9090) +# 2. Start remote app, connect to localhost:9090 + +# Analyze all packages +melos run analyze + +# Format all packages +melos run format +``` + +## Hardware + +- **Panel**: BOE DV430FHM-NN5, 43" 1080p IPS, portrait orientation (1080x1920) +- **Driver board**: Vizio mainboard (HDMI passthrough → LVDS → panel) +- **Compute**: Raspberry Pi 4 (4GB) +- **Control**: Phone over same WiFi network +- **Rotation**: Pi handles via `wlr-randr --output HDMI-A-1 --transform 90` + +## Communication + +Display app runs a WebSocket server on port 9090. Phone remote connects as a client. + +Protocol: JSON messages, typed via sealed Dart classes in `mirror_protocol` package. +- Server → Client: `StateSync` (full state on connect), `StatePatch` (deltas) +- Client → Server: `NavigateCommand`, `PlaybackCommand`, `WorkoutCommand` + +## External Services + +- **Workout Player**: http://192.168.86.172:8091 — REST API for video library, streaming, progress +- **OpenWeather**: API key needed for dashboard weather widget +- **Google Calendar**: OAuth for dashboard calendar widget +- **Plex**: Local server on same LAN + +## Build for Pi 4 + +```bash +# On the Pi (with Flutter installed): +cd apps/display +flutter build linux --release + +# Output: build/linux/arm64/release/bundle/ +# Deploy: rsync bundle to /opt/mirror-os/display/ on the Pi +``` + +## Testing + +```bash +# Unit tests +melos exec -- dart test + +# Integration: start display app, connect remote, verify state sync +``` + +## Key Design Decisions + +- Riverpod for state management (no code generation needed for basic use) +- Single `DisplayState` as source of truth on the Pi, broadcast to all remotes +- media_kit (mpv) for media playback with V4L2 hardware decoding +- Python sidecars for voice/motion (communicate via Unix domain sockets) +- No touch input on mirror — all interaction via phone remote diff --git a/apps/display/.gitignore b/apps/display/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/display/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/display/.metadata b/apps/display/.metadata new file mode 100644 index 0000000..339f86d --- /dev/null +++ b/apps/display/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "924134a44c189315be2148659913dda1671cbe99" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + - platform: web + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/display/README.md b/apps/display/README.md new file mode 100644 index 0000000..3c6431f --- /dev/null +++ b/apps/display/README.md @@ -0,0 +1,17 @@ +# mirror_display + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/display/lib/app.dart b/apps/display/lib/app.dart new file mode 100644 index 0000000..e73d224 --- /dev/null +++ b/apps/display/lib/app.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mirror_protocol/mirror_protocol.dart'; +import 'package:mirror_ui/mirror_ui.dart'; +import 'providers/display_state_provider.dart'; +import 'screens/dashboard_screen.dart'; +import 'screens/workout_screen.dart'; +import 'screens/media_screen.dart'; +import 'screens/standby_screen.dart'; + +class MirrorDisplayApp extends ConsumerWidget { + const MirrorDisplayApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final activeScreen = ref.watch( + displayStateProvider.select((s) => s.activeScreen), + ); + + return MaterialApp( + title: 'Mirror OS', + debugShowCheckedModeBanner: false, + theme: MirrorTheme.dark, + home: Scaffold( + body: AnimatedSwitcher( + duration: MirrorAnimations.screenTransitionDuration, + transitionBuilder: MirrorAnimations.screenTransitionBuilder, + child: _buildScreen(activeScreen), + ), + ), + shortcuts: { + LogicalKeySet(LogicalKeyboardKey.escape): const _ExitIntent(), + }, + actions: { + _ExitIntent: CallbackAction<_ExitIntent>( + onInvoke: (_) => SystemNavigator.pop(), + ), + }, + ); + } + + Widget _buildScreen(ScreenType screen) => switch (screen) { + ScreenType.dashboard => const DashboardScreen(key: ValueKey('dashboard')), + ScreenType.workout => const WorkoutScreen(key: ValueKey('workout')), + ScreenType.media => const MediaScreen(key: ValueKey('media')), + ScreenType.standby => const StandbyScreen(key: ValueKey('standby')), + }; +} + +class _ExitIntent extends Intent { + const _ExitIntent(); +} diff --git a/apps/display/lib/main.dart b/apps/display/lib/main.dart new file mode 100644 index 0000000..b29f972 --- /dev/null +++ b/apps/display/lib/main.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; +import 'app.dart'; +import 'providers/layout_provider.dart'; +import 'services/ws_server.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + MediaKit.ensureInitialized(); + + final container = ProviderContainer(); + + // Load layout from disk before starting WS server + await container.read(layoutProvider.future); + + final wsServer = container.read(wsServerProvider); + await wsServer.start(); + + runApp( + UncontrolledProviderScope( + container: container, + child: const MirrorDisplayApp(), + ), + ); +} diff --git a/apps/display/lib/providers/display_state_provider.dart b/apps/display/lib/providers/display_state_provider.dart new file mode 100644 index 0000000..bc42cd1 --- /dev/null +++ b/apps/display/lib/providers/display_state_provider.dart @@ -0,0 +1,921 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mirror_protocol/mirror_protocol.dart'; +import 'package:mirror_core/mirror_core.dart'; +import '../services/ws_server.dart'; +import 'player_provider.dart'; + +final displayStateProvider = + NotifierProvider(DisplayStateNotifier.new); + +final youtubeServiceProvider = Provider((ref) { + final service = YouTubeService(); + ref.onDispose(() => service.dispose()); + return service; +}); + +final plexServiceProvider = Provider((ref) { + const host = String.fromEnvironment('PLEX_HOST', defaultValue: '192.168.86.172'); + const port = int.fromEnvironment('PLEX_PORT', defaultValue: 32400); + const token = String.fromEnvironment('PLEX_TOKEN', defaultValue: ''); + return PlexService(host: host, port: port, token: token); +}); + +final spotifyServiceProvider = Provider((ref) { + return SpotifyService(); +}); + +class DisplayStateNotifier extends Notifier { + Timer? _programTimer; + + @override + DisplayState build() => DisplayState.initial(); + + void navigate(ScreenType target) { + state = state.copyWith(activeScreen: target); + _broadcast(); + } + + // --- Program handling --- + + void handleProgram(ProgramAction action, Map? payload) { + switch (action) { + case ProgramAction.playWorkout: + _startProgramWorkout(payload); + case ProgramAction.skipExercise: + _advanceProgramExercise(1); + case ProgramAction.previousExercise: + _advanceProgramExercise(-1); + case ProgramAction.pauseExercise: + _pauseProgramExercise(); + case ProgramAction.resumeExercise: + _resumeProgramExercise(); + case ProgramAction.stopProgram: + _stopProgram(); + case ProgramAction.browse: + break; + } + _broadcast(); + } + + void _startProgramWorkout(Map? payload) { + if (payload == null) return; + + final programId = payload['programId'] as String? ?? ''; + final programName = payload['programName'] as String? ?? ''; + final workoutName = payload['workoutName'] as String? ?? ''; + final rawExercises = payload['exercises'] as List? ?? []; + + final exercises = rawExercises + .map((e) => ProgramExercise.fromJson(e as Map)) + .toList(); + + if (exercises.isEmpty) return; + + state = state.copyWith( + activeScreen: ScreenType.workout, + program: ProgramSessionState( + programId: programId, + programName: programName, + workoutName: workoutName, + exercises: exercises.map((e) => e.id).toList(), + currentExerciseIndex: 0, + phase: 'playing', + currentSet: 1, + totalSets: exercises.first.sets, + ), + ); + + _playProgramExercise(exercises.first); + } + + void _playProgramExercise(ProgramExercise exercise) { + _programTimer?.cancel(); + + switch (exercise.type) { + case 'video': + if (exercise.videoPath != null) { + final client = ref.read(workoutApiClientProvider); + final streamUrl = client.streamUrl(exercise.videoPath!); + final duration = exercise.durationSeconds ?? 0; + + state = state.copyWith( + playback: PlaybackState( + mediaId: exercise.id, + title: exercise.instructionTitle ?? 'Exercise', + source: MediaSource.workout, + position: Duration.zero, + duration: Duration(seconds: duration), + isPlaying: true, + ), + ); + + ref.read(playerProvider).play(streamUrl); + } + case 'youtube': + // YouTube exercises show an instruction card with countdown + final duration = exercise.setDurationSeconds ?? exercise.durationSeconds ?? 30; + final program = state.program; + if (program != null) { + state = state.copyWith( + program: program.copyWith( + phase: 'youtube', + timerRemaining: duration, + ), + clearPlayback: true, + ); + _startProgramCountdown(duration); + } + case 'instruction': + final duration = exercise.setDurationSeconds ?? exercise.durationSeconds ?? 30; + final program = state.program; + if (program != null) { + state = state.copyWith( + program: program.copyWith( + phase: 'instruction', + timerRemaining: duration, + ), + clearPlayback: true, + ); + _startProgramCountdown(duration); + } + } + } + + void _startProgramCountdown(int seconds) { + _programTimer?.cancel(); + var remaining = seconds; + _programTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + remaining--; + final program = state.program; + if (program == null || remaining <= 0) { + timer.cancel(); + if (program != null) { + _onProgramSetCompleted(); + } + return; + } + state = state.copyWith( + program: program.copyWith(timerRemaining: remaining), + ); + _broadcast(); + }); + } + + void _onProgramSetCompleted() { + final program = state.program; + if (program == null) return; + + if (program.currentSet < program.totalSets) { + // More sets remaining + state = state.copyWith( + program: program.copyWith(currentSet: program.currentSet + 1), + ); + // Restart the same exercise + _replayCurrentProgramExercise(); + } else { + // All sets done, advance to next exercise + _advanceProgramExercise(1); + } + } + + void _replayCurrentProgramExercise() { + final program = state.program; + if (program == null) return; + // Re-fetch exercises from payload stored as IDs — we need to get fresh exercise data + // For now, re-trigger the timer for instruction/youtube types + final phase = program.phase; + if (phase == 'youtube' || phase == 'instruction') { + // Restart countdown (we'd need exercise duration, use timerRemaining as template) + // This is a simplified restart — ideally we store exercises in program state + _startProgramCountdown(program.timerRemaining > 0 ? program.timerRemaining : 30); + } + } + + void _advanceProgramExercise(int direction) { + final program = state.program; + if (program == null) return; + + _programTimer?.cancel(); + ref.read(playerProvider).stop(); + + final nextIndex = program.currentExerciseIndex + direction; + if (nextIndex < 0 || nextIndex >= program.exercises.length) { + state = state.copyWith( + program: program.copyWith(phase: 'completed'), + clearPlayback: true, + ); + return; + } + + state = state.copyWith( + program: program.copyWith( + currentExerciseIndex: nextIndex, + currentSet: 1, + phase: 'playing', + ), + ); + // Note: the remote sends exercises in playWorkout payload. + // For skip/previous we broadcast state and let the remote re-send + // the exercise data if needed, or we handle it via a stored exercises list. + // For simplicity, we update the index and broadcast — the remote can + // send a new playWorkout if complex exercise data is needed. + } + + void _pauseProgramExercise() { + _programTimer?.cancel(); + final program = state.program; + if (program != null) { + if (program.phase == 'playing') { + ref.read(playerProvider).pause(); + final current = state.playback; + if (current != null) { + state = state.copyWith(playback: current.copyWith(isPlaying: false)); + } + } + // For timer-based exercises, stopping the timer is enough + } + } + + void _resumeProgramExercise() { + final program = state.program; + if (program != null) { + if (program.phase == 'playing') { + ref.read(playerProvider).resume(); + final current = state.playback; + if (current != null) { + state = state.copyWith(playback: current.copyWith(isPlaying: true)); + } + } else if (program.phase == 'youtube' || program.phase == 'instruction') { + _startProgramCountdown(program.timerRemaining); + } + } + } + + void _stopProgram() { + _programTimer?.cancel(); + ref.read(playerProvider).stop(); + state = state.copyWith(clearProgram: true, clearPlayback: true); + } + + // --- Media handling --- + + void handleMedia(MediaAction action, Map? payload) { + switch (action) { + case MediaAction.searchYouTube: + _searchYouTube(payload); + case MediaAction.playYouTube: + _playYouTube(payload); + case MediaAction.browsePlex: + _browsePlex(payload); + case MediaAction.playPlex: + _playPlex(payload); + case MediaAction.spotifyPlay: + _spotifyPlay(payload); + case MediaAction.spotifyPause: + _spotifyPause(); + case MediaAction.spotifyNext: + _spotifyNext(); + case MediaAction.spotifyPrevious: + _spotifyPrevious(); + case MediaAction.spotifyTransfer: + _spotifyTransfer(payload); + } + _broadcast(); + } + + Future _searchYouTube(Map? payload) async { + final query = payload?['query'] as String?; + if (query == null || query.isEmpty) return; + + try { + final yt = ref.read(youtubeServiceProvider); + final results = await yt.search(query); + final data = results + .map((v) => { + 'id': v.id, + 'title': v.title, + 'author': v.author, + 'durationMs': v.duration.inMilliseconds, + 'thumbnailUrl': v.thumbnailUrl, + }) + .toList(); + // Broadcast search results as an event + ref.read(wsServerProvider).broadcast( + EventMessage(event: 'youtubeResults', data: {'results': data}), + ); + } catch (e) { + print('YouTube search failed: $e'); + } + } + + Future _playYouTube(Map? payload) async { + final videoId = payload?['videoId'] as String?; + final title = payload?['title'] as String? ?? 'YouTube'; + if (videoId == null) return; + + try { + final yt = ref.read(youtubeServiceProvider); + final streamUrl = await yt.getStreamUrl(videoId); + + state = state.copyWith( + activeScreen: ScreenType.media, + playback: PlaybackState( + mediaId: videoId, + title: title, + source: MediaSource.youtube, + position: Duration.zero, + duration: Duration.zero, + isPlaying: true, + ), + clearSpotify: true, + ); + + ref.read(playerProvider).play(streamUrl); + } catch (e) { + print('YouTube playback failed: $e'); + } + } + + Future _browsePlex(Map? payload) async { + try { + final plex = ref.read(plexServiceProvider); + final libraryId = payload?['libraryId'] as String?; + final itemId = payload?['itemId'] as String?; + + List> items; + if (itemId != null) { + final children = await plex.fetchChildren(itemId); + items = children.map((c) => c.toJson()).toList(); + } else if (libraryId != null) { + final results = await plex.fetchItems(libraryId); + items = results.map((r) => r.toJson()).toList(); + } else { + final libraries = await plex.fetchLibraries(); + items = libraries.map((l) => l.toJson()).toList(); + } + + ref.read(wsServerProvider).broadcast( + EventMessage(event: 'plexBrowse', data: {'items': items}), + ); + } catch (e) { + print('Plex browse failed: $e'); + } + } + + Future _playPlex(Map? payload) async { + final streamUrl = payload?['streamUrl'] as String?; + final title = payload?['title'] as String? ?? 'Plex'; + if (streamUrl == null || streamUrl.isEmpty) return; + + try { + state = state.copyWith( + activeScreen: ScreenType.media, + playback: PlaybackState( + mediaId: streamUrl, + title: title, + source: MediaSource.plex, + position: Duration.zero, + duration: Duration.zero, + isPlaying: true, + ), + clearSpotify: true, + ); + + ref.read(playerProvider).play(streamUrl); + } catch (e) { + print('Plex playback failed: $e'); + } + } + + Future _spotifyPlay(Map? payload) async { + try { + final spotify = ref.read(spotifyServiceProvider); + final token = payload?['token'] as String?; + if (token != null) spotify.setToken(token); + + final deviceId = payload?['deviceId'] as String?; + await spotify.play(deviceId: deviceId); + + // Fetch current state + final playback = await spotify.getCurrentPlayback(); + if (playback != null) { + state = state.copyWith( + activeScreen: ScreenType.media, + spotify: SpotifyState( + isPlaying: playback.isPlaying, + trackName: playback.trackName, + artistName: playback.artistName, + albumImageUrl: playback.albumArt, + progressMs: playback.progressMs, + durationMs: playback.durationMs, + ), + clearPlayback: true, + ); + } + } catch (e) { + print('Spotify play failed: $e'); + } + } + + Future _spotifyPause() async { + try { + final spotify = ref.read(spotifyServiceProvider); + await spotify.pause(); + final current = state.spotify; + if (current != null) { + state = state.copyWith( + spotify: current.copyWith(isPlaying: false), + ); + } + } catch (e) { + print('Spotify pause failed: $e'); + } + } + + Future _spotifyNext() async { + try { + final spotify = ref.read(spotifyServiceProvider); + await spotify.next(); + // Brief delay for Spotify to update + await Future.delayed(const Duration(milliseconds: 500)); + final playback = await spotify.getCurrentPlayback(); + if (playback != null) { + state = state.copyWith( + spotify: SpotifyState( + isPlaying: playback.isPlaying, + trackName: playback.trackName, + artistName: playback.artistName, + albumImageUrl: playback.albumArt, + progressMs: playback.progressMs, + durationMs: playback.durationMs, + ), + ); + } + } catch (e) { + print('Spotify next failed: $e'); + } + } + + Future _spotifyPrevious() async { + try { + final spotify = ref.read(spotifyServiceProvider); + await spotify.previous(); + await Future.delayed(const Duration(milliseconds: 500)); + final playback = await spotify.getCurrentPlayback(); + if (playback != null) { + state = state.copyWith( + spotify: SpotifyState( + isPlaying: playback.isPlaying, + trackName: playback.trackName, + artistName: playback.artistName, + albumImageUrl: playback.albumArt, + progressMs: playback.progressMs, + durationMs: playback.durationMs, + ), + ); + } + } catch (e) { + print('Spotify previous failed: $e'); + } + } + + Future _spotifyTransfer(Map? payload) async { + final deviceId = payload?['deviceId'] as String?; + if (deviceId == null) return; + + try { + final spotify = ref.read(spotifyServiceProvider); + await spotify.transferPlayback(deviceId); + } catch (e) { + print('Spotify transfer failed: $e'); + } + } + + void handlePlayback(PlaybackAction action, dynamic value) { + final player = ref.read(playerProvider); + switch (action) { + case PlaybackAction.pause: + final current = state.playback; + if (current != null) { + player.pause(); + state = state.copyWith(playback: current.copyWith(isPlaying: false)); + } + case PlaybackAction.resume: + final current = state.playback; + if (current != null) { + player.resume(); + state = state.copyWith(playback: current.copyWith(isPlaying: true)); + } + case PlaybackAction.seek: + final current = state.playback; + if (current != null && value is num) { + final pos = Duration(milliseconds: value.toInt()); + player.seek(pos); + state = state.copyWith(playback: current.copyWith(position: pos)); + } + case PlaybackAction.stop: + player.stop(); + state = state.copyWith(clearPlayback: true); + case PlaybackAction.setVolume: + final current = state.playback; + if (current != null && value is num) { + player.setVolume(value.toDouble()); + state = state.copyWith(playback: current.copyWith(volume: value.toDouble())); + } + default: + break; + } + _broadcast(); + } + + void handleWorkout(WorkoutAction action, Map? payload) { + final session = state.workout ?? const WorkoutSessionState(phase: WorkoutPhase.idle); + + switch (action) { + case WorkoutAction.browse: + final path = payload?['path'] as String?; + final filter = payload?['filter'] != null + ? WorkoutFilter.fromJson(payload!['filter'] as String) + : session.filter; + state = state.copyWith( + activeScreen: ScreenType.workout, + workout: session.copyWith( + phase: WorkoutPhase.browsing, + currentPath: path, + clearCurrentPath: path == null || path.isEmpty, + filter: filter, + ), + ); + _fetchVideos(path, filter); + case WorkoutAction.playVideo: + final payload2 = payload; + if (payload2 != null && payload2.containsKey('path')) { + _playFromPayload(payload2); + } else { + final videoId = payload?['videoId'] as String?; + if (videoId != null) _startVideo(videoId); + } + case WorkoutAction.playFolder: + if (payload != null && payload.containsKey('videos')) { + _playFolderFromPayload(payload); + } else { + _playFolder(payload?['unwatchedOnly'] as bool? ?? false); + } + case WorkoutAction.pauseWorkout: + handlePlayback(PlaybackAction.pause, null); + case WorkoutAction.resumeWorkout: + handlePlayback(PlaybackAction.resume, null); + case WorkoutAction.skipVideo: + _advanceQueue(); + case WorkoutAction.skipRest: + _skipRest(); + case WorkoutAction.previousVideo: + _previousVideo(); + case WorkoutAction.stopSession: + ref.read(playerProvider).stop(); + state = state.copyWith( + workout: session.copyWith(phase: WorkoutPhase.idle), + clearPlayback: true, + ); + default: + break; + } + _broadcast(); + } + + void updatePlaybackPosition(Duration position) { + final current = state.playback; + if (current != null) { + state = state.copyWith(playback: current.copyWith(position: position)); + _broadcast(); + } + } + + void onMediaCompleted() { + final session = state.workout; + if (session != null && session.phase == WorkoutPhase.playing) { + _advanceQueue(); + } else { + state = state.copyWith(clearPlayback: true); + } + _broadcast(); + } + + Future _fetchVideos(String? path, WorkoutFilter filter) async { + try { + final client = ref.read(workoutApiClientProvider); + final result = await client.fetchVideos(folder: path, filter: filter); + final stats = await client.fetchStats(); + final session = state.workout; + if (session != null) { + state = state.copyWith( + workout: session.copyWith( + videos: result.videos, + folders: result.folders, + stats: stats, + ), + ); + _broadcast(); + } + } catch (e) { + print('Failed to fetch videos: $e'); + } + } + + void _startVideo(String videoId) { + final session = state.workout; + if (session == null) return; + + final video = session.videos.firstWhere( + (v) => v.id == videoId, + orElse: () => session.videos.first, + ); + + final client = ref.read(workoutApiClientProvider); + final streamUrl = client.streamUrl(video.path); + final startPosition = video.lastPosition > 0 ? video.lastPosition : video.trimStart; + + state = state.copyWith( + workout: session.copyWith( + phase: WorkoutPhase.playing, + queue: [ + QueueItemState( + videoId: video.id, + title: video.title, + path: video.path, + duration: video.duration, + trimStart: video.trimStart, + trimEnd: video.trimEnd, + restSeconds: video.restSeconds, + ), + ], + currentIndex: 0, + ), + playback: PlaybackState( + mediaId: video.id, + title: video.title, + source: MediaSource.workout, + position: Duration(seconds: startPosition), + duration: Duration(seconds: video.duration), + isPlaying: true, + ), + ); + + ref.read(playerProvider).play( + streamUrl, + startPosition: startPosition > 0 ? Duration(seconds: startPosition) : null, + ); + } + + void _playFolder(bool unwatchedOnly) { + final session = state.workout; + if (session == null) return; + + final videos = unwatchedOnly + ? session.videos.where((v) => v.percentCompleted < 95).toList() + : session.videos; + + if (videos.isEmpty) return; + + final queue = videos + .map((v) => QueueItemState( + videoId: v.id, + title: v.title, + path: v.path, + duration: v.duration, + trimStart: v.trimStart, + trimEnd: v.trimEnd, + restSeconds: v.restSeconds, + )) + .toList(); + + final first = videos.first; + final client = ref.read(workoutApiClientProvider); + final streamUrl = client.streamUrl(first.path); + final startPos = first.lastPosition > 0 ? first.lastPosition : first.trimStart; + + state = state.copyWith( + workout: session.copyWith( + phase: WorkoutPhase.playing, + queue: queue, + currentIndex: 0, + ), + playback: PlaybackState( + mediaId: first.id, + title: first.title, + source: MediaSource.workout, + position: Duration(seconds: startPos), + duration: Duration(seconds: first.duration), + isPlaying: true, + ), + ); + + ref.read(playerProvider).play( + streamUrl, + startPosition: startPos > 0 ? Duration(seconds: startPos) : null, + ); + } + + void _advanceQueue() { + final session = state.workout; + if (session == null) return; + + final nextIndex = session.currentIndex + 1; + if (nextIndex >= session.queue.length) { + state = state.copyWith( + workout: session.copyWith(phase: WorkoutPhase.completed), + clearPlayback: true, + ); + return; + } + + final next = session.queue[nextIndex]; + if (next.restSeconds > 0) { + state = state.copyWith( + workout: session.copyWith( + phase: WorkoutPhase.resting, + currentIndex: nextIndex, + restRemaining: next.restSeconds, + ), + clearPlayback: true, + ); + _startRestCountdown(next.restSeconds); + } else { + _playQueueItem(nextIndex); + } + } + + void _previousVideo() { + final session = state.workout; + if (session == null || session.currentIndex <= 0) return; + _playQueueItem(session.currentIndex - 1); + } + + void _skipRest() { + final session = state.workout; + if (session == null) return; + _playQueueItem(session.currentIndex); + } + + void _playQueueItem(int index) { + final session = state.workout; + if (session == null || index >= session.queue.length) return; + + final item = session.queue[index]; + final client = ref.read(workoutApiClientProvider); + final streamUrl = client.streamUrl(item.path); + + state = state.copyWith( + workout: session.copyWith( + phase: WorkoutPhase.playing, + currentIndex: index, + ), + playback: PlaybackState( + mediaId: item.videoId, + title: item.title, + source: MediaSource.workout, + position: Duration(seconds: item.trimStart), + duration: Duration(seconds: item.duration), + isPlaying: true, + ), + ); + + ref.read(playerProvider).play( + streamUrl, + startPosition: item.trimStart > 0 ? Duration(seconds: item.trimStart) : null, + ); + } + + Timer? _restTimer; + + void _playFromPayload(Map payload) { + final videoId = payload['videoId'] as String; + final title = payload['title'] as String? ?? ''; + final path = payload['path'] as String; + final duration = (payload['duration'] as num?)?.toInt() ?? 0; + final trimStart = (payload['trimStart'] as num?)?.toInt() ?? 0; + final trimEnd = (payload['trimEnd'] as num?)?.toInt() ?? 0; + final restSeconds = (payload['restSeconds'] as num?)?.toInt() ?? 0; + final lastPosition = (payload['lastPosition'] as num?)?.toInt() ?? 0; + + final client = ref.read(workoutApiClientProvider); + final streamUrl = client.streamUrl(path); + final startPos = lastPosition > 0 ? lastPosition : trimStart; + + final session = state.workout ?? const WorkoutSessionState(phase: WorkoutPhase.idle); + + state = state.copyWith( + activeScreen: ScreenType.workout, + workout: session.copyWith( + phase: WorkoutPhase.playing, + queue: [ + QueueItemState( + videoId: videoId, + title: title, + path: path, + duration: duration, + trimStart: trimStart, + trimEnd: trimEnd, + restSeconds: restSeconds, + ), + ], + currentIndex: 0, + ), + playback: PlaybackState( + mediaId: videoId, + title: title, + source: MediaSource.workout, + position: Duration(seconds: startPos), + duration: Duration(seconds: duration), + isPlaying: true, + ), + ); + + ref.read(playerProvider).play( + streamUrl, + startPosition: startPos > 0 ? Duration(seconds: startPos) : null, + ); + _broadcast(); + } + + void _playFolderFromPayload(Map payload) { + final rawVideos = payload['videos'] as List; + if (rawVideos.isEmpty) return; + + final queue = rawVideos.map((v) { + final m = v as Map; + return QueueItemState( + videoId: m['videoId'] as String, + title: m['title'] as String? ?? '', + path: m['path'] as String, + duration: (m['duration'] as num?)?.toInt() ?? 0, + trimStart: (m['trimStart'] as num?)?.toInt() ?? 0, + trimEnd: (m['trimEnd'] as num?)?.toInt() ?? 0, + restSeconds: (m['restSeconds'] as num?)?.toInt() ?? 0, + ); + }).toList(); + + final first = queue.first; + final lastPosition = (rawVideos.first as Map)['lastPosition'] as num?; + final startPos = (lastPosition?.toInt() ?? 0) > 0 + ? lastPosition!.toInt() + : first.trimStart; + + final client = ref.read(workoutApiClientProvider); + final streamUrl = client.streamUrl(first.path); + + final session = state.workout ?? const WorkoutSessionState(phase: WorkoutPhase.idle); + + state = state.copyWith( + activeScreen: ScreenType.workout, + workout: session.copyWith( + phase: WorkoutPhase.playing, + queue: queue, + currentIndex: 0, + ), + playback: PlaybackState( + mediaId: first.videoId, + title: first.title, + source: MediaSource.workout, + position: Duration(seconds: startPos), + duration: Duration(seconds: first.duration), + isPlaying: true, + ), + ); + + ref.read(playerProvider).play( + streamUrl, + startPosition: startPos > 0 ? Duration(seconds: startPos) : null, + ); + _broadcast(); + } + + void _startRestCountdown(int seconds) { + _restTimer?.cancel(); + var remaining = seconds; + _restTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + remaining--; + final session = state.workout; + if (session == null || remaining <= 0) { + timer.cancel(); + if (session != null) _playQueueItem(session.currentIndex); + return; + } + state = state.copyWith( + workout: session.copyWith(restRemaining: remaining), + ); + _broadcast(); + }); + } + + void _broadcast() { + ref.read(wsServerProvider).broadcastState(state); + } +} + +final workoutApiClientProvider = Provider((ref) { + const url = String.fromEnvironment('WORKOUT_PLAYER_URL', + defaultValue: 'http://192.168.86.172:8091'); + return WorkoutApiClient(baseUrl: url); +}); diff --git a/apps/display/lib/providers/layout_provider.dart b/apps/display/lib/providers/layout_provider.dart new file mode 100644 index 0000000..464338b --- /dev/null +++ b/apps/display/lib/providers/layout_provider.dart @@ -0,0 +1,27 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mirror_protocol/mirror_protocol.dart'; +import '../services/layout_persistence.dart'; +import '../services/ws_server.dart'; + +final layoutProvider = + AsyncNotifierProvider( + LayoutNotifier.new); + +class LayoutNotifier extends AsyncNotifier { + late final LayoutPersistence _persistence; + + @override + Future build() async { + _persistence = LayoutPersistence(); + return _persistence.load(); + } + + Future updateLayout(DashboardLayoutConfig config) async { + state = AsyncData(config); + await _persistence.save(config); + // Broadcast to connected clients + ref + .read(wsServerProvider) + .broadcast(DashboardConfigResponse(layout: config)); + } +} diff --git a/apps/display/lib/providers/player_provider.dart b/apps/display/lib/providers/player_provider.dart new file mode 100644 index 0000000..3a57a91 --- /dev/null +++ b/apps/display/lib/providers/player_provider.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:mirror_protocol/mirror_protocol.dart'; +import 'display_state_provider.dart'; + +final playerProvider = Provider((ref) { + final service = PlayerService(ref); + ref.onDispose(() => service.dispose()); + return service; +}); + +final videoControllerProvider = Provider((ref) { + return ref.watch(playerProvider).videoController; +}); + +class PlayerService { + final Ref _ref; + late final Player _player; + late final VideoController _videoController; + StreamSubscription? _positionSub; + StreamSubscription? _playingSub; + StreamSubscription? _completedSub; + StreamSubscription? _durationSub; + Timer? _progressSaveTimer; + + PlayerService(this._ref) { + _player = Player(); + _videoController = VideoController(_player); + + _positionSub = _player.stream.position.listen((position) { + _ref.read(displayStateProvider.notifier).updatePlaybackPosition(position); + }); + + _playingSub = _player.stream.playing.listen((playing) { + final state = _ref.read(displayStateProvider); + final current = state.playback; + if (current != null && current.isPlaying != playing) { + _ref.read(displayStateProvider.notifier).updatePlaybackPosition( + _player.state.position, + ); + } + }); + + _durationSub = _player.stream.duration.listen((duration) { + final state = _ref.read(displayStateProvider); + final current = state.playback; + if (current != null && current.duration != duration) { + // Update duration if it wasn't known initially + } + }); + + _completedSub = _player.stream.completed.listen((completed) { + if (completed) { + _ref.read(displayStateProvider.notifier).onMediaCompleted(); + } + }); + } + + VideoController get videoController => _videoController; + Player get player => _player; + + Future play(String url, {Duration? startPosition}) async { + await _player.open(Media(url)); + if (startPosition != null && startPosition > Duration.zero) { + // Wait briefly for the player to initialize before seeking + await Future.delayed(const Duration(milliseconds: 500)); + await _player.seek(startPosition); + } + _startProgressSaving(); + } + + Future pause() async => await _player.pause(); + + Future resume() async => await _player.play(); + + Future seek(Duration position) async => await _player.seek(position); + + Future setVolume(double volume) async => + await _player.setVolume(volume * 100); + + Future stop() async { + _stopProgressSaving(); + await _player.stop(); + } + + void _startProgressSaving() { + _stopProgressSaving(); + _progressSaveTimer = Timer.periodic(const Duration(seconds: 10), (_) { + final state = _ref.read(displayStateProvider); + final workout = state.workout; + final playback = state.playback; + + if (workout != null && + workout.phase == WorkoutPhase.playing && + playback != null && + playback.source == MediaSource.workout) { + final client = _ref.read(workoutApiClientProvider); + final percent = playback.duration.inSeconds > 0 + ? (playback.position.inSeconds / playback.duration.inSeconds * 100).round() + : 0; + client.updateProgress( + videoId: playback.mediaId, + position: playback.position.inSeconds, + percent: percent, + ); + } + }); + } + + void _stopProgressSaving() { + _progressSaveTimer?.cancel(); + _progressSaveTimer = null; + } + + void dispose() { + _stopProgressSaving(); + _positionSub?.cancel(); + _playingSub?.cancel(); + _completedSub?.cancel(); + _durationSub?.cancel(); + _player.dispose(); + } +} diff --git a/apps/display/lib/screens/dashboard_screen.dart b/apps/display/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..22c2104 --- /dev/null +++ b/apps/display/lib/screens/dashboard_screen.dart @@ -0,0 +1,513 @@ +import 'dart:async'; +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/display_state_provider.dart'; +import '../providers/layout_provider.dart'; + +class DashboardScreen extends ConsumerStatefulWidget { + const DashboardScreen({super.key}); + + @override + ConsumerState createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends ConsumerState { + late Stream _clockStream; + + @override + void initState() { + super.initState(); + _clockStream = Stream.periodic( + const Duration(seconds: 1), + (_) => DateTime.now(), + ); + } + + @override + Widget build(BuildContext context) { + final layoutAsync = ref.watch(layoutProvider); + + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: layoutAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: MirrorTheme.accent), + ), + error: (_, __) => _buildWithLayout(DashboardLayoutConfig.defaultLayout()), + data: (layout) => _buildWithLayout(layout), + ), + ); + } + + Widget _buildWithLayout(DashboardLayoutConfig layout) { + final visibleWidgets = layout.widgets + .where((w) => w.visible) + .toList() + ..sort((a, b) => a.position.compareTo(b.position)); + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 80, vertical: 60), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (int i = 0; i < visibleWidgets.length; i++) ...[ + if (i > 0) const SizedBox(height: 32), + _buildWidget(visibleWidgets[i]), + ], + ], + ), + ); + } + + Widget _buildWidget(DashboardWidgetConfig widget) { + return switch (widget.type) { + 'clock' => _ClockWidget(clockStream: _clockStream), + 'weather' => _WeatherWidget(config: widget.config), + 'calendar' => const _CalendarWidget(), + 'ha_entities' => _HaEntitiesWidget(config: widget.config), + 'now_playing' => const _NowPlayingWidget(), + _ => const SizedBox.shrink(), + }; + } +} + +// --- Clock Widget --- + +class _ClockWidget extends StatelessWidget { + final Stream clockStream; + + const _ClockWidget({required this.clockStream}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: clockStream, + builder: (context, snapshot) { + final now = snapshot.data ?? DateTime.now(); + return Column( + children: [ + Text( + _formatDate(now), + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: MirrorTheme.textSecondary, + ), + ), + const SizedBox(height: 16), + Text( + _formatTime(now), + style: Theme.of(context).textTheme.displayLarge?.copyWith( + fontSize: 120, + fontWeight: FontWeight.w300, + ), + ), + ], + ); + }, + ); + } + + String _formatTime(DateTime dt) { + final hour = dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour); + final min = dt.minute.toString().padLeft(2, '0'); + return '$hour:$min'; + } + + String _formatDate(DateTime dt) { + const days = [ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', + 'Thursday', 'Friday', 'Saturday', + ]; + const months = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', + ]; + return '${days[dt.weekday % 7]}, ${months[dt.month - 1]} ${dt.day}'; + } +} + +// --- Weather Widget --- + +class _WeatherWidget extends ConsumerWidget { + final Map config; + + const _WeatherWidget({required this.config}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dashboard = + ref.watch(displayStateProvider.select((s) => s.dashboard)); + final weather = dashboard.weather; + + final temp = weather?.temp ?? 72.0; + final condition = weather?.condition ?? 'clear'; + final humidity = weather?.humidity ?? 45; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + _weatherIcon(condition), + size: 56, + color: MirrorTheme.accent, + ), + const SizedBox(width: 32), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${temp.round()}°', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w300, + ), + ), + const SizedBox(height: 4), + Text( + _capitalizeCondition(condition), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textSecondary, + ), + ), + ], + ), + const SizedBox(width: 48), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.water_drop_outlined, + size: 20, color: MirrorTheme.textMuted), + const SizedBox(width: 8), + Text( + '$humidity%', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textSecondary, + ), + ), + ], + ), + ], + ), + ], + ), + ); + } + + IconData _weatherIcon(String condition) => switch (condition.toLowerCase()) { + 'clear' || 'sunny' => Icons.wb_sunny, + 'cloudy' || 'overcast' => Icons.cloud, + 'partlycloudy' || 'partly_cloudy' => Icons.cloud_queue, + 'rainy' || 'rain' => Icons.water_drop, + 'snowy' || 'snow' => Icons.ac_unit, + 'stormy' || 'thunderstorm' => Icons.flash_on, + 'foggy' || 'fog' => Icons.blur_on, + 'windy' => Icons.air, + _ => Icons.wb_sunny, + }; + + String _capitalizeCondition(String condition) { + if (condition.isEmpty) return ''; + return condition[0].toUpperCase() + condition.substring(1); + } +} + +// --- Calendar Widget --- + +class _CalendarWidget extends ConsumerWidget { + const _CalendarWidget(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final events = ref.watch( + displayStateProvider.select((s) => s.dashboard.calendarEvents)); + + if (events.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + 'No upcoming events', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + ), + ); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today, + size: 20, color: MirrorTheme.accent), + const SizedBox(width: 12), + Text( + 'UPCOMING', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: MirrorTheme.accent, + letterSpacing: 2, + ), + ), + ], + ), + const SizedBox(height: 20), + ...events.take(3).map((event) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (event.startTime.isNotEmpty) + SizedBox( + width: 80, + child: Text( + _formatEventTime(event.startTime), + style: + Theme.of(context).textTheme.bodyMedium?.copyWith( + fontFamily: MirrorTheme.fontMono, + color: MirrorTheme.textSecondary, + ), + ), + ), + if (event.startTime.isNotEmpty) const SizedBox(width: 16), + Expanded( + child: Text( + event.summary, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + )), + ], + ), + ); + } + + String _formatEventTime(String isoTime) { + try { + final dt = DateTime.parse(isoTime); + final hour = + dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour); + final min = dt.minute.toString().padLeft(2, '0'); + final period = dt.hour >= 12 ? 'PM' : 'AM'; + return '$hour:$min $period'; + } catch (_) { + return isoTime; + } + } +} + +// --- HA Entities Widget --- + +class _HaEntitiesWidget extends ConsumerWidget { + final Map config; + + const _HaEntitiesWidget({required this.config}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final haEntities = + ref.watch(displayStateProvider.select((s) => s.dashboard.haEntities)); + + // Filter to only show entities listed in widget config + final configuredEntities = + (config['entities'] as List?)?.cast() ?? []; + + final entitiesToShow = configuredEntities.isEmpty + ? haEntities + : haEntities.where((e) { + final id = e['entity_id'] as String? ?? ''; + return configuredEntities.contains(id); + }).toList(); + + if (entitiesToShow.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(32), + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Center( + child: Text( + 'No entities configured', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + ), + ); + } + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.sensors, size: 20, color: MirrorTheme.accent), + const SizedBox(width: 12), + Text( + 'HOME', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: MirrorTheme.accent, + letterSpacing: 2, + ), + ), + ], + ), + const SizedBox(height: 20), + ...entitiesToShow.map((entity) { + final attrs = + (entity['attributes'] as Map?) ?? {}; + final friendlyName = + attrs['friendly_name'] as String? ?? + (entity['entity_id'] as String? ?? ''); + final stateValue = entity['state'] as String? ?? 'unknown'; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + friendlyName, + style: Theme.of(context).textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 16), + Text( + stateValue, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.accent, + fontFamily: MirrorTheme.fontMono, + ), + ), + ], + ), + ); + }), + ], + ), + ); + } +} + +// --- Now Playing Widget --- + +class _NowPlayingWidget extends ConsumerWidget { + const _NowPlayingWidget(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playback = ref.watch(displayStateProvider.select((s) => s.playback)); + final spotify = ref.watch(displayStateProvider.select((s) => s.spotify)); + + // Show nothing if nothing is playing + final hasPlayback = playback != null && playback.isPlaying; + final hasSpotify = spotify != null && spotify.isPlaying; + + if (!hasPlayback && !hasSpotify) { + return const SizedBox.shrink(); + } + + final title = hasSpotify + ? (spotify.trackName ?? 'Unknown Track') + : (playback?.title ?? ''); + final subtitle = hasSpotify ? (spotify.artistName ?? '') : ''; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: MirrorTheme.accentDim, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.music_note, + color: MirrorTheme.accent, + size: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: MirrorTheme.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + const Icon( + Icons.play_circle_filled, + color: MirrorTheme.accent, + size: 32, + ), + ], + ), + ); + } +} diff --git a/apps/display/lib/screens/media_screen.dart b/apps/display/lib/screens/media_screen.dart new file mode 100644 index 0000000..3ca493e --- /dev/null +++ b/apps/display/lib/screens/media_screen.dart @@ -0,0 +1,298 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:mirror_protocol/mirror_protocol.dart'; +import 'package:mirror_ui/mirror_ui.dart'; +import '../providers/display_state_provider.dart'; +import '../providers/player_provider.dart'; + +class MediaScreen extends ConsumerWidget { + const MediaScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playback = ref.watch(displayStateProvider.select((s) => s.playback)); + final spotify = ref.watch(displayStateProvider.select((s) => s.spotify)); + + // Spotify playback + if (spotify != null && spotify.trackName != null) { + return _buildSpotify(context, spotify); + } + + // Video playback (YouTube / Plex) + if (playback != null && playback.source != MediaSource.workout) { + return _buildVideoPlayback(context, ref, playback); + } + + // Nothing playing + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.music_note, size: 64, color: MirrorTheme.textMuted), + const SizedBox(height: 24), + Text( + 'Nothing playing', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSpotify(BuildContext context, SpotifyState spotify) { + final progress = spotify.durationMs > 0 + ? spotify.progressMs / spotify.durationMs + : 0.0; + + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 80), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Source indicator + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.music_note, size: 20, color: MirrorTheme.accent), + const SizedBox(width: 8), + Text( + 'SPOTIFY', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: MirrorTheme.accent, + letterSpacing: 2, + ), + ), + ], + ), + const SizedBox(height: 40), + // Album art + if (spotify.albumImageUrl != null) + ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Image.network( + spotify.albumImageUrl!, + width: 400, + height: 400, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _albumArtPlaceholder(), + ), + ) + else + _albumArtPlaceholder(), + const SizedBox(height: 60), + // Track name + Text( + spotify.trackName ?? '', + style: Theme.of(context).textTheme.headlineLarge, + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 12), + // Artist name + Text( + spotify.artistName ?? '', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 40), + // Progress bar + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 4, + backgroundColor: MirrorTheme.surfaceLight, + color: MirrorTheme.accent, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(Duration(milliseconds: spotify.progressMs)), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: MirrorTheme.fontMono, + ), + ), + Text( + _formatDuration(Duration(milliseconds: spotify.durationMs)), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: MirrorTheme.fontMono, + ), + ), + ], + ), + const SizedBox(height: 40), + Icon( + spotify.isPlaying ? Icons.pause_circle_filled : Icons.play_circle_filled, + size: 72, + color: MirrorTheme.textPrimary, + ), + ], + ), + ), + ), + ); + } + + Widget _buildVideoPlayback(BuildContext context, WidgetRef ref, PlaybackState playback) { + final progress = playback.duration.inMilliseconds > 0 + ? playback.position.inMilliseconds / playback.duration.inMilliseconds + : 0.0; + + return Container( + color: Colors.black, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + // Video output + Consumer( + builder: (context, ref, _) { + final controller = ref.watch(videoControllerProvider); + return Video(controller: controller); + }, + ), + + // Paused indicator + if (!playback.isPlaying) + const Center( + child: Icon(Icons.pause_circle_filled, size: 120, color: Colors.white70), + ), + + // Source indicator top-left + Positioned( + top: 30, + left: 30, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _sourceIcon(playback.source), + size: 18, + color: MirrorTheme.accent, + ), + const SizedBox(width: 8), + Text( + playback.source.toJson().toUpperCase(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: MirrorTheme.accent, + letterSpacing: 1, + ), + ), + ], + ), + ), + ), + + // Bottom overlay + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(40), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + playback.title, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 4, + backgroundColor: Colors.white24, + color: MirrorTheme.accent, + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(playback.position), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: MirrorTheme.fontMono, + ), + ), + Text( + _formatDuration(playback.duration), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: MirrorTheme.fontMono, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _albumArtPlaceholder() { + return Container( + width: 400, + height: 400, + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.music_note, + size: 120, + color: MirrorTheme.textMuted, + ), + ); + } + + IconData _sourceIcon(MediaSource source) => switch (source) { + MediaSource.youtube => Icons.smart_display, + MediaSource.plex => Icons.movie, + MediaSource.local => Icons.folder, + _ => Icons.music_note, + }; + + String _formatDuration(Duration d) { + final minutes = d.inMinutes; + final seconds = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } +} diff --git a/apps/display/lib/screens/standby_screen.dart b/apps/display/lib/screens/standby_screen.dart new file mode 100644 index 0000000..ed659b6 --- /dev/null +++ b/apps/display/lib/screens/standby_screen.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class StandbyScreen extends StatelessWidget { + const StandbyScreen({super.key}); + + @override + Widget build(BuildContext context) { + return const ColoredBox( + color: Colors.black, + child: SizedBox.expand(), + ); + } +} diff --git a/apps/display/lib/screens/workout_screen.dart b/apps/display/lib/screens/workout_screen.dart new file mode 100644 index 0000000..9a78770 --- /dev/null +++ b/apps/display/lib/screens/workout_screen.dart @@ -0,0 +1,512 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:mirror_protocol/mirror_protocol.dart'; +import 'package:mirror_ui/mirror_ui.dart'; +import '../providers/display_state_provider.dart'; +import '../providers/player_provider.dart'; + +class WorkoutScreen extends ConsumerWidget { + const WorkoutScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final workout = ref.watch(displayStateProvider.select((s) => s.workout)); + final program = ref.watch(displayStateProvider.select((s) => s.program)); + final playback = ref.watch(displayStateProvider.select((s) => s.playback)); + + // Program session takes priority if active + if (program != null && program.phase != 'completed') { + return _buildProgramSession(context, ref, program, playback); + } + + if (workout == null || workout.phase == WorkoutPhase.idle) { + return _buildIdle(context); + } + + return switch (workout.phase) { + WorkoutPhase.browsing => _buildBrowsing(context), + WorkoutPhase.playing => _buildPlaying(context, workout, playback), + WorkoutPhase.resting => _buildResting(context, workout), + WorkoutPhase.completed => _buildCompleted(context), + _ => _buildIdle(context), + }; + } + + // --- Program session rendering --- + + Widget _buildProgramSession( + BuildContext context, + WidgetRef ref, + ProgramSessionState program, + PlaybackState? playback, + ) { + return switch (program.phase) { + 'playing' => _buildProgramVideo(context, ref, program, playback), + 'youtube' => _buildProgramYouTube(context, program), + 'instruction' => _buildProgramInstruction(context, program), + _ => _buildCompleted(context), + }; + } + + Widget _buildProgramVideo( + BuildContext context, + WidgetRef ref, + ProgramSessionState program, + PlaybackState? playback, + ) { + if (playback == null) return _buildIdle(context); + + final progress = playback.duration.inMilliseconds > 0 + ? playback.position.inMilliseconds / playback.duration.inMilliseconds + : 0.0; + + return Container( + color: Colors.black, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + Consumer( + builder: (context, ref, _) { + final controller = ref.watch(videoControllerProvider); + return Video(controller: controller); + }, + ), + if (playback.isPlaying == false) + const Center( + child: Icon(Icons.pause_circle_filled, size: 120, color: Colors.white70), + ), + // Program info top-right + Positioned( + top: 30, + right: 30, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + program.programName, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: MirrorTheme.accent, + ), + ), + const SizedBox(height: 4), + Text( + 'Set ${program.currentSet}/${program.totalSets}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + // Bottom overlay with title and progress + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(40), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + playback.title, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 4, + backgroundColor: Colors.white24, + color: MirrorTheme.accent, + ), + ), + const SizedBox(height: 8), + Text( + '${_formatDuration(playback.position)} / ${_formatDuration(playback.duration)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: MirrorTheme.fontMono, + ), + ), + const SizedBox(height: 12), + _buildProgramProgressBar(context, program), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildProgramYouTube(BuildContext context, ProgramSessionState program) { + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 80), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.smart_display, size: 64, color: MirrorTheme.accent), + const SizedBox(height: 24), + Text( + program.workoutName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: MirrorTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TimerDisplay(seconds: program.timerRemaining), + const SizedBox(height: 32), + Text( + 'Set ${program.currentSet}/${program.totalSets}', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: MirrorTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + Text( + 'Follow along with YouTube reference', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + ], + ), + ), + ), + // Program progress at bottom + Positioned( + bottom: 40, + left: 40, + right: 40, + child: _buildProgramProgressBar(context, program), + ), + ], + ), + ); + } + + Widget _buildProgramInstruction(BuildContext context, ProgramSessionState program) { + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 80), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + program.workoutName, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: MirrorTheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + TimerDisplay(seconds: program.timerRemaining), + const SizedBox(height: 32), + Text( + 'Set ${program.currentSet}/${program.totalSets}', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: MirrorTheme.textPrimary, + ), + ), + const SizedBox(height: 16), + Text( + '${program.currentExerciseIndex + 1} of ${program.exercises.length}', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + ], + ), + ), + ), + // Program progress at bottom + Positioned( + bottom: 40, + left: 40, + right: 40, + child: _buildProgramProgressBar(context, program), + ), + ], + ), + ); + } + + Widget _buildProgramProgressBar(BuildContext context, ProgramSessionState program) { + final total = program.exercises.length; + if (total == 0) return const SizedBox.shrink(); + + return ClipRRect( + borderRadius: BorderRadius.circular(4), + child: SizedBox( + height: 8, + child: Row( + children: List.generate(total, (i) { + final Color color; + if (i < program.currentExerciseIndex) { + color = MirrorTheme.success; // completed + } else if (i == program.currentExerciseIndex) { + color = MirrorTheme.accent; // current + } else { + color = MirrorTheme.surfaceLight; // remaining + } + return Expanded( + child: Container( + margin: EdgeInsets.only(right: i < total - 1 ? 2 : 0), + color: color, + ), + ); + }), + ), + ), + ); + } + + // --- Standard workout rendering --- + + Widget _buildIdle(BuildContext context) { + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.fitness_center, size: 64, color: MirrorTheme.textMuted), + const SizedBox(height: 24), + Text( + 'Browse workouts on your phone', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + const SizedBox(height: 8), + Text( + 'mirror.local:9090', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: MirrorTheme.accent, + ), + ), + ], + ), + ), + ); + } + + Widget _buildBrowsing(BuildContext context) { + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.phone_android, size: 48, color: MirrorTheme.accent), + const SizedBox(height: 24), + Text( + 'Select a workout on your phone', + style: Theme.of(context).textTheme.headlineMedium, + ), + ], + ), + ), + ); + } + + Widget _buildPlaying(BuildContext context, WorkoutSessionState workout, PlaybackState? playback) { + if (playback == null) return _buildIdle(context); + + final progress = playback.duration.inMilliseconds > 0 + ? playback.position.inMilliseconds / playback.duration.inMilliseconds + : 0.0; + + return Container( + color: Colors.black, + width: double.infinity, + height: double.infinity, + child: Stack( + children: [ + // Real video output + Consumer( + builder: (context, ref, _) { + final controller = ref.watch(videoControllerProvider); + return Video(controller: controller); + }, + ), + + // Paused indicator + if (!playback.isPlaying) + const Center( + child: Icon(Icons.pause_circle_filled, size: 120, color: Colors.white70), + ), + + // Queue indicator + if (workout.queue.length > 1) + Positioned( + top: 30, + right: 30, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${workout.currentIndex + 1} / ${workout.queue.length}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ), + + // Bottom overlay + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.all(40), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black87], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + playback.title, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + minHeight: 4, + backgroundColor: Colors.white24, + color: MirrorTheme.accent, + ), + ), + const SizedBox(height: 8), + Text( + '${_formatDuration(playback.position)} / ${_formatDuration(playback.duration)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontFamily: MirrorTheme.fontMono, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildResting(BuildContext context, WorkoutSessionState workout) { + final nextTitle = workout.currentIndex < workout.queue.length + ? workout.queue[workout.currentIndex].title + : ''; + + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'REST', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: MirrorTheme.accent, + letterSpacing: 8, + fontSize: 24, + ), + ), + const SizedBox(height: 40), + TimerDisplay(seconds: workout.restRemaining), + const SizedBox(height: 40), + if (nextTitle.isNotEmpty) + Text( + 'Up next: $nextTitle', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + ], + ), + ), + ); + } + + Widget _buildCompleted(BuildContext context) { + return Container( + color: MirrorTheme.background, + width: double.infinity, + height: double.infinity, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, size: 96, color: MirrorTheme.success), + const SizedBox(height: 24), + Text( + 'DONE', + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: MirrorTheme.success, + ), + ), + const SizedBox(height: 12), + Text( + 'Session complete', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: MirrorTheme.textMuted, + ), + ), + ], + ), + ), + ); + } + + String _formatDuration(Duration d) { + final minutes = d.inMinutes; + final seconds = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$minutes:$seconds'; + } +} diff --git a/apps/display/lib/services/layout_persistence.dart b/apps/display/lib/services/layout_persistence.dart new file mode 100644 index 0000000..102faa0 --- /dev/null +++ b/apps/display/lib/services/layout_persistence.dart @@ -0,0 +1,36 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:mirror_protocol/mirror_protocol.dart'; + +class LayoutPersistence { + final String _path; + + LayoutPersistence({String? path}) + : _path = path ?? + '${Platform.environment['HOME']}/.config/mirror-os/dashboard_layout.json'; + + Future load() async { + final file = File(_path); + if (!await file.exists()) { + return DashboardLayoutConfig.defaultLayout(); + } + try { + final content = await file.readAsString(); + final json = jsonDecode(content) as Map; + return DashboardLayoutConfig.fromJson(json); + } catch (_) { + return DashboardLayoutConfig.defaultLayout(); + } + } + + Future save(DashboardLayoutConfig layout) async { + final file = File(_path); + final dir = file.parent; + if (!await dir.exists()) { + await dir.create(recursive: true); + } + final content = const JsonEncoder.withIndent(' ').convert(layout.toJson()); + await file.writeAsString(content); + } +} diff --git a/apps/display/lib/services/ws_server.dart b/apps/display/lib/services/ws_server.dart new file mode 100644 index 0000000..e642f1a --- /dev/null +++ b/apps/display/lib/services/ws_server.dart @@ -0,0 +1,123 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:mirror_protocol/mirror_protocol.dart'; +import '../providers/display_state_provider.dart'; +import '../providers/layout_provider.dart'; +import 'layout_persistence.dart'; + +final wsServerProvider = Provider((ref) => WsServer(ref)); + +class WsServer { + final Ref _ref; + final Set _clients = {}; + HttpServer? _server; + final int port; + final LayoutPersistence _layoutPersistence = LayoutPersistence(); + DashboardLayoutConfig? _currentLayout; + + WsServer(this._ref, {this.port = 9090}); + + Future start() async { + _currentLayout = await _layoutPersistence.load(); + + final handler = webSocketHandler((WebSocketChannel channel) { + _clients.add(channel); + + // Send current state on connect + final state = _ref.read(displayStateProvider); + final sync = StateSync(state: state); + channel.sink.add(sync.encode()); + + channel.stream.listen( + (data) => _handleMessage(channel, data as String), + onDone: () => _clients.remove(channel), + onError: (_) => _clients.remove(channel), + ); + }); + + _server = await shelf_io.serve(handler, InternetAddress.anyIPv4, port); + print('WebSocket server running on :$port'); + } + + void _handleMessage(WebSocketChannel sender, String raw) { + try { + final message = MirrorMessage.decode(raw); + _routeMessage(sender, message); + } catch (e) { + print('Invalid message: $e'); + } + } + + void _routeMessage(WebSocketChannel sender, MirrorMessage message) { + final notifier = _ref.read(displayStateProvider.notifier); + + switch (message) { + case NavigateCommand(:final target): + notifier.navigate(target); + case PlaybackCommand(:final action, :final value): + notifier.handlePlayback(action, value); + case WorkoutCommand(:final action, :final payload): + notifier.handleWorkout(action, payload); + case ProgramCommand(:final action, :final payload): + notifier.handleProgram(action, payload); + case MediaCommand(:final action, :final payload): + notifier.handleMedia(action, payload); + case RegisterMessage(): + // Already handled on connect + break; + case DashboardConfigRequest(): + _handleConfigRequest(sender); + case DashboardConfigUpdate(:final layout): + _handleConfigUpdate(layout); + default: + break; + } + } + + void _handleConfigRequest(WebSocketChannel sender) { + final layout = _currentLayout ?? DashboardLayoutConfig.defaultLayout(); + final response = DashboardConfigResponse(layout: layout); + try { + sender.sink.add(response.encode()); + } catch (_) { + _clients.remove(sender); + } + } + + void _handleConfigUpdate(DashboardLayoutConfig layout) { + _currentLayout = layout; + _layoutPersistence.save(layout); + // Update the layout provider so dashboard screen rebuilds + _ref.read(layoutProvider.notifier).updateLayout(layout); + } + + void broadcast(MirrorMessage message) { + final encoded = message.encode(); + for (final client in _clients.toList()) { + try { + client.sink.add(encoded); + } catch (_) { + _clients.remove(client); + } + } + } + + void broadcastState(DisplayState state) { + broadcast(StateSync(state: state)); + } + + DashboardLayoutConfig get currentLayout => + _currentLayout ?? DashboardLayoutConfig.defaultLayout(); + + Future stop() async { + for (final client in _clients) { + await client.sink.close(); + } + _clients.clear(); + await _server?.close(); + } +} diff --git a/apps/display/linux/.gitignore b/apps/display/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/apps/display/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/apps/display/linux/CMakeLists.txt b/apps/display/linux/CMakeLists.txt new file mode 100644 index 0000000..6b5d6e8 --- /dev/null +++ b/apps/display/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "mirror_display") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.mirror_display") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/apps/display/linux/flutter/CMakeLists.txt b/apps/display/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/apps/display/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/apps/display/linux/flutter/generated_plugin_registrant.cc b/apps/display/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e4b9cf8 --- /dev/null +++ b/apps/display/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin"); + media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar); + g_autoptr(FlPluginRegistrar) media_kit_video_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitVideoPlugin"); + media_kit_video_plugin_register_with_registrar(media_kit_video_registrar); +} diff --git a/apps/display/linux/flutter/generated_plugin_registrant.h b/apps/display/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/apps/display/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/display/linux/flutter/generated_plugins.cmake b/apps/display/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..5ea4430 --- /dev/null +++ b/apps/display/linux/flutter/generated_plugins.cmake @@ -0,0 +1,25 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + media_kit_libs_linux + media_kit_video +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/display/linux/runner/CMakeLists.txt b/apps/display/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/apps/display/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/display/linux/runner/main.cc b/apps/display/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/apps/display/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/apps/display/linux/runner/my_application.cc b/apps/display/linux/runner/my_application.cc new file mode 100644 index 0000000..5541a37 --- /dev/null +++ b/apps/display/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "mirror_display"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "mirror_display"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/apps/display/linux/runner/my_application.h b/apps/display/linux/runner/my_application.h new file mode 100644 index 0000000..db16367 --- /dev/null +++ b/apps/display/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/display/macos/.gitignore b/apps/display/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/apps/display/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/apps/display/macos/Flutter/Flutter-Debug.xcconfig b/apps/display/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/apps/display/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/display/macos/Flutter/Flutter-Release.xcconfig b/apps/display/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/apps/display/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/apps/display/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/display/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..7461e84 --- /dev/null +++ b/apps/display/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,20 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import media_kit_libs_macos_video +import media_kit_video +import nsd_macos +import package_info_plus +import wakelock_plus + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin")) + MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin")) + NsdMacosPlugin.register(with: registry.registrar(forPlugin: "NsdMacosPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) +} diff --git a/apps/display/macos/Podfile b/apps/display/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/apps/display/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/apps/display/macos/Podfile.lock b/apps/display/macos/Podfile.lock new file mode 100644 index 0000000..4cd20f2 --- /dev/null +++ b/apps/display/macos/Podfile.lock @@ -0,0 +1,28 @@ +PODS: + - FlutterMacOS (1.0.0) + - media_kit_libs_macos_video (1.0.4): + - FlutterMacOS + - media_kit_video (0.0.1): + - FlutterMacOS + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + - media_kit_libs_macos_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos`) + - media_kit_video (from `Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + media_kit_libs_macos_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_libs_macos_video/macos + media_kit_video: + :path: Flutter/ephemeral/.symlinks/plugins/media_kit_video/macos + +SPEC CHECKSUMS: + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + media_kit_libs_macos_video: 85a23e549b5f480e72cae3e5634b5514bc692f65 + media_kit_video: fa6564e3799a0a28bff39442334817088b7ca758 + +PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 + +COCOAPODS: 1.16.2 diff --git a/apps/display/macos/Runner.xcodeproj/project.pbxproj b/apps/display/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..d100fed --- /dev/null +++ b/apps/display/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,825 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 92F4B0A147574513CB9E1061 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0ADBF64CBDB7A460FC19BA16 /* Pods_Runner.framework */; }; + BC5DB3620A4042B7006CF7A3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E93B8EDF9498337873E91BD /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 0ADBF64CBDB7A460FC19BA16 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 0E8DBE2344231BA987B1CAEA /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* mirror_display.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mirror_display.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3E3B0ABD864A936C723C8D17 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 5BEECA01FA72021AD4C5C626 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 5E93B8EDF9498337873E91BD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + BA38C6EE8C9F943C3130C49D /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + E1D12E48B61A58C77478B122 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + FFC9A3FE0EE4C729366A8668 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BC5DB3620A4042B7006CF7A3 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 92F4B0A147574513CB9E1061 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + B6808CFC35874201BFC689FB /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* mirror_display.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + B6808CFC35874201BFC689FB /* Pods */ = { + isa = PBXGroup; + children = ( + 5BEECA01FA72021AD4C5C626 /* Pods-Runner.debug.xcconfig */, + 0E8DBE2344231BA987B1CAEA /* Pods-Runner.release.xcconfig */, + E1D12E48B61A58C77478B122 /* Pods-Runner.profile.xcconfig */, + 3E3B0ABD864A936C723C8D17 /* Pods-RunnerTests.debug.xcconfig */, + BA38C6EE8C9F943C3130C49D /* Pods-RunnerTests.release.xcconfig */, + FFC9A3FE0EE4C729366A8668 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0ADBF64CBDB7A460FC19BA16 /* Pods_Runner.framework */, + 5E93B8EDF9498337873E91BD /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + AB6E2BB8E62BFC0F7A5A00D2 /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + CDF9B9EEFF196EBB60634B7C /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 415CABAF4FD7908B456F221E /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* mirror_display.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + 415CABAF4FD7908B456F221E /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + AB6E2BB8E62BFC0F7A5A00D2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + CDF9B9EEFF196EBB60634B7C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3E3B0ABD864A936C723C8D17 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.mirrorDisplay.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mirror_display.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mirror_display"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = BA38C6EE8C9F943C3130C49D /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.mirrorDisplay.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mirror_display.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mirror_display"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = FFC9A3FE0EE4C729366A8668 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.mirrorDisplay.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mirror_display.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/mirror_display"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/apps/display/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/display/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/display/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/display/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/apps/display/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..6a708de --- /dev/null +++ b/apps/display/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/display/macos/Runner.xcworkspace/contents.xcworkspacedata b/apps/display/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..21a3cc1 --- /dev/null +++ b/apps/display/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/apps/display/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/apps/display/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/apps/display/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/apps/display/macos/Runner/AppDelegate.swift b/apps/display/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/apps/display/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/apps/display/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/apps/display/macos/Runner/Base.lproj/MainMenu.xib b/apps/display/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/apps/display/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/display/macos/Runner/Configs/AppInfo.xcconfig b/apps/display/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..1461f99 --- /dev/null +++ b/apps/display/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = mirror_display + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.mirrorDisplay + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/apps/display/macos/Runner/Configs/Debug.xcconfig b/apps/display/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/apps/display/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/display/macos/Runner/Configs/Release.xcconfig b/apps/display/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/apps/display/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/apps/display/macos/Runner/Configs/Warnings.xcconfig b/apps/display/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/apps/display/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/apps/display/macos/Runner/DebugProfile.entitlements b/apps/display/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..08c3ab1 --- /dev/null +++ b/apps/display/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/apps/display/macos/Runner/Info.plist b/apps/display/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/apps/display/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/apps/display/macos/Runner/MainFlutterWindow.swift b/apps/display/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/apps/display/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/apps/display/macos/Runner/Release.entitlements b/apps/display/macos/Runner/Release.entitlements new file mode 100644 index 0000000..64cabb4 --- /dev/null +++ b/apps/display/macos/Runner/Release.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.server + + com.apple.security.network.client + + + diff --git a/apps/display/macos/RunnerTests/RunnerTests.swift b/apps/display/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/apps/display/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/apps/display/pubspec.yaml b/apps/display/pubspec.yaml new file mode 100644 index 0000000..ce75f5d --- /dev/null +++ b/apps/display/pubspec.yaml @@ -0,0 +1,32 @@ +name: mirror_display +description: Mirror OS display app — fullscreen kiosk for Raspberry Pi 4 +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.5.0 + flutter: ^3.24.0 + +dependencies: + flutter: + sdk: flutter + mirror_protocol: any + mirror_core: any + mirror_ui: any + flutter_riverpod: ^2.5.0 + shelf: ^1.4.0 + shelf_web_socket: ^2.0.0 + web_socket_channel: ^3.0.0 + nsd: ^5.0.0 + media_kit: ^1.2.6 + media_kit_video: ^2.0.1 + media_kit_libs_macos_video: ^1.1.4 + media_kit_libs_linux: ^1.2.1 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/apps/display/web/favicon.png b/apps/display/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/apps/display/web/favicon.png differ diff --git a/apps/display/web/icons/Icon-192.png b/apps/display/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/apps/display/web/icons/Icon-192.png differ diff --git a/apps/display/web/icons/Icon-512.png b/apps/display/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/apps/display/web/icons/Icon-512.png differ diff --git a/apps/display/web/icons/Icon-maskable-192.png b/apps/display/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/apps/display/web/icons/Icon-maskable-192.png differ diff --git a/apps/display/web/icons/Icon-maskable-512.png b/apps/display/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/apps/display/web/icons/Icon-maskable-512.png differ diff --git a/apps/display/web/index.html b/apps/display/web/index.html new file mode 100644 index 0000000..250b31f --- /dev/null +++ b/apps/display/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + mirror_display + + + + + + + diff --git a/apps/display/web/manifest.json b/apps/display/web/manifest.json new file mode 100644 index 0000000..72f5048 --- /dev/null +++ b/apps/display/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "mirror_display", + "short_name": "mirror_display", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/apps/remote/.gitignore b/apps/remote/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/apps/remote/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/apps/remote/.metadata b/apps/remote/.metadata new file mode 100644 index 0000000..1e65aef --- /dev/null +++ b/apps/remote/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "924134a44c189315be2148659913dda1671cbe99" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + - platform: android + create_revision: 924134a44c189315be2148659913dda1671cbe99 + base_revision: 924134a44c189315be2148659913dda1671cbe99 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/apps/remote/README.md b/apps/remote/README.md new file mode 100644 index 0000000..57cd724 --- /dev/null +++ b/apps/remote/README.md @@ -0,0 +1,17 @@ +# mirror_remote + +A new Flutter project. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/apps/remote/android/.gitignore b/apps/remote/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/apps/remote/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/apps/remote/android/app/build.gradle.kts b/apps/remote/android/app/build.gradle.kts new file mode 100644 index 0000000..58addc8 --- /dev/null +++ b/apps/remote/android/app/build.gradle.kts @@ -0,0 +1,45 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.mirror_remote" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.mirror_remote" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/apps/remote/android/app/src/debug/AndroidManifest.xml b/apps/remote/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/remote/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/remote/android/app/src/main/AndroidManifest.xml b/apps/remote/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..1fa36c5 --- /dev/null +++ b/apps/remote/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/remote/android/app/src/main/kotlin/com/example/mirror_remote/MainActivity.kt b/apps/remote/android/app/src/main/kotlin/com/example/mirror_remote/MainActivity.kt new file mode 100644 index 0000000..6946302 --- /dev/null +++ b/apps/remote/android/app/src/main/kotlin/com/example/mirror_remote/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.mirror_remote + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/apps/remote/android/app/src/main/res/drawable-v21/launch_background.xml b/apps/remote/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/apps/remote/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/remote/android/app/src/main/res/drawable/launch_background.xml b/apps/remote/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/apps/remote/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/apps/remote/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/apps/remote/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/apps/remote/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/apps/remote/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/apps/remote/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/apps/remote/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/apps/remote/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/apps/remote/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/apps/remote/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/apps/remote/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/apps/remote/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/apps/remote/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/apps/remote/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/apps/remote/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/apps/remote/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/apps/remote/android/app/src/main/res/values-night/styles.xml b/apps/remote/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/apps/remote/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/remote/android/app/src/main/res/values/styles.xml b/apps/remote/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/apps/remote/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/apps/remote/android/app/src/profile/AndroidManifest.xml b/apps/remote/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/apps/remote/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/apps/remote/android/build.gradle.kts b/apps/remote/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/apps/remote/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/apps/remote/android/gradle.properties b/apps/remote/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/apps/remote/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/apps/remote/android/gradle/wrapper/gradle-wrapper.properties b/apps/remote/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2d428bf --- /dev/null +++ b/apps/remote/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/apps/remote/android/settings.gradle.kts b/apps/remote/android/settings.gradle.kts new file mode 100644 index 0000000..c21f0c5 --- /dev/null +++ b/apps/remote/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.0.1" apply false + id("org.jetbrains.kotlin.android") version "2.3.20" apply false +} + +include(":app") diff --git a/apps/remote/ios/Flutter/Generated.xcconfig b/apps/remote/ios/Flutter/Generated.xcconfig new file mode 100644 index 0000000..4ad5d3e --- /dev/null +++ b/apps/remote/ios/Flutter/Generated.xcconfig @@ -0,0 +1,15 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=/opt/homebrew/share/flutter +FLUTTER_APPLICATION_PATH=/Users/csader/mirror-os/apps/remote +FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=/Users/csader/mirror-os/apps/remote/ios/Flutter/ephemeral/Packages/.packages/FlutterFramework +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=lib/main.dart +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=0.1.0 +FLUTTER_BUILD_NUMBER=0.1.0 +EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 +EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/apps/remote/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift b/apps/remote/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift new file mode 100644 index 0000000..91f2c29 --- /dev/null +++ b/apps/remote/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. +// +// Generated file. Do not edit. +// + +import PackageDescription + +let package = Package( + name: "FlutterGeneratedPluginSwiftPackage", + platforms: [ + .iOS("13.0") + ], + products: [ + .library(name: "FlutterGeneratedPluginSwiftPackage", type: .static, targets: ["FlutterGeneratedPluginSwiftPackage"]) + ], + dependencies: [ + .package(name: "url_launcher_ios", path: "../.packages/url_launcher_ios-6.4.1"), + .package(name: "shared_preferences_foundation", path: "../.packages/shared_preferences_foundation-2.5.6"), + .package(name: "nsd_ios", path: "../.packages/nsd_ios-3.0.1"), + .package(name: "FlutterFramework", path: "../.packages/FlutterFramework") + ], + targets: [ + .target( + name: "FlutterGeneratedPluginSwiftPackage", + dependencies: [ + .product(name: "url-launcher-ios", package: "url_launcher_ios"), + .product(name: "shared-preferences-foundation", package: "shared_preferences_foundation"), + .product(name: "nsd-ios", package: "nsd_ios"), + .product(name: "FlutterFramework", package: "FlutterFramework") + ] + ) + ] +) diff --git a/apps/remote/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Sources/FlutterGeneratedPluginSwiftPackage/FlutterGeneratedPluginSwiftPackage.swift b/apps/remote/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Sources/FlutterGeneratedPluginSwiftPackage/FlutterGeneratedPluginSwiftPackage.swift new file mode 100644 index 0000000..a30d95a --- /dev/null +++ b/apps/remote/ios/Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage/Sources/FlutterGeneratedPluginSwiftPackage/FlutterGeneratedPluginSwiftPackage.swift @@ -0,0 +1,3 @@ +// +// Generated file. Do not edit. +// diff --git a/apps/remote/ios/Flutter/ephemeral/flutter_lldb_helper.py b/apps/remote/ios/Flutter/ephemeral/flutter_lldb_helper.py new file mode 100644 index 0000000..a88caf9 --- /dev/null +++ b/apps/remote/ios/Flutter/ephemeral/flutter_lldb_helper.py @@ -0,0 +1,32 @@ +# +# Generated file, do not edit. +# + +import lldb + +def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict): + """Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" + base = frame.register["x0"].GetValueAsAddress() + page_len = frame.register["x1"].GetValueAsUnsigned() + + # Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the + # first page to see if handled it correctly. This makes diagnosing + # misconfiguration (e.g. missing breakpoint) easier. + data = bytearray(page_len) + data[0:8] = b'IHELPED!' + + error = lldb.SBError() + frame.GetThread().GetProcess().WriteMemory(base, data, error) + if not error.Success(): + print(f'Failed to write into {base}[+{page_len}]', error) + return + +def __lldb_init_module(debugger: lldb.SBDebugger, _): + target = debugger.GetDummyTarget() + # Caveat: must use BreakpointCreateByRegEx here and not + # BreakpointCreateByName. For some reasons callback function does not + # get carried over from dummy target for the later. + bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$") + bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__)) + bp.SetAutoContinue(True) + print("-- LLDB integration loaded --") diff --git a/apps/remote/ios/Flutter/ephemeral/flutter_lldbinit b/apps/remote/ios/Flutter/ephemeral/flutter_lldbinit new file mode 100644 index 0000000..e3ba6fb --- /dev/null +++ b/apps/remote/ios/Flutter/ephemeral/flutter_lldbinit @@ -0,0 +1,5 @@ +# +# Generated file, do not edit. +# + +command script import --relative-to-command-file flutter_lldb_helper.py diff --git a/apps/remote/ios/Flutter/ephemeral/flutter_native_integration.env b/apps/remote/ios/Flutter/ephemeral/flutter_native_integration.env new file mode 100644 index 0000000..49ea6aa --- /dev/null +++ b/apps/remote/ios/Flutter/ephemeral/flutter_native_integration.env @@ -0,0 +1,12 @@ +FLUTTER_ROOT=/opt/homebrew/share/flutter +FLUTTER_APPLICATION_PATH=/Users/csader/mirror-os/apps/remote +FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=/Users/csader/mirror-os/apps/remote/ios/Flutter/ephemeral/Packages/.packages/FlutterFramework +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=lib/main.dart +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=0.1.0 +FLUTTER_BUILD_NUMBER=0.1.0 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/apps/remote/ios/Flutter/flutter_export_environment.sh b/apps/remote/ios/Flutter/flutter_export_environment.sh new file mode 100755 index 0000000..14109fc --- /dev/null +++ b/apps/remote/ios/Flutter/flutter_export_environment.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=/opt/homebrew/share/flutter" +export "FLUTTER_APPLICATION_PATH=/Users/csader/mirror-os/apps/remote" +export "FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=/Users/csader/mirror-os/apps/remote/ios/Flutter/ephemeral/Packages/.packages/FlutterFramework" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_TARGET=lib/main.dart" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=0.1.0" +export "FLUTTER_BUILD_NUMBER=0.1.0" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json" diff --git a/apps/remote/ios/Runner/GeneratedPluginRegistrant.h b/apps/remote/ios/Runner/GeneratedPluginRegistrant.h new file mode 100644 index 0000000..7a89092 --- /dev/null +++ b/apps/remote/ios/Runner/GeneratedPluginRegistrant.h @@ -0,0 +1,19 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GeneratedPluginRegistrant_h +#define GeneratedPluginRegistrant_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface GeneratedPluginRegistrant : NSObject ++ (void)registerWithRegistry:(NSObject*)registry; +@end + +NS_ASSUME_NONNULL_END +#endif /* GeneratedPluginRegistrant_h */ diff --git a/apps/remote/ios/Runner/GeneratedPluginRegistrant.m b/apps/remote/ios/Runner/GeneratedPluginRegistrant.m new file mode 100644 index 0000000..be28cff --- /dev/null +++ b/apps/remote/ios/Runner/GeneratedPluginRegistrant.m @@ -0,0 +1,35 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import nsd_ios; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [NsdIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"NsdIosPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; + [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; +} + +@end diff --git a/apps/remote/lib/app.dart b/apps/remote/lib/app.dart new file mode 100644 index 0000000..a3e233d --- /dev/null +++ b/apps/remote/lib/app.dart @@ -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'), + ], + ), + ); + } +} diff --git a/apps/remote/lib/main.dart b/apps/remote/lib/main.dart new file mode 100644 index 0000000..7650f33 --- /dev/null +++ b/apps/remote/lib/main.dart @@ -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()), + ); +} diff --git a/apps/remote/lib/providers/connection_provider.dart b/apps/remote/lib/providers/connection_provider.dart new file mode 100644 index 0000000..fef410d --- /dev/null +++ b/apps/remote/lib/providers/connection_provider.dart @@ -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.new); + +final mirrorStateProvider = StateProvider((ref) => null); + +final dashboardConfigProvider = StateProvider((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 { + WebSocketChannel? _channel; + Timer? _reconnectTimer; + + @override + ConnectionState build() => const ConnectionState(); + + Future 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; + } +} diff --git a/apps/remote/lib/providers/ha_provider.dart b/apps/remote/lib/providers/ha_provider.dart new file mode 100644 index 0000000..c266433 --- /dev/null +++ b/apps/remote/lib/providers/ha_provider.dart @@ -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((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.new); + +class HaEntitiesState { + final List entities; + final HaWeather? weather; + final List calendarEvents; + final String? error; + + const HaEntitiesState({ + this.entities = const [], + this.weather, + this.calendarEvents = const [], + this.error, + }); +} + +class HaEntitiesNotifier extends AsyncNotifier { + @override + Future 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 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 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(), + ); + } +} diff --git a/apps/remote/lib/providers/plex_provider.dart b/apps/remote/lib/providers/plex_provider.dart new file mode 100644 index 0000000..3a03a30 --- /dev/null +++ b/apps/remote/lib/providers/plex_provider.dart @@ -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((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.new); + +class PlexBrowseState { + final List libraries; + final PlexLibrary? currentLibrary; + final List items; + final List breadcrumb; + final String? error; + + const PlexBrowseState({ + this.libraries = const [], + this.currentLibrary, + this.items = const [], + this.breadcrumb = const [], + this.error, + }); +} + +class PlexBrowseNotifier extends AsyncNotifier { + @override + Future 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 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 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 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(); + } +} diff --git a/apps/remote/lib/providers/programs_provider.dart b/apps/remote/lib/providers/programs_provider.dart new file mode 100644 index 0000000..cec261b --- /dev/null +++ b/apps/remote/lib/providers/programs_provider.dart @@ -0,0 +1,25 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mirror_core/mirror_core.dart'; + +final programsApiProvider = Provider((ref) { + return ProgramsApiClient(baseUrl: 'http://192.168.86.172:8091'); +}); + +final programsProvider = + AsyncNotifierProvider>(ProgramsNotifier.new); + +class ProgramsNotifier extends AsyncNotifier> { + @override + Future> build() async { + final client = ref.read(programsApiProvider); + return client.fetchPrograms(); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(() async { + final client = ref.read(programsApiProvider); + return client.fetchPrograms(); + }); + } +} diff --git a/apps/remote/lib/providers/settings_provider.dart b/apps/remote/lib/providers/settings_provider.dart new file mode 100644 index 0000000..13bc5b7 --- /dev/null +++ b/apps/remote/lib/providers/settings_provider.dart @@ -0,0 +1,109 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +final settingsProvider = AsyncNotifierProvider(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 { + @override + Future 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 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); + } +} diff --git a/apps/remote/lib/providers/update_provider.dart b/apps/remote/lib/providers/update_provider.dart new file mode 100644 index 0000000..716757b --- /dev/null +++ b/apps/remote/lib/providers/update_provider.dart @@ -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((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; + 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')), + ); + } + } + } +} diff --git a/apps/remote/lib/providers/workout_browse_provider.dart b/apps/remote/lib/providers/workout_browse_provider.dart new file mode 100644 index 0000000..9ba7ea7 --- /dev/null +++ b/apps/remote/lib/providers/workout_browse_provider.dart @@ -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((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.new); + +class WorkoutBrowseState { + final List videos; + final List 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? videos, + List? 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 { + @override + Future build() async { + return _fetch('', WorkoutFilter.all); + } + + Future _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 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 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); + } +} diff --git a/apps/remote/lib/providers/youtube_search_provider.dart b/apps/remote/lib/providers/youtube_search_provider.dart new file mode 100644 index 0000000..fd6c5d9 --- /dev/null +++ b/apps/remote/lib/providers/youtube_search_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mirror_core/mirror_core.dart'; + +final youtubeServiceProvider = Provider((ref) { + return YouTubeService(); +}); + +final youtubeSearchProvider = + AsyncNotifierProvider( + YouTubeSearchNotifier.new); + +class YouTubeSearchState { + final String query; + final List results; + + const YouTubeSearchState({this.query = '', this.results = const []}); +} + +class YouTubeSearchNotifier extends AsyncNotifier { + @override + Future build() async { + return const YouTubeSearchState(); + } + + Future 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()); + } +} diff --git a/apps/remote/lib/screens/connect_screen.dart b/apps/remote/lib/screens/connect_screen.dart new file mode 100644 index 0000000..61efccb --- /dev/null +++ b/apps/remote/lib/screens/connect_screen.dart @@ -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 createState() => _ConnectScreenState(); +} + +class _ConnectScreenState extends ConsumerState { + final _hostController = TextEditingController(); + bool _loaded = false; + + @override + void initState() { + super.initState(); + _loadSavedHost(); + } + + Future _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 _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); + } + } +} diff --git a/apps/remote/lib/screens/dashboard_editor_screen.dart b/apps/remote/lib/screens/dashboard_editor_screen.dart new file mode 100644 index 0000000..d9a419d --- /dev/null +++ b/apps/remote/lib/screens/dashboard_editor_screen.dart @@ -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 createState() => _DashboardEditorScreenState(); +} + +class _DashboardEditorScreenState extends ConsumerState { + List _widgets = []; + bool _loading = true; + + static const _widgetMeta = { + '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 _openConfig(int index) async { + final widget = _widgets[index]; + switch (widget.type) { + case 'ha_entities': + final currentIds = + (widget.config['entity_ids'] as List?)?.cast() ?? []; + final result = await Navigator.of(context).push>( + 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( + 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'), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/remote/lib/screens/ha_entity_picker_screen.dart b/apps/remote/lib/screens/ha_entity_picker_screen.dart new file mode 100644 index 0000000..ec76fde --- /dev/null +++ b/apps/remote/lib/screens/ha_entity_picker_screen.dart @@ -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 selectedIds; + + const HaEntityPickerScreen({super.key, this.selectedIds = const []}); + + @override + ConsumerState createState() => _HaEntityPickerScreenState(); +} + +class _HaEntityPickerScreenState extends ConsumerState { + late Set _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 = >{}; + 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(), + ); + }, + ), + ), + ], + ); + } +} diff --git a/apps/remote/lib/screens/remote_dashboard.dart b/apps/remote/lib/screens/remote_dashboard.dart new file mode 100644 index 0000000..2b066b8 --- /dev/null +++ b/apps/remote/lib/screens/remote_dashboard.dart @@ -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 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), + ], + ), + ), + ); + } +} diff --git a/apps/remote/lib/screens/remote_media.dart b/apps/remote/lib/screens/remote_media.dart new file mode 100644 index 0000000..48ea826 --- /dev/null +++ b/apps/remote/lib/screens/remote_media.dart @@ -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 createState() => _RemoteMediaState(); +} + +class _RemoteMediaState extends ConsumerState + 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 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'; + } +} diff --git a/apps/remote/lib/screens/remote_workout.dart b/apps/remote/lib/screens/remote_workout.dart new file mode 100644 index 0000000..e5531ae --- /dev/null +++ b/apps/remote/lib/screens/remote_workout.dart @@ -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('/') : []; + final subfolders = _getSubfolders(data.folders, path); + final stats = data.stats; + + // Only show videos directly in this folder (not in subfolders) + final videos = data.videos.where((v) { + final vFolder = v.folder ?? ''; + return vFolder == path; + }).toList(); + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stats + if (stats != null) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + _StatChip('${stats.total}', 'videos'), + const SizedBox(width: 8), + _StatChip('${stats.completed}', 'done'), + const SizedBox(width: 8), + _StatChip('${stats.inProgress}', 'started'), + ], + ), + ), + + // Breadcrumb + _Breadcrumb(pathParts: pathParts, ref: ref), + + // Filter chips + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: WorkoutFilter.values.map((f) { + final active = f == data.filter; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(f.name), + selected: active, + onSelected: (_) { + ref.read(workoutBrowseProvider.notifier).setFilter(f); + }, + ), + ); + }).toList(), + ), + ), + ), + + // Play all button + if (videos.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _playFolder(ref, false), + icon: const Icon(Icons.play_arrow), + label: Text('Play All (${videos.length})'), + ), + ), + ), + ], + ), + ), + ), + + // Subfolders + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final folder = subfolders[index]; + final name = folder.split('/').last; + return ListTile( + leading: const Icon(Icons.folder, color: MirrorTheme.accent), + title: Text(name), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ref.read(workoutBrowseProvider.notifier).browse(folder); + }, + ); + }, + childCount: subfolders.length, + ), + ), + + // Videos + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final video = videos[index]; + final completed = video.percentCompleted >= 95; + final thumbUrl = video.thumbnailPath != null + ? 'http://192.168.86.172:8091/${video.thumbnailPath}' + : null; + return ListTile( + leading: SizedBox( + width: 56, + height: 56, + child: thumbUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + thumbUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + color: MirrorTheme.surface, + child: const Icon(Icons.play_circle_outline, size: 24), + ), + ), + ) + : Container( + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.play_circle_outline, size: 24), + ), + ), + title: Text( + video.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${_formatSeconds(video.duration)}' + '${completed ? ' · Done' : video.percentCompleted > 0 ? ' · ${video.percentCompleted}%' : ''}', + ), + trailing: ProgressRing( + percent: video.percentCompleted.toDouble(), + size: 28, + ), + onTap: () => _playVideo(ref, video), + ); + }, + childCount: videos.length, + ), + ), + + if (subfolders.isEmpty && videos.isEmpty) + const SliverFillRemaining( + child: Center( + child: Text('No videos here', style: TextStyle(color: MirrorTheme.textMuted)), + ), + ), + ], + ); + } + + void _playVideo(WidgetRef ref, VideoState video) { + ref.read(connectionProvider.notifier).send( + NavigateCommand(target: ScreenType.workout), + ); + ref.read(connectionProvider.notifier).send( + WorkoutCommand( + action: WorkoutAction.playVideo, + payload: { + 'videoId': video.id, + 'title': video.title, + 'path': video.path, + 'duration': video.duration, + 'trimStart': video.trimStart, + 'trimEnd': video.trimEnd, + 'restSeconds': video.restSeconds, + 'lastPosition': video.lastPosition, + }, + ), + ); + } + + void _playFolder(WidgetRef ref, bool unwatchedOnly) { + final videos = unwatchedOnly + ? data.videos.where((v) => v.percentCompleted < 95).toList() + : data.videos; + if (videos.isEmpty) return; + + ref.read(connectionProvider.notifier).send( + NavigateCommand(target: ScreenType.workout), + ); + ref.read(connectionProvider.notifier).send( + WorkoutCommand( + action: WorkoutAction.playFolder, + payload: { + 'videos': videos.map((v) => { + 'videoId': v.id, + 'title': v.title, + 'path': v.path, + 'duration': v.duration, + 'trimStart': v.trimStart, + 'trimEnd': v.trimEnd, + 'restSeconds': v.restSeconds, + 'lastPosition': v.lastPosition, + }).toList(), + }, + ), + ); + } + + List _getSubfolders(List allFolders, String currentPath) { + if (currentPath.isEmpty) { + final top = {}; + for (final f in allFolders) { + final first = f.split('/').first; + if (first.isNotEmpty) top.add(first); + } + return top.toList()..sort(); + } + final children = {}; + for (final f in allFolders) { + if (f.startsWith('$currentPath/')) { + final rest = f.substring(currentPath.length + 1); + final next = rest.split('/').first; + if (next.isNotEmpty) children.add('$currentPath/$next'); + } + } + return children.toList()..sort(); + } + + String _formatSeconds(int s) { + final m = s ~/ 60; + final sec = (s % 60).toString().padLeft(2, '0'); + return '$m:$sec'; + } +} + +class _PlaybackView extends ConsumerWidget { + final WorkoutSessionState workout; + final PlaybackState? playback; + + const _PlaybackView({required this.workout, this.playback}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isResting = workout.phase == WorkoutPhase.resting; + final currentItem = workout.currentIndex < workout.queue.length + ? workout.queue[workout.currentIndex] + : null; + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + const Spacer(), + if (isResting) ...[ + Text( + 'REST', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: MirrorTheme.accent, + letterSpacing: 6, + ), + ), + const SizedBox(height: 20), + TimerDisplay(seconds: workout.restRemaining), + const SizedBox(height: 20), + if (currentItem != null) + Text( + 'Next: ${currentItem.title}', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + FilledButton( + onPressed: () { + ref.read(connectionProvider.notifier).send( + WorkoutCommand(action: WorkoutAction.skipRest), + ); + }, + child: const Text('Skip Rest'), + ), + ] else ...[ + Text( + currentItem?.title ?? playback?.title ?? '', + style: Theme.of(context).textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + if (workout.queue.length > 1) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '${workout.currentIndex + 1} of ${workout.queue.length}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (playback != null) ...[ + const SizedBox(height: 32), + LinearProgressIndicator( + value: playback!.duration.inMilliseconds > 0 + ? playback!.position.inMilliseconds / playback!.duration.inMilliseconds + : 0, + backgroundColor: MirrorTheme.surfaceLight, + color: MirrorTheme.accent, + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(_fmt(playback!.position), + style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)), + Text(_fmt(playback!.duration), + style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)), + ], + ), + ], + ], + const Spacer(), + if (!isResting) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + iconSize: 40, + icon: const Icon(Icons.skip_previous), + onPressed: () => ref.read(connectionProvider.notifier).send( + WorkoutCommand(action: WorkoutAction.previousVideo), + ), + ), + const SizedBox(width: 16), + IconButton( + iconSize: 32, + icon: const Icon(Icons.replay_10), + onPressed: () { + if (playback != null) { + final pos = (playback!.position - const Duration(seconds: 10)); + ref.read(connectionProvider.notifier).send( + PlaybackCommand( + action: PlaybackAction.seek, + value: pos.inMilliseconds.clamp(0, playback!.duration.inMilliseconds), + ), + ); + } + }, + ), + const SizedBox(width: 16), + IconButton.filled( + iconSize: 56, + icon: Icon(playback?.isPlaying == true ? Icons.pause : Icons.play_arrow), + onPressed: () => ref.read(connectionProvider.notifier).send( + PlaybackCommand( + action: playback?.isPlaying == true + ? PlaybackAction.pause + : PlaybackAction.resume, + ), + ), + ), + const SizedBox(width: 16), + IconButton( + iconSize: 32, + icon: const Icon(Icons.forward_10), + onPressed: () { + if (playback != null) { + final pos = (playback!.position + const Duration(seconds: 10)); + ref.read(connectionProvider.notifier).send( + PlaybackCommand( + action: PlaybackAction.seek, + value: pos.inMilliseconds, + ), + ); + } + }, + ), + const SizedBox(width: 16), + IconButton( + iconSize: 40, + icon: const Icon(Icons.skip_next), + onPressed: () => ref.read(connectionProvider.notifier).send( + WorkoutCommand(action: WorkoutAction.skipVideo), + ), + ), + ], + ), + const SizedBox(height: 20), + TextButton.icon( + icon: const Icon(Icons.stop, color: MirrorTheme.danger), + label: const Text('End Session', style: TextStyle(color: MirrorTheme.danger)), + onPressed: () => ref.read(connectionProvider.notifier).send( + WorkoutCommand(action: WorkoutAction.stopSession), + ), + ), + const SizedBox(height: 20), + ], + ), + ); + } + + String _fmt(Duration d) { + final m = d.inMinutes; + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$m:$s'; + } +} + +class _CompletedView extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle, size: 80, color: MirrorTheme.success), + const SizedBox(height: 16), + Text('Session Complete', style: Theme.of(context).textTheme.headlineMedium), + const SizedBox(height: 32), + FilledButton( + onPressed: () { + ref.read(workoutBrowseProvider.notifier).browse(''); + }, + child: const Text('Back to Browse'), + ), + ], + ), + ); + } +} + +class _StatChip extends StatelessWidget { + final String value; + final String label; + const _StatChip(this.value, this.label); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: MirrorTheme.surface, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(width: 4), + Text(label, style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 13)), + ], + ), + ); + } +} + +class _Breadcrumb extends StatelessWidget { + final List pathParts; + final WidgetRef ref; + + const _Breadcrumb({required this.pathParts, required this.ref}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + GestureDetector( + onTap: () => ref.read(workoutBrowseProvider.notifier).browse(''), + child: const Text('Home', style: TextStyle(color: MirrorTheme.accent, fontSize: 13)), + ), + for (int i = 0; i < pathParts.length; i++) ...[ + const Text(' / ', style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13)), + GestureDetector( + onTap: () => ref + .read(workoutBrowseProvider.notifier) + .browse(pathParts.sublist(0, i + 1).join('/')), + child: Text( + pathParts[i], + style: TextStyle( + color: i == pathParts.length - 1 ? MirrorTheme.textPrimary : MirrorTheme.accent, + fontSize: 13, + ), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/apps/remote/lib/screens/settings_screen.dart b/apps/remote/lib/screens/settings_screen.dart new file mode 100644 index 0000000..c24adb6 --- /dev/null +++ b/apps/remote/lib/screens/settings_screen.dart @@ -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 createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + 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), + ), + ), + ); + } +} diff --git a/apps/remote/lib/screens/weather_picker_screen.dart b/apps/remote/lib/screens/weather_picker_screen.dart new file mode 100644 index 0000000..874ee9b --- /dev/null +++ b/apps/remote/lib/screens/weather_picker_screen.dart @@ -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 createState() => _WeatherPickerScreenState(); +} + +class _WeatherPickerScreenState extends ConsumerState { + 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( + title: Text(name), + subtitle: Text(entity.entityId), + value: entity.entityId, + groupValue: _selected, + activeColor: MirrorTheme.accent, + onChanged: (value) => setState(() => _selected = value), + ); + }, + ); + }, + ), + ); + } +} diff --git a/apps/remote/pubspec.yaml b/apps/remote/pubspec.yaml new file mode 100644 index 0000000..b256d79 --- /dev/null +++ b/apps/remote/pubspec.yaml @@ -0,0 +1,28 @@ +name: mirror_remote +description: Mirror OS phone remote — control your mirror from your smartphone +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.5.0 + flutter: ^3.24.0 + +dependencies: + flutter: + sdk: flutter + mirror_protocol: any + mirror_core: any + mirror_ui: any + flutter_riverpod: ^2.5.0 + web_socket_channel: ^3.0.0 + nsd: ^5.0.0 + shared_preferences: ^2.5.0 + url_launcher: ^6.3.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: + uses-material-design: true diff --git a/deploy/ota-server/README.md b/deploy/ota-server/README.md new file mode 100644 index 0000000..1681c4d --- /dev/null +++ b/deploy/ota-server/README.md @@ -0,0 +1,60 @@ +# OTA Update Server + +Static file server for hosting Mirror OS remote APK updates. Runs on the homelab alongside workout-player on port 9091. + +## Endpoints + +| Path | Description | +|------|-------------| +| `GET /api/mirror-os/version` | Version manifest (JSON) | +| `GET /mirror-os-remote.apk` | APK download | +| `GET /health` | Health check | + +## Deploy on Homelab + +```bash +# Copy files to homelab +rsync -avz deploy/ota-server/ homelab:~/ota-server/ + +# Start the server +ssh homelab 'cd ~/ota-server && docker compose up -d' +``` + +The server runs nginx on port 9091 serving static files from `public/`. + +## Publish an Update + +From the repo root: + +```bash +# Build APK, bump version, copy to public/ +./deploy/publish-apk.sh "Added new feature X" + +# Then sync to homelab +rsync -avz deploy/ota-server/public/ homelab:~/ota-server/public/ +``` + +The script: +1. Builds the release APK via `flutter build apk --release` +2. Copies the APK to `deploy/ota-server/public/mirror-os-remote.apk` +3. Increments the version number in `version.json` +4. Sets the changelog from the argument (or defaults to "Bug fixes and improvements") + +## How the App Checks for Updates + +The remote app (`apps/remote`) has an `update_provider.dart` that: +1. Fetches `http://192.168.86.172:9091/api/mirror-os/version` +2. Compares the returned `version` number against the locally installed version +3. If newer, shows a prompt to download and install the APK from `downloadUrl` + +## Version Manifest Format + +```json +{ + "version": 1, + "downloadUrl": "http://192.168.86.172:9091/mirror-os-remote.apk", + "changelog": "Initial release" +} +``` + +The `version` field is a simple incrementing integer. diff --git a/deploy/ota-server/docker-compose.yml b/deploy/ota-server/docker-compose.yml new file mode 100644 index 0000000..400b764 --- /dev/null +++ b/deploy/ota-server/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.8" + +services: + ota-server: + image: nginx:alpine + container_name: mirror-os-ota + ports: + - "9091:80" + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./public:/usr/share/nginx/html:ro + restart: unless-stopped diff --git a/deploy/ota-server/nginx.conf b/deploy/ota-server/nginx.conf new file mode 100644 index 0000000..1c2d59b --- /dev/null +++ b/deploy/ota-server/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + + # Serve version manifest as JSON + location = /api/mirror-os/version { + alias /usr/share/nginx/html/version.json; + default_type application/json; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Access-Control-Allow-Origin "*"; + } + + # Serve APK with correct content type + location = /mirror-os-remote.apk { + default_type application/vnd.android.package-archive; + add_header Content-Disposition 'attachment; filename="mirror-os-remote.apk"'; + add_header Access-Control-Allow-Origin "*"; + } + + # Health check + location = /health { + return 200 '{"status":"ok"}'; + default_type application/json; + } +} diff --git a/deploy/ota-server/public/version.json b/deploy/ota-server/public/version.json new file mode 100644 index 0000000..1a2f4b3 --- /dev/null +++ b/deploy/ota-server/public/version.json @@ -0,0 +1,5 @@ +{ + "version": 1, + "downloadUrl": "http://192.168.86.172:9091/mirror-os-remote.apk", + "changelog": "Initial release" +} diff --git a/deploy/publish-apk.sh b/deploy/publish-apk.sh new file mode 100755 index 0000000..d406772 --- /dev/null +++ b/deploy/publish-apk.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# publish-apk.sh — Build release APK and update OTA server files. +# Usage: ./deploy/publish-apk.sh [changelog message] +# Make executable: chmod +x deploy/publish-apk.sh + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OTA_DIR="$REPO_ROOT/deploy/ota-server/public" +VERSION_FILE="$OTA_DIR/version.json" +APK_SOURCE="$REPO_ROOT/apps/remote/build/app/outputs/flutter-apk/app-release.apk" +APK_DEST="$OTA_DIR/mirror-os-remote.apk" + +CHANGELOG="${1:-Bug fixes and improvements}" + +# --- Build the release APK --- +echo "Building release APK..." +cd "$REPO_ROOT/apps/remote" +flutter build apk --release + +if [ ! -f "$APK_SOURCE" ]; then + echo "ERROR: APK not found at $APK_SOURCE" + exit 1 +fi + +# --- Copy APK to OTA public directory --- +echo "Copying APK to OTA server..." +cp "$APK_SOURCE" "$APK_DEST" + +# --- Increment version in version.json --- +CURRENT_VERSION=$(python3 -c "import json; print(json.load(open('$VERSION_FILE'))['version'])") +NEW_VERSION=$((CURRENT_VERSION + 1)) + +python3 -c " +import json + +data = { + 'version': $NEW_VERSION, + 'downloadUrl': 'http://192.168.86.172:9091/mirror-os-remote.apk', + 'changelog': '''$CHANGELOG''' +} + +with open('$VERSION_FILE', 'w') as f: + json.dump(data, f, indent=2) + f.write('\n') +" + +echo "" +echo "Published version $NEW_VERSION" +echo " Changelog: $CHANGELOG" +echo " APK size: $(du -h "$APK_DEST" | cut -f1)" +echo "" +echo "--- Deploy instructions ---" +echo "" +echo "If running locally:" +echo " cd deploy/ota-server && docker compose up -d" +echo "" +echo "If deploying to homelab (192.168.86.172):" +echo " rsync -avz deploy/ota-server/ homelab:~/ota-server/" +echo " ssh homelab 'cd ~/ota-server && docker compose up -d'" diff --git a/melos.yaml b/melos.yaml new file mode 100644 index 0000000..6222745 --- /dev/null +++ b/melos.yaml @@ -0,0 +1,19 @@ +name: mirror_os + +packages: + - apps/* + - packages/* + +command: + bootstrap: + usePubspecOverrides: false + +scripts: + analyze: + exec: dart analyze . + format: + exec: dart format . + gen: + exec: dart run build_runner build --delete-conflicting-outputs + packageFilters: + dependsOn: build_runner diff --git a/packages/mirror_core/lib/mirror_core.dart b/packages/mirror_core/lib/mirror_core.dart new file mode 100644 index 0000000..83c0499 --- /dev/null +++ b/packages/mirror_core/lib/mirror_core.dart @@ -0,0 +1,9 @@ +library mirror_core; + +export 'models/program.dart'; +export 'services/home_assistant_service.dart'; +export 'services/plex_service.dart'; +export 'services/programs_api_client.dart'; +export 'services/spotify_service.dart'; +export 'services/workout_api_client.dart'; +export 'services/youtube_service.dart'; diff --git a/packages/mirror_core/lib/models/program.dart b/packages/mirror_core/lib/models/program.dart new file mode 100644 index 0000000..4d51e04 --- /dev/null +++ b/packages/mirror_core/lib/models/program.dart @@ -0,0 +1,153 @@ +class ProgramExercise { + final String id; + final String workoutId; + final int position; + final String type; // 'video' | 'youtube' | 'instruction' + final String? videoPath; + final String? videoId; + final String? youtubeUrl; + final String? instructionTitle; + final String? instructionText; + final int? durationSeconds; + final int restSeconds; + final int sets; + final int? setDurationSeconds; + + ProgramExercise({ + required this.id, + required this.workoutId, + required this.position, + required this.type, + this.videoPath, + this.videoId, + this.youtubeUrl, + this.instructionTitle, + this.instructionText, + this.durationSeconds, + required this.restSeconds, + required this.sets, + this.setDurationSeconds, + }); + + factory ProgramExercise.fromJson(Map json) { + return ProgramExercise( + id: json['id'] as String, + workoutId: json['workoutId'] as String, + position: (json['position'] as num).toInt(), + type: json['type'] as String, + videoPath: json['videoPath'] as String?, + videoId: json['videoId'] as String?, + youtubeUrl: json['youtubeUrl'] as String?, + instructionTitle: json['instructionTitle'] as String?, + instructionText: json['instructionText'] as String?, + durationSeconds: (json['durationSeconds'] as num?)?.toInt(), + restSeconds: (json['restSeconds'] as num?)?.toInt() ?? 0, + sets: (json['sets'] as num?)?.toInt() ?? 1, + setDurationSeconds: (json['setDurationSeconds'] as num?)?.toInt(), + ); + } + + Map toJson() => { + 'id': id, + 'workoutId': workoutId, + 'position': position, + 'type': type, + if (videoPath != null) 'videoPath': videoPath, + if (videoId != null) 'videoId': videoId, + if (youtubeUrl != null) 'youtubeUrl': youtubeUrl, + if (instructionTitle != null) 'instructionTitle': instructionTitle, + if (instructionText != null) 'instructionText': instructionText, + if (durationSeconds != null) 'durationSeconds': durationSeconds, + 'restSeconds': restSeconds, + 'sets': sets, + if (setDurationSeconds != null) 'setDurationSeconds': setDurationSeconds, + }; +} + +class ProgramWorkout { + final String id; + final String programId; + final String name; + final int position; + final DateTime? completedAt; + final List exercises; + + ProgramWorkout({ + required this.id, + required this.programId, + required this.name, + required this.position, + this.completedAt, + required this.exercises, + }); + + factory ProgramWorkout.fromJson(Map json) { + return ProgramWorkout( + id: json['id'] as String, + programId: json['programId'] as String, + name: json['name'] as String, + position: (json['position'] as num).toInt(), + completedAt: json['completedAt'] != null + ? DateTime.parse(json['completedAt'] as String) + : null, + exercises: (json['exercises'] as List?) + ?.map((e) => ProgramExercise.fromJson(e as Map)) + .toList() ?? + [], + ); + } + + Map toJson() => { + 'id': id, + 'programId': programId, + 'name': name, + 'position': position, + 'completedAt': completedAt?.toIso8601String(), + 'exercises': exercises.map((e) => e.toJson()).toList(), + }; +} + +class Program { + final String id; + final String name; + final String? description; + final int currentWorkoutIndex; + final DateTime createdAt; + final DateTime updatedAt; + final List workouts; + + Program({ + required this.id, + required this.name, + this.description, + required this.currentWorkoutIndex, + required this.createdAt, + required this.updatedAt, + required this.workouts, + }); + + factory Program.fromJson(Map json) { + return Program( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String?, + currentWorkoutIndex: (json['currentWorkoutIndex'] as num?)?.toInt() ?? 0, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + workouts: (json['workouts'] as List?) + ?.map((e) => ProgramWorkout.fromJson(e as Map)) + .toList() ?? + [], + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + if (description != null) 'description': description, + 'currentWorkoutIndex': currentWorkoutIndex, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'workouts': workouts.map((w) => w.toJson()).toList(), + }; +} diff --git a/packages/mirror_core/lib/services/home_assistant_service.dart b/packages/mirror_core/lib/services/home_assistant_service.dart new file mode 100644 index 0000000..077ef91 --- /dev/null +++ b/packages/mirror_core/lib/services/home_assistant_service.dart @@ -0,0 +1,243 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +class HaEntity { + final String entityId; + final String state; + final Map attributes; + final DateTime? lastChanged; + + HaEntity({ + required this.entityId, + required this.state, + required this.attributes, + this.lastChanged, + }); + + factory HaEntity.fromJson(Map json) => HaEntity( + entityId: json['entity_id'] as String, + state: json['state'] as String? ?? '', + attributes: + (json['attributes'] as Map?) ?? const {}, + lastChanged: json['last_changed'] != null + ? DateTime.tryParse(json['last_changed'] as String) + : null, + ); + + Map toJson() => { + 'entity_id': entityId, + 'state': state, + 'attributes': attributes, + 'last_changed': lastChanged?.toIso8601String(), + }; +} + +class HaForecast { + final DateTime datetime; + final double temperature; + final String condition; + + HaForecast({ + required this.datetime, + required this.temperature, + required this.condition, + }); + + factory HaForecast.fromJson(Map json) => HaForecast( + datetime: DateTime.parse(json['datetime'] as String), + temperature: (json['temperature'] as num).toDouble(), + condition: json['condition'] as String? ?? '', + ); + + Map toJson() => { + 'datetime': datetime.toIso8601String(), + 'temperature': temperature, + 'condition': condition, + }; +} + +class HaWeather { + final double temperature; + final double humidity; + final String condition; + final List forecast; + + HaWeather({ + required this.temperature, + required this.humidity, + required this.condition, + required this.forecast, + }); + + factory HaWeather.fromEntity(HaEntity entity) { + final attrs = entity.attributes; + final rawForecast = attrs['forecast'] as List? ?? []; + return HaWeather( + temperature: (attrs['temperature'] as num?)?.toDouble() ?? 0, + humidity: (attrs['humidity'] as num?)?.toDouble() ?? 0, + condition: entity.state, + forecast: rawForecast + .map((e) => HaForecast.fromJson(e as Map)) + .toList(), + ); + } + + Map toJson() => { + 'temperature': temperature, + 'humidity': humidity, + 'condition': condition, + 'forecast': forecast.map((f) => f.toJson()).toList(), + }; +} + +class HaCalendarEvent { + final String summary; + final DateTime start; + final DateTime end; + final String? description; + + HaCalendarEvent({ + required this.summary, + required this.start, + required this.end, + this.description, + }); + + factory HaCalendarEvent.fromJson(Map json) => + HaCalendarEvent( + summary: json['summary'] as String? ?? '', + start: _parseDateTime(json['start']), + end: _parseDateTime(json['end']), + description: json['description'] as String?, + ); + + static DateTime _parseDateTime(dynamic value) { + if (value is Map) { + // HA returns {dateTime: "..."} or {date: "..."} + final dt = value['dateTime'] as String? ?? value['date'] as String? ?? ''; + return DateTime.parse(dt); + } + return DateTime.parse(value as String); + } + + Map toJson() => { + 'summary': summary, + 'start': start.toIso8601String(), + 'end': end.toIso8601String(), + if (description != null) 'description': description, + }; +} + +class HomeAssistantService { + final String baseUrl; + final String token; + final http.Client _client; + + HomeAssistantService({ + required this.baseUrl, + required this.token, + http.Client? client, + }) : _client = client ?? http.Client(); + + Map get _headers => { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }; + + /// GET /api/states — all entity states. + Future> getStates() async { + final response = await _client.get( + Uri.parse('$baseUrl/api/states'), + headers: _headers, + ); + if (response.statusCode != 200) { + throw Exception('Failed to fetch states: ${response.statusCode}'); + } + final data = jsonDecode(response.body) as List; + return data + .map((e) => HaEntity.fromJson(e as Map)) + .toList(); + } + + /// GET /api/states/{entity_id} — single entity state. + Future getState(String entityId) async { + final response = await _client.get( + Uri.parse('$baseUrl/api/states/$entityId'), + headers: _headers, + ); + if (response.statusCode == 404) return null; + if (response.statusCode != 200) { + throw Exception('Failed to fetch state: ${response.statusCode}'); + } + final data = jsonDecode(response.body) as Map; + return HaEntity.fromJson(data); + } + + /// POST /api/services/{domain}/{service} — call a service. + Future callService( + String domain, + String service, { + String? entityId, + Map? data, + }) async { + final body = { + if (entityId != null) 'entity_id': entityId, + if (data != null) ...data, + }; + + final response = await _client.post( + Uri.parse('$baseUrl/api/services/$domain/$service'), + headers: _headers, + body: jsonEncode(body), + ); + return response.statusCode == 200; + } + + /// Parse a weather entity into structured weather data. + Future getWeather(String entityId) async { + final entity = await getState(entityId); + if (entity == null) return null; + return HaWeather.fromEntity(entity); + } + + /// GET /api/calendars/{entity_id}?start=...&end=... + Future> getCalendarEvents( + String entityId, { + DateTime? start, + DateTime? end, + }) async { + final now = DateTime.now(); + final queryStart = start ?? now; + final queryEnd = end ?? now.add(const Duration(days: 7)); + + final uri = + Uri.parse('$baseUrl/api/calendars/$entityId').replace(queryParameters: { + 'start': queryStart.toIso8601String(), + 'end': queryEnd.toIso8601String(), + }); + + final response = await _client.get(uri, headers: _headers); + if (response.statusCode != 200) { + throw Exception( + 'Failed to fetch calendar events: ${response.statusCode}'); + } + final data = jsonDecode(response.body) as List; + return data + .map((e) => HaCalendarEvent.fromJson(e as Map)) + .toList(); + } + + /// Convenience: turn on an entity. + Future turnOn(String entityId) async { + final domain = entityId.split('.').first; + return callService(domain, 'turn_on', entityId: entityId); + } + + /// Convenience: turn off an entity. + Future turnOff(String entityId) async { + final domain = entityId.split('.').first; + return callService(domain, 'turn_off', entityId: entityId); + } + + void dispose() => _client.close(); +} diff --git a/packages/mirror_core/lib/services/plex_service.dart b/packages/mirror_core/lib/services/plex_service.dart new file mode 100644 index 0000000..735cdc9 --- /dev/null +++ b/packages/mirror_core/lib/services/plex_service.dart @@ -0,0 +1,178 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class PlexService { + final String host; + final int port; + final String token; + final http.Client _client; + + PlexService({ + required this.host, + required this.port, + required this.token, + http.Client? client, + }) : _client = client ?? http.Client(); + + Uri _uri(String path, [Map? queryParameters]) { + return Uri( + scheme: 'http', + host: host, + port: port, + path: path, + queryParameters: queryParameters, + ); + } + + Map get _headers => { + 'X-Plex-Token': token, + 'Accept': 'application/json', + }; + + Future> fetchLibraries() async { + final response = await _client.get(_uri('/library/sections'), headers: _headers); + if (response.statusCode != 200) { + throw PlexException('Failed to fetch libraries: ${response.statusCode}'); + } + final json = jsonDecode(response.body) as Map; + final container = json['MediaContainer'] as Map; + final directories = (container['Directory'] as List?) ?? []; + return directories + .map((d) => PlexLibrary.fromJson(d as Map)) + .toList(); + } + + Future> fetchItems(String libraryId) async { + final response = await _client.get( + _uri('/library/sections/$libraryId/all'), + headers: _headers, + ); + if (response.statusCode != 200) { + throw PlexException('Failed to fetch items: ${response.statusCode}'); + } + final json = jsonDecode(response.body) as Map; + final container = json['MediaContainer'] as Map; + final metadata = (container['Metadata'] as List?) ?? []; + return metadata + .map((m) => PlexItem.fromJson(m as Map)) + .toList(); + } + + Future> fetchChildren(String itemId) async { + final response = await _client.get( + _uri('/library/metadata/$itemId/children'), + headers: _headers, + ); + if (response.statusCode != 200) { + throw PlexException('Failed to fetch children: ${response.statusCode}'); + } + final json = jsonDecode(response.body) as Map; + final container = json['MediaContainer'] as Map; + final metadata = (container['Metadata'] as List?) ?? []; + return metadata + .map((m) => PlexItem.fromJson(m as Map)) + .toList(); + } + + String getStreamUrl(String partKey) { + return _uri(partKey, {'X-Plex-Token': token}).toString(); + } + + String getThumbnailUrl(String thumbPath, {int width = 200, int height = 200}) { + return _uri('/photo/:/transcode', { + 'url': thumbPath, + 'width': width.toString(), + 'height': height.toString(), + 'X-Plex-Token': token, + }).toString(); + } + + void dispose() => _client.close(); +} + +class PlexLibrary { + final String id; + final String title; + final String type; + + const PlexLibrary({ + required this.id, + required this.title, + required this.type, + }); + + factory PlexLibrary.fromJson(Map json) { + return PlexLibrary( + id: json['key'] as String, + title: json['title'] as String, + type: json['type'] as String, + ); + } + + Map toJson() => { + 'key': id, + 'title': title, + 'type': type, + }; +} + +class PlexItem { + final String id; + final String title; + final String type; + final int? year; + final int? duration; + final String? thumbPath; + final String? partKey; + + const PlexItem({ + required this.id, + required this.title, + required this.type, + this.year, + this.duration, + this.thumbPath, + this.partKey, + }); + + factory PlexItem.fromJson(Map json) { + // Extract partKey from nested Media → Part structure + String? partKey; + final media = json['Media'] as List?; + if (media != null && media.isNotEmpty) { + final parts = (media[0] as Map)['Part'] as List?; + if (parts != null && parts.isNotEmpty) { + partKey = (parts[0] as Map)['key'] as String?; + } + } + + return PlexItem( + id: json['ratingKey'] as String, + title: json['title'] as String, + type: json['type'] as String, + year: json['year'] as int?, + duration: json['duration'] as int?, + thumbPath: json['thumb'] as String?, + partKey: partKey, + ); + } + + Map toJson() => { + 'ratingKey': id, + 'title': title, + 'type': type, + if (year != null) 'year': year, + if (duration != null) 'duration': duration, + if (thumbPath != null) 'thumb': thumbPath, + if (partKey != null) 'partKey': partKey, + }; +} + +class PlexException implements Exception { + final String message; + const PlexException(this.message); + + @override + String toString() => 'PlexException: $message'; +} diff --git a/packages/mirror_core/lib/services/programs_api_client.dart b/packages/mirror_core/lib/services/programs_api_client.dart new file mode 100644 index 0000000..1af2514 --- /dev/null +++ b/packages/mirror_core/lib/services/programs_api_client.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/program.dart'; + +class ProgramsApiClient { + final String baseUrl; + final http.Client _client; + + ProgramsApiClient({ + required this.baseUrl, + http.Client? client, + }) : _client = client ?? http.Client(); + + Future> fetchPrograms() async { + final response = await _client.get(Uri.parse('$baseUrl/api/programs')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch programs: ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map; + return (data['programs'] as List?) + ?.map((e) => Program.fromJson(e as Map)) + .toList() ?? + []; + } + + Future markWorkoutCompleted(String programId, String workoutId) async { + final response = await _client.patch( + Uri.parse('$baseUrl/api/programs'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'id': programId, + 'markWorkoutCompleted': workoutId, + }), + ); + + if (response.statusCode != 200) { + throw Exception( + 'Failed to mark workout completed: ${response.statusCode}'); + } + } + + Future advanceWorkout(String programId) async { + final response = await _client.patch( + Uri.parse('$baseUrl/api/programs'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'id': programId, + 'advanceWorkout': true, + }), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to advance workout: ${response.statusCode}'); + } + } + + void dispose() => _client.close(); +} diff --git a/packages/mirror_core/lib/services/spotify_service.dart b/packages/mirror_core/lib/services/spotify_service.dart new file mode 100644 index 0000000..5dff920 --- /dev/null +++ b/packages/mirror_core/lib/services/spotify_service.dart @@ -0,0 +1,319 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +// --- Model Classes --- + +class SpotifyPlaybackState { + final bool isPlaying; + final String trackName; + final String artistName; + final String? albumArt; + final int progressMs; + final int durationMs; + final String? deviceName; + + SpotifyPlaybackState({ + required this.isPlaying, + required this.trackName, + required this.artistName, + this.albumArt, + required this.progressMs, + required this.durationMs, + this.deviceName, + }); + + factory SpotifyPlaybackState.fromJson(Map json) { + final item = json['item'] as Map?; + final artists = (item?['artists'] as List?) + ?.map((a) => a['name'] as String) + .join(', ') ?? + ''; + final images = item?['album']?['images'] as List?; + final device = json['device'] as Map?; + + return SpotifyPlaybackState( + isPlaying: json['is_playing'] as bool? ?? false, + trackName: item?['name'] as String? ?? '', + artistName: artists, + albumArt: (images != null && images.isNotEmpty) + ? images.first['url'] as String? + : null, + progressMs: json['progress_ms'] as int? ?? 0, + durationMs: item?['duration_ms'] as int? ?? 0, + deviceName: device?['name'] as String?, + ); + } + + Map toJson() => { + 'isPlaying': isPlaying, + 'trackName': trackName, + 'artistName': artistName, + 'albumArt': albumArt, + 'progressMs': progressMs, + 'durationMs': durationMs, + 'deviceName': deviceName, + }; +} + +class SpotifyPlaylist { + final String id; + final String name; + final String? imageUrl; + final int trackCount; + + SpotifyPlaylist({ + required this.id, + required this.name, + this.imageUrl, + required this.trackCount, + }); + + factory SpotifyPlaylist.fromJson(Map json) { + final images = json['images'] as List?; + return SpotifyPlaylist( + id: json['id'] as String, + name: json['name'] as String, + imageUrl: (images != null && images.isNotEmpty) + ? images.first['url'] as String? + : null, + trackCount: (json['tracks'] as Map?)?['total'] as int? ?? 0, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'imageUrl': imageUrl, + 'trackCount': trackCount, + }; +} + +class SpotifyTrack { + final String id; + final String name; + final String artist; + final String album; + final int durationMs; + final String? imageUrl; + + SpotifyTrack({ + required this.id, + required this.name, + required this.artist, + required this.album, + required this.durationMs, + this.imageUrl, + }); + + factory SpotifyTrack.fromJson(Map json) { + // Playlist track items are wrapped in a 'track' key + final track = json.containsKey('track') + ? json['track'] as Map + : json; + final artists = (track['artists'] as List?) + ?.map((a) => a['name'] as String) + .join(', ') ?? + ''; + final albumData = track['album'] as Map?; + final images = albumData?['images'] as List?; + + return SpotifyTrack( + id: track['id'] as String? ?? '', + name: track['name'] as String? ?? '', + artist: artists, + album: albumData?['name'] as String? ?? '', + durationMs: track['duration_ms'] as int? ?? 0, + imageUrl: (images != null && images.isNotEmpty) + ? images.first['url'] as String? + : null, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'artist': artist, + 'album': album, + 'durationMs': durationMs, + 'imageUrl': imageUrl, + }; +} + +class SpotifyDevice { + final String id; + final String name; + final bool isActive; + + SpotifyDevice({ + required this.id, + required this.name, + required this.isActive, + }); + + factory SpotifyDevice.fromJson(Map json) { + return SpotifyDevice( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + isActive: json['is_active'] as bool? ?? false, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'isActive': isActive, + }; +} + +// --- Service --- + +class SpotifyService { + static const _baseUrl = 'https://api.spotify.com'; + + String? _accessToken; + final http.Client _client; + + SpotifyService({String? accessToken, http.Client? client}) + : _accessToken = accessToken, + _client = client ?? http.Client(); + + void setToken(String token) { + _accessToken = token; + } + + Map get _headers => { + 'Authorization': 'Bearer $_accessToken', + 'Content-Type': 'application/json', + }; + + // --- Playback --- + + Future getCurrentPlayback() async { + final response = await _client.get( + Uri.parse('$_baseUrl/v1/me/player'), + headers: _headers, + ); + if (response.statusCode == 204 || response.body.isEmpty) { + return null; // Nothing playing + } + if (response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + return SpotifyPlaybackState.fromJson( + json.decode(response.body) as Map, + ); + } + + Future play({String? deviceId}) async { + final uri = Uri.parse('$_baseUrl/v1/me/player/play').replace( + queryParameters: deviceId != null ? {'device_id': deviceId} : null, + ); + final response = await _client.put(uri, headers: _headers); + if (response.statusCode != 204 && response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + } + + Future pause() async { + final response = await _client.put( + Uri.parse('$_baseUrl/v1/me/player/pause'), + headers: _headers, + ); + if (response.statusCode != 204 && response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + } + + Future next() async { + final response = await _client.post( + Uri.parse('$_baseUrl/v1/me/player/next'), + headers: _headers, + ); + if (response.statusCode != 204 && response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + } + + Future previous() async { + final response = await _client.post( + Uri.parse('$_baseUrl/v1/me/player/previous'), + headers: _headers, + ); + if (response.statusCode != 204 && response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + } + + // --- Playlists --- + + Future> getPlaylists() async { + final response = await _client.get( + Uri.parse('$_baseUrl/v1/me/playlists?limit=50'), + headers: _headers, + ); + if (response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + final data = json.decode(response.body) as Map; + final items = data['items'] as List? ?? []; + return items + .map((e) => SpotifyPlaylist.fromJson(e as Map)) + .toList(); + } + + Future> getPlaylistTracks(String playlistId) async { + final response = await _client.get( + Uri.parse('$_baseUrl/v1/playlists/$playlistId/tracks?limit=100'), + headers: _headers, + ); + if (response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + final data = json.decode(response.body) as Map; + final items = data['items'] as List? ?? []; + return items + .map((e) => SpotifyTrack.fromJson(e as Map)) + .toList(); + } + + // --- Devices --- + + Future> getDevices() async { + final response = await _client.get( + Uri.parse('$_baseUrl/v1/me/player/devices'), + headers: _headers, + ); + if (response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + final data = json.decode(response.body) as Map; + final devices = data['devices'] as List? ?? []; + return devices + .map((e) => SpotifyDevice.fromJson(e as Map)) + .toList(); + } + + Future transferPlayback(String deviceId) async { + final response = await _client.put( + Uri.parse('$_baseUrl/v1/me/player'), + headers: _headers, + body: json.encode({ + 'device_ids': [deviceId], + 'play': true, + }), + ); + if (response.statusCode != 204 && response.statusCode != 200) { + throw SpotifyApiException(response.statusCode, response.body); + } + } +} + +class SpotifyApiException implements Exception { + final int statusCode; + final String body; + + SpotifyApiException(this.statusCode, this.body); + + @override + String toString() => 'SpotifyApiException($statusCode): $body'; +} diff --git a/packages/mirror_core/lib/services/workout_api_client.dart b/packages/mirror_core/lib/services/workout_api_client.dart new file mode 100644 index 0000000..2faff09 --- /dev/null +++ b/packages/mirror_core/lib/services/workout_api_client.dart @@ -0,0 +1,113 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:mirror_protocol/mirror_protocol.dart'; + +class WorkoutApiClient { + final String baseUrl; + final http.Client _client; + + WorkoutApiClient({ + required this.baseUrl, + http.Client? client, + }) : _client = client ?? http.Client(); + + Future<({List videos, List folders})> fetchVideos({ + String? folder, + WorkoutFilter filter = WorkoutFilter.all, + }) async { + final uri = Uri.parse('$baseUrl/api/videos').replace( + queryParameters: { + if (folder != null && folder.isNotEmpty) 'folder': folder, + }, + ); + + final response = await _client.get(uri); + if (response.statusCode != 200) { + throw Exception('Failed to fetch videos: ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map; + final rawVideos = (data['videos'] as List?) + ?.map((e) => _parseVideo(e as Map)) + .toList() ?? + []; + final folders = (data['folders'] as List?) + ?.map((e) => e as String) + .toList() ?? + []; + + final videos = _applyFilter(rawVideos, filter); + return (videos: videos, folders: folders); + } + + Future fetchStats() async { + final response = await _client.get(Uri.parse('$baseUrl/api/stats')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch stats: ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map; + return WorkoutStats.fromJson(data); + } + + Future>> fetchRoutines() async { + final response = await _client.get(Uri.parse('$baseUrl/api/routines')); + if (response.statusCode != 200) { + throw Exception('Failed to fetch routines: ${response.statusCode}'); + } + + final data = jsonDecode(response.body) as Map; + return (data['routines'] as List?) + ?.map((e) => e as Map) + .toList() ?? + []; + } + + Future updateProgress({ + required String videoId, + required int position, + required int percent, + }) async { + final response = await _client.patch( + Uri.parse('$baseUrl/api/videos'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'videoId': videoId, + 'position': position, + 'percent': percent, + }), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to save progress: ${response.statusCode}'); + } + } + + String streamUrl(String videoPath) => + '$baseUrl/api/stream?path=${Uri.encodeComponent(videoPath)}'; + + VideoState _parseVideo(Map json) => VideoState( + id: json['id'] as String, + title: json['title'] as String? ?? json['name'] as String? ?? '', + path: json['path'] as String, + folder: json['folder'] as String?, + thumbnailPath: json['thumbnailPath'] as String?, + duration: (json['duration'] as num?)?.toInt() ?? 0, + trimStart: (json['trimStart'] as num?)?.toInt() ?? 0, + trimEnd: (json['trimEnd'] as num?)?.toInt() ?? 0, + lastPosition: (json['lastPosition'] as num?)?.toInt() ?? 0, + percentCompleted: (json['percentCompleted'] as num?)?.toInt() ?? 0, + restSeconds: (json['restSeconds'] as num?)?.toInt() ?? 0, + ); + + List _applyFilter(List videos, WorkoutFilter filter) => + switch (filter) { + WorkoutFilter.all => videos, + WorkoutFilter.unwatched => videos.where((v) => v.percentCompleted == 0).toList(), + WorkoutFilter.inProgress => + videos.where((v) => v.percentCompleted > 0 && v.percentCompleted < 95).toList(), + WorkoutFilter.completed => videos.where((v) => v.percentCompleted >= 95).toList(), + }; + + void dispose() => _client.close(); +} diff --git a/packages/mirror_core/lib/services/youtube_service.dart b/packages/mirror_core/lib/services/youtube_service.dart new file mode 100644 index 0000000..dc9c6c0 --- /dev/null +++ b/packages/mirror_core/lib/services/youtube_service.dart @@ -0,0 +1,67 @@ +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +class YouTubeService { + final YoutubeExplode _yt; + + YouTubeService() : _yt = YoutubeExplode(); + + Future getVideo(String urlOrId) async { + final video = await _yt.videos.get(urlOrId); + return YouTubeVideo( + id: video.id.value, + title: video.title, + author: video.author, + duration: video.duration ?? Duration.zero, + thumbnailUrl: video.thumbnails.highResUrl, + ); + } + + Future getStreamUrl(String urlOrId) async { + final manifest = await _yt.videos.streamsClient.getManifest(urlOrId); + // Prefer muxed stream (video + audio) at highest quality available + final muxed = manifest.muxed.sortByVideoQuality(); + if (muxed.isNotEmpty) { + return muxed.first.url.toString(); + } + // Fallback to audio-only for music + final audio = manifest.audioOnly.sortByBitrate(); + if (audio.isNotEmpty) { + return audio.first.url.toString(); + } + throw Exception('No playable streams found for $urlOrId'); + } + + Future> search(String query, {int limit = 10}) async { + final results = await _yt.search.search(query); + return results.take(limit).map((item) { + if (item is SearchVideo) { + return YouTubeVideo( + id: item.id.value, + title: item.title, + author: item.author, + duration: item.duration ?? Duration.zero, + thumbnailUrl: item.thumbnails.highResUrl, + ); + } + return null; + }).whereType().toList(); + } + + void dispose() => _yt.close(); +} + +class YouTubeVideo { + final String id; + final String title; + final String author; + final Duration duration; + final String? thumbnailUrl; + + const YouTubeVideo({ + required this.id, + required this.title, + required this.author, + required this.duration, + this.thumbnailUrl, + }); +} diff --git a/packages/mirror_core/pubspec.yaml b/packages/mirror_core/pubspec.yaml new file mode 100644 index 0000000..0194698 --- /dev/null +++ b/packages/mirror_core/pubspec.yaml @@ -0,0 +1,13 @@ +name: mirror_core +description: Shared business logic, models, and API clients for Mirror OS +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.5.0 + +dependencies: + mirror_protocol: any + http: ^1.2.0 + youtube_explode_dart: ^2.2.0 diff --git a/packages/mirror_protocol/lib/mirror_protocol.dart b/packages/mirror_protocol/lib/mirror_protocol.dart new file mode 100644 index 0000000..f699d77 --- /dev/null +++ b/packages/mirror_protocol/lib/mirror_protocol.dart @@ -0,0 +1,6 @@ +library mirror_protocol; + +export 'src/messages.dart'; +export 'src/commands.dart'; +export 'src/state.dart'; +export 'src/dashboard_config.dart'; diff --git a/packages/mirror_protocol/lib/src/commands.dart b/packages/mirror_protocol/lib/src/commands.dart new file mode 100644 index 0000000..1bae4f1 --- /dev/null +++ b/packages/mirror_protocol/lib/src/commands.dart @@ -0,0 +1,124 @@ +enum ScreenType { + dashboard, + workout, + media, + standby; + + static ScreenType fromJson(String value) => + ScreenType.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum PlaybackAction { + play, + pause, + resume, + seek, + skip, + previous, + stop, + setVolume; + + static PlaybackAction fromJson(String value) => + PlaybackAction.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum WorkoutAction { + browse, + setFilter, + playVideo, + playFolder, + pauseWorkout, + resumeWorkout, + skipVideo, + skipRest, + previousVideo, + stopSession; + + static WorkoutAction fromJson(String value) => + WorkoutAction.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum MediaSource { + workout, + youtube, + plex, + local; + + static MediaSource fromJson(String value) => + MediaSource.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum WorkoutPhase { + idle, + browsing, + playing, + resting, + completed; + + static WorkoutPhase fromJson(String value) => + WorkoutPhase.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum WorkoutFilter { + all, + unwatched, + inProgress, + completed; + + static WorkoutFilter fromJson(String value) => + WorkoutFilter.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum ProgramAction { + browse, + playWorkout, + skipExercise, + previousExercise, + pauseExercise, + resumeExercise, + stopProgram; + + static ProgramAction fromJson(String value) => + ProgramAction.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum MediaAction { + searchYouTube, + playYouTube, + browsePlex, + playPlex, + spotifyPlay, + spotifyPause, + spotifyNext, + spotifyPrevious, + spotifyTransfer; + + static MediaAction fromJson(String value) => + MediaAction.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} + +enum DashboardAction { + refresh, + toggleWidget; + + static DashboardAction fromJson(String value) => + DashboardAction.values.firstWhere((e) => e.name == value); + + String toJson() => name; +} diff --git a/packages/mirror_protocol/lib/src/dashboard_config.dart b/packages/mirror_protocol/lib/src/dashboard_config.dart new file mode 100644 index 0000000..53e11e9 --- /dev/null +++ b/packages/mirror_protocol/lib/src/dashboard_config.dart @@ -0,0 +1,74 @@ +class DashboardWidgetConfig { + final String id; + final String type; + final bool visible; + final int position; + final Map config; + + const DashboardWidgetConfig({ + required this.id, + required this.type, + this.visible = true, + required this.position, + this.config = const {}, + }); + + DashboardWidgetConfig copyWith({ + String? id, + String? type, + bool? visible, + int? position, + Map? config, + }) => + DashboardWidgetConfig( + id: id ?? this.id, + type: type ?? this.type, + visible: visible ?? this.visible, + position: position ?? this.position, + config: config ?? this.config, + ); + + factory DashboardWidgetConfig.fromJson(Map json) => + DashboardWidgetConfig( + id: json['id'] as String, + type: json['type'] as String, + visible: json['visible'] as bool? ?? true, + position: json['position'] as int, + config: (json['config'] as Map?) ?? {}, + ); + + Map toJson() => { + 'id': id, + 'type': type, + 'visible': visible, + 'position': position, + 'config': config, + }; +} + +class DashboardLayoutConfig { + final List widgets; + + const DashboardLayoutConfig({required this.widgets}); + + factory DashboardLayoutConfig.defaultLayout() => DashboardLayoutConfig( + widgets: [ + DashboardWidgetConfig(id: 'clock', type: 'clock', position: 0), + DashboardWidgetConfig(id: 'weather', type: 'weather', position: 1), + DashboardWidgetConfig(id: 'calendar', type: 'calendar', position: 2), + DashboardWidgetConfig( + id: 'ha_entities', type: 'ha_entities', position: 3), + ], + ); + + factory DashboardLayoutConfig.fromJson(Map json) => + DashboardLayoutConfig( + widgets: (json['widgets'] as List) + .map((e) => DashboardWidgetConfig.fromJson(e as Map)) + .toList(), + ); + + Map toJson() => { + 'widgets': widgets.map((e) => e.toJson()).toList(), + }; +} diff --git a/packages/mirror_protocol/lib/src/messages.dart b/packages/mirror_protocol/lib/src/messages.dart new file mode 100644 index 0000000..87fa239 --- /dev/null +++ b/packages/mirror_protocol/lib/src/messages.dart @@ -0,0 +1,233 @@ +import 'dart:convert'; +import 'commands.dart'; +import 'dashboard_config.dart'; +import 'state.dart'; + +sealed class MirrorMessage { + String get type; + Map toJson(); + + String encode() => jsonEncode(toJson()); + + static MirrorMessage decode(String raw) { + final json = jsonDecode(raw) as Map; + return MirrorMessage.fromJson(json); + } + + static MirrorMessage fromJson(Map json) { + final type = json['type'] as String; + return switch (type) { + 'state_sync' => StateSync.fromJson(json), + 'state_patch' => StatePatch.fromJson(json), + 'navigate' => NavigateCommand.fromJson(json), + 'playback' => PlaybackCommand.fromJson(json), + 'workout' => WorkoutCommand.fromJson(json), + 'program' => ProgramCommand.fromJson(json), + 'media' => MediaCommand.fromJson(json), + 'event' => EventMessage.fromJson(json), + 'register' => RegisterMessage.fromJson(json), + 'dashboard_config_request' => DashboardConfigRequest.fromJson(json), + 'dashboard_config_update' => DashboardConfigUpdate.fromJson(json), + 'dashboard_config_response' => DashboardConfigResponse.fromJson(json), + _ => throw FormatException('Unknown message type: $type'), + }; + } +} + +// --- Server → Client messages --- + +class StateSync extends MirrorMessage { + @override + String get type => 'state_sync'; + final DisplayState state; + + StateSync({required this.state}); + + static StateSync fromJson(Map json) => + StateSync(state: DisplayState.fromJson(json['state'] as Map)); + + @override + Map toJson() => {'type': type, 'state': state.toJson()}; +} + +class StatePatch extends MirrorMessage { + @override + String get type => 'state_patch'; + final Map patch; + + StatePatch({required this.patch}); + + static StatePatch fromJson(Map json) => + StatePatch(patch: json['patch'] as Map); + + @override + Map toJson() => {'type': type, 'patch': patch}; +} + +class EventMessage extends MirrorMessage { + @override + String get type => 'event'; + final String event; + final Map? data; + + EventMessage({required this.event, this.data}); + + static EventMessage fromJson(Map json) => EventMessage( + event: json['event'] as String, + data: json['data'] as Map?, + ); + + @override + Map toJson() => {'type': type, 'event': event, if (data != null) 'data': data}; +} + +// --- Client → Server messages --- + +class RegisterMessage extends MirrorMessage { + @override + String get type => 'register'; + final String role; + + RegisterMessage({required this.role}); + + static RegisterMessage fromJson(Map json) => + RegisterMessage(role: json['role'] as String); + + @override + Map toJson() => {'type': type, 'role': role}; +} + +class NavigateCommand extends MirrorMessage { + @override + String get type => 'navigate'; + final ScreenType target; + + NavigateCommand({required this.target}); + + static NavigateCommand fromJson(Map json) => + NavigateCommand(target: ScreenType.fromJson(json['target'] as String)); + + @override + Map toJson() => {'type': type, 'target': target.toJson()}; +} + +class PlaybackCommand extends MirrorMessage { + @override + String get type => 'playback'; + final PlaybackAction action; + final dynamic value; + + PlaybackCommand({required this.action, this.value}); + + static PlaybackCommand fromJson(Map json) => PlaybackCommand( + action: PlaybackAction.fromJson(json['action'] as String), + value: json['value'], + ); + + @override + Map toJson() => + {'type': type, 'action': action.toJson(), if (value != null) 'value': value}; +} + +class WorkoutCommand extends MirrorMessage { + @override + String get type => 'workout'; + final WorkoutAction action; + final Map? payload; + + WorkoutCommand({required this.action, this.payload}); + + static WorkoutCommand fromJson(Map json) => WorkoutCommand( + action: WorkoutAction.fromJson(json['action'] as String), + payload: json['payload'] as Map?, + ); + + @override + Map toJson() => + {'type': type, 'action': action.toJson(), if (payload != null) 'payload': payload}; +} + +class ProgramCommand extends MirrorMessage { + @override + String get type => 'program'; + final ProgramAction action; + final Map? payload; + + ProgramCommand({required this.action, this.payload}); + + static ProgramCommand fromJson(Map json) => ProgramCommand( + action: ProgramAction.fromJson(json['action'] as String), + payload: json['payload'] as Map?, + ); + + @override + Map toJson() => + {'type': type, 'action': action.toJson(), if (payload != null) 'payload': payload}; +} + +class MediaCommand extends MirrorMessage { + @override + String get type => 'media'; + final MediaAction action; + final Map? payload; + + MediaCommand({required this.action, this.payload}); + + static MediaCommand fromJson(Map json) => MediaCommand( + action: MediaAction.fromJson(json['action'] as String), + payload: json['payload'] as Map?, + ); + + @override + Map toJson() => + {'type': type, 'action': action.toJson(), if (payload != null) 'payload': payload}; +} + +// --- Dashboard config messages --- + +class DashboardConfigRequest extends MirrorMessage { + @override + String get type => 'dashboard_config_request'; + + DashboardConfigRequest(); + + static DashboardConfigRequest fromJson(Map json) => + DashboardConfigRequest(); + + @override + Map toJson() => {'type': type}; +} + +class DashboardConfigUpdate extends MirrorMessage { + @override + String get type => 'dashboard_config_update'; + final DashboardLayoutConfig layout; + + DashboardConfigUpdate({required this.layout}); + + static DashboardConfigUpdate fromJson(Map json) => + DashboardConfigUpdate( + layout: + DashboardLayoutConfig.fromJson(json['layout'] as Map), + ); + + @override + Map toJson() => {'type': type, 'layout': layout.toJson()}; +} + +class DashboardConfigResponse extends MirrorMessage { + @override + String get type => 'dashboard_config_response'; + final DashboardLayoutConfig layout; + + DashboardConfigResponse({required this.layout}); + + static DashboardConfigResponse fromJson(Map json) => + DashboardConfigResponse( + layout: + DashboardLayoutConfig.fromJson(json['layout'] as Map), + ); + + @override + Map toJson() => {'type': type, 'layout': layout.toJson()}; +} diff --git a/packages/mirror_protocol/lib/src/state.dart b/packages/mirror_protocol/lib/src/state.dart new file mode 100644 index 0000000..9e22875 --- /dev/null +++ b/packages/mirror_protocol/lib/src/state.dart @@ -0,0 +1,554 @@ +import 'commands.dart'; + +class DisplayState { + final ScreenType activeScreen; + final PlaybackState? playback; + final WorkoutSessionState? workout; + final ProgramSessionState? program; + final SpotifyState? spotify; + final DashboardState dashboard; + + const DisplayState({ + required this.activeScreen, + this.playback, + this.workout, + this.program, + this.spotify, + required this.dashboard, + }); + + DisplayState copyWith({ + ScreenType? activeScreen, + PlaybackState? playback, + bool clearPlayback = false, + WorkoutSessionState? workout, + bool clearWorkout = false, + ProgramSessionState? program, + bool clearProgram = false, + SpotifyState? spotify, + bool clearSpotify = false, + DashboardState? dashboard, + }) => + DisplayState( + activeScreen: activeScreen ?? this.activeScreen, + playback: clearPlayback ? null : (playback ?? this.playback), + workout: clearWorkout ? null : (workout ?? this.workout), + program: clearProgram ? null : (program ?? this.program), + spotify: clearSpotify ? null : (spotify ?? this.spotify), + dashboard: dashboard ?? this.dashboard, + ); + + factory DisplayState.initial() => DisplayState( + activeScreen: ScreenType.dashboard, + dashboard: DashboardState(), + ); + + factory DisplayState.fromJson(Map json) => DisplayState( + activeScreen: ScreenType.fromJson(json['activeScreen'] as String), + playback: json['playback'] != null + ? PlaybackState.fromJson(json['playback'] as Map) + : null, + workout: json['workout'] != null + ? WorkoutSessionState.fromJson(json['workout'] as Map) + : null, + program: json['program'] != null + ? ProgramSessionState.fromJson(json['program'] as Map) + : null, + spotify: json['spotify'] != null + ? SpotifyState.fromJson(json['spotify'] as Map) + : null, + dashboard: json['dashboard'] != null + ? DashboardState.fromJson(json['dashboard'] as Map) + : DashboardState(), + ); + + Map toJson() => { + 'activeScreen': activeScreen.toJson(), + if (playback != null) 'playback': playback!.toJson(), + if (workout != null) 'workout': workout!.toJson(), + if (program != null) 'program': program!.toJson(), + if (spotify != null) 'spotify': spotify!.toJson(), + 'dashboard': dashboard.toJson(), + }; +} + +class PlaybackState { + final String mediaId; + final String title; + final MediaSource source; + final Duration position; + final Duration duration; + final bool isPlaying; + final double volume; + + const PlaybackState({ + required this.mediaId, + required this.title, + required this.source, + required this.position, + required this.duration, + required this.isPlaying, + this.volume = 1.0, + }); + + PlaybackState copyWith({ + String? mediaId, + String? title, + MediaSource? source, + Duration? position, + Duration? duration, + bool? isPlaying, + double? volume, + }) => + PlaybackState( + mediaId: mediaId ?? this.mediaId, + title: title ?? this.title, + source: source ?? this.source, + position: position ?? this.position, + duration: duration ?? this.duration, + isPlaying: isPlaying ?? this.isPlaying, + volume: volume ?? this.volume, + ); + + factory PlaybackState.fromJson(Map json) => PlaybackState( + mediaId: json['mediaId'] as String, + title: json['title'] as String, + source: MediaSource.fromJson(json['source'] as String), + position: Duration(milliseconds: json['positionMs'] as int), + duration: Duration(milliseconds: json['durationMs'] as int), + isPlaying: json['isPlaying'] as bool, + volume: (json['volume'] as num?)?.toDouble() ?? 1.0, + ); + + Map toJson() => { + 'mediaId': mediaId, + 'title': title, + 'source': source.toJson(), + 'positionMs': position.inMilliseconds, + 'durationMs': duration.inMilliseconds, + 'isPlaying': isPlaying, + 'volume': volume, + }; +} + +class WorkoutSessionState { + final WorkoutPhase phase; + final List queue; + final int currentIndex; + final int restRemaining; + final String? routineName; + final String? currentPath; + final WorkoutFilter filter; + final List videos; + final List folders; + final WorkoutStats? stats; + + const WorkoutSessionState({ + required this.phase, + this.queue = const [], + this.currentIndex = 0, + this.restRemaining = 0, + this.routineName, + this.currentPath, + this.filter = WorkoutFilter.all, + this.videos = const [], + this.folders = const [], + this.stats, + }); + + WorkoutSessionState copyWith({ + WorkoutPhase? phase, + List? queue, + int? currentIndex, + int? restRemaining, + String? routineName, + String? currentPath, + bool clearCurrentPath = false, + WorkoutFilter? filter, + List? videos, + List? folders, + WorkoutStats? stats, + }) => + WorkoutSessionState( + phase: phase ?? this.phase, + queue: queue ?? this.queue, + currentIndex: currentIndex ?? this.currentIndex, + restRemaining: restRemaining ?? this.restRemaining, + routineName: routineName ?? this.routineName, + currentPath: clearCurrentPath ? null : (currentPath ?? this.currentPath), + filter: filter ?? this.filter, + videos: videos ?? this.videos, + folders: folders ?? this.folders, + stats: stats ?? this.stats, + ); + + factory WorkoutSessionState.fromJson(Map json) => WorkoutSessionState( + phase: WorkoutPhase.fromJson(json['phase'] as String), + queue: (json['queue'] as List?) + ?.map((e) => QueueItemState.fromJson(e as Map)) + .toList() ?? + [], + currentIndex: json['currentIndex'] as int? ?? 0, + restRemaining: json['restRemaining'] as int? ?? 0, + routineName: json['routineName'] as String?, + currentPath: json['currentPath'] as String?, + filter: json['filter'] != null + ? WorkoutFilter.fromJson(json['filter'] as String) + : WorkoutFilter.all, + videos: (json['videos'] as List?) + ?.map((e) => VideoState.fromJson(e as Map)) + .toList() ?? + [], + folders: + (json['folders'] as List?)?.map((e) => e as String).toList() ?? [], + stats: json['stats'] != null + ? WorkoutStats.fromJson(json['stats'] as Map) + : null, + ); + + Map toJson() => { + 'phase': phase.toJson(), + 'queue': queue.map((e) => e.toJson()).toList(), + 'currentIndex': currentIndex, + 'restRemaining': restRemaining, + if (routineName != null) 'routineName': routineName, + if (currentPath != null) 'currentPath': currentPath, + 'filter': filter.toJson(), + 'videos': videos.map((e) => e.toJson()).toList(), + 'folders': folders, + if (stats != null) 'stats': stats!.toJson(), + }; +} + +class QueueItemState { + final String videoId; + final String title; + final String path; + final int duration; + final int trimStart; + final int trimEnd; + final int restSeconds; + + const QueueItemState({ + required this.videoId, + required this.title, + required this.path, + this.duration = 0, + this.trimStart = 0, + this.trimEnd = 0, + this.restSeconds = 0, + }); + + factory QueueItemState.fromJson(Map json) => QueueItemState( + videoId: json['videoId'] as String, + title: json['title'] as String, + path: json['path'] as String, + duration: json['duration'] as int? ?? 0, + trimStart: json['trimStart'] as int? ?? 0, + trimEnd: json['trimEnd'] as int? ?? 0, + restSeconds: json['restSeconds'] as int? ?? 0, + ); + + Map toJson() => { + 'videoId': videoId, + 'title': title, + 'path': path, + 'duration': duration, + 'trimStart': trimStart, + 'trimEnd': trimEnd, + 'restSeconds': restSeconds, + }; +} + +class VideoState { + final String id; + final String title; + final String path; + final String? folder; + final String? thumbnailPath; + final int duration; + final int trimStart; + final int trimEnd; + final int lastPosition; + final int percentCompleted; + final int restSeconds; + + const VideoState({ + required this.id, + required this.title, + required this.path, + this.folder, + this.thumbnailPath, + this.duration = 0, + this.trimStart = 0, + this.trimEnd = 0, + this.lastPosition = 0, + this.percentCompleted = 0, + this.restSeconds = 0, + }); + + factory VideoState.fromJson(Map json) => VideoState( + id: json['id'] as String, + title: json['title'] as String, + path: json['path'] as String, + folder: json['folder'] as String?, + thumbnailPath: json['thumbnailPath'] as String?, + duration: json['duration'] as int? ?? 0, + trimStart: json['trimStart'] as int? ?? 0, + trimEnd: json['trimEnd'] as int? ?? 0, + lastPosition: json['lastPosition'] as int? ?? 0, + percentCompleted: json['percentCompleted'] as int? ?? 0, + restSeconds: json['restSeconds'] as int? ?? 0, + ); + + Map toJson() => { + 'id': id, + 'title': title, + 'path': path, + if (folder != null) 'folder': folder, + if (thumbnailPath != null) 'thumbnailPath': thumbnailPath, + 'duration': duration, + 'trimStart': trimStart, + 'trimEnd': trimEnd, + 'lastPosition': lastPosition, + 'percentCompleted': percentCompleted, + 'restSeconds': restSeconds, + }; +} + +class WorkoutStats { + final int total; + final int completed; + final int inProgress; + + const WorkoutStats({ + required this.total, + required this.completed, + required this.inProgress, + }); + + factory WorkoutStats.fromJson(Map json) => WorkoutStats( + total: json['total'] as int, + completed: json['completed'] as int, + inProgress: json['inProgress'] as int, + ); + + Map toJson() => { + 'total': total, + 'completed': completed, + 'inProgress': inProgress, + }; +} + +class DashboardState { + final List widgets; + final WeatherInfo? weather; + final List calendarEvents; + final List> haEntities; + + DashboardState({ + this.widgets = const ['clock', 'weather', 'calendar'], + this.weather, + this.calendarEvents = const [], + this.haEntities = const [], + }); + + factory DashboardState.fromJson(Map json) => DashboardState( + widgets: (json['widgets'] as List?)?.map((e) => e as String).toList() ?? + ['clock', 'weather', 'calendar'], + weather: json['weather'] != null + ? WeatherInfo.fromJson(json['weather'] as Map) + : null, + calendarEvents: (json['calendarEvents'] as List?) + ?.map((e) => CalendarEvent.fromJson(e as Map)) + .toList() ?? + [], + haEntities: (json['haEntities'] as List?) + ?.map((e) => e as Map) + .toList() ?? + [], + ); + + Map toJson() => { + 'widgets': widgets, + if (weather != null) 'weather': weather!.toJson(), + 'calendarEvents': calendarEvents.map((e) => e.toJson()).toList(), + 'haEntities': haEntities, + }; +} + +class WeatherInfo { + final double temp; + final String condition; + final int? humidity; + + const WeatherInfo({ + required this.temp, + required this.condition, + this.humidity, + }); + + factory WeatherInfo.fromJson(Map json) => WeatherInfo( + temp: (json['temp'] as num).toDouble(), + condition: json['condition'] as String, + humidity: json['humidity'] as int?, + ); + + Map toJson() => { + 'temp': temp, + 'condition': condition, + if (humidity != null) 'humidity': humidity, + }; +} + +class CalendarEvent { + final String summary; + final String startTime; + final String endTime; + + const CalendarEvent({ + required this.summary, + required this.startTime, + required this.endTime, + }); + + factory CalendarEvent.fromJson(Map json) => CalendarEvent( + summary: json['summary'] as String, + startTime: json['startTime'] as String, + endTime: json['endTime'] as String, + ); + + Map toJson() => { + 'summary': summary, + 'startTime': startTime, + 'endTime': endTime, + }; +} + +class ProgramSessionState { + final String programId; + final String programName; + final String workoutName; + final List exercises; + final int currentExerciseIndex; + final String phase; // playing, resting, instruction, completed + final int timerRemaining; + final int currentSet; + final int totalSets; + + const ProgramSessionState({ + required this.programId, + required this.programName, + required this.workoutName, + this.exercises = const [], + this.currentExerciseIndex = 0, + this.phase = 'playing', + this.timerRemaining = 0, + this.currentSet = 1, + this.totalSets = 1, + }); + + ProgramSessionState copyWith({ + String? programId, + String? programName, + String? workoutName, + List? exercises, + int? currentExerciseIndex, + String? phase, + int? timerRemaining, + int? currentSet, + int? totalSets, + }) => + ProgramSessionState( + programId: programId ?? this.programId, + programName: programName ?? this.programName, + workoutName: workoutName ?? this.workoutName, + exercises: exercises ?? this.exercises, + currentExerciseIndex: currentExerciseIndex ?? this.currentExerciseIndex, + phase: phase ?? this.phase, + timerRemaining: timerRemaining ?? this.timerRemaining, + currentSet: currentSet ?? this.currentSet, + totalSets: totalSets ?? this.totalSets, + ); + + factory ProgramSessionState.fromJson(Map json) => + ProgramSessionState( + programId: json['programId'] as String, + programName: json['programName'] as String, + workoutName: json['workoutName'] as String, + exercises: (json['exercises'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + currentExerciseIndex: json['currentExerciseIndex'] as int? ?? 0, + phase: json['phase'] as String? ?? 'playing', + timerRemaining: json['timerRemaining'] as int? ?? 0, + currentSet: json['currentSet'] as int? ?? 1, + totalSets: json['totalSets'] as int? ?? 1, + ); + + Map toJson() => { + 'programId': programId, + 'programName': programName, + 'workoutName': workoutName, + 'exercises': exercises, + 'currentExerciseIndex': currentExerciseIndex, + 'phase': phase, + 'timerRemaining': timerRemaining, + 'currentSet': currentSet, + 'totalSets': totalSets, + }; +} + +class SpotifyState { + final bool isPlaying; + final String? trackName; + final String? artistName; + final String? albumImageUrl; + final int progressMs; + final int durationMs; + + const SpotifyState({ + this.isPlaying = false, + this.trackName, + this.artistName, + this.albumImageUrl, + this.progressMs = 0, + this.durationMs = 0, + }); + + SpotifyState copyWith({ + bool? isPlaying, + String? trackName, + String? artistName, + String? albumImageUrl, + int? progressMs, + int? durationMs, + }) => + SpotifyState( + isPlaying: isPlaying ?? this.isPlaying, + trackName: trackName ?? this.trackName, + artistName: artistName ?? this.artistName, + albumImageUrl: albumImageUrl ?? this.albumImageUrl, + progressMs: progressMs ?? this.progressMs, + durationMs: durationMs ?? this.durationMs, + ); + + factory SpotifyState.fromJson(Map json) => SpotifyState( + isPlaying: json['isPlaying'] as bool? ?? false, + trackName: json['trackName'] as String?, + artistName: json['artistName'] as String?, + albumImageUrl: json['albumImageUrl'] as String?, + progressMs: json['progressMs'] as int? ?? 0, + durationMs: json['durationMs'] as int? ?? 0, + ); + + Map toJson() => { + 'isPlaying': isPlaying, + if (trackName != null) 'trackName': trackName, + if (artistName != null) 'artistName': artistName, + if (albumImageUrl != null) 'albumImageUrl': albumImageUrl, + 'progressMs': progressMs, + 'durationMs': durationMs, + }; +} diff --git a/packages/mirror_protocol/pubspec.yaml b/packages/mirror_protocol/pubspec.yaml new file mode 100644 index 0000000..b392e27 --- /dev/null +++ b/packages/mirror_protocol/pubspec.yaml @@ -0,0 +1,11 @@ +name: mirror_protocol +description: WebSocket message types and serialization for Mirror OS communication +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.5.0 + +dependencies: + uuid: ^4.5.1 diff --git a/packages/mirror_ui/lib/animations.dart b/packages/mirror_ui/lib/animations.dart new file mode 100644 index 0000000..34d45c6 --- /dev/null +++ b/packages/mirror_ui/lib/animations.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class MirrorAnimations { + MirrorAnimations._(); + + static const screenTransitionDuration = Duration(milliseconds: 800); + static const quickTransitionDuration = Duration(milliseconds: 300); + static const overlayFadeDuration = Duration(milliseconds: 500); + + static Widget screenTransitionBuilder( + Widget child, + Animation animation, + ) { + return FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: Tween(begin: 0.95, end: 1.0).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutCubic), + ), + child: child, + ), + ); + } + + static Widget slideUpTransitionBuilder( + Widget child, + Animation animation, + ) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.05), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: child, + ), + ); + } +} diff --git a/packages/mirror_ui/lib/mirror_ui.dart b/packages/mirror_ui/lib/mirror_ui.dart new file mode 100644 index 0000000..c9c0cde --- /dev/null +++ b/packages/mirror_ui/lib/mirror_ui.dart @@ -0,0 +1,6 @@ +library mirror_ui; + +export 'theme.dart'; +export 'animations.dart'; +export 'widgets/progress_ring.dart'; +export 'widgets/timer_display.dart'; diff --git a/packages/mirror_ui/lib/theme.dart b/packages/mirror_ui/lib/theme.dart new file mode 100644 index 0000000..6c9741c --- /dev/null +++ b/packages/mirror_ui/lib/theme.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +class MirrorTheme { + MirrorTheme._(); + + static const _zinc50 = Color(0xFFFAFAFA); + static const _zinc100 = Color(0xFFF4F4F5); + static const _zinc200 = Color(0xFFE4E4E7); + static const _zinc400 = Color(0xFFA1A1AA); + static const _zinc500 = Color(0xFF71717A); + static const _zinc700 = Color(0xFF3F3F46); + static const _zinc800 = Color(0xFF27272A); + static const _zinc900 = Color(0xFF18181B); + static const _zinc950 = Color(0xFF09090B); + + static const _accent = Color(0xFF60A5FA); // blue-400 + static const _accentDim = Color(0xFF1E3A5F); + static const _success = Color(0xFF4ADE80); // green-400 + static const _danger = Color(0xFFF87171); // red-400 + static const _warning = Color(0xFFFBBF24); // amber-400 + + static const fontFamily = 'Inter'; + static const fontMono = 'JetBrains Mono'; + + static ThemeData get dark => ThemeData( + brightness: Brightness.dark, + fontFamily: fontFamily, + scaffoldBackgroundColor: _zinc950, + colorScheme: const ColorScheme.dark( + surface: _zinc900, + primary: _accent, + secondary: _zinc700, + error: _danger, + onSurface: _zinc100, + onPrimary: _zinc950, + ), + cardTheme: const CardThemeData( + color: _zinc900, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + ), + ), + textTheme: const TextTheme( + displayLarge: TextStyle( + fontSize: 96, + fontWeight: FontWeight.w200, + fontFamily: fontMono, + color: _zinc50, + letterSpacing: -2, + ), + displayMedium: TextStyle( + fontSize: 60, + fontWeight: FontWeight.w200, + fontFamily: fontMono, + color: _zinc50, + ), + displaySmall: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w300, + color: _zinc50, + ), + headlineLarge: TextStyle( + fontSize: 28, + fontWeight: FontWeight.w600, + color: _zinc50, + ), + headlineMedium: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + color: _zinc50, + ), + bodyLarge: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w400, + color: _zinc200, + ), + bodyMedium: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: _zinc400, + ), + bodySmall: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: _zinc500, + ), + labelLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: _zinc50, + letterSpacing: 1.5, + ), + ), + iconTheme: const IconThemeData(color: _zinc400, size: 24), + dividerColor: _zinc800, + ); + + static const Color accent = _accent; + static const Color accentDim = _accentDim; + static const Color success = _success; + static const Color danger = _danger; + static const Color warning = _warning; + static const Color surface = _zinc900; + static const Color surfaceLight = _zinc800; + static const Color background = _zinc950; + static const Color textPrimary = _zinc50; + static const Color textSecondary = _zinc400; + static const Color textMuted = _zinc500; +} diff --git a/packages/mirror_ui/lib/widgets/progress_ring.dart b/packages/mirror_ui/lib/widgets/progress_ring.dart new file mode 100644 index 0000000..3c269e8 --- /dev/null +++ b/packages/mirror_ui/lib/widgets/progress_ring.dart @@ -0,0 +1,86 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; +import '../theme.dart'; + +class ProgressRing extends StatelessWidget { + final double percent; + final double size; + final double strokeWidth; + final Color? color; + final Color? backgroundColor; + + const ProgressRing({ + super.key, + required this.percent, + this.size = 36, + this.strokeWidth = 3, + this.color, + this.backgroundColor, + }); + + @override + Widget build(BuildContext context) { + final effectiveColor = percent >= 95 + ? MirrorTheme.success + : (color ?? MirrorTheme.accent); + + return SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _ProgressRingPainter( + percent: percent.clamp(0, 100), + strokeWidth: strokeWidth, + color: effectiveColor, + backgroundColor: backgroundColor ?? MirrorTheme.surfaceLight, + ), + ), + ); + } +} + +class _ProgressRingPainter extends CustomPainter { + final double percent; + final double strokeWidth; + final Color color; + final Color backgroundColor; + + _ProgressRingPainter({ + required this.percent, + required this.strokeWidth, + required this.color, + required this.backgroundColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = (size.width - strokeWidth) / 2; + + final bgPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth; + + final fgPaint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = strokeWidth + ..strokeCap = StrokeCap.round; + + canvas.drawCircle(center, radius, bgPaint); + + final sweepAngle = (percent / 100) * 2 * math.pi; + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + sweepAngle, + false, + fgPaint, + ); + } + + @override + bool shouldRepaint(_ProgressRingPainter oldDelegate) => + oldDelegate.percent != percent || oldDelegate.color != color; +} diff --git a/packages/mirror_ui/lib/widgets/timer_display.dart b/packages/mirror_ui/lib/widgets/timer_display.dart new file mode 100644 index 0000000..d165a00 --- /dev/null +++ b/packages/mirror_ui/lib/widgets/timer_display.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import '../theme.dart'; + +class TimerDisplay extends StatelessWidget { + final int seconds; + final String? label; + final TextStyle? style; + + const TimerDisplay({ + super.key, + required this.seconds, + this.label, + this.style, + }); + + @override + Widget build(BuildContext context) { + final minutes = seconds ~/ 60; + final secs = seconds % 60; + final timeText = minutes > 0 + ? '$minutes:${secs.toString().padLeft(2, '0')}' + : '$secs'; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (label != null) + Text( + label!, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: MirrorTheme.accent, + letterSpacing: 8, + ), + ), + if (label != null) const SizedBox(height: 40), + Text( + timeText, + style: style ?? Theme.of(context).textTheme.displayLarge?.copyWith( + color: MirrorTheme.accent, + ), + ), + ], + ); + } +} diff --git a/packages/mirror_ui/pubspec.yaml b/packages/mirror_ui/pubspec.yaml new file mode 100644 index 0000000..85de906 --- /dev/null +++ b/packages/mirror_ui/pubspec.yaml @@ -0,0 +1,17 @@ +name: mirror_ui +description: Shared theme, animations, and common widgets for Mirror OS +version: 0.1.0 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.5.0 + flutter: ^3.24.0 + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..62c547a --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,15 @@ +name: mirror_os_workspace +publish_to: none + +environment: + sdk: ^3.5.0 + +dev_dependencies: + melos: ^6.1.0 + +workspace: + - apps/display + - apps/remote + - packages/mirror_protocol + - packages/mirror_core + - packages/mirror_ui