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

Native iOS Control Center controls and Android Quick Settings tiles for Flutter, with shared state, durable actions, and native code generation.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:system_controls/system_controls.dart';

import 'system_controls.g.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const ControlsExampleApp());
}

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

  @override
  Widget build(BuildContext context) => MaterialApp(
    title: 'System Controls',
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff16775b))
          .copyWith(
            primary: const Color(0xff16775b),
            secondary: const Color(0xffbd4664),
            surface: const Color(0xfff8faf9),
          ),
      useMaterial3: true,
      inputDecorationTheme: const InputDecorationTheme(
        border: OutlineInputBorder(),
      ),
    ),
    home: const ControlsPage(),
  );
}

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

  @override
  State<ControlsPage> createState() => _ControlsPageState();
}

class _ControlsPageState extends State<ControlsPage>
    with WidgetsBindingObserver {
  final _controls = SystemControls();
  final _entries = <Map<String, dynamic>>[];
  SharedPreferences? _preferences;
  ControlsAvailability? _availability;
  bool _ready = false;
  bool _focus = false;
  bool _updating = false;
  String? _error;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
  }

  Future<void> _initialize() async {
    try {
      final prefs = await SharedPreferences.getInstance();
      _preferences = prefs;
      final saved = prefs.getString('entries');
      if (saved != null) {
        _entries.addAll(
          (jsonDecode(saved) as List).cast<Map<String, dynamic>>(),
        );
      }
      _availability = await _controls.availability;
      if (!_availability!.supported) {
        if (mounted) {
          setState(
            () => _error = 'System controls are unavailable on this device.',
          );
        }
        return;
      }
      await _controls.initialize(
        controls: AppSystemControls.controls,
        appGroup: AppSystemControls.appGroup,
        onAction: _onAction,
        onError: (error, _) {
          if (mounted) setState(() => _error = '$error');
        },
      );
      final focus = await _controls.getValue('focus_mode');
      if (mounted) {
        setState(() {
          _focus = focus;
          _ready = true;
        });
      }
    } catch (error) {
      if (mounted) setState(() => _error = '$error');
    }
  }

  Future<void> _onAction(ControlAction action) async {
    // Persist an event before acknowledging it so restart delivery is idempotent.
    if (_entries.any((entry) => entry['eventId'] == action.eventId)) return;
    final label = switch (action.controlId) {
      'focus_mode' =>
        action.value == true
            ? 'Focus session started'
            : 'Focus session stopped',
      'quick_expense' => 'Expense shortcut opened',
      'quick_note' => 'Note shortcut opened',
      _ => action.controlId,
    };
    await _addEntry(label, eventId: action.eventId);
    if (!mounted) return;
    if (action.controlId == 'focus_mode') {
      final value = await _controls.getValue('focus_mode');
      if (mounted) setState(() => _focus = value);
    } else if (action.controlId == 'quick_expense' ||
        action.controlId == 'quick_note') {
      await _compose(action.controlId == 'quick_expense');
    }
  }

  Future<void> _addEntry(String title, {String? eventId}) async {
    final entry = <String, dynamic>{
      'title': title,
      'timestamp': DateTime.now().toIso8601String(),
      'eventId': ?eventId,
    };
    final next = [entry, ..._entries];
    if (!(await _preferences!.setString('entries', jsonEncode(next)))) {
      throw StateError('Could not save activity.');
    }
    if (mounted) {
      setState(() {
        _entries.clear();
        _entries.addAll(next);
      });
    }
  }

  Future<void> _compose(bool expense) async {
    if (!mounted) return;
    final text = await showDialog<String>(
      context: context,
      builder: (_) => _EntryDialog(expense: expense),
    );
    if (text != null && text.trim().isNotEmpty) {
      await _addEntry(expense ? 'Expense: ${text.trim()}' : text.trim());
    }
  }

  Future<void> _toggle(bool value) async {
    setState(() => _updating = true);
    try {
      await _controls.setValue('focus_mode', value);
      if (mounted) setState(() => _focus = value);
    } catch (error) {
      if (mounted) setState(() => _error = '$error');
    } finally {
      if (mounted) setState(() => _updating = false);
    }
  }

  Future<void> _requestAdd(String id) async {
    try {
      final result = await _controls.requestAdd(id);
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Text(switch (result) {
            AddControlResult.added => 'Tile added',
            AddControlResult.alreadyAdded => 'Tile already added',
            AddControlResult.declined => 'Tile not added',
            AddControlResult.unsupported => 'Tile placement is unavailable',
          }),
        ),
      );
    } catch (error) {
      if (mounted) setState(() => _error = '$error');
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed && _ready) {
      unawaited(
        _controls
            .getValue('focus_mode')
            .then((value) {
              if (mounted) setState(() => _focus = value);
            })
            .catchError((Object error) {
              if (mounted) setState(() => _error = '$error');
            }),
      );
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    unawaited(_controls.dispose());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => Scaffold(
    appBar: AppBar(
      title: const Text('System Controls'),
      actions: [
        IconButton(
          tooltip: 'Refresh controls',
          onPressed: !_ready
              ? null
              : () async {
                  try {
                    await _controls.reload();
                    await _controls.refresh();
                  } catch (error) {
                    if (mounted) setState(() => _error = '$error');
                  }
                },
          icon: const Icon(Icons.refresh),
        ),
      ],
    ),
    body: SafeArea(
      child: Align(
        alignment: Alignment.topCenter,
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 680),
          child: ListView(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
            children: [
              if (!_ready && _error == null) const LinearProgressIndicator(),
              if (_error != null)
                Padding(
                  padding: const EdgeInsets.only(bottom: 20),
                  child: Text(
                    _error!,
                    style: TextStyle(
                      color: Theme.of(context).colorScheme.error,
                    ),
                  ),
                ),
              Text(
                'Quick actions',
                style: Theme.of(context).textTheme.titleLarge,
              ),
              const SizedBox(height: 18),
              for (final control in AppSystemControls.controls) ...[
                _ControlRow(
                  title: control.title,
                  icon: switch (control.id) {
                    'quick_expense' => Icons.add_card_outlined,
                    'focus_mode' => Icons.timer_outlined,
                    _ => Icons.edit_note_outlined,
                  },
                  color: switch (control.id) {
                    'quick_expense' => const Color(0xff16775b),
                    'focus_mode' => const Color(0xffa45412),
                    _ => const Color(0xffbd4664),
                  },
                  trailing: Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      if (control.kind == ControlKind.toggle)
                        Switch(
                          value: _focus,
                          onChanged: _ready && !_updating ? _toggle : null,
                        )
                      else
                        IconButton(
                          tooltip: control.title,
                          onPressed: _ready
                              ? () => _compose(control.id == 'quick_expense')
                              : null,
                          icon: const Icon(Icons.arrow_forward),
                        ),
                      if (_availability?.canRequestAdd == true)
                        IconButton(
                          tooltip: 'Add ${control.title} tile',
                          onPressed: _ready
                              ? () => _requestAdd(control.id)
                              : null,
                          icon: const Icon(Icons.playlist_add),
                        ),
                    ],
                  ),
                ),
                const Divider(height: 1),
              ],
              const SizedBox(height: 36),
              Text('Activity', style: Theme.of(context).textTheme.titleLarge),
              const SizedBox(height: 12),
              if (_entries.isEmpty)
                const Padding(
                  padding: EdgeInsets.symmetric(vertical: 24),
                  child: Text('No activity yet'),
                ),
              for (final entry in _entries.take(100))
                ListTile(
                  contentPadding: EdgeInsets.zero,
                  leading: Icon(
                    entry['eventId'] == null
                        ? Icons.receipt_long_outlined
                        : Icons.touch_app_outlined,
                  ),
                  title: Text(entry['title'] as String),
                  subtitle: Text(
                    MaterialLocalizations.of(context).formatTimeOfDay(
                      TimeOfDay.fromDateTime(
                        DateTime.parse(entry['timestamp'] as String),
                      ),
                    ),
                  ),
                ),
            ],
          ),
        ),
      ),
    ),
  );
}

