Preserve Flutter mirror-os codebase
This commit is contained in:
@@ -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
|
||||
@@ -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'
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
+34
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
@@ -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
@@ -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
|
||||
@@ -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'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user