monster_halo 0.1.0
monster_halo: ^0.1.0 copied to clipboard
Drive the iQOO/Vivo Monster Halo LED ring from Flutter — solid colors, marquee, chase, comet, strobe and per-LED control over the vivo_light_service binder.
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:monster_halo/monster_halo.dart';
void main() => runApp(const HaloApp());
class HaloApp extends StatelessWidget {
const HaloApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Monster Halo',
theme: ThemeData.dark(useMaterial3: true),
home: const HaloHome(),
);
}
extension on HaloStyle {
String get label {
switch (this) {
case HaloStyle.solid:
return 'Solid (1 color)';
case HaloStyle.perLedSolid:
return 'Solid per-LED';
case HaloStyle.strobe:
return 'Strobe';
case HaloStyle.chase:
return 'Chase';
case HaloStyle.pairFlip:
return 'Pair flip';
case HaloStyle.slowPulse:
return 'Slow pulse';
case HaloStyle.squareWave:
return 'Square wave';
case HaloStyle.comet:
return 'Comet';
case HaloStyle.altChase:
return 'Alt chase';
}
}
IconData get icon {
switch (this) {
case HaloStyle.solid:
case HaloStyle.perLedSolid:
return Icons.lightbulb;
case HaloStyle.strobe:
return Icons.flash_on;
case HaloStyle.chase:
return Icons.directions_run;
case HaloStyle.pairFlip:
return Icons.swap_horiz;
case HaloStyle.slowPulse:
return Icons.favorite;
case HaloStyle.squareWave:
return Icons.crop_square;
case HaloStyle.comet:
return Icons.auto_awesome;
case HaloStyle.altChase:
return Icons.shuffle;
}
}
}
class HaloHome extends StatefulWidget {
const HaloHome({super.key});
@override
State<HaloHome> createState() => _HaloHomeState();
}
class _HaloHomeState extends State<HaloHome> {
final _halo = MonsterHalo.instance;
HaloStyle _style = HaloStyle.solid;
int _periodMs = 1500;
int _count = 5;
int _brightness = 100;
bool _countPriority = true;
bool _sameColorForAllLeds = true;
Color _c1 = const Color(0xFFFF0000);
Color _c2 = const Color(0xFF00FF00);
Color _c3 = const Color(0xFF0000FF);
Color _c4 = const Color(0xFFFFFF00);
HaloHandle? _lastHandle;
String _log = '';
final _jsonCtrl = TextEditingController();
void _append(String s) => setState(
() => _log =
'${DateTime.now().toIso8601String().substring(11, 19)} $s\n$_log',
);
Future<void> _safe(String label, Future<dynamic> Function() fn) async {
try {
final v = await fn();
_append('$label → $v');
} on PlatformException catch (e) {
_append('$label FAILED: ${e.code} ${e.message}');
} catch (e) {
_append('$label THREW: $e');
}
}
HaloColor _hc(Color c) => HaloColor.fromColor(c);
HaloEffect _buildEffect() {
final b = HaloEffectBuilder()
.style(_style)
.brightness(_brightness)
.periodMs(_periodMs)
.count(_count)
.stopByCount(_countPriority);
if (_style == HaloStyle.perLedSolid || !_sameColorForAllLeds) {
b.perLed(_hc(_c1), _hc(_c2), _hc(_c3), _hc(_c4));
} else {
b.color(_hc(_c1));
}
return b.build();
}
Future<void> _fire() async {
final raw = _jsonCtrl.text.trim();
if (raw.isNotEmpty) {
_append('JSON →\n$raw');
await _safe('playRawJson', () async {
final h = await _halo.playRawJson(raw);
_lastHandle = h;
return h;
});
return;
}
final eff = _buildEffect();
_append(
'Effect →\n${const JsonEncoder.withIndent(' ').convert(eff.toJson())}');
await _safe('play', () async {
final h = await _halo.play(eff);
_lastHandle = h;
return h;
});
}
Future<void> _pickColor(int idx) async {
final current = [_c1, _c2, _c3, _c4][idx - 1];
final picked = await showDialog<Color>(
context: context,
builder: (ctx) {
Color tmp = current;
const swatches = <Color>[
Color(0xFFFF0000),
Color(0xFFFF6A00),
Color(0xFFFFD800),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0094FF),
Color(0xFF0026FF),
Color(0xFF4800FF),
Color(0xFFFF00DC),
Color(0xFFFF006E),
Color(0xFFFFFFFF),
Color(0xFFFF8000),
];
return StatefulBuilder(
builder: (c, set) => AlertDialog(
title: Text('LED $idx color'),
content: SizedBox(
width: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: swatches
.map((sw) => GestureDetector(
onTap: () => set(() => tmp = sw),
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: sw,
border: Border.all(
color:
tmp == sw ? Colors.white : Colors.grey,
width: tmp == sw ? 3 : 1,
),
borderRadius: BorderRadius.circular(6),
),
),
))
.toList(),
),
const SizedBox(height: 12),
_channelSlider('R', (tmp.toARGB32() >> 16) & 0xFF, (v) {
set(() => tmp = Color(
0xFF000000 | (v << 16) | (tmp.toARGB32() & 0xFFFF)));
}),
_channelSlider('G', (tmp.toARGB32() >> 8) & 0xFF, (v) {
set(() => tmp = Color(0xFF000000 |
(tmp.toARGB32() & 0xFF0000) |
(v << 8) |
(tmp.toARGB32() & 0xFF)));
}),
_channelSlider('B', tmp.toARGB32() & 0xFF, (v) {
set(() => tmp =
Color(0xFF000000 | (tmp.toARGB32() & 0xFFFF00) | v));
}),
Container(height: 30, color: tmp),
Text('#${HaloColor.fromColor(tmp).toRRGGBB()}',
style: const TextStyle(fontFamily: 'monospace')),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, null),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, tmp),
child: const Text('OK')),
],
),
);
},
);
if (picked != null) {
setState(() {
if (idx == 1) _c1 = picked;
if (idx == 2) _c2 = picked;
if (idx == 3) _c3 = picked;
if (idx == 4) _c4 = picked;
});
}
}
Widget _channelSlider(String label, int value, ValueChanged<int> onChanged) =>
Row(
children: [
SizedBox(
width: 18,
child: Text(label, style: const TextStyle(fontSize: 12))),
Expanded(
child: Slider(
value: value.toDouble(),
min: 0,
max: 255,
divisions: 255,
label: '$value',
onChanged: (d) => onChanged(d.toInt()),
),
),
SizedBox(width: 32, child: Text('$value', textAlign: TextAlign.end)),
],
);
Widget _colorChip(int idx, Color c, {bool enabled = true}) => Opacity(
opacity: enabled ? 1 : 0.35,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: GestureDetector(
onTap: enabled ? () => _pickColor(idx) : null,
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: c,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white24, width: 2),
boxShadow: [
BoxShadow(color: c.withValues(alpha: 0.6), blurRadius: 12)
],
),
),
Text('LED $idx', style: const TextStyle(fontSize: 11)),
],
),
),
),
);
Widget _slider(
String label, int v, int min, int max, ValueChanged<int> onChanged) =>
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 90,
child: Text(label, style: const TextStyle(fontSize: 12))),
Expanded(
child: Slider(
value: v.toDouble(),
min: min.toDouble(),
max: max.toDouble(),
divisions: (max - min).clamp(1, 200),
label: '$v',
onChanged: (d) => onChanged(d.toInt()),
),
),
SizedBox(width: 56, child: Text('$v', textAlign: TextAlign.end)),
],
),
);
List<({String name, HaloEffect effect, HaloStyle style})> get _presets => [
(
name: 'Solid red',
effect: HaloPresets.solidRed,
style: HaloStyle.solid
),
(
name: 'Solid white',
effect: HaloPresets.solidWhite,
style: HaloStyle.solid
),
(
name: 'Solid cyan',
effect: HaloPresets.solidCyan,
style: HaloStyle.solid
),
(
name: 'Solid purple',
effect: HaloPresets.solidPurple,
style: HaloStyle.solid
),
(
name: 'RGB rainbow',
effect: HaloPresets.rgbRainbow,
style: HaloStyle.perLedSolid
),
(
name: 'Pink/cyan rainbow',
effect: HaloPresets.pinkCyanRainbow,
style: HaloStyle.perLedSolid
),
(
name: 'Slow red chase',
effect: HaloPresets.slowRedChase,
style: HaloStyle.chase
),
(
name: 'Fast white chase',
effect: HaloPresets.fastWhiteChase,
style: HaloStyle.chase
),
(
name: 'RGB chase',
effect: HaloPresets.rgbChase,
style: HaloStyle.chase
),
(
name: 'Pair flip blue/orange',
effect: HaloPresets.pairFlipBlueOrange,
style: HaloStyle.pairFlip
),
(
name: 'Heartbeat',
effect: HaloPresets.heartbeat,
style: HaloStyle.slowPulse
),
(
name: 'Notification ping',
effect: HaloPresets.notificationPing,
style: HaloStyle.squareWave
),
(
name: 'Comet red',
effect: HaloPresets.cometRed,
style: HaloStyle.comet
),
(
name: 'Comet rainbow',
effect: HaloPresets.cometRainbow,
style: HaloStyle.comet
),
(
name: 'Police strobe',
effect: HaloPresets.policeStrobe,
style: HaloStyle.strobe
),
];
void _applyPreset(({String name, HaloEffect effect, HaloStyle style}) p) {
_safe('play ${p.name}', () async {
final h = await _halo.play(p.effect);
_lastHandle = h;
return h;
});
}
@override
Widget build(BuildContext context) {
final isSolid =
_style == HaloStyle.solid || _style == HaloStyle.perLedSolid;
final isMarquee = _style != HaloStyle.solid &&
_style != HaloStyle.perLedSolid &&
_style != HaloStyle.strobe;
return Scaffold(
appBar: AppBar(
title: const Text('Monster Halo'),
actions: [
IconButton(
icon: const Icon(Icons.power_settings_new),
tooltip: 'Stop all',
onPressed: () => _safe('stopAll', () => _halo.stopAll()),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(8),
child: Wrap(
spacing: 8,
children: [
FilledButton.tonal(
onPressed: () =>
_safe('hasLight', () => _halo.hasLight()),
child: const Text('hasLight'),
),
FilledButton.tonal(
onPressed: () =>
_safe('getLightCase', () => _halo.getLightCase()),
child: const Text('getLightCase'),
),
FilledButton.tonal(
onPressed: () =>
_safe('getLightType', () => _halo.getLightType()),
child: const Text('getLightType'),
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Style',
style: TextStyle(fontWeight: FontWeight.bold)),
Wrap(
spacing: 6,
runSpacing: 6,
children: HaloStyle.values
.map((m) => ChoiceChip(
avatar: Icon(m.icon, size: 16),
label: Text(m.label),
selected: _style == m,
onSelected: (_) => setState(() => _style = m),
))
.toList(),
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text('Colors',
style: TextStyle(fontWeight: FontWeight.bold)),
),
Row(
children: [
const Text('Same on all LEDs',
style: TextStyle(fontSize: 12)),
Switch(
value: _sameColorForAllLeds,
onChanged: _style == HaloStyle.perLedSolid
? null
: (v) =>
setState(() => _sameColorForAllLeds = v),
),
],
),
],
),
Center(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_colorChip(1, _c1),
_colorChip(2, _sameColorForAllLeds ? _c1 : _c2,
enabled: !_sameColorForAllLeds),
_colorChip(3, _sameColorForAllLeds ? _c1 : _c3,
enabled: !_sameColorForAllLeds),
_colorChip(4, _sameColorForAllLeds ? _c1 : _c4,
enabled: !_sameColorForAllLeds),
],
),
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Timing & brightness',
style: TextStyle(fontWeight: FontWeight.bold)),
_slider('brightness', _brightness, 1, 100,
(v) => setState(() => _brightness = v)),
if (!isSolid) ...[
_slider('period ms', _periodMs, 100, 5000,
(v) => setState(() => _periodMs = v)),
_slider('count', _count, 1, 60,
(v) => setState(() => _count = v)),
Row(
children: [
const Text('Stop by ',
style: TextStyle(fontSize: 12)),
ChoiceChip(
label: const Text('count'),
selected: _countPriority,
onSelected: (_) =>
setState(() => _countPriority = true),
),
const SizedBox(width: 6),
ChoiceChip(
label: const Text('total time'),
selected: !_countPriority,
onSelected: (_) =>
setState(() => _countPriority = false),
),
],
),
],
if (isMarquee)
const Padding(
padding: EdgeInsets.only(top: 6),
child: Text('marquee',
style:
TextStyle(fontSize: 11, color: Colors.white54)),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _fire,
icon: const Icon(Icons.play_arrow),
label: const Text('FIRE'),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: _lastHandle == null
? null
: () => _safe('stopById',
() => _halo.stopById(_lastHandle!)),
child: const Text('Stop last'),
),
],
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Presets (tap to fire)',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _presets
.map((p) => ActionChip(
avatar: Icon(p.style.icon, size: 16),
label: Text(p.name),
onPressed: () => _applyPreset(p),
))
.toList(),
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text('Raw JSON (overrides if non-empty)',
style: TextStyle(fontWeight: FontWeight.bold)),
),
TextButton(
onPressed: () => setState(() {
_jsonCtrl.text = const JsonEncoder.withIndent(' ')
.convert(_buildEffect().toJson());
}),
child: const Text('Load'),
),
TextButton(
onPressed: () => setState(() => _jsonCtrl.clear()),
child: const Text('Clear'),
),
],
),
TextField(
controller: _jsonCtrl,
maxLines: 8,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11),
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'paste/edit JSON, leave empty to use builder',
),
),
],
),
),
),
Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text('Log',
style: TextStyle(fontWeight: FontWeight.bold))),
TextButton(
onPressed: () => setState(() => _log = ''),
child: const Text('Clear')),
],
),
Container(
height: 200,
width: double.infinity,
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(6),
),
child: SingleChildScrollView(
child: Text(_log,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11)),
),
),
],
),
),
),
],
),
),
);
}
}