Preserve Flutter mirror-os codebase
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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()};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
library mirror_ui;
|
||||
|
||||
export 'theme.dart';
|
||||
export 'animations.dart';
|
||||
export 'widgets/progress_ring.dart';
|
||||
export 'widgets/timer_display.dart';
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MirrorTheme {
|
||||
MirrorTheme._();
|
||||
|
||||
static const _zinc50 = Color(0xFFFAFAFA);
|
||||
static const _zinc100 = Color(0xFFF4F4F5);
|
||||
static const _zinc200 = Color(0xFFE4E4E7);
|
||||
static const _zinc400 = Color(0xFFA1A1AA);
|
||||
static const _zinc500 = Color(0xFF71717A);
|
||||
static const _zinc700 = Color(0xFF3F3F46);
|
||||
static const _zinc800 = Color(0xFF27272A);
|
||||
static const _zinc900 = Color(0xFF18181B);
|
||||
static const _zinc950 = Color(0xFF09090B);
|
||||
|
||||
static const _accent = Color(0xFF60A5FA); // blue-400
|
||||
static const _accentDim = Color(0xFF1E3A5F);
|
||||
static const _success = Color(0xFF4ADE80); // green-400
|
||||
static const _danger = Color(0xFFF87171); // red-400
|
||||
static const _warning = Color(0xFFFBBF24); // amber-400
|
||||
|
||||
static const fontFamily = 'Inter';
|
||||
static const fontMono = 'JetBrains Mono';
|
||||
|
||||
static ThemeData get dark => ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
fontFamily: fontFamily,
|
||||
scaffoldBackgroundColor: _zinc950,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
surface: _zinc900,
|
||||
primary: _accent,
|
||||
secondary: _zinc700,
|
||||
error: _danger,
|
||||
onSurface: _zinc100,
|
||||
onPrimary: _zinc950,
|
||||
),
|
||||
cardTheme: const CardThemeData(
|
||||
color: _zinc900,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
textTheme: const TextTheme(
|
||||
displayLarge: TextStyle(
|
||||
fontSize: 96,
|
||||
fontWeight: FontWeight.w200,
|
||||
fontFamily: fontMono,
|
||||
color: _zinc50,
|
||||
letterSpacing: -2,
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontSize: 60,
|
||||
fontWeight: FontWeight.w200,
|
||||
fontFamily: fontMono,
|
||||
color: _zinc50,
|
||||
),
|
||||
displaySmall: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w300,
|
||||
color: _zinc50,
|
||||
),
|
||||
headlineLarge: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _zinc50,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _zinc50,
|
||||
),
|
||||
bodyLarge: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: _zinc200,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: _zinc400,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: _zinc500,
|
||||
),
|
||||
labelLarge: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _zinc50,
|
||||
letterSpacing: 1.5,
|
||||
),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: _zinc400, size: 24),
|
||||
dividerColor: _zinc800,
|
||||
);
|
||||
|
||||
static const Color accent = _accent;
|
||||
static const Color accentDim = _accentDim;
|
||||
static const Color success = _success;
|
||||
static const Color danger = _danger;
|
||||
static const Color warning = _warning;
|
||||
static const Color surface = _zinc900;
|
||||
static const Color surfaceLight = _zinc800;
|
||||
static const Color background = _zinc950;
|
||||
static const Color textPrimary = _zinc50;
|
||||
static const Color textSecondary = _zinc400;
|
||||
static const Color textMuted = _zinc500;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user