Preserve Flutter mirror-os codebase

This commit is contained in:
Chris
2026-07-11 21:51:34 -05:00
commit 27631ed108
142 changed files with 11896 additions and 0 deletions
+45
View File
@@ -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
+30
View File
@@ -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'
+17
View File
@@ -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.
+14
View File
@@ -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
+45
View File
@@ -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 = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,47 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="mirror_remote"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,5 @@
package com.example.mirror_remote
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -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<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -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
@@ -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
+26
View File
@@ -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")
@@ -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
@@ -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")
]
)
]
)
@@ -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 --")
@@ -0,0 +1,5 @@
#
# Generated file, do not edit.
#
command script import --relative-to-command-file flutter_lldb_helper.py
@@ -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
+14
View File
@@ -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"
@@ -0,0 +1,19 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GeneratedPluginRegistrant_h
#define GeneratedPluginRegistrant_h
#import <Flutter/Flutter.h>
NS_ASSUME_NONNULL_BEGIN
@interface GeneratedPluginRegistrant : NSObject
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
@end
NS_ASSUME_NONNULL_END
#endif /* GeneratedPluginRegistrant_h */
@@ -0,0 +1,35 @@
//
// Generated file. Do not edit.
//
// clang-format off
#import "GeneratedPluginRegistrant.h"
#if __has_include(<nsd_ios/NsdIosPlugin.h>)
#import <nsd_ios/NsdIosPlugin.h>
#else
@import nsd_ios;
#endif
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
#else
@import shared_preferences_foundation;
#endif
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
#import <url_launcher_ios/URLLauncherPlugin.h>
#else
@import url_launcher_ios;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[NsdIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"NsdIosPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
}
@end
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import 'providers/connection_provider.dart';
import 'screens/connect_screen.dart';
import 'screens/remote_dashboard.dart';
import 'screens/remote_workout.dart';
import 'screens/remote_media.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
class MirrorRemoteApp extends StatelessWidget {
const MirrorRemoteApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Mirror Remote',
debugShowCheckedModeBanner: false,
theme: MirrorTheme.dark,
home: const _RemoteShell(),
);
}
}
class _RemoteShell extends ConsumerWidget {
const _RemoteShell();
@override
Widget build(BuildContext context, WidgetRef ref) {
final connection = ref.watch(connectionProvider);
if (!connection.connected) {
return const ConnectScreen();
}
return const _ConnectedRemote();
}
}
class _ConnectedRemote extends ConsumerStatefulWidget {
const _ConnectedRemote();
@override
ConsumerState<_ConnectedRemote> createState() => _ConnectedRemoteState();
}
class _ConnectedRemoteState extends ConsumerState<_ConnectedRemote> {
int _currentIndex = 0;
@override
Widget build(BuildContext context) {
final activeScreen = ref.watch(
mirrorStateProvider.select((s) => s?.activeScreen),
);
// Sync tab to mirror's active screen
final tabIndex = switch (activeScreen) {
ScreenType.dashboard => 0,
ScreenType.workout => 1,
ScreenType.media => 2,
_ => _currentIndex,
};
return Scaffold(
body: SafeArea(
child: IndexedStack(
index: tabIndex,
children: const [
RemoteDashboard(),
RemoteWorkout(),
RemoteMedia(),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: tabIndex,
onDestinationSelected: (index) {
setState(() => _currentIndex = index);
final target = switch (index) {
0 => ScreenType.dashboard,
1 => ScreenType.workout,
2 => ScreenType.media,
_ => ScreenType.dashboard,
};
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: target),
);
},
destinations: const [
NavigationDestination(icon: Icon(Icons.dashboard), label: 'Home'),
NavigationDestination(icon: Icon(Icons.fitness_center), label: 'Workout'),
NavigationDestination(icon: Icon(Icons.music_note), label: 'Media'),
],
),
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'app.dart';
void main() {
runApp(
const ProviderScope(child: MirrorRemoteApp()),
);
}
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
final connectionProvider =
NotifierProvider<ConnectionNotifier, ConnectionState>(ConnectionNotifier.new);
final mirrorStateProvider = StateProvider<DisplayState?>((ref) => null);
final dashboardConfigProvider = StateProvider<DashboardLayoutConfig?>((ref) => null);
class ConnectionState {
final bool connected;
final bool connecting;
final String? host;
final String? error;
final bool manualDisconnect;
const ConnectionState({
this.connected = false,
this.connecting = false,
this.host,
this.error,
this.manualDisconnect = false,
});
ConnectionState copyWith({
bool? connected,
bool? connecting,
String? host,
String? error,
bool clearError = false,
bool? manualDisconnect,
}) =>
ConnectionState(
connected: connected ?? this.connected,
connecting: connecting ?? this.connecting,
host: host ?? this.host,
error: clearError ? null : (error ?? this.error),
manualDisconnect: manualDisconnect ?? this.manualDisconnect,
);
}
class ConnectionNotifier extends Notifier<ConnectionState> {
WebSocketChannel? _channel;
Timer? _reconnectTimer;
@override
ConnectionState build() => const ConnectionState();
Future<void> connect(String host, {int port = 9090}) async {
state = state.copyWith(connecting: true, clearError: true, manualDisconnect: false);
try {
final uri = Uri.parse('ws://$host:$port');
_channel = WebSocketChannel.connect(uri);
await _channel!.ready;
state = state.copyWith(connected: true, connecting: false, host: host);
// Send registration
_channel!.sink.add(RegisterMessage(role: 'remote').encode());
// Listen for messages
_channel!.stream.listen(
_handleMessage,
onDone: () => _onDisconnected(),
onError: (e) => _onDisconnected(error: e.toString()),
);
} catch (e) {
state = state.copyWith(
connected: false,
connecting: false,
error: 'Failed to connect: $e',
);
}
}
void _handleMessage(dynamic data) {
try {
final message = MirrorMessage.decode(data as String);
switch (message) {
case StateSync(state: final mirrorState):
ref.read(mirrorStateProvider.notifier).state = mirrorState;
case DashboardConfigResponse(layout: final layout):
ref.read(dashboardConfigProvider.notifier).state = layout;
case StatePatch():
break;
case EventMessage():
break;
default:
break;
}
} catch (e) {
print('Failed to parse message: $e');
}
}
void _onDisconnected({String? error}) {
state = state.copyWith(connected: false, error: error);
_channel = null;
// Only auto-reconnect if not intentionally disconnected
if (state.host != null && error != null) {
_reconnectTimer?.cancel();
_reconnectTimer = Timer(const Duration(seconds: 3), () {
if (state.host != null && !state.connected) {
connect(state.host!);
}
});
}
}
void send(MirrorMessage message) {
_channel?.sink.add(message.encode());
}
void disconnect() {
_reconnectTimer?.cancel();
_channel?.sink.close();
_channel = null;
state = const ConnectionState(manualDisconnect: true);
ref.read(mirrorStateProvider.notifier).state = null;
}
}
+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'settings_provider.dart';
final haServiceProvider = Provider<HomeAssistantService?>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null || settings.haBaseUrl == null || settings.haToken == null) {
return null;
}
return HomeAssistantService(
baseUrl: settings.haBaseUrl!,
token: settings.haToken!,
);
});
final haEntitiesProvider =
AsyncNotifierProvider<HaEntitiesNotifier, HaEntitiesState>(HaEntitiesNotifier.new);
class HaEntitiesState {
final List<HaEntity> entities;
final HaWeather? weather;
final List<HaCalendarEvent> calendarEvents;
final String? error;
const HaEntitiesState({
this.entities = const [],
this.weather,
this.calendarEvents = const [],
this.error,
});
}
class HaEntitiesNotifier extends AsyncNotifier<HaEntitiesState> {
@override
Future<HaEntitiesState> build() async {
final service = ref.watch(haServiceProvider);
if (service == null) {
return const HaEntitiesState(error: 'Configure Home Assistant in Settings');
}
try {
final entities = await service.getStates();
// Find weather entity
final weatherEntity = entities.firstWhere(
(e) => e.entityId.startsWith('weather.'),
orElse: () => HaEntity(entityId: '', state: '', attributes: {}, lastChanged: null),
);
HaWeather? weather;
if (weatherEntity.entityId.isNotEmpty) {
weather = HaWeather.fromEntity(weatherEntity);
}
return HaEntitiesState(
entities: entities,
weather: weather,
);
} catch (e) {
return HaEntitiesState(error: 'HA connection failed: $e');
}
}
Future<void> toggleEntity(String entityId) async {
final service = ref.read(haServiceProvider);
if (service == null) return;
final entity = state.valueOrNull?.entities.firstWhere(
(e) => e.entityId == entityId,
orElse: () => HaEntity(entityId: '', state: '', attributes: {}, lastChanged: null),
);
if (entity == null || entity.entityId.isEmpty) return;
final isOn = entity.state == 'on';
if (isOn) {
await service.turnOff(entityId);
} else {
await service.turnOn(entityId);
}
// Refresh state
ref.invalidateSelf();
}
Future<void> callScene(String entityId) async {
final service = ref.read(haServiceProvider);
if (service == null) return;
await service.callService('scene', 'turn_on', entityId: entityId);
ref.invalidateSelf();
}
void refresh() => ref.invalidateSelf();
}
class HaWeather {
final double? temperature;
final String? condition;
final double? humidity;
const HaWeather({this.temperature, this.condition, this.humidity});
factory HaWeather.fromEntity(HaEntity entity) {
final attrs = entity.attributes;
return HaWeather(
temperature: (attrs['temperature'] as num?)?.toDouble(),
condition: entity.state,
humidity: (attrs['humidity'] as num?)?.toDouble(),
);
}
}
@@ -0,0 +1,99 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'settings_provider.dart';
final plexServiceProvider = Provider<PlexService?>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null || settings.plexHost == null || settings.plexToken == null) {
return null;
}
return PlexService(
host: settings.plexHost!,
port: settings.plexPort,
token: settings.plexToken!,
);
});
final plexBrowseProvider =
AsyncNotifierProvider<PlexBrowseNotifier, PlexBrowseState>(
PlexBrowseNotifier.new);
class PlexBrowseState {
final List<PlexLibrary> libraries;
final PlexLibrary? currentLibrary;
final List<PlexItem> items;
final List<String> breadcrumb;
final String? error;
const PlexBrowseState({
this.libraries = const [],
this.currentLibrary,
this.items = const [],
this.breadcrumb = const [],
this.error,
});
}
class PlexBrowseNotifier extends AsyncNotifier<PlexBrowseState> {
@override
Future<PlexBrowseState> build() async {
final service = ref.watch(plexServiceProvider);
if (service == null) {
return const PlexBrowseState(error: 'Configure Plex in Settings');
}
final libraries = await service.fetchLibraries();
return PlexBrowseState(libraries: libraries);
}
Future<void> openLibrary(PlexLibrary library) async {
final service = ref.read(plexServiceProvider);
if (service == null) return;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final items = await service.fetchItems(library.id);
return PlexBrowseState(
libraries: state.valueOrNull?.libraries ?? [],
currentLibrary: library,
items: items,
breadcrumb: [library.title],
);
});
}
Future<void> openItem(PlexItem item) async {
final service = ref.read(plexServiceProvider);
if (service == null) return;
if (item.type == 'show' || item.type == 'season' || item.type == 'artist' || item.type == 'album') {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final children = await service.fetchChildren(item.id);
final prev = state.valueOrNull;
return PlexBrowseState(
libraries: prev?.libraries ?? [],
currentLibrary: prev?.currentLibrary,
items: children,
breadcrumb: [...(prev?.breadcrumb ?? []), item.title],
);
});
}
}
Future<void> goBack() async {
final prev = state.valueOrNull;
if (prev == null) return;
if (prev.breadcrumb.length <= 1) {
state = AsyncValue.data(PlexBrowseState(
libraries: prev.libraries,
));
} else {
if (prev.currentLibrary != null) {
await openLibrary(prev.currentLibrary!);
}
}
}
void refresh() {
ref.invalidateSelf();
}
}
@@ -0,0 +1,25 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
final programsApiProvider = Provider<ProgramsApiClient>((ref) {
return ProgramsApiClient(baseUrl: 'http://192.168.86.172:8091');
});
final programsProvider =
AsyncNotifierProvider<ProgramsNotifier, List<Program>>(ProgramsNotifier.new);
class ProgramsNotifier extends AsyncNotifier<List<Program>> {
@override
Future<List<Program>> build() async {
final client = ref.read(programsApiProvider);
return client.fetchPrograms();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final client = ref.read(programsApiProvider);
return client.fetchPrograms();
});
}
}
@@ -0,0 +1,109 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
final settingsProvider = AsyncNotifierProvider<SettingsNotifier, AppSettings>(SettingsNotifier.new);
class AppSettings {
final String mirrorHost;
final int mirrorPort;
final String workoutPlayerUrl;
final String? plexHost;
final int plexPort;
final String? plexToken;
final String? haBaseUrl;
final String? haToken;
final String? spotifyClientId;
final bool spotifyConnected;
const AppSettings({
this.mirrorHost = 'mirror.local',
this.mirrorPort = 9090,
this.workoutPlayerUrl = 'http://192.168.86.172:8091',
this.plexHost,
this.plexPort = 32400,
this.plexToken,
this.haBaseUrl,
this.haToken,
this.spotifyClientId,
this.spotifyConnected = false,
});
AppSettings copyWith({
String? mirrorHost,
int? mirrorPort,
String? workoutPlayerUrl,
String? plexHost,
int? plexPort,
String? plexToken,
String? haBaseUrl,
String? haToken,
String? spotifyClientId,
bool? spotifyConnected,
}) =>
AppSettings(
mirrorHost: mirrorHost ?? this.mirrorHost,
mirrorPort: mirrorPort ?? this.mirrorPort,
workoutPlayerUrl: workoutPlayerUrl ?? this.workoutPlayerUrl,
plexHost: plexHost ?? this.plexHost,
plexPort: plexPort ?? this.plexPort,
plexToken: plexToken ?? this.plexToken,
haBaseUrl: haBaseUrl ?? this.haBaseUrl,
haToken: haToken ?? this.haToken,
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
spotifyConnected: spotifyConnected ?? this.spotifyConnected,
);
}
class SettingsNotifier extends AsyncNotifier<AppSettings> {
@override
Future<AppSettings> build() async {
final prefs = await SharedPreferences.getInstance();
return AppSettings(
mirrorHost: prefs.getString('mirror_host') ?? 'mirror.local',
mirrorPort: prefs.getInt('mirror_port') ?? 9090,
workoutPlayerUrl: prefs.getString('workout_player_url') ?? 'http://192.168.86.172:8091',
plexHost: prefs.getString('plex_host'),
plexPort: prefs.getInt('plex_port') ?? 32400,
plexToken: prefs.getString('plex_token'),
haBaseUrl: prefs.getString('ha_base_url'),
haToken: prefs.getString('ha_token'),
spotifyClientId: prefs.getString('spotify_client_id'),
spotifyConnected: prefs.getBool('spotify_connected') ?? false,
);
}
Future<void> save(AppSettings settings) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('mirror_host', settings.mirrorHost);
await prefs.setInt('mirror_port', settings.mirrorPort);
await prefs.setString('workout_player_url', settings.workoutPlayerUrl);
if (settings.plexHost != null) {
await prefs.setString('plex_host', settings.plexHost!);
} else {
await prefs.remove('plex_host');
}
await prefs.setInt('plex_port', settings.plexPort);
if (settings.plexToken != null) {
await prefs.setString('plex_token', settings.plexToken!);
} else {
await prefs.remove('plex_token');
}
if (settings.haBaseUrl != null) {
await prefs.setString('ha_base_url', settings.haBaseUrl!);
} else {
await prefs.remove('ha_base_url');
}
if (settings.haToken != null) {
await prefs.setString('ha_token', settings.haToken!);
} else {
await prefs.remove('ha_token');
}
if (settings.spotifyClientId != null) {
await prefs.setString('spotify_client_id', settings.spotifyClientId!);
} else {
await prefs.remove('spotify_client_id');
}
await prefs.setBool('spotify_connected', settings.spotifyConnected);
state = AsyncValue.data(settings);
}
}
@@ -0,0 +1,127 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'settings_provider.dart';
const _currentVersion = 1;
final updateCheckProvider = FutureProvider<UpdateInfo?>((ref) async {
final settings = ref.watch(settingsProvider).valueOrNull;
if (settings == null) return null;
final baseUrl = settings.workoutPlayerUrl;
// Update server runs alongside workout-player
final host = Uri.parse(baseUrl).host;
try {
final response = await http.get(
Uri.parse('http://$host:9091/api/mirror-os/version'),
).timeout(const Duration(seconds: 5));
if (response.statusCode != 200) return null;
final data = jsonDecode(response.body) as Map<String, dynamic>;
final latestVersion = data['version'] as int? ?? 0;
final downloadUrl = data['downloadUrl'] as String?;
final changelog = data['changelog'] as String?;
if (latestVersion > _currentVersion && downloadUrl != null) {
return UpdateInfo(
currentVersion: _currentVersion,
latestVersion: latestVersion,
downloadUrl: downloadUrl,
changelog: changelog,
);
}
return null;
} catch (_) {
return null;
}
});
class UpdateInfo {
final int currentVersion;
final int latestVersion;
final String downloadUrl;
final String? changelog;
const UpdateInfo({
required this.currentVersion,
required this.latestVersion,
required this.downloadUrl,
this.changelog,
});
}
class UpdateBanner extends ConsumerWidget {
const UpdateBanner({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final update = ref.watch(updateCheckProvider).valueOrNull;
if (update == null) return const SizedBox.shrink();
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E3A5F),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF60A5FA), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.system_update, color: Color(0xFF60A5FA), size: 20),
const SizedBox(width: 8),
Text(
'Update available (v${update.latestVersion})',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: Colors.white,
),
),
],
),
if (update.changelog != null) ...[
const SizedBox(height: 8),
Text(
update.changelog!,
style: const TextStyle(fontSize: 13, color: Colors.white70),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _downloadUpdate(context, update.downloadUrl),
icon: const Icon(Icons.download, size: 18),
label: const Text('Download & Install'),
),
),
],
),
);
}
void _downloadUpdate(BuildContext context, String url) async {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Open in browser: $url')),
);
}
}
}
}
@@ -0,0 +1,83 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import 'settings_provider.dart';
final workoutApiProvider = Provider<WorkoutApiClient>((ref) {
final settings = ref.watch(settingsProvider).valueOrNull;
final url = settings?.workoutPlayerUrl ?? 'http://192.168.86.172:8091';
return WorkoutApiClient(baseUrl: url);
});
final workoutBrowseProvider =
AsyncNotifierProvider<WorkoutBrowseNotifier, WorkoutBrowseState>(
WorkoutBrowseNotifier.new);
class WorkoutBrowseState {
final List<VideoState> videos;
final List<String> folders;
final WorkoutStats? stats;
final String currentPath;
final WorkoutFilter filter;
const WorkoutBrowseState({
this.videos = const [],
this.folders = const [],
this.stats,
this.currentPath = '',
this.filter = WorkoutFilter.all,
});
WorkoutBrowseState copyWith({
List<VideoState>? videos,
List<String>? folders,
WorkoutStats? stats,
String? currentPath,
WorkoutFilter? filter,
}) =>
WorkoutBrowseState(
videos: videos ?? this.videos,
folders: folders ?? this.folders,
stats: stats ?? this.stats,
currentPath: currentPath ?? this.currentPath,
filter: filter ?? this.filter,
);
}
class WorkoutBrowseNotifier extends AsyncNotifier<WorkoutBrowseState> {
@override
Future<WorkoutBrowseState> build() async {
return _fetch('', WorkoutFilter.all);
}
Future<WorkoutBrowseState> _fetch(String path, WorkoutFilter filter) async {
final client = ref.read(workoutApiProvider);
final result = await client.fetchVideos(folder: path.isEmpty ? null : path, filter: filter);
final stats = await client.fetchStats();
return WorkoutBrowseState(
videos: result.videos,
folders: result.folders,
stats: stats,
currentPath: path,
filter: filter,
);
}
Future<void> browse(String path, {WorkoutFilter? filter}) async {
final currentFilter = filter ?? state.valueOrNull?.filter ?? WorkoutFilter.all;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch(path, currentFilter));
}
Future<void> setFilter(WorkoutFilter filter) async {
final path = state.valueOrNull?.currentPath ?? '';
state = const AsyncValue.loading();
state = await AsyncValue.guard(() => _fetch(path, filter));
}
String streamUrl(String videoPath) {
final client = ref.read(workoutApiProvider);
return client.streamUrl(videoPath);
}
}
@@ -0,0 +1,38 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
final youtubeServiceProvider = Provider<YouTubeService>((ref) {
return YouTubeService();
});
final youtubeSearchProvider =
AsyncNotifierProvider<YouTubeSearchNotifier, YouTubeSearchState>(
YouTubeSearchNotifier.new);
class YouTubeSearchState {
final String query;
final List<YouTubeVideo> results;
const YouTubeSearchState({this.query = '', this.results = const []});
}
class YouTubeSearchNotifier extends AsyncNotifier<YouTubeSearchState> {
@override
Future<YouTubeSearchState> build() async {
return const YouTubeSearchState();
}
Future<void> search(String query) async {
if (query.trim().isEmpty) return;
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final service = ref.read(youtubeServiceProvider);
final results = await service.search(query, limit: 15);
return YouTubeSearchState(query: query, results: results);
});
}
void clear() {
state = const AsyncValue.data(YouTubeSearchState());
}
}
+145
View File
@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/connection_provider.dart';
class ConnectScreen extends ConsumerStatefulWidget {
const ConnectScreen({super.key});
@override
ConsumerState<ConnectScreen> createState() => _ConnectScreenState();
}
class _ConnectScreenState extends ConsumerState<ConnectScreen> {
final _hostController = TextEditingController();
bool _loaded = false;
@override
void initState() {
super.initState();
_loadSavedHost();
}
Future<void> _loadSavedHost() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString('mirror_host') ?? 'mirror.local';
_hostController.text = saved;
setState(() => _loaded = true);
// Auto-connect only if not manually disconnected
final connection = ref.read(connectionProvider);
if (!connection.manualDisconnect && saved.isNotEmpty && saved != 'mirror.local') {
_connect();
}
}
Future<void> _saveHost(String host) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('mirror_host', host);
}
@override
void dispose() {
_hostController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final connection = ref.watch(connectionProvider);
if (!_loaded) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Spacer(),
Icon(
Icons.cast_connected,
size: 64,
color: MirrorTheme.accent,
),
const SizedBox(height: 24),
Text(
'Mirror OS',
style: Theme.of(context).textTheme.headlineLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Connect to your mirror',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: _hostController,
decoration: InputDecoration(
labelText: 'Mirror address',
hintText: 'mirror.local or 192.168.x.x',
filled: true,
fillColor: MirrorTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: const Icon(Icons.wifi),
),
keyboardType: TextInputType.url,
onSubmitted: (_) => _connect(),
),
const SizedBox(height: 16),
FilledButton(
onPressed: connection.connecting ? null : _connect,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: connection.connecting
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
if (connection.error != null) ...[
const SizedBox(height: 16),
Text(
connection.error!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: MirrorTheme.danger,
),
textAlign: TextAlign.center,
),
],
const Spacer(),
Text(
'Make sure your phone is on the same\nWiFi network as the mirror',
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
),
);
}
void _connect() {
final host = _hostController.text.trim();
if (host.isNotEmpty) {
_saveHost(host);
ref.read(connectionProvider.notifier).connect(host);
}
}
}
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/connection_provider.dart';
import 'ha_entity_picker_screen.dart';
import 'weather_picker_screen.dart';
class DashboardEditorScreen extends ConsumerStatefulWidget {
const DashboardEditorScreen({super.key});
@override
ConsumerState<DashboardEditorScreen> createState() => _DashboardEditorScreenState();
}
class _DashboardEditorScreenState extends ConsumerState<DashboardEditorScreen> {
List<DashboardWidgetConfig> _widgets = [];
bool _loading = true;
static const _widgetMeta = <String, ({IconData icon, String label})>{
'clock': (icon: Icons.access_time, label: 'Clock'),
'weather': (icon: Icons.cloud, label: 'Weather'),
'calendar': (icon: Icons.calendar_month, label: 'Calendar'),
'ha_entities': (icon: Icons.home, label: 'HA Entities'),
};
@override
void initState() {
super.initState();
_requestConfig();
}
void _requestConfig() {
final existing = ref.read(dashboardConfigProvider);
if (existing != null) {
setState(() {
_widgets = List.from(existing.widgets);
_loading = false;
});
} else {
// Request from display
ref.read(connectionProvider.notifier).send(DashboardConfigRequest());
// Wait briefly then fall back to default
Future.delayed(const Duration(seconds: 2), () {
if (_loading && mounted) {
setState(() {
_widgets = List.from(DashboardLayoutConfig.defaultLayout().widgets);
_loading = false;
});
}
});
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Listen for config response
ref.listen(dashboardConfigProvider, (prev, next) {
if (next != null && _loading) {
setState(() {
_widgets = List.from(next.widgets);
_loading = false;
});
}
});
}
void _onReorder(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex--;
final item = _widgets.removeAt(oldIndex);
_widgets.insert(newIndex, item);
// Update positions
for (var i = 0; i < _widgets.length; i++) {
_widgets[i] = _widgets[i].copyWith(position: i);
}
});
}
void _toggleVisibility(int index) {
setState(() {
_widgets[index] = _widgets[index].copyWith(visible: !_widgets[index].visible);
});
}
Future<void> _openConfig(int index) async {
final widget = _widgets[index];
switch (widget.type) {
case 'ha_entities':
final currentIds =
(widget.config['entity_ids'] as List<dynamic>?)?.cast<String>() ?? [];
final result = await Navigator.of(context).push<List<String>>(
MaterialPageRoute(
builder: (_) => HaEntityPickerScreen(selectedIds: currentIds),
),
);
if (result != null && mounted) {
setState(() {
_widgets[index] = widget.copyWith(
config: {...widget.config, 'entity_ids': result},
);
});
}
case 'weather':
final currentEntity = widget.config['entity_id'] as String?;
final result = await Navigator.of(context).push<String>(
MaterialPageRoute(
builder: (_) => WeatherPickerScreen(selectedEntityId: currentEntity),
),
);
if (result != null && mounted) {
setState(() {
_widgets[index] = widget.copyWith(
config: {...widget.config, 'entity_id': result},
);
});
}
default:
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('No settings for ${widget.type}')),
);
}
}
void _saveLayout() {
final layout = DashboardLayoutConfig(widgets: _widgets);
ref.read(connectionProvider.notifier).send(DashboardConfigUpdate(layout: layout));
ref.read(dashboardConfigProvider.notifier).state = layout;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Dashboard layout saved')),
);
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Dashboard Layout'),
backgroundColor: MirrorTheme.background,
),
backgroundColor: MirrorTheme.background,
body: _loading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
Expanded(
child: ReorderableListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _widgets.length,
onReorderItem: (oldIndex, newIndex) => _onReorder(oldIndex, newIndex),
itemBuilder: (context, index) {
final widget = _widgets[index];
final meta = _widgetMeta[widget.type];
final icon = meta?.icon ?? Icons.widgets;
final label = meta?.label ?? widget.type;
final hasConfig =
widget.type == 'ha_entities' || widget.type == 'weather';
return Card(
key: ValueKey(widget.id),
color: MirrorTheme.surface,
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: Icon(icon, color: MirrorTheme.accent),
title: Text(label),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (hasConfig)
IconButton(
icon: const Icon(Icons.settings, size: 20),
onPressed: () => _openConfig(index),
),
Switch(
value: widget.visible,
onChanged: (_) => _toggleVisibility(index),
activeThumbColor: MirrorTheme.accent,
),
const Icon(Icons.drag_handle),
],
),
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saveLayout,
icon: const Icon(Icons.save),
label: const Text('Save Layout'),
),
),
),
],
),
);
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/ha_provider.dart';
class HaEntityPickerScreen extends ConsumerStatefulWidget {
final List<String> selectedIds;
const HaEntityPickerScreen({super.key, this.selectedIds = const []});
@override
ConsumerState<HaEntityPickerScreen> createState() => _HaEntityPickerScreenState();
}
class _HaEntityPickerScreenState extends ConsumerState<HaEntityPickerScreen> {
late Set<String> _selected;
String _search = '';
@override
void initState() {
super.initState();
_selected = Set.from(widget.selectedIds);
}
String _domainOf(String entityId) {
final dotIndex = entityId.indexOf('.');
return dotIndex > 0 ? entityId.substring(0, dotIndex) : 'other';
}
@override
Widget build(BuildContext context) {
final entitiesAsync = ref.watch(haEntitiesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Select HA Entities'),
backgroundColor: MirrorTheme.background,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(_selected.toList()),
child: Text('Done (${_selected.length})'),
),
],
),
backgroundColor: MirrorTheme.background,
body: entitiesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (state) {
if (state.error != null) {
return Center(child: Text(state.error!));
}
return _buildList(state.entities);
},
),
);
}
Widget _buildList(List entities) {
// Filter by search
final filtered = entities.where((e) {
final id = e.entityId as String;
final name =
(e.attributes['friendly_name'] as String?) ?? id;
final query = _search.toLowerCase();
return id.toLowerCase().contains(query) || name.toLowerCase().contains(query);
}).toList();
// Group by domain
final grouped = <String, List<dynamic>>{};
for (final entity in filtered) {
final domain = _domainOf(entity.entityId as String);
grouped.putIfAbsent(domain, () => []).add(entity);
}
final sortedDomains = grouped.keys.toList()..sort();
return Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
decoration: InputDecoration(
hintText: 'Search entities...',
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: MirrorTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
),
onChanged: (v) => setState(() => _search = v),
),
),
Expanded(
child: ListView.builder(
itemCount: sortedDomains.length,
itemBuilder: (context, domainIndex) {
final domain = sortedDomains[domainIndex];
final items = grouped[domain]!;
return ExpansionTile(
title: Text(
domain,
style: TextStyle(color: MirrorTheme.accent, fontWeight: FontWeight.w600),
),
initiallyExpanded: items.any((e) => _selected.contains(e.entityId)),
children: items.map((entity) {
final id = entity.entityId as String;
final name =
(entity.attributes['friendly_name'] as String?) ?? id;
return CheckboxListTile(
title: Text(name),
subtitle: Text(id, style: const TextStyle(fontSize: 12)),
value: _selected.contains(id),
activeColor: MirrorTheme.accent,
onChanged: (checked) {
setState(() {
if (checked == true) {
_selected.add(id);
} else {
_selected.remove(id);
}
});
},
);
}).toList(),
);
},
),
),
],
);
}
}
@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'package:mirror_ui/mirror_ui.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import '../providers/connection_provider.dart';
import '../providers/ha_provider.dart';
import '../providers/update_provider.dart';
import 'settings_screen.dart';
class RemoteDashboard extends ConsumerWidget {
const RemoteDashboard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final haState = ref.watch(haEntitiesProvider);
return SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Mirror OS', style: Theme.of(context).textTheme.headlineMedium),
IconButton(
icon: const Icon(Icons.settings),
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
},
),
],
),
const SizedBox(height: 16),
const UpdateBanner(),
const SizedBox(height: 16),
Text(
'Quick Actions',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: MirrorTheme.textMuted,
),
),
const SizedBox(height: 16),
_ActionCard(
icon: Icons.fitness_center,
title: 'Start Workout',
subtitle: 'Browse your video library',
color: MirrorTheme.accent,
onTap: () {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.workout),
);
ref.read(connectionProvider.notifier).send(
WorkoutCommand(
action: WorkoutAction.browse,
payload: {'path': '', 'filter': 'all'},
),
);
},
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.music_note,
title: 'Media',
subtitle: 'Play music or video',
color: MirrorTheme.success,
onTap: () {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.media),
);
},
),
const SizedBox(height: 12),
_ActionCard(
icon: Icons.nightlight_round,
title: 'Standby',
subtitle: 'Turn off the display',
color: MirrorTheme.textMuted,
onTap: () {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.standby),
);
},
),
// --- Home Assistant Device Controls ---
const SizedBox(height: 32),
Text(
'Home Assistant',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: MirrorTheme.textMuted,
),
),
const SizedBox(height: 16),
haState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Text('HA Error: $e', style: TextStyle(color: MirrorTheme.danger, fontSize: 13)),
data: (data) {
if (data.error != null) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(Icons.home, size: 36, color: MirrorTheme.textMuted),
const SizedBox(height: 8),
Text(data.error!, style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13)),
],
),
);
}
return _HaDeviceControls(entities: data.entities);
},
),
// --- Scenes ---
const SizedBox(height: 24),
Text(
'Scenes',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: MirrorTheme.textMuted,
),
),
const SizedBox(height: 12),
_HaSceneButtons(),
],
),
);
}
}
// --- HA Device Controls ---
class _HaDeviceControls extends ConsumerWidget {
final List<HaEntity> entities;
const _HaDeviceControls({required this.entities});
@override
Widget build(BuildContext context, WidgetRef ref) {
final toggleable = entities.where((e) {
return e.entityId.startsWith('light.') || e.entityId.startsWith('switch.');
}).toList();
if (toggleable.isEmpty) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Icon(Icons.lightbulb_outline, size: 36, color: MirrorTheme.textMuted),
const SizedBox(height: 8),
Text(
'No lights or switches found',
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
],
),
);
}
return Column(
children: toggleable.map((entity) {
final friendlyName = entity.attributes['friendly_name'] as String? ??
entity.entityId.split('.').last;
final isOn = entity.state == 'on';
final isLight = entity.entityId.startsWith('light.');
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
isLight ? Icons.lightbulb : Icons.power_settings_new,
color: isOn ? MirrorTheme.accent : MirrorTheme.textMuted,
size: 24,
),
const SizedBox(width: 12),
Expanded(
child: Text(friendlyName, style: const TextStyle(fontSize: 14)),
),
Switch(
value: isOn,
onChanged: (_) {
ref.read(haEntitiesProvider.notifier).toggleEntity(entity.entityId);
},
),
],
),
);
}).toList(),
);
}
}
// --- Scene Buttons ---
class _HaSceneButtons extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final haState = ref.watch(haEntitiesProvider).valueOrNull;
if (haState == null || haState.error != null) return const SizedBox.shrink();
final scenes = haState.entities
.where((e) => e.entityId.startsWith('scene.'))
.toList();
if (scenes.isEmpty) return const SizedBox.shrink();
return Wrap(
spacing: 8,
runSpacing: 8,
children: scenes.map((scene) {
final name = scene.attributes['friendly_name'] as String? ??
scene.entityId.split('.').last.replaceAll('_', ' ');
return ActionChip(
avatar: const Icon(Icons.play_arrow, size: 18),
label: Text(name),
onPressed: () {
ref.read(haEntitiesProvider.notifier).callScene(scene.entityId);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Activating $name...')),
);
},
);
}).toList(),
);
}
}
class _ActionCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final Color color;
final VoidCallback onTap;
const _ActionCard({
required this.icon,
required this.title,
required this.subtitle,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 2),
Text(
subtitle,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Icon(Icons.chevron_right, color: MirrorTheme.textMuted),
],
),
),
);
}
}
+679
View File
@@ -0,0 +1,679 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/connection_provider.dart';
import '../providers/youtube_search_provider.dart';
import '../providers/plex_provider.dart';
class RemoteMedia extends ConsumerStatefulWidget {
const RemoteMedia({super.key});
@override
ConsumerState<RemoteMedia> createState() => _RemoteMediaState();
}
class _RemoteMediaState extends ConsumerState<RemoteMedia>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final playback = ref.watch(mirrorStateProvider.select((s) => s?.playback));
// If media is playing (non-workout), show now-playing overlay on top
if (playback != null && playback.source != MediaSource.workout) {
return Column(
children: [
_NowPlayingBar(playback: playback),
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'YouTube'),
Tab(text: 'Plex'),
Tab(text: 'Spotify'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: const [
_YouTubeTab(),
_PlexTab(),
_SpotifyTab(),
],
),
),
],
);
}
return Column(
children: [
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'YouTube'),
Tab(text: 'Plex'),
Tab(text: 'Spotify'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: const [
_YouTubeTab(),
_PlexTab(),
_SpotifyTab(),
],
),
),
],
);
}
}
// --- Now Playing Bar ---
class _NowPlayingBar extends ConsumerWidget {
final PlaybackState playback;
const _NowPlayingBar({required this.playback});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: MirrorTheme.surface,
border: Border(bottom: BorderSide(color: MirrorTheme.surfaceLight)),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
playback.title,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
playback.source.toJson().toUpperCase(),
style: TextStyle(color: MirrorTheme.accent, fontSize: 11, letterSpacing: 1),
),
],
),
),
IconButton(
icon: Icon(playback.isPlaying ? Icons.pause : Icons.play_arrow),
iconSize: 28,
onPressed: () => ref.read(connectionProvider.notifier).send(
PlaybackCommand(
action: playback.isPlaying ? PlaybackAction.pause : PlaybackAction.resume,
),
),
),
IconButton(
icon: const Icon(Icons.stop),
iconSize: 24,
onPressed: () => ref.read(connectionProvider.notifier).send(
PlaybackCommand(action: PlaybackAction.stop),
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: playback.duration.inMilliseconds > 0
? playback.position.inMilliseconds / playback.duration.inMilliseconds
: 0,
backgroundColor: MirrorTheme.surfaceLight,
color: MirrorTheme.accent,
minHeight: 2,
),
],
),
);
}
}
// --- YouTube Tab ---
class _YouTubeTab extends ConsumerStatefulWidget {
const _YouTubeTab();
@override
ConsumerState<_YouTubeTab> createState() => _YouTubeTabState();
}
class _YouTubeTabState extends ConsumerState<_YouTubeTab> {
final _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final searchState = ref.watch(youtubeSearchProvider);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search YouTube...',
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
ref.read(youtubeSearchProvider.notifier).clear();
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
filled: true,
fillColor: MirrorTheme.surface,
),
textInputAction: TextInputAction.search,
onSubmitted: (query) {
ref.read(youtubeSearchProvider.notifier).search(query);
},
),
),
Expanded(
child: searchState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Text('Search failed: $err', style: const TextStyle(color: MirrorTheme.danger)),
),
data: (data) {
if (data.results.isEmpty && data.query.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.smart_display, size: 64, color: MirrorTheme.textMuted),
const SizedBox(height: 16),
Text(
'Search for videos to play',
style: TextStyle(color: MirrorTheme.textMuted),
),
],
),
);
}
if (data.results.isEmpty) {
return const Center(child: Text('No results found'));
}
return ListView.builder(
itemCount: data.results.length,
itemBuilder: (context, index) {
final video = data.results[index];
return _YouTubeResultTile(video: video);
},
);
},
),
),
],
);
}
}
class _YouTubeResultTile extends ConsumerWidget {
final YouTubeVideo video;
const _YouTubeResultTile({required this.video});
@override
Widget build(BuildContext context, WidgetRef ref) {
final duration = _formatDuration(video.duration);
return ListTile(
leading: SizedBox(
width: 64,
height: 48,
child: video.thumbnailUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
video.thumbnailUrl!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: MirrorTheme.surface,
child: const Icon(Icons.smart_display, size: 24),
),
),
)
: Container(
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.smart_display, size: 24),
),
),
title: Text(
video.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
subtitle: Text(
'${video.author} · $duration',
style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted),
),
onTap: () {
final url = 'https://www.youtube.com/watch?v=${video.id}';
ref.read(connectionProvider.notifier).send(
MediaCommand(
action: MediaAction.playYouTube,
payload: {'url': url, 'title': video.title},
),
);
},
);
}
String _formatDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes % 60;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
if (h > 0) return '$h:${m.toString().padLeft(2, '0')}:$s';
return '$m:$s';
}
}
// --- Plex Tab ---
class _PlexTab extends ConsumerWidget {
const _PlexTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final plexState = ref.watch(plexBrowseProvider);
return plexState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: MirrorTheme.danger),
const SizedBox(height: 16),
Text('Plex connection failed', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
Text(
'Configure Plex in settings',
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.invalidate(plexBrowseProvider),
child: const Text('Retry'),
),
],
),
),
data: (data) {
if (data.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.movie, size: 48, color: MirrorTheme.textMuted),
const SizedBox(height: 16),
Text(data.error!, style: TextStyle(color: MirrorTheme.textMuted)),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.invalidate(plexBrowseProvider),
child: const Text('Retry'),
),
],
),
);
}
// Show library list
if (data.currentLibrary == null) {
return _PlexLibraryList(libraries: data.libraries);
}
// Show items in library
return _PlexItemList(data: data);
},
);
}
}
class _PlexLibraryList extends ConsumerWidget {
final List<PlexLibrary> libraries;
const _PlexLibraryList({required this.libraries});
@override
Widget build(BuildContext context, WidgetRef ref) {
if (libraries.isEmpty) {
return const Center(
child: Text('No Plex libraries found', style: TextStyle(color: MirrorTheme.textMuted)),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: libraries.length,
itemBuilder: (context, index) {
final lib = libraries[index];
final icon = switch (lib.type) {
'movie' => Icons.movie,
'show' => Icons.tv,
'artist' => Icons.music_note,
_ => Icons.folder,
};
return ListTile(
leading: Icon(icon, color: MirrorTheme.accent),
title: Text(lib.title),
subtitle: Text(lib.type, style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 12)),
trailing: const Icon(Icons.chevron_right),
onTap: () => ref.read(plexBrowseProvider.notifier).openLibrary(lib),
);
},
);
}
}
class _PlexItemList extends ConsumerWidget {
final PlexBrowseState data;
const _PlexItemList({required this.data});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
// Breadcrumb / back
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => ref.read(plexBrowseProvider.notifier).goBack(),
),
Expanded(
child: Text(
data.breadcrumb.join(' / '),
style: const TextStyle(fontSize: 13, color: MirrorTheme.textMuted),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
Expanded(
child: ListView.builder(
itemCount: data.items.length,
itemBuilder: (context, index) {
final item = data.items[index];
final isPlayable = item.partKey != null;
final hasChildren = item.type == 'show' ||
item.type == 'season' ||
item.type == 'artist' ||
item.type == 'album';
return ListTile(
leading: SizedBox(
width: 48,
height: 48,
child: Container(
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
isPlayable ? Icons.play_circle_outline : Icons.folder,
color: MirrorTheme.accent,
),
),
),
title: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
[
item.type,
if (item.year != null) '${item.year}',
if (item.duration != null) _formatMs(item.duration!),
].join(' · '),
style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted),
),
trailing: hasChildren
? const Icon(Icons.chevron_right)
: isPlayable
? const Icon(Icons.play_arrow, color: MirrorTheme.accent)
: null,
onTap: () {
if (hasChildren) {
ref.read(plexBrowseProvider.notifier).openItem(item);
} else if (isPlayable) {
final plex = ref.read(plexServiceProvider);
if (plex == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Plex not configured')),
);
return;
}
final streamUrl = plex.getStreamUrl(item.partKey!);
ref.read(connectionProvider.notifier).send(
MediaCommand(
action: MediaAction.playPlex,
payload: {'streamUrl': streamUrl, 'title': item.title},
),
);
}
},
);
},
),
),
],
);
}
String _formatMs(int ms) {
final d = Duration(milliseconds: ms);
final h = d.inHours;
final m = d.inMinutes % 60;
if (h > 0) return '${h}h ${m}m';
return '${m}m';
}
}
// --- Spotify Tab ---
class _SpotifyTab extends ConsumerWidget {
const _SpotifyTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
final spotify = ref.watch(mirrorStateProvider.select((s) => s?.spotify));
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Now playing section
if (spotify != null && spotify.trackName != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
if (spotify.albumImageUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.network(
spotify.albumImageUrl!,
width: 120,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: 120,
height: 120,
color: MirrorTheme.surfaceLight,
child: const Icon(Icons.music_note, size: 48),
),
),
),
const SizedBox(height: 16),
Text(
spotify.trackName ?? '',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (spotify.artistName != null)
Text(
spotify.artistName!,
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
const SizedBox(height: 16),
// Progress
LinearProgressIndicator(
value: spotify.durationMs > 0
? spotify.progressMs / spotify.durationMs
: 0,
backgroundColor: MirrorTheme.surfaceLight,
color: const Color(0xFF1DB954), // Spotify green
minHeight: 3,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_fmtMs(spotify.progressMs),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 11),
),
Text(
_fmtMs(spotify.durationMs),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 11),
),
],
),
const SizedBox(height: 16),
// Transport
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.skip_previous),
iconSize: 36,
onPressed: () => ref.read(connectionProvider.notifier).send(
MediaCommand(action: MediaAction.spotifyPrevious),
),
),
const SizedBox(width: 16),
IconButton.filled(
icon: Icon(spotify.isPlaying ? Icons.pause : Icons.play_arrow),
iconSize: 48,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF1DB954),
),
onPressed: () => ref.read(connectionProvider.notifier).send(
MediaCommand(
action: spotify.isPlaying
? MediaAction.spotifyPause
: MediaAction.spotifyPlay,
),
),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.skip_next),
iconSize: 36,
onPressed: () => ref.read(connectionProvider.notifier).send(
MediaCommand(action: MediaAction.spotifyNext),
),
),
],
),
],
),
),
] else ...[
// Not connected / nothing playing
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(Icons.music_note, size: 48, color: MirrorTheme.textMuted),
const SizedBox(height: 16),
Text(
'Spotify',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
spotify == null
? 'Not connected'
: 'Nothing playing',
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
const SizedBox(height: 16),
OutlinedButton.icon(
icon: const Icon(Icons.link),
label: const Text('Connect Spotify'),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify OAuth not yet configured')),
);
},
),
],
),
),
],
],
),
);
}
String _fmtMs(int ms) {
final d = Duration(milliseconds: ms);
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}
+950
View File
@@ -0,0 +1,950 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_core/mirror_core.dart';
import 'package:mirror_protocol/mirror_protocol.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/connection_provider.dart';
import '../providers/workout_browse_provider.dart';
import '../providers/programs_provider.dart';
class RemoteWorkout extends ConsumerWidget {
const RemoteWorkout({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final playback = ref.watch(mirrorStateProvider.select((s) => s?.playback));
final workout = ref.watch(mirrorStateProvider.select((s) => s?.workout));
final program = ref.watch(mirrorStateProvider.select((s) => s?.program));
// Program playback view
if (program != null && program.phase != 'completed') {
return _ProgramPlaybackView(program: program, playback: playback);
}
// If we're playing/resting, show playback controls
if (workout != null &&
(workout.phase == WorkoutPhase.playing || workout.phase == WorkoutPhase.resting)) {
return _PlaybackView(workout: workout, playback: playback);
}
if (workout != null && workout.phase == WorkoutPhase.completed) {
return _CompletedView();
}
// Otherwise show the browse view with programs toggle
return const _WorkoutTabs();
}
}
class _WorkoutTabs extends ConsumerStatefulWidget {
const _WorkoutTabs();
@override
ConsumerState<_WorkoutTabs> createState() => _WorkoutTabsState();
}
class _WorkoutTabsState extends ConsumerState<_WorkoutTabs>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
TabBar(
controller: _tabController,
tabs: const [
Tab(text: 'Videos'),
Tab(text: 'Programs'),
],
),
Expanded(
child: TabBarView(
controller: _tabController,
children: const [
_BrowseView(),
_ProgramsView(),
],
),
),
],
);
}
}
// --- Programs View ---
class _ProgramsView extends ConsumerWidget {
const _ProgramsView();
@override
Widget build(BuildContext context, WidgetRef ref) {
final programsAsync = ref.watch(programsProvider);
return programsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: MirrorTheme.danger),
const SizedBox(height: 16),
Text('Failed to load programs', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
Text(err.toString(), style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(programsProvider.notifier).refresh(),
child: const Text('Retry'),
),
],
),
),
data: (programs) {
if (programs.isEmpty) {
return const Center(
child: Text(
'No programs found',
style: TextStyle(color: MirrorTheme.textMuted),
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: programs.length,
itemBuilder: (context, index) {
final program = programs[index];
return _ProgramCard(program: program);
},
);
},
);
}
}
class _ProgramCard extends ConsumerWidget {
final Program program;
const _ProgramCard({required this.program});
@override
Widget build(BuildContext context, WidgetRef ref) {
final completedCount = program.workouts.where((w) => w.completedAt != null).length;
final totalCount = program.workouts.length;
final nextWorkout = program.currentWorkoutIndex < totalCount
? program.workouts[program.currentWorkoutIndex]
: null;
return Card(
color: MirrorTheme.surface,
margin: const EdgeInsets.only(bottom: 12),
child: ExpansionTile(
leading: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: MirrorTheme.accent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.event_note, color: MirrorTheme.accent),
),
title: Text(program.name, style: const TextStyle(fontWeight: FontWeight.w600)),
subtitle: Text(
'$completedCount/$totalCount workouts completed',
style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
children: [
if (nextWorkout != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _startWorkout(ref, program, nextWorkout),
icon: const Icon(Icons.play_arrow),
label: Text('Start: ${nextWorkout.name}'),
),
),
),
const SizedBox(height: 8),
...program.workouts.map((workout) {
final isCurrent = workout.position == program.currentWorkoutIndex;
final isCompleted = workout.completedAt != null;
return ListTile(
dense: true,
leading: Icon(
isCompleted
? Icons.check_circle
: isCurrent
? Icons.arrow_right
: Icons.circle_outlined,
color: isCompleted
? MirrorTheme.success
: isCurrent
? MirrorTheme.accent
: MirrorTheme.textMuted,
size: 20,
),
title: Text(
workout.name,
style: TextStyle(
fontSize: 14,
color: isCompleted ? MirrorTheme.textMuted : MirrorTheme.textPrimary,
decoration: isCompleted ? TextDecoration.lineThrough : null,
),
),
subtitle: Text(
'${workout.exercises.length} exercises',
style: const TextStyle(fontSize: 12, color: MirrorTheme.textMuted),
),
trailing: isCurrent && !isCompleted
? const Text('NEXT', style: TextStyle(color: MirrorTheme.accent, fontSize: 11, fontWeight: FontWeight.bold))
: null,
onTap: () => _startWorkout(ref, program, workout),
);
}),
const SizedBox(height: 8),
],
),
);
}
void _startWorkout(WidgetRef ref, Program program, ProgramWorkout workout) {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.workout),
);
ref.read(connectionProvider.notifier).send(
ProgramCommand(
action: ProgramAction.playWorkout,
payload: {
'programId': program.id,
'workoutId': workout.id,
'exercises': workout.exercises.map((e) => e.toJson()).toList(),
},
),
);
}
}
// --- Program Playback View ---
class _ProgramPlaybackView extends ConsumerWidget {
final ProgramSessionState program;
final PlaybackState? playback;
const _ProgramPlaybackView({required this.program, this.playback});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isResting = program.phase == 'resting';
final isInstruction = program.phase == 'instruction';
final exerciseName = program.currentExerciseIndex < program.exercises.length
? program.exercises[program.currentExerciseIndex]
: 'Exercise';
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
// Program & workout name
Text(
program.programName,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: MirrorTheme.accent,
letterSpacing: 1,
),
),
const SizedBox(height: 4),
Text(
program.workoutName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
// Exercise progress
Text(
'${program.currentExerciseIndex + 1} / ${program.exercises.length}',
style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 13),
),
const Spacer(),
if (isResting) ...[
Text(
'REST',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: MirrorTheme.accent,
letterSpacing: 6,
),
),
const SizedBox(height: 20),
TimerDisplay(seconds: program.timerRemaining),
const SizedBox(height: 32),
FilledButton(
onPressed: () => ref.read(connectionProvider.notifier).send(
ProgramCommand(action: ProgramAction.skipExercise),
),
child: const Text('Skip Rest'),
),
] else if (isInstruction) ...[
Icon(Icons.info_outline, size: 48, color: MirrorTheme.accent),
const SizedBox(height: 16),
Text(
exerciseName,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
if (program.timerRemaining > 0) ...[
const SizedBox(height: 16),
TimerDisplay(seconds: program.timerRemaining),
],
] else ...[
// Playing
Text(
exerciseName,
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Set counter
if (program.totalSets > 1)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'Set ${program.currentSet} of ${program.totalSets}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
),
// Playback progress
if (playback != null) ...[
const SizedBox(height: 24),
LinearProgressIndicator(
value: playback!.duration.inMilliseconds > 0
? playback!.position.inMilliseconds / playback!.duration.inMilliseconds
: 0,
backgroundColor: MirrorTheme.surfaceLight,
color: MirrorTheme.accent,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(playback!.position),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)),
Text(_fmt(playback!.duration),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)),
],
),
],
// Type indicator
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.play_circle_outline, size: 16, color: MirrorTheme.textMuted),
const SizedBox(width: 4),
Text(
'VIDEO',
style: TextStyle(color: MirrorTheme.textMuted, fontSize: 11, letterSpacing: 2),
),
],
),
],
const Spacer(),
// Transport controls
if (!isResting)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 40,
icon: const Icon(Icons.skip_previous),
onPressed: () => ref.read(connectionProvider.notifier).send(
ProgramCommand(action: ProgramAction.previousExercise),
),
),
const SizedBox(width: 16),
IconButton.filled(
iconSize: 56,
icon: Icon(
program.phase == 'playing' ? Icons.pause : Icons.play_arrow,
),
onPressed: () => ref.read(connectionProvider.notifier).send(
ProgramCommand(
action: program.phase == 'playing'
? ProgramAction.pauseExercise
: ProgramAction.resumeExercise,
),
),
),
const SizedBox(width: 16),
IconButton(
iconSize: 40,
icon: const Icon(Icons.skip_next),
onPressed: () => ref.read(connectionProvider.notifier).send(
ProgramCommand(action: ProgramAction.skipExercise),
),
),
],
),
const SizedBox(height: 20),
TextButton.icon(
icon: const Icon(Icons.stop, color: MirrorTheme.danger),
label: const Text('End Program', style: TextStyle(color: MirrorTheme.danger)),
onPressed: () => ref.read(connectionProvider.notifier).send(
ProgramCommand(action: ProgramAction.stopProgram),
),
),
const SizedBox(height: 20),
],
),
);
}
String _fmt(Duration d) {
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}
// --- Videos Browse View (existing) ---
class _BrowseView extends ConsumerWidget {
const _BrowseView();
@override
Widget build(BuildContext context, WidgetRef ref) {
final browseState = ref.watch(workoutBrowseProvider);
return browseState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: MirrorTheme.danger),
const SizedBox(height: 16),
Text('Failed to load videos', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 8),
Text(err.toString(), style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 16),
FilledButton(
onPressed: () => ref.read(workoutBrowseProvider.notifier).browse(''),
child: const Text('Retry'),
),
],
),
),
data: (data) => _BrowseContent(data: data),
);
}
}
class _BrowseContent extends ConsumerWidget {
final WorkoutBrowseState data;
const _BrowseContent({required this.data});
@override
Widget build(BuildContext context, WidgetRef ref) {
final path = data.currentPath;
final pathParts = path.isNotEmpty ? path.split('/') : <String>[];
final subfolders = _getSubfolders(data.folders, path);
final stats = data.stats;
// Only show videos directly in this folder (not in subfolders)
final videos = data.videos.where((v) {
final vFolder = v.folder ?? '';
return vFolder == path;
}).toList();
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stats
if (stats != null)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
_StatChip('${stats.total}', 'videos'),
const SizedBox(width: 8),
_StatChip('${stats.completed}', 'done'),
const SizedBox(width: 8),
_StatChip('${stats.inProgress}', 'started'),
],
),
),
// Breadcrumb
_Breadcrumb(pathParts: pathParts, ref: ref),
// Filter chips
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: WorkoutFilter.values.map((f) {
final active = f == data.filter;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f.name),
selected: active,
onSelected: (_) {
ref.read(workoutBrowseProvider.notifier).setFilter(f);
},
),
);
}).toList(),
),
),
),
// Play all button
if (videos.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _playFolder(ref, false),
icon: const Icon(Icons.play_arrow),
label: Text('Play All (${videos.length})'),
),
),
),
],
),
),
),
// Subfolders
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final folder = subfolders[index];
final name = folder.split('/').last;
return ListTile(
leading: const Icon(Icons.folder, color: MirrorTheme.accent),
title: Text(name),
trailing: const Icon(Icons.chevron_right),
onTap: () {
ref.read(workoutBrowseProvider.notifier).browse(folder);
},
);
},
childCount: subfolders.length,
),
),
// Videos
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final video = videos[index];
final completed = video.percentCompleted >= 95;
final thumbUrl = video.thumbnailPath != null
? 'http://192.168.86.172:8091/${video.thumbnailPath}'
: null;
return ListTile(
leading: SizedBox(
width: 56,
height: 56,
child: thumbUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
thumbUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
color: MirrorTheme.surface,
child: const Icon(Icons.play_circle_outline, size: 24),
),
),
)
: Container(
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(6),
),
child: const Icon(Icons.play_circle_outline, size: 24),
),
),
title: Text(
video.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
'${_formatSeconds(video.duration)}'
'${completed ? ' · Done' : video.percentCompleted > 0 ? ' · ${video.percentCompleted}%' : ''}',
),
trailing: ProgressRing(
percent: video.percentCompleted.toDouble(),
size: 28,
),
onTap: () => _playVideo(ref, video),
);
},
childCount: videos.length,
),
),
if (subfolders.isEmpty && videos.isEmpty)
const SliverFillRemaining(
child: Center(
child: Text('No videos here', style: TextStyle(color: MirrorTheme.textMuted)),
),
),
],
);
}
void _playVideo(WidgetRef ref, VideoState video) {
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.workout),
);
ref.read(connectionProvider.notifier).send(
WorkoutCommand(
action: WorkoutAction.playVideo,
payload: {
'videoId': video.id,
'title': video.title,
'path': video.path,
'duration': video.duration,
'trimStart': video.trimStart,
'trimEnd': video.trimEnd,
'restSeconds': video.restSeconds,
'lastPosition': video.lastPosition,
},
),
);
}
void _playFolder(WidgetRef ref, bool unwatchedOnly) {
final videos = unwatchedOnly
? data.videos.where((v) => v.percentCompleted < 95).toList()
: data.videos;
if (videos.isEmpty) return;
ref.read(connectionProvider.notifier).send(
NavigateCommand(target: ScreenType.workout),
);
ref.read(connectionProvider.notifier).send(
WorkoutCommand(
action: WorkoutAction.playFolder,
payload: {
'videos': videos.map((v) => {
'videoId': v.id,
'title': v.title,
'path': v.path,
'duration': v.duration,
'trimStart': v.trimStart,
'trimEnd': v.trimEnd,
'restSeconds': v.restSeconds,
'lastPosition': v.lastPosition,
}).toList(),
},
),
);
}
List<String> _getSubfolders(List<String> allFolders, String currentPath) {
if (currentPath.isEmpty) {
final top = <String>{};
for (final f in allFolders) {
final first = f.split('/').first;
if (first.isNotEmpty) top.add(first);
}
return top.toList()..sort();
}
final children = <String>{};
for (final f in allFolders) {
if (f.startsWith('$currentPath/')) {
final rest = f.substring(currentPath.length + 1);
final next = rest.split('/').first;
if (next.isNotEmpty) children.add('$currentPath/$next');
}
}
return children.toList()..sort();
}
String _formatSeconds(int s) {
final m = s ~/ 60;
final sec = (s % 60).toString().padLeft(2, '0');
return '$m:$sec';
}
}
class _PlaybackView extends ConsumerWidget {
final WorkoutSessionState workout;
final PlaybackState? playback;
const _PlaybackView({required this.workout, this.playback});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isResting = workout.phase == WorkoutPhase.resting;
final currentItem = workout.currentIndex < workout.queue.length
? workout.queue[workout.currentIndex]
: null;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
const Spacer(),
if (isResting) ...[
Text(
'REST',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: MirrorTheme.accent,
letterSpacing: 6,
),
),
const SizedBox(height: 20),
TimerDisplay(seconds: workout.restRemaining),
const SizedBox(height: 20),
if (currentItem != null)
Text(
'Next: ${currentItem.title}',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
FilledButton(
onPressed: () {
ref.read(connectionProvider.notifier).send(
WorkoutCommand(action: WorkoutAction.skipRest),
);
},
child: const Text('Skip Rest'),
),
] else ...[
Text(
currentItem?.title ?? playback?.title ?? '',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
if (workout.queue.length > 1)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${workout.currentIndex + 1} of ${workout.queue.length}',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (playback != null) ...[
const SizedBox(height: 32),
LinearProgressIndicator(
value: playback!.duration.inMilliseconds > 0
? playback!.position.inMilliseconds / playback!.duration.inMilliseconds
: 0,
backgroundColor: MirrorTheme.surfaceLight,
color: MirrorTheme.accent,
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_fmt(playback!.position),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)),
Text(_fmt(playback!.duration),
style: const TextStyle(fontFamily: MirrorTheme.fontMono, fontSize: 12)),
],
),
],
],
const Spacer(),
if (!isResting)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 40,
icon: const Icon(Icons.skip_previous),
onPressed: () => ref.read(connectionProvider.notifier).send(
WorkoutCommand(action: WorkoutAction.previousVideo),
),
),
const SizedBox(width: 16),
IconButton(
iconSize: 32,
icon: const Icon(Icons.replay_10),
onPressed: () {
if (playback != null) {
final pos = (playback!.position - const Duration(seconds: 10));
ref.read(connectionProvider.notifier).send(
PlaybackCommand(
action: PlaybackAction.seek,
value: pos.inMilliseconds.clamp(0, playback!.duration.inMilliseconds),
),
);
}
},
),
const SizedBox(width: 16),
IconButton.filled(
iconSize: 56,
icon: Icon(playback?.isPlaying == true ? Icons.pause : Icons.play_arrow),
onPressed: () => ref.read(connectionProvider.notifier).send(
PlaybackCommand(
action: playback?.isPlaying == true
? PlaybackAction.pause
: PlaybackAction.resume,
),
),
),
const SizedBox(width: 16),
IconButton(
iconSize: 32,
icon: const Icon(Icons.forward_10),
onPressed: () {
if (playback != null) {
final pos = (playback!.position + const Duration(seconds: 10));
ref.read(connectionProvider.notifier).send(
PlaybackCommand(
action: PlaybackAction.seek,
value: pos.inMilliseconds,
),
);
}
},
),
const SizedBox(width: 16),
IconButton(
iconSize: 40,
icon: const Icon(Icons.skip_next),
onPressed: () => ref.read(connectionProvider.notifier).send(
WorkoutCommand(action: WorkoutAction.skipVideo),
),
),
],
),
const SizedBox(height: 20),
TextButton.icon(
icon: const Icon(Icons.stop, color: MirrorTheme.danger),
label: const Text('End Session', style: TextStyle(color: MirrorTheme.danger)),
onPressed: () => ref.read(connectionProvider.notifier).send(
WorkoutCommand(action: WorkoutAction.stopSession),
),
),
const SizedBox(height: 20),
],
),
);
}
String _fmt(Duration d) {
final m = d.inMinutes;
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
return '$m:$s';
}
}
class _CompletedView extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, size: 80, color: MirrorTheme.success),
const SizedBox(height: 16),
Text('Session Complete', style: Theme.of(context).textTheme.headlineMedium),
const SizedBox(height: 32),
FilledButton(
onPressed: () {
ref.read(workoutBrowseProvider.notifier).browse('');
},
child: const Text('Back to Browse'),
),
],
),
);
}
}
class _StatChip extends StatelessWidget {
final String value;
final String label;
const _StatChip(this.value, this.label);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: MirrorTheme.surface,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(value, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
const SizedBox(width: 4),
Text(label, style: const TextStyle(color: MirrorTheme.textMuted, fontSize: 13)),
],
),
);
}
}
class _Breadcrumb extends StatelessWidget {
final List<String> pathParts;
final WidgetRef ref;
const _Breadcrumb({required this.pathParts, required this.ref});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
GestureDetector(
onTap: () => ref.read(workoutBrowseProvider.notifier).browse(''),
child: const Text('Home', style: TextStyle(color: MirrorTheme.accent, fontSize: 13)),
),
for (int i = 0; i < pathParts.length; i++) ...[
const Text(' / ', style: TextStyle(color: MirrorTheme.textMuted, fontSize: 13)),
GestureDetector(
onTap: () => ref
.read(workoutBrowseProvider.notifier)
.browse(pathParts.sublist(0, i + 1).join('/')),
child: Text(
pathParts[i],
style: TextStyle(
color: i == pathParts.length - 1 ? MirrorTheme.textPrimary : MirrorTheme.accent,
fontSize: 13,
),
),
),
],
],
),
),
);
}
}
@@ -0,0 +1,309 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/settings_provider.dart';
import '../providers/connection_provider.dart';
import 'dashboard_editor_screen.dart';
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
late TextEditingController _mirrorHostCtrl;
late TextEditingController _mirrorPortCtrl;
late TextEditingController _workoutUrlCtrl;
late TextEditingController _plexHostCtrl;
late TextEditingController _plexPortCtrl;
late TextEditingController _plexTokenCtrl;
late TextEditingController _haUrlCtrl;
late TextEditingController _haTokenCtrl;
late TextEditingController _spotifyClientIdCtrl;
bool _initialized = false;
@override
void initState() {
super.initState();
_mirrorHostCtrl = TextEditingController();
_mirrorPortCtrl = TextEditingController();
_workoutUrlCtrl = TextEditingController();
_plexHostCtrl = TextEditingController();
_plexPortCtrl = TextEditingController();
_plexTokenCtrl = TextEditingController();
_haUrlCtrl = TextEditingController();
_haTokenCtrl = TextEditingController();
_spotifyClientIdCtrl = TextEditingController();
}
void _loadFromSettings(AppSettings settings) {
if (_initialized) return;
_initialized = true;
_mirrorHostCtrl.text = settings.mirrorHost;
_mirrorPortCtrl.text = settings.mirrorPort.toString();
_workoutUrlCtrl.text = settings.workoutPlayerUrl;
_plexHostCtrl.text = settings.plexHost ?? '';
_plexPortCtrl.text = settings.plexPort.toString();
_plexTokenCtrl.text = settings.plexToken ?? '';
_haUrlCtrl.text = settings.haBaseUrl ?? '';
_haTokenCtrl.text = settings.haToken ?? '';
_spotifyClientIdCtrl.text = settings.spotifyClientId ?? '';
}
@override
void dispose() {
_mirrorHostCtrl.dispose();
_mirrorPortCtrl.dispose();
_workoutUrlCtrl.dispose();
_plexHostCtrl.dispose();
_plexPortCtrl.dispose();
_plexTokenCtrl.dispose();
_haUrlCtrl.dispose();
_haTokenCtrl.dispose();
_spotifyClientIdCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final settingsAsync = ref.watch(settingsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
backgroundColor: MirrorTheme.background,
actions: [
TextButton(
onPressed: _save,
child: const Text('Save'),
),
],
),
backgroundColor: MirrorTheme.background,
body: settingsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (settings) {
_loadFromSettings(settings);
return _buildForm(context);
},
),
);
}
Widget _buildForm(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
_SectionHeader('Mirror Connection'),
_SettingsField(
controller: _mirrorHostCtrl,
label: 'Host',
hint: 'mirror.local or 192.168.x.x',
icon: Icons.cast_connected,
),
_SettingsField(
controller: _mirrorPortCtrl,
label: 'Port',
hint: '9090',
icon: Icons.numbers,
keyboardType: TextInputType.number,
),
const SizedBox(height: 24),
_SectionHeader('Workout Player'),
_SettingsField(
controller: _workoutUrlCtrl,
label: 'URL',
hint: 'http://192.168.86.172:8091',
icon: Icons.fitness_center,
),
const SizedBox(height: 24),
_SectionHeader('Home Assistant'),
_SettingsField(
controller: _haUrlCtrl,
label: 'URL',
hint: 'http://homeassistant.local:8123',
icon: Icons.home,
),
_SettingsField(
controller: _haTokenCtrl,
label: 'Long-Lived Access Token',
hint: 'eyJ...',
icon: Icons.key,
obscure: true,
),
const SizedBox(height: 24),
_SectionHeader('Dashboard Widgets'),
Card(
color: MirrorTheme.surface,
child: ListTile(
leading: const Icon(Icons.dashboard, color: MirrorTheme.accent),
title: const Text('Dashboard Layout'),
subtitle: const Text('Reorder and configure widgets'),
trailing: const Icon(Icons.chevron_right),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DashboardEditorScreen()),
);
},
),
),
const SizedBox(height: 24),
_SectionHeader('Plex'),
_SettingsField(
controller: _plexHostCtrl,
label: 'Host',
hint: '192.168.86.x',
icon: Icons.movie,
),
_SettingsField(
controller: _plexPortCtrl,
label: 'Port',
hint: '32400',
icon: Icons.numbers,
keyboardType: TextInputType.number,
),
_SettingsField(
controller: _plexTokenCtrl,
label: 'Token',
hint: 'X-Plex-Token',
icon: Icons.key,
obscure: true,
),
const SizedBox(height: 24),
_SectionHeader('Spotify'),
_SettingsField(
controller: _spotifyClientIdCtrl,
label: 'Client ID',
hint: 'From developer.spotify.com',
icon: Icons.music_note,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
// Spotify OAuth flow would go here
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Spotify OAuth not yet implemented')),
);
},
icon: const Icon(Icons.login),
label: const Text('Connect Spotify'),
),
),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _save,
icon: const Icon(Icons.save),
label: const Text('Save Settings'),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
ref.read(connectionProvider.notifier).disconnect();
Navigator.of(context).pop();
},
icon: const Icon(Icons.link_off, color: MirrorTheme.danger),
label: const Text('Disconnect from Mirror', style: TextStyle(color: MirrorTheme.danger)),
),
),
const SizedBox(height: 40),
],
);
}
void _save() {
final settings = AppSettings(
mirrorHost: _mirrorHostCtrl.text.trim(),
mirrorPort: int.tryParse(_mirrorPortCtrl.text.trim()) ?? 9090,
workoutPlayerUrl: _workoutUrlCtrl.text.trim(),
plexHost: _plexHostCtrl.text.trim().isEmpty ? null : _plexHostCtrl.text.trim(),
plexPort: int.tryParse(_plexPortCtrl.text.trim()) ?? 32400,
plexToken: _plexTokenCtrl.text.trim().isEmpty ? null : _plexTokenCtrl.text.trim(),
haBaseUrl: _haUrlCtrl.text.trim().isEmpty ? null : _haUrlCtrl.text.trim(),
haToken: _haTokenCtrl.text.trim().isEmpty ? null : _haTokenCtrl.text.trim(),
spotifyClientId: _spotifyClientIdCtrl.text.trim().isEmpty ? null : _spotifyClientIdCtrl.text.trim(),
);
ref.read(settingsProvider.notifier).save(settings);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Settings saved')),
);
Navigator.of(context).pop();
}
}
class _SectionHeader extends StatelessWidget {
final String title;
const _SectionHeader(this.title);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
title,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: MirrorTheme.accent,
letterSpacing: 1,
),
),
);
}
}
class _SettingsField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final IconData icon;
final bool obscure;
final TextInputType? keyboardType;
const _SettingsField({
required this.controller,
required this.label,
required this.hint,
required this.icon,
this.obscure = false,
this.keyboardType,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: controller,
obscureText: obscure,
keyboardType: keyboardType,
decoration: InputDecoration(
labelText: label,
hintText: hint,
filled: true,
fillColor: MirrorTheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
prefixIcon: Icon(icon, size: 20),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mirror_ui/mirror_ui.dart';
import '../providers/ha_provider.dart';
class WeatherPickerScreen extends ConsumerStatefulWidget {
final String? selectedEntityId;
const WeatherPickerScreen({super.key, this.selectedEntityId});
@override
ConsumerState<WeatherPickerScreen> createState() => _WeatherPickerScreenState();
}
class _WeatherPickerScreenState extends ConsumerState<WeatherPickerScreen> {
String? _selected;
@override
void initState() {
super.initState();
_selected = widget.selectedEntityId;
}
@override
Widget build(BuildContext context) {
final entitiesAsync = ref.watch(haEntitiesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Select Weather Entity'),
backgroundColor: MirrorTheme.background,
actions: [
TextButton(
onPressed: _selected != null ? () => Navigator.of(context).pop(_selected) : null,
child: const Text('Done'),
),
],
),
backgroundColor: MirrorTheme.background,
body: entitiesAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
data: (state) {
if (state.error != null) {
return Center(child: Text(state.error!));
}
final weatherEntities = state.entities
.where((e) => e.entityId.startsWith('weather.'))
.toList();
if (weatherEntities.isEmpty) {
return const Center(
child: Text('No weather entities found in Home Assistant'),
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: weatherEntities.length,
itemBuilder: (context, index) {
final entity = weatherEntities[index];
final name =
(entity.attributes['friendly_name'] as String?) ?? entity.entityId;
return RadioListTile<String>(
title: Text(name),
subtitle: Text(entity.entityId),
value: entity.entityId,
groupValue: _selected,
activeColor: MirrorTheme.accent,
onChanged: (value) => setState(() => _selected = value),
);
},
);
},
),
);
}
}
+28
View File
@@ -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