Preserve Flutter mirror-os codebase

This commit is contained in:
Chris
2026-07-11 21:51:34 -05:00
commit 27631ed108
142 changed files with 11896 additions and 0 deletions
@@ -0,0 +1,9 @@
library mirror_core;
export 'models/program.dart';
export 'services/home_assistant_service.dart';
export 'services/plex_service.dart';
export 'services/programs_api_client.dart';
export 'services/spotify_service.dart';
export 'services/workout_api_client.dart';
export 'services/youtube_service.dart';
@@ -0,0 +1,153 @@
class ProgramExercise {
final String id;
final String workoutId;
final int position;
final String type; // 'video' | 'youtube' | 'instruction'
final String? videoPath;
final String? videoId;
final String? youtubeUrl;
final String? instructionTitle;
final String? instructionText;
final int? durationSeconds;
final int restSeconds;
final int sets;
final int? setDurationSeconds;
ProgramExercise({
required this.id,
required this.workoutId,
required this.position,
required this.type,
this.videoPath,
this.videoId,
this.youtubeUrl,
this.instructionTitle,
this.instructionText,
this.durationSeconds,
required this.restSeconds,
required this.sets,
this.setDurationSeconds,
});
factory ProgramExercise.fromJson(Map<String, dynamic> json) {
return ProgramExercise(
id: json['id'] as String,
workoutId: json['workoutId'] as String,
position: (json['position'] as num).toInt(),
type: json['type'] as String,
videoPath: json['videoPath'] as String?,
videoId: json['videoId'] as String?,
youtubeUrl: json['youtubeUrl'] as String?,
instructionTitle: json['instructionTitle'] as String?,
instructionText: json['instructionText'] as String?,
durationSeconds: (json['durationSeconds'] as num?)?.toInt(),
restSeconds: (json['restSeconds'] as num?)?.toInt() ?? 0,
sets: (json['sets'] as num?)?.toInt() ?? 1,
setDurationSeconds: (json['setDurationSeconds'] as num?)?.toInt(),
);
}
Map<String, dynamic> toJson() => {
'id': id,
'workoutId': workoutId,
'position': position,
'type': type,
if (videoPath != null) 'videoPath': videoPath,
if (videoId != null) 'videoId': videoId,
if (youtubeUrl != null) 'youtubeUrl': youtubeUrl,
if (instructionTitle != null) 'instructionTitle': instructionTitle,
if (instructionText != null) 'instructionText': instructionText,
if (durationSeconds != null) 'durationSeconds': durationSeconds,
'restSeconds': restSeconds,
'sets': sets,
if (setDurationSeconds != null) 'setDurationSeconds': setDurationSeconds,
};
}
class ProgramWorkout {
final String id;
final String programId;
final String name;
final int position;
final DateTime? completedAt;
final List<ProgramExercise> exercises;
ProgramWorkout({
required this.id,
required this.programId,
required this.name,
required this.position,
this.completedAt,
required this.exercises,
});
factory ProgramWorkout.fromJson(Map<String, dynamic> json) {
return ProgramWorkout(
id: json['id'] as String,
programId: json['programId'] as String,
name: json['name'] as String,
position: (json['position'] as num).toInt(),
completedAt: json['completedAt'] != null
? DateTime.parse(json['completedAt'] as String)
: null,
exercises: (json['exercises'] as List<dynamic>?)
?.map((e) => ProgramExercise.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'programId': programId,
'name': name,
'position': position,
'completedAt': completedAt?.toIso8601String(),
'exercises': exercises.map((e) => e.toJson()).toList(),
};
}
class Program {
final String id;
final String name;
final String? description;
final int currentWorkoutIndex;
final DateTime createdAt;
final DateTime updatedAt;
final List<ProgramWorkout> workouts;
Program({
required this.id,
required this.name,
this.description,
required this.currentWorkoutIndex,
required this.createdAt,
required this.updatedAt,
required this.workouts,
});
factory Program.fromJson(Map<String, dynamic> json) {
return Program(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
currentWorkoutIndex: (json['currentWorkoutIndex'] as num?)?.toInt() ?? 0,
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
workouts: (json['workouts'] as List<dynamic>?)
?.map((e) => ProgramWorkout.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (description != null) 'description': description,
'currentWorkoutIndex': currentWorkoutIndex,
'createdAt': createdAt.toIso8601String(),
'updatedAt': updatedAt.toIso8601String(),
'workouts': workouts.map((w) => w.toJson()).toList(),
};
}
@@ -0,0 +1,243 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class HaEntity {
final String entityId;
final String state;
final Map<String, dynamic> attributes;
final DateTime? lastChanged;
HaEntity({
required this.entityId,
required this.state,
required this.attributes,
this.lastChanged,
});
factory HaEntity.fromJson(Map<String, dynamic> json) => HaEntity(
entityId: json['entity_id'] as String,
state: json['state'] as String? ?? '',
attributes:
(json['attributes'] as Map<String, dynamic>?) ?? const {},
lastChanged: json['last_changed'] != null
? DateTime.tryParse(json['last_changed'] as String)
: null,
);
Map<String, dynamic> toJson() => {
'entity_id': entityId,
'state': state,
'attributes': attributes,
'last_changed': lastChanged?.toIso8601String(),
};
}
class HaForecast {
final DateTime datetime;
final double temperature;
final String condition;
HaForecast({
required this.datetime,
required this.temperature,
required this.condition,
});
factory HaForecast.fromJson(Map<String, dynamic> json) => HaForecast(
datetime: DateTime.parse(json['datetime'] as String),
temperature: (json['temperature'] as num).toDouble(),
condition: json['condition'] as String? ?? '',
);
Map<String, dynamic> toJson() => {
'datetime': datetime.toIso8601String(),
'temperature': temperature,
'condition': condition,
};
}
class HaWeather {
final double temperature;
final double humidity;
final String condition;
final List<HaForecast> forecast;
HaWeather({
required this.temperature,
required this.humidity,
required this.condition,
required this.forecast,
});
factory HaWeather.fromEntity(HaEntity entity) {
final attrs = entity.attributes;
final rawForecast = attrs['forecast'] as List<dynamic>? ?? [];
return HaWeather(
temperature: (attrs['temperature'] as num?)?.toDouble() ?? 0,
humidity: (attrs['humidity'] as num?)?.toDouble() ?? 0,
condition: entity.state,
forecast: rawForecast
.map((e) => HaForecast.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() => {
'temperature': temperature,
'humidity': humidity,
'condition': condition,
'forecast': forecast.map((f) => f.toJson()).toList(),
};
}
class HaCalendarEvent {
final String summary;
final DateTime start;
final DateTime end;
final String? description;
HaCalendarEvent({
required this.summary,
required this.start,
required this.end,
this.description,
});
factory HaCalendarEvent.fromJson(Map<String, dynamic> json) =>
HaCalendarEvent(
summary: json['summary'] as String? ?? '',
start: _parseDateTime(json['start']),
end: _parseDateTime(json['end']),
description: json['description'] as String?,
);
static DateTime _parseDateTime(dynamic value) {
if (value is Map<String, dynamic>) {
// HA returns {dateTime: "..."} or {date: "..."}
final dt = value['dateTime'] as String? ?? value['date'] as String? ?? '';
return DateTime.parse(dt);
}
return DateTime.parse(value as String);
}
Map<String, dynamic> toJson() => {
'summary': summary,
'start': start.toIso8601String(),
'end': end.toIso8601String(),
if (description != null) 'description': description,
};
}
class HomeAssistantService {
final String baseUrl;
final String token;
final http.Client _client;
HomeAssistantService({
required this.baseUrl,
required this.token,
http.Client? client,
}) : _client = client ?? http.Client();
Map<String, String> get _headers => {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
};
/// GET /api/states — all entity states.
Future<List<HaEntity>> getStates() async {
final response = await _client.get(
Uri.parse('$baseUrl/api/states'),
headers: _headers,
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch states: ${response.statusCode}');
}
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((e) => HaEntity.fromJson(e as Map<String, dynamic>))
.toList();
}
/// GET /api/states/{entity_id} — single entity state.
Future<HaEntity?> getState(String entityId) async {
final response = await _client.get(
Uri.parse('$baseUrl/api/states/$entityId'),
headers: _headers,
);
if (response.statusCode == 404) return null;
if (response.statusCode != 200) {
throw Exception('Failed to fetch state: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return HaEntity.fromJson(data);
}
/// POST /api/services/{domain}/{service} — call a service.
Future<bool> callService(
String domain,
String service, {
String? entityId,
Map<String, dynamic>? data,
}) async {
final body = <String, dynamic>{
if (entityId != null) 'entity_id': entityId,
if (data != null) ...data,
};
final response = await _client.post(
Uri.parse('$baseUrl/api/services/$domain/$service'),
headers: _headers,
body: jsonEncode(body),
);
return response.statusCode == 200;
}
/// Parse a weather entity into structured weather data.
Future<HaWeather?> getWeather(String entityId) async {
final entity = await getState(entityId);
if (entity == null) return null;
return HaWeather.fromEntity(entity);
}
/// GET /api/calendars/{entity_id}?start=...&end=...
Future<List<HaCalendarEvent>> getCalendarEvents(
String entityId, {
DateTime? start,
DateTime? end,
}) async {
final now = DateTime.now();
final queryStart = start ?? now;
final queryEnd = end ?? now.add(const Duration(days: 7));
final uri =
Uri.parse('$baseUrl/api/calendars/$entityId').replace(queryParameters: {
'start': queryStart.toIso8601String(),
'end': queryEnd.toIso8601String(),
});
final response = await _client.get(uri, headers: _headers);
if (response.statusCode != 200) {
throw Exception(
'Failed to fetch calendar events: ${response.statusCode}');
}
final data = jsonDecode(response.body) as List<dynamic>;
return data
.map((e) => HaCalendarEvent.fromJson(e as Map<String, dynamic>))
.toList();
}
/// Convenience: turn on an entity.
Future<bool> turnOn(String entityId) async {
final domain = entityId.split('.').first;
return callService(domain, 'turn_on', entityId: entityId);
}
/// Convenience: turn off an entity.
Future<bool> turnOff(String entityId) async {
final domain = entityId.split('.').first;
return callService(domain, 'turn_off', entityId: entityId);
}
void dispose() => _client.close();
}
@@ -0,0 +1,178 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class PlexService {
final String host;
final int port;
final String token;
final http.Client _client;
PlexService({
required this.host,
required this.port,
required this.token,
http.Client? client,
}) : _client = client ?? http.Client();
Uri _uri(String path, [Map<String, String>? queryParameters]) {
return Uri(
scheme: 'http',
host: host,
port: port,
path: path,
queryParameters: queryParameters,
);
}
Map<String, String> get _headers => {
'X-Plex-Token': token,
'Accept': 'application/json',
};
Future<List<PlexLibrary>> fetchLibraries() async {
final response = await _client.get(_uri('/library/sections'), headers: _headers);
if (response.statusCode != 200) {
throw PlexException('Failed to fetch libraries: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final container = json['MediaContainer'] as Map<String, dynamic>;
final directories = (container['Directory'] as List?) ?? [];
return directories
.map((d) => PlexLibrary.fromJson(d as Map<String, dynamic>))
.toList();
}
Future<List<PlexItem>> fetchItems(String libraryId) async {
final response = await _client.get(
_uri('/library/sections/$libraryId/all'),
headers: _headers,
);
if (response.statusCode != 200) {
throw PlexException('Failed to fetch items: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final container = json['MediaContainer'] as Map<String, dynamic>;
final metadata = (container['Metadata'] as List?) ?? [];
return metadata
.map((m) => PlexItem.fromJson(m as Map<String, dynamic>))
.toList();
}
Future<List<PlexItem>> fetchChildren(String itemId) async {
final response = await _client.get(
_uri('/library/metadata/$itemId/children'),
headers: _headers,
);
if (response.statusCode != 200) {
throw PlexException('Failed to fetch children: ${response.statusCode}');
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final container = json['MediaContainer'] as Map<String, dynamic>;
final metadata = (container['Metadata'] as List?) ?? [];
return metadata
.map((m) => PlexItem.fromJson(m as Map<String, dynamic>))
.toList();
}
String getStreamUrl(String partKey) {
return _uri(partKey, {'X-Plex-Token': token}).toString();
}
String getThumbnailUrl(String thumbPath, {int width = 200, int height = 200}) {
return _uri('/photo/:/transcode', {
'url': thumbPath,
'width': width.toString(),
'height': height.toString(),
'X-Plex-Token': token,
}).toString();
}
void dispose() => _client.close();
}
class PlexLibrary {
final String id;
final String title;
final String type;
const PlexLibrary({
required this.id,
required this.title,
required this.type,
});
factory PlexLibrary.fromJson(Map<String, dynamic> json) {
return PlexLibrary(
id: json['key'] as String,
title: json['title'] as String,
type: json['type'] as String,
);
}
Map<String, dynamic> toJson() => {
'key': id,
'title': title,
'type': type,
};
}
class PlexItem {
final String id;
final String title;
final String type;
final int? year;
final int? duration;
final String? thumbPath;
final String? partKey;
const PlexItem({
required this.id,
required this.title,
required this.type,
this.year,
this.duration,
this.thumbPath,
this.partKey,
});
factory PlexItem.fromJson(Map<String, dynamic> json) {
// Extract partKey from nested Media → Part structure
String? partKey;
final media = json['Media'] as List?;
if (media != null && media.isNotEmpty) {
final parts = (media[0] as Map<String, dynamic>)['Part'] as List?;
if (parts != null && parts.isNotEmpty) {
partKey = (parts[0] as Map<String, dynamic>)['key'] as String?;
}
}
return PlexItem(
id: json['ratingKey'] as String,
title: json['title'] as String,
type: json['type'] as String,
year: json['year'] as int?,
duration: json['duration'] as int?,
thumbPath: json['thumb'] as String?,
partKey: partKey,
);
}
Map<String, dynamic> toJson() => {
'ratingKey': id,
'title': title,
'type': type,
if (year != null) 'year': year,
if (duration != null) 'duration': duration,
if (thumbPath != null) 'thumb': thumbPath,
if (partKey != null) 'partKey': partKey,
};
}
class PlexException implements Exception {
final String message;
const PlexException(this.message);
@override
String toString() => 'PlexException: $message';
}
@@ -0,0 +1,59 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/program.dart';
class ProgramsApiClient {
final String baseUrl;
final http.Client _client;
ProgramsApiClient({
required this.baseUrl,
http.Client? client,
}) : _client = client ?? http.Client();
Future<List<Program>> fetchPrograms() async {
final response = await _client.get(Uri.parse('$baseUrl/api/programs'));
if (response.statusCode != 200) {
throw Exception('Failed to fetch programs: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['programs'] as List<dynamic>?)
?.map((e) => Program.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
}
Future<void> markWorkoutCompleted(String programId, String workoutId) async {
final response = await _client.patch(
Uri.parse('$baseUrl/api/programs'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'id': programId,
'markWorkoutCompleted': workoutId,
}),
);
if (response.statusCode != 200) {
throw Exception(
'Failed to mark workout completed: ${response.statusCode}');
}
}
Future<void> advanceWorkout(String programId) async {
final response = await _client.patch(
Uri.parse('$baseUrl/api/programs'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'id': programId,
'advanceWorkout': true,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to advance workout: ${response.statusCode}');
}
}
void dispose() => _client.close();
}
@@ -0,0 +1,319 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
// --- Model Classes ---
class SpotifyPlaybackState {
final bool isPlaying;
final String trackName;
final String artistName;
final String? albumArt;
final int progressMs;
final int durationMs;
final String? deviceName;
SpotifyPlaybackState({
required this.isPlaying,
required this.trackName,
required this.artistName,
this.albumArt,
required this.progressMs,
required this.durationMs,
this.deviceName,
});
factory SpotifyPlaybackState.fromJson(Map<String, dynamic> json) {
final item = json['item'] as Map<String, dynamic>?;
final artists = (item?['artists'] as List<dynamic>?)
?.map((a) => a['name'] as String)
.join(', ') ??
'';
final images = item?['album']?['images'] as List<dynamic>?;
final device = json['device'] as Map<String, dynamic>?;
return SpotifyPlaybackState(
isPlaying: json['is_playing'] as bool? ?? false,
trackName: item?['name'] as String? ?? '',
artistName: artists,
albumArt: (images != null && images.isNotEmpty)
? images.first['url'] as String?
: null,
progressMs: json['progress_ms'] as int? ?? 0,
durationMs: item?['duration_ms'] as int? ?? 0,
deviceName: device?['name'] as String?,
);
}
Map<String, dynamic> toJson() => {
'isPlaying': isPlaying,
'trackName': trackName,
'artistName': artistName,
'albumArt': albumArt,
'progressMs': progressMs,
'durationMs': durationMs,
'deviceName': deviceName,
};
}
class SpotifyPlaylist {
final String id;
final String name;
final String? imageUrl;
final int trackCount;
SpotifyPlaylist({
required this.id,
required this.name,
this.imageUrl,
required this.trackCount,
});
factory SpotifyPlaylist.fromJson(Map<String, dynamic> json) {
final images = json['images'] as List<dynamic>?;
return SpotifyPlaylist(
id: json['id'] as String,
name: json['name'] as String,
imageUrl: (images != null && images.isNotEmpty)
? images.first['url'] as String?
: null,
trackCount: (json['tracks'] as Map<String, dynamic>?)?['total'] as int? ?? 0,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'imageUrl': imageUrl,
'trackCount': trackCount,
};
}
class SpotifyTrack {
final String id;
final String name;
final String artist;
final String album;
final int durationMs;
final String? imageUrl;
SpotifyTrack({
required this.id,
required this.name,
required this.artist,
required this.album,
required this.durationMs,
this.imageUrl,
});
factory SpotifyTrack.fromJson(Map<String, dynamic> json) {
// Playlist track items are wrapped in a 'track' key
final track = json.containsKey('track')
? json['track'] as Map<String, dynamic>
: json;
final artists = (track['artists'] as List<dynamic>?)
?.map((a) => a['name'] as String)
.join(', ') ??
'';
final albumData = track['album'] as Map<String, dynamic>?;
final images = albumData?['images'] as List<dynamic>?;
return SpotifyTrack(
id: track['id'] as String? ?? '',
name: track['name'] as String? ?? '',
artist: artists,
album: albumData?['name'] as String? ?? '',
durationMs: track['duration_ms'] as int? ?? 0,
imageUrl: (images != null && images.isNotEmpty)
? images.first['url'] as String?
: null,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'artist': artist,
'album': album,
'durationMs': durationMs,
'imageUrl': imageUrl,
};
}
class SpotifyDevice {
final String id;
final String name;
final bool isActive;
SpotifyDevice({
required this.id,
required this.name,
required this.isActive,
});
factory SpotifyDevice.fromJson(Map<String, dynamic> json) {
return SpotifyDevice(
id: json['id'] as String? ?? '',
name: json['name'] as String? ?? '',
isActive: json['is_active'] as bool? ?? false,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'isActive': isActive,
};
}
// --- Service ---
class SpotifyService {
static const _baseUrl = 'https://api.spotify.com';
String? _accessToken;
final http.Client _client;
SpotifyService({String? accessToken, http.Client? client})
: _accessToken = accessToken,
_client = client ?? http.Client();
void setToken(String token) {
_accessToken = token;
}
Map<String, String> get _headers => {
'Authorization': 'Bearer $_accessToken',
'Content-Type': 'application/json',
};
// --- Playback ---
Future<SpotifyPlaybackState?> getCurrentPlayback() async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/me/player'),
headers: _headers,
);
if (response.statusCode == 204 || response.body.isEmpty) {
return null; // Nothing playing
}
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
return SpotifyPlaybackState.fromJson(
json.decode(response.body) as Map<String, dynamic>,
);
}
Future<void> play({String? deviceId}) async {
final uri = Uri.parse('$_baseUrl/v1/me/player/play').replace(
queryParameters: deviceId != null ? {'device_id': deviceId} : null,
);
final response = await _client.put(uri, headers: _headers);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
Future<void> pause() async {
final response = await _client.put(
Uri.parse('$_baseUrl/v1/me/player/pause'),
headers: _headers,
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
Future<void> next() async {
final response = await _client.post(
Uri.parse('$_baseUrl/v1/me/player/next'),
headers: _headers,
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
Future<void> previous() async {
final response = await _client.post(
Uri.parse('$_baseUrl/v1/me/player/previous'),
headers: _headers,
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
// --- Playlists ---
Future<List<SpotifyPlaylist>> getPlaylists() async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/me/playlists?limit=50'),
headers: _headers,
);
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
final data = json.decode(response.body) as Map<String, dynamic>;
final items = data['items'] as List<dynamic>? ?? [];
return items
.map((e) => SpotifyPlaylist.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<SpotifyTrack>> getPlaylistTracks(String playlistId) async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/playlists/$playlistId/tracks?limit=100'),
headers: _headers,
);
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
final data = json.decode(response.body) as Map<String, dynamic>;
final items = data['items'] as List<dynamic>? ?? [];
return items
.map((e) => SpotifyTrack.fromJson(e as Map<String, dynamic>))
.toList();
}
// --- Devices ---
Future<List<SpotifyDevice>> getDevices() async {
final response = await _client.get(
Uri.parse('$_baseUrl/v1/me/player/devices'),
headers: _headers,
);
if (response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
final data = json.decode(response.body) as Map<String, dynamic>;
final devices = data['devices'] as List<dynamic>? ?? [];
return devices
.map((e) => SpotifyDevice.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> transferPlayback(String deviceId) async {
final response = await _client.put(
Uri.parse('$_baseUrl/v1/me/player'),
headers: _headers,
body: json.encode({
'device_ids': [deviceId],
'play': true,
}),
);
if (response.statusCode != 204 && response.statusCode != 200) {
throw SpotifyApiException(response.statusCode, response.body);
}
}
}
class SpotifyApiException implements Exception {
final int statusCode;
final String body;
SpotifyApiException(this.statusCode, this.body);
@override
String toString() => 'SpotifyApiException($statusCode): $body';
}
@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:mirror_protocol/mirror_protocol.dart';
class WorkoutApiClient {
final String baseUrl;
final http.Client _client;
WorkoutApiClient({
required this.baseUrl,
http.Client? client,
}) : _client = client ?? http.Client();
Future<({List<VideoState> videos, List<String> folders})> fetchVideos({
String? folder,
WorkoutFilter filter = WorkoutFilter.all,
}) async {
final uri = Uri.parse('$baseUrl/api/videos').replace(
queryParameters: {
if (folder != null && folder.isNotEmpty) 'folder': folder,
},
);
final response = await _client.get(uri);
if (response.statusCode != 200) {
throw Exception('Failed to fetch videos: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final rawVideos = (data['videos'] as List<dynamic>?)
?.map((e) => _parseVideo(e as Map<String, dynamic>))
.toList() ??
[];
final folders = (data['folders'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[];
final videos = _applyFilter(rawVideos, filter);
return (videos: videos, folders: folders);
}
Future<WorkoutStats> fetchStats() async {
final response = await _client.get(Uri.parse('$baseUrl/api/stats'));
if (response.statusCode != 200) {
throw Exception('Failed to fetch stats: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return WorkoutStats.fromJson(data);
}
Future<List<Map<String, dynamic>>> fetchRoutines() async {
final response = await _client.get(Uri.parse('$baseUrl/api/routines'));
if (response.statusCode != 200) {
throw Exception('Failed to fetch routines: ${response.statusCode}');
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
return (data['routines'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[];
}
Future<void> updateProgress({
required String videoId,
required int position,
required int percent,
}) async {
final response = await _client.patch(
Uri.parse('$baseUrl/api/videos'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'videoId': videoId,
'position': position,
'percent': percent,
}),
);
if (response.statusCode != 200) {
throw Exception('Failed to save progress: ${response.statusCode}');
}
}
String streamUrl(String videoPath) =>
'$baseUrl/api/stream?path=${Uri.encodeComponent(videoPath)}';
VideoState _parseVideo(Map<String, dynamic> json) => VideoState(
id: json['id'] as String,
title: json['title'] as String? ?? json['name'] as String? ?? '',
path: json['path'] as String,
folder: json['folder'] as String?,
thumbnailPath: json['thumbnailPath'] as String?,
duration: (json['duration'] as num?)?.toInt() ?? 0,
trimStart: (json['trimStart'] as num?)?.toInt() ?? 0,
trimEnd: (json['trimEnd'] as num?)?.toInt() ?? 0,
lastPosition: (json['lastPosition'] as num?)?.toInt() ?? 0,
percentCompleted: (json['percentCompleted'] as num?)?.toInt() ?? 0,
restSeconds: (json['restSeconds'] as num?)?.toInt() ?? 0,
);
List<VideoState> _applyFilter(List<VideoState> videos, WorkoutFilter filter) =>
switch (filter) {
WorkoutFilter.all => videos,
WorkoutFilter.unwatched => videos.where((v) => v.percentCompleted == 0).toList(),
WorkoutFilter.inProgress =>
videos.where((v) => v.percentCompleted > 0 && v.percentCompleted < 95).toList(),
WorkoutFilter.completed => videos.where((v) => v.percentCompleted >= 95).toList(),
};
void dispose() => _client.close();
}
@@ -0,0 +1,67 @@
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class YouTubeService {
final YoutubeExplode _yt;
YouTubeService() : _yt = YoutubeExplode();
Future<YouTubeVideo> getVideo(String urlOrId) async {
final video = await _yt.videos.get(urlOrId);
return YouTubeVideo(
id: video.id.value,
title: video.title,
author: video.author,
duration: video.duration ?? Duration.zero,
thumbnailUrl: video.thumbnails.highResUrl,
);
}
Future<String> getStreamUrl(String urlOrId) async {
final manifest = await _yt.videos.streamsClient.getManifest(urlOrId);
// Prefer muxed stream (video + audio) at highest quality available
final muxed = manifest.muxed.sortByVideoQuality();
if (muxed.isNotEmpty) {
return muxed.first.url.toString();
}
// Fallback to audio-only for music
final audio = manifest.audioOnly.sortByBitrate();
if (audio.isNotEmpty) {
return audio.first.url.toString();
}
throw Exception('No playable streams found for $urlOrId');
}
Future<List<YouTubeVideo>> search(String query, {int limit = 10}) async {
final results = await _yt.search.search(query);
return results.take(limit).map((item) {
if (item is SearchVideo) {
return YouTubeVideo(
id: item.id.value,
title: item.title,
author: item.author,
duration: item.duration ?? Duration.zero,
thumbnailUrl: item.thumbnails.highResUrl,
);
}
return null;
}).whereType<YouTubeVideo>().toList();
}
void dispose() => _yt.close();
}
class YouTubeVideo {
final String id;
final String title;
final String author;
final Duration duration;
final String? thumbnailUrl;
const YouTubeVideo({
required this.id,
required this.title,
required this.author,
required this.duration,
this.thumbnailUrl,
});
}
+13
View File
@@ -0,0 +1,13 @@
name: mirror_core
description: Shared business logic, models, and API clients for Mirror OS
version: 0.1.0
publish_to: none
resolution: workspace
environment:
sdk: ^3.5.0
dependencies:
mirror_protocol: any
http: ^1.2.0
youtube_explode_dart: ^2.2.0
@@ -0,0 +1,6 @@
library mirror_protocol;
export 'src/messages.dart';
export 'src/commands.dart';
export 'src/state.dart';
export 'src/dashboard_config.dart';
@@ -0,0 +1,124 @@
enum ScreenType {
dashboard,
workout,
media,
standby;
static ScreenType fromJson(String value) =>
ScreenType.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum PlaybackAction {
play,
pause,
resume,
seek,
skip,
previous,
stop,
setVolume;
static PlaybackAction fromJson(String value) =>
PlaybackAction.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum WorkoutAction {
browse,
setFilter,
playVideo,
playFolder,
pauseWorkout,
resumeWorkout,
skipVideo,
skipRest,
previousVideo,
stopSession;
static WorkoutAction fromJson(String value) =>
WorkoutAction.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum MediaSource {
workout,
youtube,
plex,
local;
static MediaSource fromJson(String value) =>
MediaSource.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum WorkoutPhase {
idle,
browsing,
playing,
resting,
completed;
static WorkoutPhase fromJson(String value) =>
WorkoutPhase.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum WorkoutFilter {
all,
unwatched,
inProgress,
completed;
static WorkoutFilter fromJson(String value) =>
WorkoutFilter.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum ProgramAction {
browse,
playWorkout,
skipExercise,
previousExercise,
pauseExercise,
resumeExercise,
stopProgram;
static ProgramAction fromJson(String value) =>
ProgramAction.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum MediaAction {
searchYouTube,
playYouTube,
browsePlex,
playPlex,
spotifyPlay,
spotifyPause,
spotifyNext,
spotifyPrevious,
spotifyTransfer;
static MediaAction fromJson(String value) =>
MediaAction.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
enum DashboardAction {
refresh,
toggleWidget;
static DashboardAction fromJson(String value) =>
DashboardAction.values.firstWhere((e) => e.name == value);
String toJson() => name;
}
@@ -0,0 +1,74 @@
class DashboardWidgetConfig {
final String id;
final String type;
final bool visible;
final int position;
final Map<String, dynamic> config;
const DashboardWidgetConfig({
required this.id,
required this.type,
this.visible = true,
required this.position,
this.config = const {},
});
DashboardWidgetConfig copyWith({
String? id,
String? type,
bool? visible,
int? position,
Map<String, dynamic>? config,
}) =>
DashboardWidgetConfig(
id: id ?? this.id,
type: type ?? this.type,
visible: visible ?? this.visible,
position: position ?? this.position,
config: config ?? this.config,
);
factory DashboardWidgetConfig.fromJson(Map<String, dynamic> json) =>
DashboardWidgetConfig(
id: json['id'] as String,
type: json['type'] as String,
visible: json['visible'] as bool? ?? true,
position: json['position'] as int,
config: (json['config'] as Map<String, dynamic>?) ?? {},
);
Map<String, dynamic> toJson() => {
'id': id,
'type': type,
'visible': visible,
'position': position,
'config': config,
};
}
class DashboardLayoutConfig {
final List<DashboardWidgetConfig> widgets;
const DashboardLayoutConfig({required this.widgets});
factory DashboardLayoutConfig.defaultLayout() => DashboardLayoutConfig(
widgets: [
DashboardWidgetConfig(id: 'clock', type: 'clock', position: 0),
DashboardWidgetConfig(id: 'weather', type: 'weather', position: 1),
DashboardWidgetConfig(id: 'calendar', type: 'calendar', position: 2),
DashboardWidgetConfig(
id: 'ha_entities', type: 'ha_entities', position: 3),
],
);
factory DashboardLayoutConfig.fromJson(Map<String, dynamic> json) =>
DashboardLayoutConfig(
widgets: (json['widgets'] as List<dynamic>)
.map((e) => DashboardWidgetConfig.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> toJson() => {
'widgets': widgets.map((e) => e.toJson()).toList(),
};
}
@@ -0,0 +1,233 @@
import 'dart:convert';
import 'commands.dart';
import 'dashboard_config.dart';
import 'state.dart';
sealed class MirrorMessage {
String get type;
Map<String, dynamic> toJson();
String encode() => jsonEncode(toJson());
static MirrorMessage decode(String raw) {
final json = jsonDecode(raw) as Map<String, dynamic>;
return MirrorMessage.fromJson(json);
}
static MirrorMessage fromJson(Map<String, dynamic> json) {
final type = json['type'] as String;
return switch (type) {
'state_sync' => StateSync.fromJson(json),
'state_patch' => StatePatch.fromJson(json),
'navigate' => NavigateCommand.fromJson(json),
'playback' => PlaybackCommand.fromJson(json),
'workout' => WorkoutCommand.fromJson(json),
'program' => ProgramCommand.fromJson(json),
'media' => MediaCommand.fromJson(json),
'event' => EventMessage.fromJson(json),
'register' => RegisterMessage.fromJson(json),
'dashboard_config_request' => DashboardConfigRequest.fromJson(json),
'dashboard_config_update' => DashboardConfigUpdate.fromJson(json),
'dashboard_config_response' => DashboardConfigResponse.fromJson(json),
_ => throw FormatException('Unknown message type: $type'),
};
}
}
// --- Server → Client messages ---
class StateSync extends MirrorMessage {
@override
String get type => 'state_sync';
final DisplayState state;
StateSync({required this.state});
static StateSync fromJson(Map<String, dynamic> json) =>
StateSync(state: DisplayState.fromJson(json['state'] as Map<String, dynamic>));
@override
Map<String, dynamic> toJson() => {'type': type, 'state': state.toJson()};
}
class StatePatch extends MirrorMessage {
@override
String get type => 'state_patch';
final Map<String, dynamic> patch;
StatePatch({required this.patch});
static StatePatch fromJson(Map<String, dynamic> json) =>
StatePatch(patch: json['patch'] as Map<String, dynamic>);
@override
Map<String, dynamic> toJson() => {'type': type, 'patch': patch};
}
class EventMessage extends MirrorMessage {
@override
String get type => 'event';
final String event;
final Map<String, dynamic>? data;
EventMessage({required this.event, this.data});
static EventMessage fromJson(Map<String, dynamic> json) => EventMessage(
event: json['event'] as String,
data: json['data'] as Map<String, dynamic>?,
);
@override
Map<String, dynamic> toJson() => {'type': type, 'event': event, if (data != null) 'data': data};
}
// --- Client → Server messages ---
class RegisterMessage extends MirrorMessage {
@override
String get type => 'register';
final String role;
RegisterMessage({required this.role});
static RegisterMessage fromJson(Map<String, dynamic> json) =>
RegisterMessage(role: json['role'] as String);
@override
Map<String, dynamic> toJson() => {'type': type, 'role': role};
}
class NavigateCommand extends MirrorMessage {
@override
String get type => 'navigate';
final ScreenType target;
NavigateCommand({required this.target});
static NavigateCommand fromJson(Map<String, dynamic> json) =>
NavigateCommand(target: ScreenType.fromJson(json['target'] as String));
@override
Map<String, dynamic> toJson() => {'type': type, 'target': target.toJson()};
}
class PlaybackCommand extends MirrorMessage {
@override
String get type => 'playback';
final PlaybackAction action;
final dynamic value;
PlaybackCommand({required this.action, this.value});
static PlaybackCommand fromJson(Map<String, dynamic> json) => PlaybackCommand(
action: PlaybackAction.fromJson(json['action'] as String),
value: json['value'],
);
@override
Map<String, dynamic> toJson() =>
{'type': type, 'action': action.toJson(), if (value != null) 'value': value};
}
class WorkoutCommand extends MirrorMessage {
@override
String get type => 'workout';
final WorkoutAction action;
final Map<String, dynamic>? payload;
WorkoutCommand({required this.action, this.payload});
static WorkoutCommand fromJson(Map<String, dynamic> json) => WorkoutCommand(
action: WorkoutAction.fromJson(json['action'] as String),
payload: json['payload'] as Map<String, dynamic>?,
);
@override
Map<String, dynamic> toJson() =>
{'type': type, 'action': action.toJson(), if (payload != null) 'payload': payload};
}
class ProgramCommand extends MirrorMessage {
@override
String get type => 'program';
final ProgramAction action;
final Map<String, dynamic>? payload;
ProgramCommand({required this.action, this.payload});
static ProgramCommand fromJson(Map<String, dynamic> json) => ProgramCommand(
action: ProgramAction.fromJson(json['action'] as String),
payload: json['payload'] as Map<String, dynamic>?,
);
@override
Map<String, dynamic> toJson() =>
{'type': type, 'action': action.toJson(), if (payload != null) 'payload': payload};
}
class MediaCommand extends MirrorMessage {
@override
String get type => 'media';
final MediaAction action;
final Map<String, dynamic>? payload;
MediaCommand({required this.action, this.payload});
static MediaCommand fromJson(Map<String, dynamic> json) => MediaCommand(
action: MediaAction.fromJson(json['action'] as String),
payload: json['payload'] as Map<String, dynamic>?,
);
@override
Map<String, dynamic> toJson() =>
{'type': type, 'action': action.toJson(), if (payload != null) 'payload': payload};
}
// --- Dashboard config messages ---
class DashboardConfigRequest extends MirrorMessage {
@override
String get type => 'dashboard_config_request';
DashboardConfigRequest();
static DashboardConfigRequest fromJson(Map<String, dynamic> json) =>
DashboardConfigRequest();
@override
Map<String, dynamic> toJson() => {'type': type};
}
class DashboardConfigUpdate extends MirrorMessage {
@override
String get type => 'dashboard_config_update';
final DashboardLayoutConfig layout;
DashboardConfigUpdate({required this.layout});
static DashboardConfigUpdate fromJson(Map<String, dynamic> json) =>
DashboardConfigUpdate(
layout:
DashboardLayoutConfig.fromJson(json['layout'] as Map<String, dynamic>),
);
@override
Map<String, dynamic> toJson() => {'type': type, 'layout': layout.toJson()};
}
class DashboardConfigResponse extends MirrorMessage {
@override
String get type => 'dashboard_config_response';
final DashboardLayoutConfig layout;
DashboardConfigResponse({required this.layout});
static DashboardConfigResponse fromJson(Map<String, dynamic> json) =>
DashboardConfigResponse(
layout:
DashboardLayoutConfig.fromJson(json['layout'] as Map<String, dynamic>),
);
@override
Map<String, dynamic> toJson() => {'type': type, 'layout': layout.toJson()};
}
+554
View File
@@ -0,0 +1,554 @@
import 'commands.dart';
class DisplayState {
final ScreenType activeScreen;
final PlaybackState? playback;
final WorkoutSessionState? workout;
final ProgramSessionState? program;
final SpotifyState? spotify;
final DashboardState dashboard;
const DisplayState({
required this.activeScreen,
this.playback,
this.workout,
this.program,
this.spotify,
required this.dashboard,
});
DisplayState copyWith({
ScreenType? activeScreen,
PlaybackState? playback,
bool clearPlayback = false,
WorkoutSessionState? workout,
bool clearWorkout = false,
ProgramSessionState? program,
bool clearProgram = false,
SpotifyState? spotify,
bool clearSpotify = false,
DashboardState? dashboard,
}) =>
DisplayState(
activeScreen: activeScreen ?? this.activeScreen,
playback: clearPlayback ? null : (playback ?? this.playback),
workout: clearWorkout ? null : (workout ?? this.workout),
program: clearProgram ? null : (program ?? this.program),
spotify: clearSpotify ? null : (spotify ?? this.spotify),
dashboard: dashboard ?? this.dashboard,
);
factory DisplayState.initial() => DisplayState(
activeScreen: ScreenType.dashboard,
dashboard: DashboardState(),
);
factory DisplayState.fromJson(Map<String, dynamic> json) => DisplayState(
activeScreen: ScreenType.fromJson(json['activeScreen'] as String),
playback: json['playback'] != null
? PlaybackState.fromJson(json['playback'] as Map<String, dynamic>)
: null,
workout: json['workout'] != null
? WorkoutSessionState.fromJson(json['workout'] as Map<String, dynamic>)
: null,
program: json['program'] != null
? ProgramSessionState.fromJson(json['program'] as Map<String, dynamic>)
: null,
spotify: json['spotify'] != null
? SpotifyState.fromJson(json['spotify'] as Map<String, dynamic>)
: null,
dashboard: json['dashboard'] != null
? DashboardState.fromJson(json['dashboard'] as Map<String, dynamic>)
: DashboardState(),
);
Map<String, dynamic> toJson() => {
'activeScreen': activeScreen.toJson(),
if (playback != null) 'playback': playback!.toJson(),
if (workout != null) 'workout': workout!.toJson(),
if (program != null) 'program': program!.toJson(),
if (spotify != null) 'spotify': spotify!.toJson(),
'dashboard': dashboard.toJson(),
};
}
class PlaybackState {
final String mediaId;
final String title;
final MediaSource source;
final Duration position;
final Duration duration;
final bool isPlaying;
final double volume;
const PlaybackState({
required this.mediaId,
required this.title,
required this.source,
required this.position,
required this.duration,
required this.isPlaying,
this.volume = 1.0,
});
PlaybackState copyWith({
String? mediaId,
String? title,
MediaSource? source,
Duration? position,
Duration? duration,
bool? isPlaying,
double? volume,
}) =>
PlaybackState(
mediaId: mediaId ?? this.mediaId,
title: title ?? this.title,
source: source ?? this.source,
position: position ?? this.position,
duration: duration ?? this.duration,
isPlaying: isPlaying ?? this.isPlaying,
volume: volume ?? this.volume,
);
factory PlaybackState.fromJson(Map<String, dynamic> json) => PlaybackState(
mediaId: json['mediaId'] as String,
title: json['title'] as String,
source: MediaSource.fromJson(json['source'] as String),
position: Duration(milliseconds: json['positionMs'] as int),
duration: Duration(milliseconds: json['durationMs'] as int),
isPlaying: json['isPlaying'] as bool,
volume: (json['volume'] as num?)?.toDouble() ?? 1.0,
);
Map<String, dynamic> toJson() => {
'mediaId': mediaId,
'title': title,
'source': source.toJson(),
'positionMs': position.inMilliseconds,
'durationMs': duration.inMilliseconds,
'isPlaying': isPlaying,
'volume': volume,
};
}
class WorkoutSessionState {
final WorkoutPhase phase;
final List<QueueItemState> queue;
final int currentIndex;
final int restRemaining;
final String? routineName;
final String? currentPath;
final WorkoutFilter filter;
final List<VideoState> videos;
final List<String> folders;
final WorkoutStats? stats;
const WorkoutSessionState({
required this.phase,
this.queue = const [],
this.currentIndex = 0,
this.restRemaining = 0,
this.routineName,
this.currentPath,
this.filter = WorkoutFilter.all,
this.videos = const [],
this.folders = const [],
this.stats,
});
WorkoutSessionState copyWith({
WorkoutPhase? phase,
List<QueueItemState>? queue,
int? currentIndex,
int? restRemaining,
String? routineName,
String? currentPath,
bool clearCurrentPath = false,
WorkoutFilter? filter,
List<VideoState>? videos,
List<String>? folders,
WorkoutStats? stats,
}) =>
WorkoutSessionState(
phase: phase ?? this.phase,
queue: queue ?? this.queue,
currentIndex: currentIndex ?? this.currentIndex,
restRemaining: restRemaining ?? this.restRemaining,
routineName: routineName ?? this.routineName,
currentPath: clearCurrentPath ? null : (currentPath ?? this.currentPath),
filter: filter ?? this.filter,
videos: videos ?? this.videos,
folders: folders ?? this.folders,
stats: stats ?? this.stats,
);
factory WorkoutSessionState.fromJson(Map<String, dynamic> json) => WorkoutSessionState(
phase: WorkoutPhase.fromJson(json['phase'] as String),
queue: (json['queue'] as List<dynamic>?)
?.map((e) => QueueItemState.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
currentIndex: json['currentIndex'] as int? ?? 0,
restRemaining: json['restRemaining'] as int? ?? 0,
routineName: json['routineName'] as String?,
currentPath: json['currentPath'] as String?,
filter: json['filter'] != null
? WorkoutFilter.fromJson(json['filter'] as String)
: WorkoutFilter.all,
videos: (json['videos'] as List<dynamic>?)
?.map((e) => VideoState.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
folders:
(json['folders'] as List<dynamic>?)?.map((e) => e as String).toList() ?? [],
stats: json['stats'] != null
? WorkoutStats.fromJson(json['stats'] as Map<String, dynamic>)
: null,
);
Map<String, dynamic> toJson() => {
'phase': phase.toJson(),
'queue': queue.map((e) => e.toJson()).toList(),
'currentIndex': currentIndex,
'restRemaining': restRemaining,
if (routineName != null) 'routineName': routineName,
if (currentPath != null) 'currentPath': currentPath,
'filter': filter.toJson(),
'videos': videos.map((e) => e.toJson()).toList(),
'folders': folders,
if (stats != null) 'stats': stats!.toJson(),
};
}
class QueueItemState {
final String videoId;
final String title;
final String path;
final int duration;
final int trimStart;
final int trimEnd;
final int restSeconds;
const QueueItemState({
required this.videoId,
required this.title,
required this.path,
this.duration = 0,
this.trimStart = 0,
this.trimEnd = 0,
this.restSeconds = 0,
});
factory QueueItemState.fromJson(Map<String, dynamic> json) => QueueItemState(
videoId: json['videoId'] as String,
title: json['title'] as String,
path: json['path'] as String,
duration: json['duration'] as int? ?? 0,
trimStart: json['trimStart'] as int? ?? 0,
trimEnd: json['trimEnd'] as int? ?? 0,
restSeconds: json['restSeconds'] as int? ?? 0,
);
Map<String, dynamic> toJson() => {
'videoId': videoId,
'title': title,
'path': path,
'duration': duration,
'trimStart': trimStart,
'trimEnd': trimEnd,
'restSeconds': restSeconds,
};
}
class VideoState {
final String id;
final String title;
final String path;
final String? folder;
final String? thumbnailPath;
final int duration;
final int trimStart;
final int trimEnd;
final int lastPosition;
final int percentCompleted;
final int restSeconds;
const VideoState({
required this.id,
required this.title,
required this.path,
this.folder,
this.thumbnailPath,
this.duration = 0,
this.trimStart = 0,
this.trimEnd = 0,
this.lastPosition = 0,
this.percentCompleted = 0,
this.restSeconds = 0,
});
factory VideoState.fromJson(Map<String, dynamic> json) => VideoState(
id: json['id'] as String,
title: json['title'] as String,
path: json['path'] as String,
folder: json['folder'] as String?,
thumbnailPath: json['thumbnailPath'] as String?,
duration: json['duration'] as int? ?? 0,
trimStart: json['trimStart'] as int? ?? 0,
trimEnd: json['trimEnd'] as int? ?? 0,
lastPosition: json['lastPosition'] as int? ?? 0,
percentCompleted: json['percentCompleted'] as int? ?? 0,
restSeconds: json['restSeconds'] as int? ?? 0,
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'path': path,
if (folder != null) 'folder': folder,
if (thumbnailPath != null) 'thumbnailPath': thumbnailPath,
'duration': duration,
'trimStart': trimStart,
'trimEnd': trimEnd,
'lastPosition': lastPosition,
'percentCompleted': percentCompleted,
'restSeconds': restSeconds,
};
}
class WorkoutStats {
final int total;
final int completed;
final int inProgress;
const WorkoutStats({
required this.total,
required this.completed,
required this.inProgress,
});
factory WorkoutStats.fromJson(Map<String, dynamic> json) => WorkoutStats(
total: json['total'] as int,
completed: json['completed'] as int,
inProgress: json['inProgress'] as int,
);
Map<String, dynamic> toJson() => {
'total': total,
'completed': completed,
'inProgress': inProgress,
};
}
class DashboardState {
final List<String> widgets;
final WeatherInfo? weather;
final List<CalendarEvent> calendarEvents;
final List<Map<String, dynamic>> haEntities;
DashboardState({
this.widgets = const ['clock', 'weather', 'calendar'],
this.weather,
this.calendarEvents = const [],
this.haEntities = const [],
});
factory DashboardState.fromJson(Map<String, dynamic> json) => DashboardState(
widgets: (json['widgets'] as List<dynamic>?)?.map((e) => e as String).toList() ??
['clock', 'weather', 'calendar'],
weather: json['weather'] != null
? WeatherInfo.fromJson(json['weather'] as Map<String, dynamic>)
: null,
calendarEvents: (json['calendarEvents'] as List<dynamic>?)
?.map((e) => CalendarEvent.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
haEntities: (json['haEntities'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ??
[],
);
Map<String, dynamic> toJson() => {
'widgets': widgets,
if (weather != null) 'weather': weather!.toJson(),
'calendarEvents': calendarEvents.map((e) => e.toJson()).toList(),
'haEntities': haEntities,
};
}
class WeatherInfo {
final double temp;
final String condition;
final int? humidity;
const WeatherInfo({
required this.temp,
required this.condition,
this.humidity,
});
factory WeatherInfo.fromJson(Map<String, dynamic> json) => WeatherInfo(
temp: (json['temp'] as num).toDouble(),
condition: json['condition'] as String,
humidity: json['humidity'] as int?,
);
Map<String, dynamic> toJson() => {
'temp': temp,
'condition': condition,
if (humidity != null) 'humidity': humidity,
};
}
class CalendarEvent {
final String summary;
final String startTime;
final String endTime;
const CalendarEvent({
required this.summary,
required this.startTime,
required this.endTime,
});
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
summary: json['summary'] as String,
startTime: json['startTime'] as String,
endTime: json['endTime'] as String,
);
Map<String, dynamic> toJson() => {
'summary': summary,
'startTime': startTime,
'endTime': endTime,
};
}
class ProgramSessionState {
final String programId;
final String programName;
final String workoutName;
final List<String> exercises;
final int currentExerciseIndex;
final String phase; // playing, resting, instruction, completed
final int timerRemaining;
final int currentSet;
final int totalSets;
const ProgramSessionState({
required this.programId,
required this.programName,
required this.workoutName,
this.exercises = const [],
this.currentExerciseIndex = 0,
this.phase = 'playing',
this.timerRemaining = 0,
this.currentSet = 1,
this.totalSets = 1,
});
ProgramSessionState copyWith({
String? programId,
String? programName,
String? workoutName,
List<String>? exercises,
int? currentExerciseIndex,
String? phase,
int? timerRemaining,
int? currentSet,
int? totalSets,
}) =>
ProgramSessionState(
programId: programId ?? this.programId,
programName: programName ?? this.programName,
workoutName: workoutName ?? this.workoutName,
exercises: exercises ?? this.exercises,
currentExerciseIndex: currentExerciseIndex ?? this.currentExerciseIndex,
phase: phase ?? this.phase,
timerRemaining: timerRemaining ?? this.timerRemaining,
currentSet: currentSet ?? this.currentSet,
totalSets: totalSets ?? this.totalSets,
);
factory ProgramSessionState.fromJson(Map<String, dynamic> json) =>
ProgramSessionState(
programId: json['programId'] as String,
programName: json['programName'] as String,
workoutName: json['workoutName'] as String,
exercises: (json['exercises'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ??
[],
currentExerciseIndex: json['currentExerciseIndex'] as int? ?? 0,
phase: json['phase'] as String? ?? 'playing',
timerRemaining: json['timerRemaining'] as int? ?? 0,
currentSet: json['currentSet'] as int? ?? 1,
totalSets: json['totalSets'] as int? ?? 1,
);
Map<String, dynamic> toJson() => {
'programId': programId,
'programName': programName,
'workoutName': workoutName,
'exercises': exercises,
'currentExerciseIndex': currentExerciseIndex,
'phase': phase,
'timerRemaining': timerRemaining,
'currentSet': currentSet,
'totalSets': totalSets,
};
}
class SpotifyState {
final bool isPlaying;
final String? trackName;
final String? artistName;
final String? albumImageUrl;
final int progressMs;
final int durationMs;
const SpotifyState({
this.isPlaying = false,
this.trackName,
this.artistName,
this.albumImageUrl,
this.progressMs = 0,
this.durationMs = 0,
});
SpotifyState copyWith({
bool? isPlaying,
String? trackName,
String? artistName,
String? albumImageUrl,
int? progressMs,
int? durationMs,
}) =>
SpotifyState(
isPlaying: isPlaying ?? this.isPlaying,
trackName: trackName ?? this.trackName,
artistName: artistName ?? this.artistName,
albumImageUrl: albumImageUrl ?? this.albumImageUrl,
progressMs: progressMs ?? this.progressMs,
durationMs: durationMs ?? this.durationMs,
);
factory SpotifyState.fromJson(Map<String, dynamic> json) => SpotifyState(
isPlaying: json['isPlaying'] as bool? ?? false,
trackName: json['trackName'] as String?,
artistName: json['artistName'] as String?,
albumImageUrl: json['albumImageUrl'] as String?,
progressMs: json['progressMs'] as int? ?? 0,
durationMs: json['durationMs'] as int? ?? 0,
);
Map<String, dynamic> toJson() => {
'isPlaying': isPlaying,
if (trackName != null) 'trackName': trackName,
if (artistName != null) 'artistName': artistName,
if (albumImageUrl != null) 'albumImageUrl': albumImageUrl,
'progressMs': progressMs,
'durationMs': durationMs,
};
}
+11
View File
@@ -0,0 +1,11 @@
name: mirror_protocol
description: WebSocket message types and serialization for Mirror OS communication
version: 0.1.0
publish_to: none
resolution: workspace
environment:
sdk: ^3.5.0
dependencies:
uuid: ^4.5.1
+40
View File
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
class MirrorAnimations {
MirrorAnimations._();
static const screenTransitionDuration = Duration(milliseconds: 800);
static const quickTransitionDuration = Duration(milliseconds: 300);
static const overlayFadeDuration = Duration(milliseconds: 500);
static Widget screenTransitionBuilder(
Widget child,
Animation<double> animation,
) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween(begin: 0.95, end: 1.0).animate(
CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
),
child: child,
),
);
}
static Widget slideUpTransitionBuilder(
Widget child,
Animation<double> animation,
) {
return FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(
begin: const Offset(0, 0.05),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
child: child,
),
);
}
}
+6
View File
@@ -0,0 +1,6 @@
library mirror_ui;
export 'theme.dart';
export 'animations.dart';
export 'widgets/progress_ring.dart';
export 'widgets/timer_display.dart';
+110
View File
@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
class MirrorTheme {
MirrorTheme._();
static const _zinc50 = Color(0xFFFAFAFA);
static const _zinc100 = Color(0xFFF4F4F5);
static const _zinc200 = Color(0xFFE4E4E7);
static const _zinc400 = Color(0xFFA1A1AA);
static const _zinc500 = Color(0xFF71717A);
static const _zinc700 = Color(0xFF3F3F46);
static const _zinc800 = Color(0xFF27272A);
static const _zinc900 = Color(0xFF18181B);
static const _zinc950 = Color(0xFF09090B);
static const _accent = Color(0xFF60A5FA); // blue-400
static const _accentDim = Color(0xFF1E3A5F);
static const _success = Color(0xFF4ADE80); // green-400
static const _danger = Color(0xFFF87171); // red-400
static const _warning = Color(0xFFFBBF24); // amber-400
static const fontFamily = 'Inter';
static const fontMono = 'JetBrains Mono';
static ThemeData get dark => ThemeData(
brightness: Brightness.dark,
fontFamily: fontFamily,
scaffoldBackgroundColor: _zinc950,
colorScheme: const ColorScheme.dark(
surface: _zinc900,
primary: _accent,
secondary: _zinc700,
error: _danger,
onSurface: _zinc100,
onPrimary: _zinc950,
),
cardTheme: const CardThemeData(
color: _zinc900,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(16)),
),
),
textTheme: const TextTheme(
displayLarge: TextStyle(
fontSize: 96,
fontWeight: FontWeight.w200,
fontFamily: fontMono,
color: _zinc50,
letterSpacing: -2,
),
displayMedium: TextStyle(
fontSize: 60,
fontWeight: FontWeight.w200,
fontFamily: fontMono,
color: _zinc50,
),
displaySmall: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w300,
color: _zinc50,
),
headlineLarge: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
color: _zinc50,
),
headlineMedium: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w500,
color: _zinc50,
),
bodyLarge: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w400,
color: _zinc200,
),
bodyMedium: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
color: _zinc400,
),
bodySmall: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: _zinc500,
),
labelLarge: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _zinc50,
letterSpacing: 1.5,
),
),
iconTheme: const IconThemeData(color: _zinc400, size: 24),
dividerColor: _zinc800,
);
static const Color accent = _accent;
static const Color accentDim = _accentDim;
static const Color success = _success;
static const Color danger = _danger;
static const Color warning = _warning;
static const Color surface = _zinc900;
static const Color surfaceLight = _zinc800;
static const Color background = _zinc950;
static const Color textPrimary = _zinc50;
static const Color textSecondary = _zinc400;
static const Color textMuted = _zinc500;
}
@@ -0,0 +1,86 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../theme.dart';
class ProgressRing extends StatelessWidget {
final double percent;
final double size;
final double strokeWidth;
final Color? color;
final Color? backgroundColor;
const ProgressRing({
super.key,
required this.percent,
this.size = 36,
this.strokeWidth = 3,
this.color,
this.backgroundColor,
});
@override
Widget build(BuildContext context) {
final effectiveColor = percent >= 95
? MirrorTheme.success
: (color ?? MirrorTheme.accent);
return SizedBox(
width: size,
height: size,
child: CustomPaint(
painter: _ProgressRingPainter(
percent: percent.clamp(0, 100),
strokeWidth: strokeWidth,
color: effectiveColor,
backgroundColor: backgroundColor ?? MirrorTheme.surfaceLight,
),
),
);
}
}
class _ProgressRingPainter extends CustomPainter {
final double percent;
final double strokeWidth;
final Color color;
final Color backgroundColor;
_ProgressRingPainter({
required this.percent,
required this.strokeWidth,
required this.color,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = (size.width - strokeWidth) / 2;
final bgPaint = Paint()
..color = backgroundColor
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final fgPaint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, bgPaint);
final sweepAngle = (percent / 100) * 2 * math.pi;
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
sweepAngle,
false,
fgPaint,
);
}
@override
bool shouldRepaint(_ProgressRingPainter oldDelegate) =>
oldDelegate.percent != percent || oldDelegate.color != color;
}
@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import '../theme.dart';
class TimerDisplay extends StatelessWidget {
final int seconds;
final String? label;
final TextStyle? style;
const TimerDisplay({
super.key,
required this.seconds,
this.label,
this.style,
});
@override
Widget build(BuildContext context) {
final minutes = seconds ~/ 60;
final secs = seconds % 60;
final timeText = minutes > 0
? '$minutes:${secs.toString().padLeft(2, '0')}'
: '$secs';
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (label != null)
Text(
label!,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: MirrorTheme.accent,
letterSpacing: 8,
),
),
if (label != null) const SizedBox(height: 40),
Text(
timeText,
style: style ?? Theme.of(context).textTheme.displayLarge?.copyWith(
color: MirrorTheme.accent,
),
),
],
);
}
}
+17
View File
@@ -0,0 +1,17 @@
name: mirror_ui
description: Shared theme, animations, and common widgets for Mirror OS
version: 0.1.0
publish_to: none
resolution: workspace
environment:
sdk: ^3.5.0
flutter: ^3.24.0
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter