virtual_gamepad_pro 0.3.1 copy "virtual_gamepad_pro: ^0.3.1" to clipboard
virtual_gamepad_pro: ^0.3.1 copied to clipboard

Advanced virtual controller suite: joystick, d-pad, buttons, and runtime layout editor with JSON serialization. Optimized for remote play.

example/lib/main.dart

import 'dart:convert';

import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData, rootBundle;
import 'package:virtual_gamepad_pro/virtual_gamepad_pro.dart';

import 'platform/file_io.dart';
import 'platform/kv_store.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '布局编辑器',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        brightness: Brightness.light,
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.black,
          brightness: Brightness.light,
          surface: Colors.white,
          onSurface: Colors.black87,
        ),
        scaffoldBackgroundColor: const Color(
          0xFFF5F5F7,
        ), // Apple-like light gray
        useMaterial3: true,
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.white,
          foregroundColor: Colors.black,
          elevation: 0,
          scrolledUnderElevation: 0,
          titleTextStyle: TextStyle(
            color: Colors.black,
            fontSize: 16,
            fontWeight: FontWeight.w600,
          ),
          iconTheme: IconThemeData(color: Colors.black87),
        ),
        cardTheme: CardThemeData(
          elevation: 0,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
            side: BorderSide(color: Colors.grey.withValues(alpha: 0.1)),
          ),
          color: Colors.white,
        ),
        dividerTheme: DividerThemeData(
          color: Colors.grey.withValues(alpha: 0.1),
          thickness: 1,
        ),
      ),
      home: const LayoutManagerPage(),
    );
  }
}

class LayoutManagerPage extends StatefulWidget {
  const LayoutManagerPage({super.key});

  @override
  State<LayoutManagerPage> createState() => _LayoutManagerPageState();
}

class _LayoutManagerPageState extends State<LayoutManagerPage> {
  final _repo = _LayoutRepo(createStore());

  bool _loading = true;
  String? _selectedId;
  List<String> _ids = const [];
  Map<String, String> _names = {};

  // Canvas State
  bool _isEditing = false;
  double _canvasWidth = 812;
  double _canvasHeight = 375;
  String _selectedPreset = 'iPhone X/11/12/13 (L)';
  int _stateRevision = 0;

  static const _presets = <String, Size>{
    'iPhone 8 (L)': Size(667, 375),
    'iPhone 8 Plus (L)': Size(736, 414),
    'iPhone X/11/12/13 (L)': Size(812, 375),
    'iPhone 14/15 Pro Max (L)': Size(932, 430),
    'iPad (L)': Size(1024, 768),
    'iPad Pro 12.9 (L)': Size(1366, 1024),
    'Android 1080p (L)': Size(1920, 1080),
    'Android 720p (L)': Size(1280, 720),
    'Custom': Size.zero,
  };

  static const VirtualControlTheme _theme = DefaultVirtualControlTheme();

  final VirtualControllerLayout _definition = VirtualControllerLayout(
    schemaVersion: 1,
    name: 'unnamed',
    controls: [],
  );

  @override
  void initState() {
    super.initState();
    _init();
  }

  Future<void> _init() async {
    await _repo.init();
    final ids = await _repo.listIds();
    final selected = await _repo.getSelectedId() ?? ids.first;

    // Load all names
    final names = <String, String>{};
    for (final id in ids) {
      final s = await _repo.loadState(id);
      names[id] = s.name ?? id;
    }

    if (!mounted) return;
    setState(() {
      _ids = ids;
      _selectedId = selected;
      _names = names;
      _loading = false;
    });
  }

  Future<void> _select(String id) async {
    await _repo.loadState(id);
    await _repo.setSelectedId(id);
    if (!mounted) return;
    setState(() {
      _selectedId = id;
    });
  }

