system_controls 0.1.0
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.
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'),
),
],
);
}