Thing/flutter/eve_parity/lib/main.dart

1032 lines
34 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const EveParityApp());
}
class EveParityApp extends StatefulWidget {
const EveParityApp({super.key, this.assetPath = 'assets/current-surface.json'});
final String assetPath;
@override
State<EveParityApp> createState() => _EveParityAppState();
}
class _EveParityAppState extends State<EveParityApp> {
late Future<EveSurfaceState> surface;
@override
void initState() {
super.initState();
surface = EveSurfaceState.load(widget.assetPath);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: FutureBuilder<EveSurfaceState>(
future: surface,
builder: (context, snapshot) {
final state = snapshot.data;
final tokens = state?.tokens ?? EveTokens.defaults;
return Scaffold(
backgroundColor: tokens.background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: snapshot.hasError
? Text(
'Eve surface load failed\n${snapshot.error}',
style: const TextStyle(color: Color(0xFFFFB84F), fontFamily: 'monospace'),
)
: state == null
? const SizedBox.shrink()
: SingleChildScrollView(child: EveSurfaceView(state: state)),
),
),
);
},
),
);
}
}
class EveSurfaceState {
EveSurfaceState({
required this.providerId,
required this.title,
required this.root,
required this.tokens,
required this.controlSkins,
required this.values,
});
final String providerId;
final String title;
final EveNode root;
final EveTokens tokens;
final Map<String, List<EveNode>> controlSkins;
final Map<String, dynamic> values;
static Future<EveSurfaceState> load(String assetPath) async {
final raw = await rootBundle.loadString(assetPath);
final json = jsonDecode(raw) as Map<String, dynamic>;
final surface = json['surface'] as Map<String, dynamic>;
final styles = (surface['styles'] as Map<String, dynamic>?) ?? const {};
final skins = <String, List<EveNode>>{};
final rawSkins = (styles['controlSkins'] as Map<String, dynamic>?) ?? const {};
for (final entry in rawSkins.entries) {
final skin = entry.value as Map<String, dynamic>;
skins[entry.key] = ((skin['children'] as List<dynamic>?) ?? const [])
.map((child) => EveNode.fromJson(child as Map<String, dynamic>))
.toList();
}
return EveSurfaceState(
providerId: (json['providerId'] ?? '').toString(),
title: (json['title'] ?? '').toString(),
root: EveNode.fromJson(surface['root'] as Map<String, dynamic>),
tokens: EveTokens.fromJson((styles['tokens'] as Map<String, dynamic>?) ?? const {}),
controlSkins: skins,
values: (json['values'] as Map<String, dynamic>?) ?? const {},
);
}
}
class EveNode {
EveNode({
required this.id,
required this.kind,
required this.props,
required this.layout,
required this.children,
required this.embeddedDocuments,
});
final String id;
final String kind;
final Map<String, dynamic> props;
final Map<String, dynamic> layout;
final List<EveNode> children;
final List<EveEmbeddedDocumentSlot> embeddedDocuments;
factory EveNode.fromJson(Map<String, dynamic> json) {
return EveNode(
id: (json['id'] ?? '').toString(),
kind: (json['kind'] ?? 'panel').toString(),
props: (json['props'] as Map<String, dynamic>?) ?? const {},
layout: (json['layout'] as Map<String, dynamic>?) ?? const {},
children: ((json['children'] as List<dynamic>?) ?? const [])
.map((child) => EveNode.fromJson(child as Map<String, dynamic>))
.toList(),
embeddedDocuments: ((json['embeddedDocuments'] as List<dynamic>?) ?? const [])
.map((slot) => EveEmbeddedDocumentSlot.fromJson(slot as Map<String, dynamic>))
.toList(),
);
}
}
class EveEmbeddedDocumentSlot {
EveEmbeddedDocumentSlot({
required this.slotId,
required this.documentId,
required this.schemaId,
required this.presentationKind,
});
final String slotId;
final String documentId;
final String schemaId;
final String presentationKind;
factory EveEmbeddedDocumentSlot.fromJson(Map<String, dynamic> json) {
return EveEmbeddedDocumentSlot(
slotId: (json['slotId'] ?? '').toString(),
documentId: (json['documentId'] ?? '').toString(),
schemaId: (json['schemaId'] ?? '').toString(),
presentationKind: (json['presentationKind'] ?? '').toString(),
);
}
}
class EveTokens {
const EveTokens({
required this.background,
required this.panel,
required this.panelAlt,
required this.panelInset,
required this.border,
required this.text,
required this.muted,
required this.accent,
});
final Color background;
final Color panel;
final Color panelAlt;
final Color panelInset;
final Color border;
final Color text;
final Color muted;
final Color accent;
static const defaults = EveTokens(
background: Color(0xFF020909),
panel: Color(0xFF071918),
panelAlt: Color(0xFF15151D),
panelInset: Color(0x57000000),
border: Color(0x4767F0E4),
text: Color(0xFFE7F1F1),
muted: Color(0xFF8BA5A3),
accent: Color(0xFFFFB84F),
);
factory EveTokens.fromJson(Map<String, dynamic> json) {
return EveTokens(
background: _color(json['colorBackground'], defaults.background),
panel: _color(json['colorPanel'], defaults.panel),
panelAlt: _color(json['colorPanelAlt'], defaults.panelAlt),
panelInset: _color(json['colorPanelInset'], defaults.panelInset),
border: _color(json['colorPanelBorder'], defaults.border),
text: _color(json['colorText'], defaults.text),
muted: _color(json['colorMuted'], defaults.muted),
accent: _color(json['colorAccent'], defaults.accent),
);
}
static Color _color(Object? value, Color fallback) {
if (value is! String) return fallback;
final hex = value.trim();
if (!hex.startsWith('#')) return fallback;
final body = hex.substring(1);
if (body.length == 6) return Color(int.parse('ff$body', radix: 16));
if (body.length == 8) return Color(int.parse(body, radix: 16));
return fallback;
}
}
class EveSurfaceView extends StatelessWidget {
const EveSurfaceView({required this.state, super.key});
final EveSurfaceState state;
@override
Widget build(BuildContext context) {
return EveNodeView(state: state, node: state.root);
}
}
class EveNodeView extends StatelessWidget {
const EveNodeView({required this.state, required this.node, super.key});
final EveSurfaceState state;
final EveNode node;
@override
Widget build(BuildContext context) {
switch (node.kind) {
case 'surface':
return _surface();
case 'partition':
return _partition();
case 'pane':
case 'panel':
return _pane();
case 'card':
case 'card.external':
return _card();
case 'label':
case 'text':
case 'text.title':
case 'text.dialogue':
return _text();
case 'control.slider':
return EveSlider(state: state, node: node);
case 'control.button':
return _button();
case 'image.preview':
return _imagePreview();
case 'canvas.preview':
case 'canvas.editor':
return _canvasPlaceholder(editor: node.kind == 'canvas.editor');
case 'status.stage':
return _statusStage();
case 'input.file':
case 'dropzone':
return _button();
case 'input.number':
case 'input.select':
case 'control.range':
return _field();
case 'control.toggle':
return _toggle();
case 'color.swatch':
return _swatch();
case 'vn.stage':
return _vnStage();
case 'image.background':
return _background();
case 'embed.norn':
return _norn();
case 'embed.tex':
return _tex();
case 'panel.dialogue':
return _dialogue();
case 'rail.actions':
return _actions();
case 'surface.slot':
return _surfaceSlot();
default:
return _fallback();
}
}
Widget _surfaceSlot() {
final slot = node.embeddedDocuments.firstOrNull;
final label = node.props['presentationKind']?.toString() ??
slot?.presentationKind ??
node.props['slotId']?.toString() ??
slot?.slotId ??
'embedded surface';
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panelAlt.withValues(alpha: 0.65),
border: Border.all(color: state.tokens.accent.withValues(alpha: 0.45)),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
label,
style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12),
),
),
);
}
Widget _vnStage() {
final background = node.children.where((child) => child.kind == 'image.background').firstOrNull;
final sceneChildren = node.children.where((child) {
return child.kind != 'image.background' && child.kind != 'panel.dialogue' && child.kind != 'rail.actions';
}).toList();
final dialogue = node.children.where((child) => child.kind == 'panel.dialogue').firstOrNull;
final actions = node.children.where((child) => child.kind == 'rail.actions').firstOrNull;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
final height = constraints.maxHeight.isFinite ? constraints.maxHeight : width * 0.56;
return SizedBox(
width: width,
height: height.clamp(520, 900),
child: Stack(
children: [
Positioned.fill(
child: background == null
? DecoratedBox(decoration: BoxDecoration(color: state.tokens.background))
: EveNodeView(state: state, node: background),
),
for (final child in sceneChildren) _stageOverlay(child),
if (dialogue != null)
Positioned(
left: 28,
right: 28,
bottom: actions == null ? 24 : 96,
child: EveNodeView(state: state, node: dialogue),
),
if (actions != null)
Positioned(left: 28, right: 28, bottom: 24, child: EveNodeView(state: state, node: actions)),
],
),
);
},
);
}
Positioned _stageOverlay(EveNode child) {
if (child.kind == 'layer.embedded-surfaces' || child.kind == 'layer.cards') {
return Positioned.fill(
child: IgnorePointer(
child: Stack(children: [for (final grandchild in child.children) _stageOverlay(grandchild)]),
),
);
}
final placement = _map(child.props['placement']);
final anchor = placement['anchor']?.toString();
if (child.kind == 'embed.norn' || anchor == 'whiteboard') {
return Positioned(left: 54, top: 46, width: 520, height: 310, child: EveNodeView(state: state, node: child));
}
if (child.kind == 'embed.tex' || anchor == 'whiteboard-equation') {
return Positioned(left: 96, top: 350, width: 500, height: 94, child: EveNodeView(state: state, node: child));
}
return Positioned(right: 32, top: 52, width: 300, child: EveNodeView(state: state, node: child));
}
Widget _background() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.background,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [state.tokens.panelAlt, state.tokens.background, const Color(0xFF10161F)],
),
),
child: Stack(
children: [
Positioned.fill(
child: CustomPaint(painter: EveGridPainter(color: state.tokens.accent.withValues(alpha: 0.10))),
),
Positioned(
left: 28,
top: 24,
child: Text(
node.props['label']?.toString() ?? 'Scene',
style: TextStyle(
color: state.tokens.muted,
fontFamily: 'EveParity',
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
);
}
Widget _norn() {
final graph = _map(node.props['graph']);
final nodes = (graph['nodes'] as List<dynamic>?) ?? const [];
final edges = (graph['edges'] as List<dynamic>?) ?? const [];
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel.withValues(alpha: 0.82),
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: CustomPaint(
painter: EveNornPainter(nodes: nodes, edges: edges, tokens: state.tokens),
child: const SizedBox.expand(),
),
),
);
}
Widget _tex() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel.withValues(alpha: 0.88),
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(5),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (node.props['label'] != null)
Text(
node.props['label'].toString().toUpperCase(),
style: TextStyle(
color: state.tokens.accent,
fontFamily: 'EveParity',
fontSize: 11,
fontWeight: FontWeight.w700,
),
),
Text(
node.props['source']?.toString() ?? '',
style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 18),
),
],
),
),
);
}
Widget _dialogue() {
final speaker = node.props['speaker']?.toString() ?? '';
final text = node.props['text']?.toString() ?? '';
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel.withValues(alpha: 0.94),
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(8),
boxShadow: const [BoxShadow(color: Color(0x8A000000), blurRadius: 24, offset: Offset(0, 10))],
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
speaker,
style: TextStyle(
color: state.tokens.accent,
fontFamily: 'EveParity',
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(text, style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 20)),
],
),
),
);
}
Widget _actions() {
return Row(
children: [
for (var index = 0; index < node.children.length; index += 1) ...[
Expanded(child: EveNodeView(state: state, node: node.children[index])),
if (index != node.children.length - 1) const SizedBox(width: 10),
],
],
);
}
Widget _button() {
final label = node.props['label']?.toString() ?? node.props['text']?.toString() ?? '';
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.accent.withValues(alpha: 0.17),
border: Border.all(color: state.tokens.accent.withValues(alpha: 0.64)),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
color: state.tokens.text,
fontFamily: 'EveParity',
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
),
);
}
Widget _imagePreview() {
final label = node.props['label']?.toString() ?? 'Image';
final assetPath = _previewAssetPath(node.props['src']);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 1,
child: DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panelInset,
border: Border.all(color: state.tokens.border, width: 2),
),
child: Stack(
children: [
Positioned.fill(child: CustomPaint(painter: EveGridPainter(color: state.tokens.accent.withValues(alpha: 0.10)))),
if (assetPath != null)
Positioned.fill(
child: Image.asset(assetPath, fit: BoxFit.cover, filterQuality: FilterQuality.none),
)
else
Center(
child: Padding(
padding: const EdgeInsets.all(10),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12),
),
),
),
],
),
),
),
const SizedBox(height: 8),
Text(label, style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12)),
],
);
}
Widget _canvasPlaceholder({required bool editor}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
node.props['label']?.toString() ?? 'Canvas',
style: TextStyle(color: state.tokens.accent, fontFamily: 'EveParity', fontSize: 12, fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
SizedBox(
height: editor ? 280 : 170,
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
color: editor ? const Color(0xFF142033) : state.tokens.panelInset,
border: Border.all(color: state.tokens.border, width: 2),
),
child: Stack(
children: [
Positioned.fill(child: CustomPaint(painter: EveGridPainter(color: state.tokens.accent.withValues(alpha: editor ? 0.18 : 0.08)))),
Center(
child: Text(
node.props['state']?.toString() ?? '',
style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12),
),
),
],
),
),
),
],
);
}
Widget _statusStage() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panelAlt,
border: Border.all(color: state.tokens.border, width: 2),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(node.props['label']?.toString() ?? 'Status', style: TextStyle(color: state.tokens.accent, fontFamily: 'EveParity', fontSize: 12, fontWeight: FontWeight.w700)),
const SizedBox(height: 6),
Text(node.props['stage']?.toString() ?? '', style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 15, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(node.props['detail']?.toString() ?? '', style: TextStyle(color: state.tokens.muted, fontFamily: 'EveParity', fontSize: 12)),
],
),
),
);
}
Widget _field() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(node.props['label']?.toString() ?? '', style: TextStyle(color: state.tokens.accent, fontFamily: 'EveParity', fontSize: 11, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
DecoratedBox(
decoration: BoxDecoration(color: state.tokens.panelInset, border: Border.all(color: state.tokens.border)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(node.props['value']?.toString() ?? '', style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 13)),
),
),
],
);
}
Widget _toggle() {
return DecoratedBox(
decoration: BoxDecoration(color: state.tokens.panelInset, border: Border.all(color: state.tokens.border)),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
'${node.props['label'] ?? 'Toggle'}: ${node.props['value'] == true ? 'on' : 'off'}',
style: TextStyle(color: state.tokens.text, fontFamily: 'EveParity', fontSize: 12),
),
),
);
}
Widget _swatch() {
return Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: _parseColor(node.props['value']) ?? state.tokens.text,
border: Border.all(color: state.tokens.accent, width: 2),
),
);
}
Widget _surface() {
final padding = _number(node.layout['padding'] ?? node.props['padding'], 0);
final direction = node.layout['direction']?.toString();
return Padding(
padding: EdgeInsets.all(padding),
child: LayoutBuilder(
builder: (context, constraints) {
if (direction == 'vertical') {
return _children(axis: Axis.vertical, maxWidth: 1280);
}
if (direction == 'horizontal') {
return _children(axis: Axis.horizontal, maxWidth: 1280);
}
if (constraints.maxWidth >= 900 && node.children.length > 1) {
return _children(axis: Axis.horizontal, maxWidth: 1280);
}
return _children(axis: Axis.vertical, maxWidth: 1180);
},
),
);
}
Widget _partition() {
final split = (node.props['split'] ?? _layoutDirection()).toString();
final gap = _number(node.props['gap'] ?? node.layout['gap'], 0);
final padding = _number(node.props['padding'] ?? node.layout['padding'], 0);
final body = _children(axis: split == 'x' ? Axis.horizontal : Axis.vertical);
return Padding(
padding: EdgeInsets.all(padding),
child: split == 'x'
? LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 520 && node.props['role'] == 'inspector.row') {
return _column(gap);
}
return body;
},
)
: body,
);
}
Widget _pane() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panel,
border: Border.all(color: state.tokens.border),
borderRadius: BorderRadius.circular(6),
boxShadow: const [
BoxShadow(color: Color(0x59000000), blurRadius: 26, offset: Offset(0, 12)),
],
),
child: Padding(
padding: EdgeInsets.all(_number(node.props['padding'], 12)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (node.props['title'] != null)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
node.props['title'].toString().toUpperCase(),
style: TextStyle(
color: state.tokens.muted,
fontFamily: 'EveParity',
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
..._spacedChildren(Axis.vertical, _number(node.props['gap'], 0)),
],
),
),
);
}
Widget _card() {
return DecoratedBox(
decoration: BoxDecoration(
color: state.tokens.panelAlt,
border: Border.all(color: const Color(0x10FFFFFF)),
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (node.props['title'] != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
node.props['title'].toString(),
style: TextStyle(
color: state.tokens.muted,
fontFamily: 'EveParity',
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
_children(axis: Axis.vertical),
],
),
),
);
}
Widget _text() {
final bind = node.props['bind']?.toString();
final value = bind == null ? node.props['text'] : state.values[bind];
final text = '${node.props['prefix'] ?? ''}${value ?? ''}${node.props['suffix'] ?? ''}';
return Text(
node.kind == 'label' ? text.toUpperCase() : text,
style: TextStyle(
color: node.kind == 'label' ? state.tokens.accent : state.tokens.text,
fontFamily: 'EveParity',
fontSize: node.kind == 'text.title' ? 18 : 13,
fontWeight: node.kind == 'label' ? FontWeight.w700 : FontWeight.w500,
),
);
}
Widget _fallback() {
return node.children.isEmpty ? const SizedBox.shrink() : _children(axis: Axis.vertical);
}
Widget _children({required Axis axis, double? maxWidth}) {
final gap = _number(node.props['gap'], 0);
final childWidgets = _spacedChildren(axis, gap);
final content = axis == Axis.horizontal
? Row(crossAxisAlignment: CrossAxisAlignment.center, children: childWidgets)
: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: childWidgets);
final constrained = maxWidth == null
? content
: Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(constraints: BoxConstraints(maxWidth: maxWidth), child: content),
);
return constrained;
}
Widget _column(double gap) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _spacedChildren(Axis.vertical, gap),
);
}
List<Widget> _spacedChildren(Axis axis, double gap) {
final widgets = <Widget>[];
for (var index = 0; index < node.children.length; index += 1) {
final child = node.children[index];
Widget view = EveNodeView(state: state, node: child);
if (axis == Axis.horizontal && _isFlexible(child)) {
view = Expanded(child: view);
}
widgets.add(view);
if (gap > 0 && index != node.children.length - 1) {
widgets.add(axis == Axis.horizontal ? SizedBox(width: gap) : SizedBox(height: gap));
}
}
return widgets;
}
bool _isFlexible(EveNode child) {
final size = child.props['size']?.toString();
return size == null || size.endsWith('fr');
}
String _layoutDirection() {
final direction = node.layout['direction']?.toString();
if (direction == 'horizontal') {
return 'x';
}
return 'y';
}
Map<String, dynamic> _map(Object? value) => value is Map<String, dynamic> ? value : const {};
Color? _parseColor(Object? value) {
if (value is! String || !value.startsWith('#')) return null;
final body = value.substring(1);
if (body.length == 8) return Color(int.parse(body, radix: 16));
if (body.length == 6) return Color(int.parse('ff$body', radix: 16));
return null;
}
String? _previewAssetPath(Object? source) {
final src = source?.toString() ?? '';
if (src.endsWith('/character-input.png')) return 'assets/repixelizer/character-input.png';
if (src.endsWith('/character-repixelized.png')) return 'assets/repixelizer/character-repixelized.png';
return null;
}
}
class EveGridPainter extends CustomPainter {
EveGridPainter({required this.color});
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
for (var x = 0.0; x < size.width; x += 32) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
}
for (var y = 0.0; y < size.height; y += 32) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
}
}
@override
bool shouldRepaint(EveGridPainter oldDelegate) => oldDelegate.color != color;
}
class EveNornPainter extends CustomPainter {
EveNornPainter({required this.nodes, required this.edges, required this.tokens});
final List<dynamic> nodes;
final List<dynamic> edges;
final EveTokens tokens;
@override
void paint(Canvas canvas, Size size) {
final nodeById = <String, Offset>{};
for (final raw in nodes) {
if (raw is! Map<String, dynamic>) continue;
final id = raw['id']?.toString();
if (id == null) continue;
nodeById[id] = Offset(_number(raw['x'], 0.5) * size.width, _number(raw['y'], 0.5) * size.height);
}
final edgePaint = Paint()
..color = tokens.muted.withValues(alpha: 0.5)
..strokeWidth = 2;
for (final raw in edges) {
if (raw is! Map<String, dynamic>) continue;
final source = nodeById[raw['source']?.toString()];
final target = nodeById[raw['target']?.toString()];
if (source != null && target != null) {
canvas.drawLine(source, target, edgePaint);
}
}
for (final raw in nodes) {
if (raw is! Map<String, dynamic>) continue;
final id = raw['id']?.toString();
final center = id == null ? null : nodeById[id];
if (center == null) continue;
final current = raw['current'] == true;
canvas.drawCircle(center, current ? 24 : 20, Paint()..color = current ? tokens.accent : tokens.panelAlt);
canvas.drawCircle(
center,
current ? 24 : 20,
Paint()
..color = current ? tokens.accent : tokens.border
..style = PaintingStyle.stroke
..strokeWidth = 2,
);
final label = raw['label']?.toString() ?? id;
final paragraph = TextPainter(
text: TextSpan(
text: label,
style: TextStyle(color: tokens.text, fontFamily: 'EveParity', fontSize: 13, fontWeight: FontWeight.w700),
),
textDirection: TextDirection.ltr,
)..layout(maxWidth: 100);
paragraph.paint(canvas, center + Offset(-paragraph.width / 2, 30));
}
}
@override
bool shouldRepaint(EveNornPainter oldDelegate) => oldDelegate.nodes != nodes || oldDelegate.edges != edges;
}
class EveSlider extends StatelessWidget {
const EveSlider({required this.state, required this.node, super.key});
final EveSurfaceState state;
final EveNode node;
@override
Widget build(BuildContext context) {
final bind = node.props['bind']?.toString();
final min = _number(node.props['min'], 0);
final max = _number(node.props['max'], 1);
final rawValue = bind == null ? node.props['value'] : state.values[bind];
final value = _number(rawValue, min).clamp(min, max);
final percent = max == min ? 0.0 : ((value - min) / (max - min)).clamp(0.0, 1.0);
final skin = state.controlSkins[node.props['skin']?.toString()] ?? const [];
final box = skin.where((part) => part.kind == 'control.box').firstOrNull?.props ?? const {};
return CustomPaint(
painter: EveSliderPainter(
value: percent,
tokens: state.tokens,
skin: skin,
),
child: SizedBox(
height: _number(box['height'], 22).clamp(18, 36),
width: double.infinity,
),
);
}
}
class EveSliderPainter extends CustomPainter {
EveSliderPainter({required this.value, required this.tokens, required this.skin});
final double value;
final EveTokens tokens;
final List<EveNode> skin;
@override
void paint(Canvas canvas, Size size) {
final centerY = size.height / 2;
final trackHeight = _partSize('track', 1, 6);
final thumbSize = _partSize('thumb', 0, 12);
final track = RRect.fromRectAndRadius(
Rect.fromLTWH(0, centerY - trackHeight / 2, size.width, trackHeight),
Radius.circular(_partRadius('track', 2)),
);
final fill = RRect.fromRectAndRadius(
Rect.fromLTWH(0, centerY - trackHeight / 2, size.width * value, trackHeight),
Radius.circular(_partRadius('fill', 2)),
);
canvas.drawRRect(track, Paint()..color = tokens.panelInset);
canvas.drawRRect(fill, Paint()..color = tokens.accent);
final thumbCenter = Offset(size.width * value, centerY);
canvas.drawCircle(
thumbCenter,
thumbSize / 2 + _partBleed('thumb', 0),
Paint()
..color = tokens.accent.withValues(alpha: 0.35)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 5),
);
canvas.drawCircle(thumbCenter, thumbSize / 2, Paint()..color = tokens.accent);
}
double _partSize(String name, int index, double fallback) {
final part = _part(name);
final size = part?['size'];
if (size is List && size.length > index) return _number(size[index], fallback);
return fallback;
}
double _partRadius(String name, double fallback) => _number(_part(name)?['radius'], fallback);
double _partBleed(String name, double fallback) => _number(_part(name)?['bleed'], fallback);
Map<String, dynamic>? _part(String name) {
for (final node in skin) {
if (node.kind == 'control.part' && node.props['name'] == name) return node.props;
}
return null;
}
@override
bool shouldRepaint(covariant EveSliderPainter oldDelegate) {
return oldDelegate.value != value || oldDelegate.tokens != tokens || oldDelegate.skin != skin;
}
}
double _number(Object? value, double fallback) {
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value) ?? fallback;
return fallback;
}