monster_halo 0.1.0 copy "monster_halo: ^0.1.0" to clipboard
monster_halo: ^0.1.0 copied to clipboard

PlatformAndroid

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.

example/lib/main.dart

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)),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}
0
likes
150
points
11
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#vivo #iqoo #led #hardware #android

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on monster_halo

Packages that implement monster_halo