  Future<void> _createNew({bool duplicate = false}) async {
    final id = await _repo.create(baseId: duplicate ? _selectedId : null);
    final ids = await _repo.listIds();
    final state = await _repo.loadState(id);

    await _select(id);
    if (!mounted) return;
    setState(() {
      _ids = ids;
      final newNames = Map<String, String>.from(_names);
      newNames[id] = state.name ?? id;
      _names = newNames;
    });
  }

  Future<void> _deleteCurrent() async {
    final id = _selectedId;
    if (id == null) return;
    if (_ids.length <= 1) return;
    final ok = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('删除布局'),
        content: Text('确定删除 "${_names[id] ?? id}" 吗?此操作只影响本地存储。'),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('取消'),
          ),
          FilledButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: const Text('删除'),
          ),
        ],
      ),
    );
    if (ok != true) return;
    await _repo.delete(id);
    final ids = await _repo.listIds();
    final next = ids.first;
    await _select(next);
    if (!mounted) return;
    setState(() {
      _ids = ids;
      final newNames = Map<String, String>.from(_names);
      newNames.remove(id);
      _names = newNames;
    });
  }

  void _toggleEdit() {
    setState(() => _isEditing = !_isEditing);
  }

  Future<void> _exportCurrent() async {
    final id = _selectedId;
    if (id == null) return;
    final state = await _repo.loadState(id);
    final jsonStr = const JsonEncoder.withIndent('  ').convert(state.toJson());
    final name = state.name ?? id;
    try {
      await downloadTextFile('$name.state.json', jsonStr);
    } catch (_) {
      await _showExportDialog(filename: '$name.state.json', content: jsonStr);
    }
  }

  Future<void> _importNew() async {
    String? filename;
    String? content;
    try {
      final picked = await pickTextFile();
      if (picked == null) return;
      filename = picked.name;
      content = picked.content;
    } catch (_) {
      final pasted = await _showImportDialog();
      if (pasted == null) return;
      filename = 'imported.state.json';
      content = pasted;
    }

    final dynamic decoded = jsonDecode(content);
    if (decoded is! Map) return;
    final state = VirtualControllerState.fromJson(
      Map<String, dynamic>.from(decoded),
    );
    final name = filename.replaceAll(RegExp(r'\.state\.json$'), '');
    final id = await _repo.importAs(name, state);
    final ids = await _repo.listIds();
    await _select(id);
    if (!mounted) return;
    setState(() {
      _ids = ids;
      final newNames = Map<String, String>.from(_names);
      newNames[id] = state.name ?? id;
      _names = newNames;
    });
  }

  Future<void> _showExportDialog({
    required String filename,
    required String content,
  }) async {
    await showDialog<void>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('导出:$filename'),
        content: SizedBox(
          width: 520,
          child: SingleChildScrollView(child: SelectableText(content)),
        ),
        actions: [
          TextButton(
            onPressed: () async {
              await Clipboard.setData(ClipboardData(text: content));
              if (context.mounted) Navigator.of(context).pop();
            },
            child: const Text('复制并关闭'),
          ),
          FilledButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('关闭'),
          ),
        ],
      ),
    );
  }

  Future<String?> _showImportDialog() async {
    final controller = TextEditingController();
    return showDialog<String>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('导入布局 State JSON'),
        content: SizedBox(
          width: 520,
          child: TextField(
            controller: controller,
            autofocus: true,
            minLines: 8,
            maxLines: 16,
            decoration: const InputDecoration(
              hintText: '粘贴 VirtualControllerState 的 JSON',
              border: OutlineInputBorder(),
            ),
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(),
            child: const Text('取消'),
          ),
          FilledButton(
            onPressed: () => Navigator.of(context).pop(controller.text.trim()),
            child: const Text('导入'),
          ),
        ],
      ),
    );
  }

  void _updateCanvasSize(String preset) {
    setState(() {
      _selectedPreset = preset;
      final size = _presets[preset];
      if (size != null && size != Size.zero) {
        _canvasWidth = size.width;
        _canvasHeight = size.height;
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    if (_loading) {
      return const Scaffold(body: Center(child: CircularProgressIndicator()));
    }
    final selected = _selectedId ?? _ids.first;

    return Scaffold(
      body: Row(
        children: [
          // Left Sidebar
          SizedBox(
            width: 280,
            child: _Sidebar(
              selectedId: selected,
              ids: _ids,
              names: _names,
              onSelect: _select,
              onNew: () => _createNew(duplicate: false),
              onDuplicate: () => _createNew(duplicate: true),
              onDelete: _deleteCurrent,
              onExport: _exportCurrent,
              onImport: _importNew,
            ),
          ),
          // Main Content
          Expanded(
            child: Column(
              children: [
                // Top Toolbar
                _Toolbar(
                  isEditing: _isEditing,
                  onToggleEdit: _toggleEdit,
                  selectedPreset: _selectedPreset,
                  presets: _presets.keys.toList(),
                  onPresetChanged: (v) {
                    if (v != null) _updateCanvasSize(v);
                  },
                  width: _canvasWidth,
                  height: _canvasHeight,
                  onWidthChanged: (v) => setState(() {
                    _canvasWidth = v;
                    _selectedPreset = 'Custom';
                  }),
                  onHeightChanged: (v) => setState(() {
                    _canvasHeight = v;
                    _selectedPreset = 'Custom';
                  }),
                ),
                // Canvas Area
                Expanded(
                  child: Container(
                    color: const Color(0xFFE5E5EA), // Light gray background
                    child: Center(
                      child: SingleChildScrollView(
                        scrollDirection: Axis.horizontal,
                        child: SingleChildScrollView(
                          scrollDirection: Axis.vertical,
                          padding: const EdgeInsets.all(40),
                          child: Container(
                            width: _canvasWidth,
                            height: _canvasHeight,
                            decoration: BoxDecoration(
                              color: Colors.blueGrey,
                              borderRadius: BorderRadius.circular(24),
                              boxShadow: [
                                BoxShadow(
                                  color: Colors.black.withValues(alpha: 0.1),
                                  blurRadius: 20,
                                  offset: const Offset(0, 4),
                                ),
                              ],
                            ),
                            child: ClipRRect(
                              borderRadius: BorderRadius.circular(24),
                              child: Stack(
                                children: [
                                  Positioned.fill(
                                    child: CustomPaint(
                                      painter: _GridPaperPainter(
                                        color: const Color(0xFFF0F0F2),
                                        interval: 20,
                                      ),
                                    ),
                                  ),
                                  if (_isEditing)
                                    VirtualControllerLayoutEditor(
                                      key: ValueKey('editor_$selected'),
                                      layoutId: selected,
                                      loadDefinition: (_) async => _definition,
                                      loadState: _repo.loadState,
                                      saveState: (id, state) async {
                                        await _repo.saveState(id, state);
                                        if (mounted) {
                                          setState(() {
                                            _stateRevision++;
                                            final newNames =
                                                Map<String, String>.from(
                                                  _names,
                                                );
                                            newNames[id] = state.name ?? id;
                                            _names = newNames;
                                          });
                                        }
                                      },
                                      previewDecorator: (layout) =>
                                          layout.mapControls(_theme.decorate),
                                      onClose: _toggleEdit,
                                      allowMove: true,
                                      allowResize: true,
                                      allowAddRemove: true,
                                      readOnly: false,
                                      allowRename: true,
                                      immersive: true,
                                    )
                                  else
                                    FutureBuilder<VirtualControllerState>(
                                      key: ValueKey(
                                        'overlay_${selected}_$_stateRevision',
                                      ),
                                      future: _repo.loadState(selected),
                                      builder: (context, snapshot) {
                                        final state = snapshot.data;
                                        if (state == null) {
                                          return const Center(
                                            child: SizedBox(
                                              width: 22,
                                              height: 22,
                                              child:
                                                  CircularProgressIndicator(),
                                            ),
                                          );
                                        }
                                        final definition = buildDefinitionFromState(
                                          state,
                                          runtimeDefaults: true,
                                          fallbackName: _definition.name,
                                        );
                                        return VirtualControllerOverlay(
                                          definition: definition,
                                          state: state,
                                          theme: _theme,
                                          onInputEvent: (_) {},
                                          opacity: 1.0,
                                          showLabels: true,
                                          immersive: true,
                                        );
                                      },
                                    ),
                                ],
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class _Sidebar extends StatelessWidget {
  const _Sidebar({
    required this.selectedId,
    required this.ids,
    required this.names,
    required this.onSelect,
    required this.onNew,
    required this.onDuplicate,
    required this.onDelete,
    required this.onExport,
    required this.onImport,
  });

  final String selectedId;
  final List<String> ids;
  final Map<String, String> names;
  final ValueChanged<String> onSelect;
  final VoidCallback onNew;
  final VoidCallback onDuplicate;
  final VoidCallback onDelete;
  final VoidCallback onExport;
  final VoidCallback onImport;

  @override
  Widget build(BuildContext context) {
    return Container(
      color: Colors.white,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          Container(
            padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
            child: const Text(
              'Virtual Gamepad',
              style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
          ),
          const Divider(height: 1),
          Expanded(
            child: ListView.builder(
              padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
              itemCount: ids.length,
              itemBuilder: (context, index) {
                final id = ids[index];
                final name = names[id] ?? id;
                final isSelected = id == selectedId;
                return Padding(
                  padding: const EdgeInsets.only(bottom: 2),
                  child: ListTile(
                    title: Text(
                      name,
                      style: TextStyle(
                        fontWeight: isSelected
                            ? FontWeight.w600
                            : FontWeight.normal,
                        color: isSelected ? Colors.black : Colors.black87,
                        fontSize: 14,
                      ),
                    ),
                    shape: RoundedRectangleBorder(
                      borderRadius: BorderRadius.circular(8),
                    ),
                    selected: isSelected,
                    selectedTileColor: const Color(0xFFF5F5F7),
                    contentPadding: const EdgeInsets.symmetric(horizontal: 12),
                    onTap: () => onSelect(id),
                    dense: true,
                    visualDensity: VisualDensity.compact,
                  ),
                );
              },
            ),
          ),
          const Divider(height: 1),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                _SidebarButton(
                  icon: CupertinoIcons.add,
                  label: '新建布局',
                  onTap: onNew,
                  textColor: Colors.blue,
                ),
                _SidebarButton(
                  icon: CupertinoIcons.arrow_up_doc,
                  label: '导入 JSON',
                  onTap: onImport,
                ),
                // More actions menu
                MenuAnchor(
                  style: MenuStyle(
                    backgroundColor: const WidgetStatePropertyAll(Colors.white),
                    surfaceTintColor: const WidgetStatePropertyAll(
                      Colors.white,
                    ),
                    padding: const WidgetStatePropertyAll(
                      EdgeInsets.symmetric(vertical: 8),
                    ),
                    shape: WidgetStatePropertyAll(
                      RoundedRectangleBorder(
                        borderRadius: BorderRadius.circular(12),
                      ),
                    ),
                    elevation: const WidgetStatePropertyAll(6),
                    shadowColor: WidgetStatePropertyAll(
                      Colors.black.withValues(alpha: 0.2),
                    ),
                  ),
                  builder: (context, controller, child) {
                    return _SidebarButton(
                      icon: CupertinoIcons.ellipsis_circle,
                      label: '更多操作',
                      onTap: () {
                        if (controller.isOpen) {
                          controller.close();
                        } else {
                          controller.open();
                        }
                      },
                    );
                  },
                  menuChildren: [
                    _MenuItem(
                      onPressed: onDuplicate,
                      icon: CupertinoIcons.doc_on_doc,
                      label: '复制当前布局',
                    ),
                    _MenuItem(
                      onPressed: onExport,
                      icon: CupertinoIcons.arrow_down_doc,
                      label: '导出 JSON',
                    ),
                    _MenuItem(
                      onPressed: ids.length <= 1 ? null : onDelete,
                      icon: CupertinoIcons.trash,
                      label: '删除',
                      isDestructive: true,
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class _SidebarButton extends StatefulWidget {
  const _SidebarButton({
    required this.icon,
    required this.label,
    required this.onTap,
    this.textColor = Colors.black87,
  });
  final IconData icon;
  final String label;
  final VoidCallback onTap;
  final Color textColor;

  @override
  State<_SidebarButton> createState() => _SidebarButtonState();
}

class _SidebarButtonState extends State<_SidebarButton> {
  bool _isHovered = false;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) => setState(() => _isHovered = true),
      onExit: (_) => setState(() => _isHovered = false),
      cursor: SystemMouseCursors.click,
      child: GestureDetector(
        onTap: widget.onTap,
        child: Container(
          decoration: BoxDecoration(
            color: _isHovered ? const Color(0xFFF5F5F7) : Colors.transparent,
            borderRadius: BorderRadius.circular(8),
          ),
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
          child: Row(
            children: [
              Icon(
                widget.icon,
                size: 20,
                color: widget.textColor == Colors.blue
                    ? Colors.blue
                    : Colors.black54,
              ),
              const SizedBox(width: 12),
              Text(
                widget.label,
                style: TextStyle(
                  color: widget.textColor,
                  fontSize: 14,
                  fontWeight: FontWeight.w500,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _MenuItem extends StatelessWidget {
  const _MenuItem({
    required this.onPressed,
    required this.icon,
    required this.label,
    this.isDestructive = false,
  });

  final VoidCallback? onPressed;
  final IconData icon;
  final String label;
  final bool isDestructive;

  @override
  Widget build(BuildContext context) {
    return MenuItemButton(
      onPressed: onPressed,
      style: ButtonStyle(
        overlayColor: WidgetStateProperty.resolveWith((states) {
          if (states.contains(WidgetState.hovered)) {
            return const Color(0xFFF5F5F7);
          }
          return null;
        }),
        padding: const WidgetStatePropertyAll(
          EdgeInsets.symmetric(horizontal: 16, vertical: 12),
        ),
        minimumSize: const WidgetStatePropertyAll(Size(200, 40)),
      ),
      leadingIcon: Icon(
        icon,
        size: 18,
        color: onPressed == null
            ? Colors.grey
            : (isDestructive ? Colors.red : Colors.black87),
      ),
      child: Text(
        label,
        style: TextStyle(
          color: onPressed == null
              ? Colors.grey
              : (isDestructive ? Colors.red : Colors.black87),
          fontSize: 14,
        ),
      ),
    );
  }
}

class _Toolbar extends StatelessWidget {
  const _Toolbar({
    required this.isEditing,
    required this.onToggleEdit,
    required this.selectedPreset,
    required this.presets,
    required this.onPresetChanged,
    required this.width,
    required this.height,
    required this.onWidthChanged,
    required this.onHeightChanged,
  });

  final bool isEditing;
  final VoidCallback onToggleEdit;
  final String selectedPreset;
  final List<String> presets;
  final ValueChanged<String?> onPresetChanged;
  final double width;
  final double height;
  final ValueChanged<double> onWidthChanged;
  final ValueChanged<double> onHeightChanged;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 60,
      padding: const EdgeInsets.symmetric(horizontal: 24),
      decoration: BoxDecoration(
        color: Colors.white,
        border: Border(
          bottom: BorderSide(color: Colors.grey.withValues(alpha: 0.1)),
          left: BorderSide(color: Colors.grey.withValues(alpha: 0.1)),
        ),
      ),
      child: SingleChildScrollView(
        scrollDirection: Axis.horizontal,
        child: Row(
          children: [
            SizedBox(
              width: 200,
              child: InputDecorator(
                decoration: const InputDecoration(
                  isDense: true,
                  border: InputBorder.none,
                  contentPadding: EdgeInsets.zero,
                  floatingLabelBehavior: FloatingLabelBehavior.always,
                ),
                child: DropdownButtonHideUnderline(
                  child: DropdownButton<String>(
                    value: selectedPreset,
                    isExpanded: true,
                    items: presets
                        .map((e) => DropdownMenuItem(value: e, child: Text(e)))
                        .toList(),
                    onChanged: onPresetChanged,
                  ),
                ),
              ),
            ),
            const VerticalDivider(indent: 12, endIndent: 12, width: 32),
            _SizeInput(label: 'W', value: width, onChanged: onWidthChanged),
            const SizedBox(width: 16),
            _SizeInput(label: 'H', value: height, onChanged: onHeightChanged),
            const SizedBox(width: 16),
            SegmentedButton<bool>(
              segments: const [
                ButtonSegment(
                  value: false,
                  label: Text('预览', style: TextStyle(fontSize: 12)),
                ),
                ButtonSegment(
                  value: true,
                  label: Text('编辑', style: TextStyle(fontSize: 12)),
                ),
              ],
              selected: {isEditing},
              onSelectionChanged: (_) => onToggleEdit(),
              showSelectedIcon: false,
            ),
          ],
        ),
      ),
    );
  }
}

class _SizeInput extends StatefulWidget {
  const _SizeInput({
    required this.label,
    required this.value,
    required this.onChanged,
  });

  final String label;
  final double value;
  final ValueChanged<double> onChanged;

  @override
  State<_SizeInput> createState() => _SizeInputState();
}

class _SizeInputState extends State<_SizeInput> {
  late TextEditingController _ctrl;

  @override
  void initState() {
    super.initState();
    _ctrl = TextEditingController(text: widget.value.toStringAsFixed(0));
  }

  @override
  void didUpdateWidget(covariant _SizeInput oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (widget.value != oldWidget.value &&
        double.tryParse(_ctrl.text) != widget.value) {
      _ctrl.text = widget.value.toStringAsFixed(0);
    }
  }

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 80,
      child: TextField(
        controller: _ctrl,
        keyboardType: TextInputType.number,
        decoration: InputDecoration(
          labelText: widget.label,
          border: const OutlineInputBorder(),
          contentPadding: const EdgeInsets.symmetric(
            horizontal: 8,
            vertical: 0,
          ),
          isDense: true,
        ),
        onSubmitted: (v) {
          final d = double.tryParse(v);
          if (d != null) widget.onChanged(d);
        },
      ),
    );
  }
}

class _GridPaperPainter extends CustomPainter {
  const _GridPaperPainter({required this.color, required this.interval});

  final Color color;
  final double interval;

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1.0;

    for (double x = 0; x <= size.width; x += interval) {
      canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint);
    }

    for (double y = 0; y <= size.height; y += interval) {
      canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
    }
  }

  @override
  bool shouldRepaint(covariant _GridPaperPainter oldDelegate) {
    return oldDelegate.color != color || oldDelegate.interval != interval;
  }
}

class _LayoutRepo {
  _LayoutRepo(this._store);

  final KeyValueStore _store;

  static const _kIds = 'vkp_layout_ids';
  static const _kSelected = 'vkp_selected_id';
  static const _kPrefix = 'vkp_state_';
  static const _kSampleStateAssetPath = 'lib/PS.state.json';
  static const _kSeededSample = 'vkp_seeded_sample_v1';

  Future<void> init() async {
    final seeded = _store.getString(_kSeededSample) == '1';
    if (!seeded) {
      final existingIds = await listIds();
      final prevSelected = await getSelectedId();
      final sample = await _loadBundledSampleState();
      if (sample != null) {
        await importAs(sample.name ?? 'PS', sample);
        if (existingIds.isNotEmpty) {
          final keep = prevSelected ?? existingIds.first;
          await setSelectedId(keep);
        }
      }
      await _store.setString(_kSeededSample, '1');
    }

    final ids = await listIds();
    if (ids.isNotEmpty) return;
    await create();
  }

  Future<VirtualControllerState?> _loadBundledSampleState() async {
    try {
      final raw = await rootBundle.loadString(_kSampleStateAssetPath);
      final decoded = jsonDecode(raw);
      if (decoded is! Map) return null;
      return VirtualControllerState.fromJson(Map<String, dynamic>.from(decoded));
    } catch (_) {
      return null;
    }
  }

  Future<List<String>> listIds() async {
    final raw = _store.getString(_kIds);
    if (raw == null || raw.trim().isEmpty) return const [];
    final v = jsonDecode(raw);
    if (v is! List) return const [];
    return v.map((e) => e.toString()).where((e) => e.isNotEmpty).toList();
  }

  Future<String?> getSelectedId() async {
    final v = _store.getString(_kSelected);
    final s = v?.trim();
    return (s == null || s.isEmpty) ? null : s;
  }

  Future<void> setSelectedId(String id) => _store.setString(_kSelected, id);

  String _stateKey(String id) => '$_kPrefix$id';

  Future<VirtualControllerState> loadState(String layoutId) async {
    final raw = _store.getString(_stateKey(layoutId));
    if (raw == null || raw.trim().isEmpty) {
      return const VirtualControllerState(schemaVersion: 1, controls: []);
    }
    final decoded = jsonDecode(raw);
    if (decoded is! Map) {
      return const VirtualControllerState(schemaVersion: 1, controls: []);
    }
    return VirtualControllerState.fromJson(Map<String, dynamic>.from(decoded));
  }

  Future<void> saveState(String layoutId, VirtualControllerState state) async {
    await _store.setString(_stateKey(layoutId), jsonEncode(state.toJson()));
  }

  Future<void> delete(String layoutId) async {
    final ids = (await listIds()).where((e) => e != layoutId).toList();
    await _store.setString(_kIds, jsonEncode(ids));
    await _store.remove(_stateKey(layoutId));
  }

  Future<String> create({String? baseId}) async {
    final ids = await listIds();
    final id = _uniqueId(
      'layout_${DateTime.now().millisecondsSinceEpoch}',
      ids,
    );
    final state = baseId == null
        ? VirtualControllerState(
            schemaVersion: 1,
            controls: [],
            name: 'unnamed',
          )
        : await loadState(baseId);
    await _store.setString(_kIds, jsonEncode([...ids, id]));
    await saveState(id, state);
    await setSelectedId(id);
    return id;
  }

  Future<String> importAs(
    String preferredId,
    VirtualControllerState state,
  ) async {
    final ids = await listIds();
    final base = preferredId.trim().isEmpty ? 'imported' : preferredId.trim();
    final id = _uniqueId(base, ids);
    await _store.setString(_kIds, jsonEncode([...ids, id]));
    await saveState(id, state);
    await setSelectedId(id);
    return id;
  }

  String _uniqueId(
    String base,
    List<String> existing, {
    bool allowSame = false,
  }) {
    final normalized = base.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_');
    if (normalized.isEmpty) return _uniqueId('layout', existing);
    if (allowSame && !existing.contains(normalized)) return normalized;
    if (!existing.contains(normalized)) return normalized;
    var i = 2;
    while (true) {
      final c = '${normalized}_$i';
      if (!existing.contains(c)) return c;
      i++;
    }
  }
}
1
likes
160
points
400
downloads
screenshot

Publisher

verified publisherliliin.icu

Weekly Downloads

Advanced virtual controller suite: joystick, d-pad, buttons, and runtime layout editor with JSON serialization. Optimized for remote play.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on virtual_gamepad_pro