class _ControlRow extends StatelessWidget {
  const _ControlRow({
    required this.title,
    required this.icon,
    required this.color,
    required this.trailing,
  });
  final String title;
  final IconData icon;
  final Color color;
  final Widget trailing;

  @override
  Widget build(BuildContext context) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 12),
    child: Row(
      children: [
        SizedBox(
          width: 44,
          height: 48,
          child: Icon(icon, color: color, size: 28),
        ),
        const SizedBox(width: 8),
        Expanded(
          child: Text(title, style: Theme.of(context).textTheme.titleMedium),
        ),
        trailing,
      ],
    ),
  );
}

class _EntryDialog extends StatefulWidget {
  const _EntryDialog({required this.expense});
  final bool expense;

  @override
  State<_EntryDialog> createState() => _EntryDialogState();
}

class _EntryDialogState extends State<_EntryDialog> {
  final _text = TextEditingController();
  @override
  void dispose() {
    _text.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => AlertDialog(
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
    title: Text(widget.expense ? 'Add expense' : 'New note'),
    content: TextField(
      controller: _text,
      autofocus: true,
      maxLines: widget.expense ? 1 : 4,
      decoration: InputDecoration(
        labelText: widget.expense ? 'Purchase and amount' : 'Note',
      ),
      onChanged: (_) => setState(() {}),
    ),
    actions: [
      TextButton(
        onPressed: () => Navigator.pop(context),
        child: const Text('Cancel'),
      ),
      FilledButton(
        onPressed: _text.text.trim().isEmpty
            ? null
            : () => Navigator.pop(context, _text.text),
        child: const Text('Save'),
      ),
    ],
  );
}
0
likes
150
points
--
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Native iOS Control Center controls and Android Quick Settings tiles for Flutter, with shared state, durable actions, and native code generation.

Repository (GitHub)
View/report issues

Topics

#control-center #quick-settings #widgetkit #app-intents

License

MIT (license)

Dependencies

flutter, path, xml, yaml

More

Packages that depend on system_controls

Packages that implement system_controls