ctrlapp 0.1.0
ctrlapp: ^0.1.0 copied to clipboard
Remote kill-switch, feature flags, forced update, forms and inbox for Flutter, plus diagnostics, device-integrity checks, push permission and hosted login.
example/lib/main.dart
import 'dart:async';
import 'dart:convert';
import 'package:audioplayers/audioplayers.dart';
import 'package:ctrlapp/ctrlapp.dart';
import 'package:flutter/material.dart';
final _audio = AudioPlayer();
// Create an app at https://ctrlapp.dev and run with:
// flutter run --dart-define=CTRLAPP_API_KEY=ck_sdk_...
const _apiKey = String.fromEnvironment('CTRLAPP_API_KEY',
defaultValue: 'ck_sdk_YOUR_API_KEY');
const _baseUrl = String.fromEnvironment('CTRLAPP_BASE_URL',
defaultValue: 'https://ctrlapp.dev');
final navigatorKey = GlobalKey<NavigatorState>();
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await CtrlApp.init(
apiKey: _apiKey,
baseUrl: _baseUrl,
attributes: {'plan': 'demo', 'country': 'US'},
);
// Warm the form schema so the Forms tab renders instantly on first open.
unawaited(CtrlApp.instance.fetchForm('app-feedback'));
CtrlApp.instance.messages.listen((msg) async {
final ctx = navigatorKey.currentContext;
if (ctx == null) return;
await CtrlApp.instance.ackMessage(msg, seen: true);
if (msg.sound != null && msg.sound!.isNotEmpty) {
try {
final s = msg.sound!;
if (s.startsWith('http')) {
await _audio.play(UrlSource(s));
} else {
await _audio.play(AssetSource(s));
}
} catch (_) {}
}
if (!ctx.mounted) return;
showDialog<void>(
context: ctx,
builder: (_) => AlertDialog(
title: Text(msg.title ?? 'Message'),
content: Text(msg.body ?? ''),
actions: [
TextButton(
onPressed: () {
CtrlApp.instance.ackMessage(msg, dismissed: true);
Navigator.pop(ctx);
},
child: const Text('Dismiss'),
),
TextButton(
onPressed: () {
CtrlApp.instance.ackMessage(msg, clicked: true);
Navigator.pop(ctx);
},
child: const Text('OK'),
),
],
),
);
});
runApp(const DemoApp());
}
// ============================================================
// Icon registry — maps remote icon names to Material icons
// ============================================================
const Map<String, IconData> kIconRegistry = {
'dashboard': Icons.dashboard,
'flag': Icons.flag,
'tune': Icons.tune,
'dynamic_form': Icons.dynamic_form,
'bolt': Icons.bolt,
'settings': Icons.settings,
'person': Icons.person,
'rocket_launch': Icons.rocket_launch,
'star': Icons.star,
'shopping_cart': Icons.shopping_cart,
'home': Icons.home,
'search': Icons.search,
'notifications': Icons.notifications,
'favorite': Icons.favorite,
'help': Icons.help,
'info': Icons.info,
'menu': Icons.menu,
'add': Icons.add,
};
IconData iconOf(String? name, {IconData fallback = Icons.circle}) =>
kIconRegistry[name ?? ''] ?? fallback;
class DemoApp extends StatefulWidget {
const DemoApp({super.key});
@override
State<DemoApp> createState() => _DemoAppState();
}
class _DemoAppState extends State<DemoApp> {
StreamSubscription<Map<String, dynamic>>? _sub;
@override
void initState() {
super.initState();
_sub = CtrlApp.instance.controlChanges.listen((_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_sub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Remotely-controlled primary color.
final colorHex = CtrlApp.instance
.configValue<String>('primary_color', defaultValue: '#6366F1');
final seed = _parseColor(colorHex);
return MaterialApp(
navigatorKey: navigatorKey,
title: 'CtrlApp SDK Playground',
theme: ThemeData(
colorSchemeSeed: seed,
useMaterial3: true,
brightness: Brightness.light),
darkTheme: ThemeData(
colorSchemeSeed: seed,
useMaterial3: true,
brightness: Brightness.dark),
home: const Shell(),
);
}
Color _parseColor(String hex) {
final h = hex.replaceAll('#', '');
final v = int.tryParse(h.length == 6 ? 'FF$h' : h, radix: 16);
return Color(v ?? 0xFF6366F1);
}
}
// ============================================================
// Shell — bottom nav is driven by demo.nav.tabs config_value
// ============================================================
class Shell extends StatefulWidget {
const Shell({super.key});
@override
State<Shell> createState() => _ShellState();
}
class _ShellState extends State<Shell> with WidgetsBindingObserver {
int _tab = 0;
StreamSubscription<CtrlAppConfig>? _configSub;
StreamSubscription<Map<String, dynamic>>? _controlsSub;
DateTime? _lastControlsUpdate;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_configSub = CtrlApp.instance.configChanges.listen((_) {
if (mounted) setState(() {});
});
_controlsSub = CtrlApp.instance.controlChanges.listen((_) {
if (mounted) setState(() => _lastControlsUpdate = DateTime.now());
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_configSub?.cancel();
_controlsSub?.cancel();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
CtrlApp.instance.refresh();
// Restart timing for the currently-visible screen.
final tabs = _remoteTabs();
final idx = _tab.clamp(0, tabs.length - 1);
if (tabs.isNotEmpty) CtrlApp.instance.trackScreen(tabs[idx].key);
} else if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.detached) {
CtrlApp.instance.flushOnAppClose();
}
}
List<_NavTab> _remoteTabs() {
final raw = CtrlApp.instance
.configValue<List<dynamic>>('demo.nav.tabs', defaultValue: const []);
final tabs = <_NavTab>[];
for (final e in raw) {
if (e is Map) {
tabs.add(_NavTab(
key: e['key']?.toString() ?? '',
label: e['label']?.toString() ?? '',
icon: iconOf(e['icon']?.toString(), fallback: Icons.circle),
));
}
}
if (tabs.isEmpty) {
return const [
_NavTab(key: 'live', label: 'Live', icon: Icons.dashboard),
_NavTab(key: 'flags', label: 'Flags', icon: Icons.flag),
_NavTab(key: 'config', label: 'Config', icon: Icons.tune),
_NavTab(key: 'forms', label: 'Forms', icon: Icons.dynamic_form),
_NavTab(key: 'events', label: 'Events', icon: Icons.bolt),
];
}
return tabs;
}
Widget _pageFor(String key) {
switch (key) {
case 'flags':
return const FlagsPage();
case 'config':
return const ConfigPage();
case 'forms':
return const FormsPage();
case 'events':
return const EventsPage();
case 'live':
default:
return LivePage(lastUpdate: _lastControlsUpdate);
}
}
bool _navImpressed = false;
@override
Widget build(BuildContext context) {
final tabs = _remoteTabs();
final idx = _tab.clamp(0, tabs.length - 1);
if (!_navImpressed && tabs.isNotEmpty) {
_navImpressed = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
// Whole-control impression + one per tab so the dashboard
// breakdown shows each tab individually.
CtrlApp.instance.trackControl('demo.nav.tabs', 'impression');
for (final t in tabs) {
CtrlApp.instance.trackControl('demo.nav.tabs', 'impression', target: t.key);
}
// Start screen-time tracking on the initial tab.
CtrlApp.instance.trackScreen(tabs[idx].key);
});
}
return Scaffold(
body: SafeArea(child: _pageFor(tabs[idx].key)),
drawer: const _RemoteDrawer(),
bottomNavigationBar: NavigationBar(
selectedIndex: idx,
onDestinationSelected: (i) {
setState(() => _tab = i);
final tabKey = tabs[i].key;
CtrlApp.instance.trackControl('demo.nav.tabs', 'click', target: tabKey);
CtrlApp.instance.track('nav.tap', props: {'tab': tabKey});
CtrlApp.instance.trackScreen(tabKey);
},
destinations: [
for (final t in tabs)
NavigationDestination(icon: Icon(t.icon), label: t.label),
],
),
);
}
}
class _NavTab {
final String key;
final String label;
final IconData icon;
const _NavTab({required this.key, required this.label, required this.icon});
}
// ============================================================
// Remote drawer — pulls drawer_item controls
// ============================================================
class _RemoteDrawer extends StatelessWidget {
const _RemoteDrawer();
@override
Widget build(BuildContext context) {
final items = CtrlApp.instance.controls.entries
.where((e) => e.value['type'] == 'drawer_item' ||
e.value['type'] == 'menu_item')
.toList();
return Drawer(
child: SafeArea(
child: ListView(children: [
const DrawerHeader(
child: Text('Remote menu',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)),
),
if (items.isEmpty)
const ListTile(
title: Text('No drawer/menu items configured remotely.')),
for (final e in items)
ListTile(
leading: Icon(iconOf(
(e.value['payload'] as Map?)?['icon']?.toString(),
fallback: Icons.chevron_right)),
title: Text((e.value['payload'] as Map?)?['text']?.toString() ??
e.key),
trailing: Text(e.value['type']?.toString() ?? '',
style: Theme.of(context).textTheme.labelSmall),
onTap: () {
CtrlApp.instance.trackControl(e.key, 'click');
CtrlApp.instance.track('drawer.tap', props: {'key': e.key});
Navigator.pop(context);
},
),
]),
),
);
}
}
// ============================================================
// LIVE PAGE — everything on this screen is a remote control
// ============================================================
class LivePage extends StatefulWidget {
final DateTime? lastUpdate;
const LivePage({super.key, this.lastUpdate});
@override
State<LivePage> createState() => _LivePageState();
}
class _LivePageState extends State<LivePage> {
final Set<String> _impressed = {};
Map<String, dynamic>? _payload(String key) {
final c = CtrlApp.instance.controls[key];
if (c == null || c['is_enabled'] == false) return null;
final p = c['payload'];
return p is Map<String, dynamic> ? p : null;
}
void _impressAll(Iterable<String> keys) {
WidgetsBinding.instance.addPostFrameCallback((_) {
for (final k in keys) {
if (_impressed.add(k)) {
CtrlApp.instance.trackControl(k, 'impression');
}
}
});
}
void _click(String key) {
CtrlApp.instance.trackControl(key, 'click');
CtrlApp.instance.track('control.click', props: {'key': key});
}
@override
Widget build(BuildContext context) {
final cfg = CtrlApp.instance.config;
final blocked = cfg.isBlocked;
final controls = CtrlApp.instance.controls;
final scheme = Theme.of(context).colorScheme;
final headline = _payload('demo.text.headline');
final banner = _payload('demo.hero.banner');
final heroImg = _payload('demo.image.hero');
final cta = _payload('demo.cta.primary');
final card = _payload('demo.card.promo');
final link = _payload('demo.link.support');
final gate = _payload('demo.gate.new_checkout');
final sheetCtl = _payload('demo.sheet.rate');
final modalCtl = _payload('demo.modal.announcement');
// Fire impressions for every visible control this frame.
final visible = <String>[
if (headline != null) 'demo.text.headline',
if (banner != null) 'demo.hero.banner',
if (heroImg != null) 'demo.image.hero',
if (cta != null) 'demo.cta.primary',
if (card != null) 'demo.card.promo',
if (link != null) 'demo.link.support',
if (gate != null) 'demo.gate.new_checkout',
if (sheetCtl != null) 'demo.sheet.rate',
if (modalCtl != null) 'demo.modal.announcement',
];
_impressAll(visible);
return CustomScrollView(
slivers: [
SliverAppBar.large(
title: const Text('CtrlApp'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => CtrlApp.instance.refresh()),
],
),
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList.list(children: [
_KillSwitchCard(blocked: blocked, message: cfg.blockMessage),
const SizedBox(height: 12),
_StreamStatus(count: controls.length, lastUpdate: widget.lastUpdate),
const SizedBox(height: 16),
if (headline != null) ...[
Text(headline['text']?.toString() ?? '',
style: Theme.of(context).textTheme.headlineSmall),
if (headline['subtitle'] != null)
Text(headline['subtitle'].toString(),
style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 12),
],
if (banner != null)
GestureDetector(
onTap: () => _click('demo.hero.banner'),
child: _BannerBlock(payload: banner),
),
if (heroImg != null && heroImg['image_url'] != null) ...[
const SizedBox(height: 12),
GestureDetector(
onTap: () => _click('demo.image.hero'),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(heroImg['image_url'].toString(),
height: 180,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
height: 180,
color: scheme.surfaceContainerHighest,
child: const Center(child: Icon(Icons.broken_image)))),
),
),
],
if (cta != null) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () {
_click('demo.cta.primary');
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('CTA → ${cta['action_value']}')));
},
icon: Icon(iconOf(cta['icon']?.toString(),
fallback: Icons.arrow_forward)),
label: Text(cta['text']?.toString() ?? 'Go'),
),
],
if (card != null) ...[
const SizedBox(height: 16),
GestureDetector(
onTap: () => _click('demo.card.promo'),
child: _PromoCard(payload: card),
),
],
if (link != null) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _click('demo.link.support'),
icon: const Icon(Icons.link),
label: Text(link['text']?.toString() ?? 'Link'),
),
],
if (gate != null && gate['visible'] == true) ...[
const SizedBox(height: 8),
Card(
color: scheme.tertiaryContainer,
child: const ListTile(
leading: Icon(Icons.lock_open),
title: Text('demo.gate.new_checkout is OPEN'),
subtitle: Text(
'Gate controls hide/lock entire features remotely.'),
),
),
],
if (sheetCtl != null) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
_click('demo.sheet.rate');
_showRemoteSheet(context, sheetCtl);
},
icon: const Icon(Icons.vertical_split),
label: Text('Show sheet: ${sheetCtl['text'] ?? ''}'),
),
],
if (modalCtl != null) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: () {
_click('demo.modal.announcement');
_showRemoteModal(context, modalCtl);
},
icon: const Icon(Icons.open_in_new),
label: Text('Show modal: ${modalCtl['text'] ?? ''}'),
),
],
const SizedBox(height: 24),
Text('All live controls',
style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (controls.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Center(child: Text('No controls yet.')))),
...controls.entries.map((e) => _ControlTile(entry: e)),
]),
),
],
);
}
void _showRemoteSheet(BuildContext context, Map<String, dynamic> p) {
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => Padding(
padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text(p['text']?.toString() ?? '',
style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 8),
Text(p['subtitle']?.toString() ?? ''),
const SizedBox(height: 16),
FilledButton(
onPressed: () => Navigator.pop(context),
child: Text(p['cta']?.toString() ?? 'OK')),
]),
),
);
}
void _showRemoteModal(BuildContext context, Map<String, dynamic> p) {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: Text(p['text']?.toString() ?? ''),
content: Text(p['subtitle']?.toString() ?? ''),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(p['cta']?.toString() ?? 'OK')),
],
),
);
}
}
class _KillSwitchCard extends StatelessWidget {
final bool blocked;
final String? message;
const _KillSwitchCard({required this.blocked, this.message});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: blocked
? [Colors.red.shade400, Colors.red.shade700]
: [scheme.primary, scheme.primary.withValues(alpha: 0.7)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: Row(children: [
Icon(blocked ? Icons.block : Icons.check_circle,
color: Colors.white, size: 40),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(blocked ? 'App blocked' : 'App is live',
style: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.bold)),
Text(message ?? 'Kill-switch controlled via /sync',
style: TextStyle(color: Colors.white.withValues(alpha: 0.9))),
]),
),
]),
);
}
}
class _StreamStatus extends StatelessWidget {
final int count;
final DateTime? lastUpdate;
const _StreamStatus({required this.count, this.lastUpdate});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
leading: Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: Colors.green, shape: BoxShape.circle)),
title: const Text('Streaming'),
subtitle: Text(lastUpdate == null
? 'Waiting for first delta…'
: 'Last update: ${_ago(lastUpdate!)}'),
trailing: Text('$count controls',
style: Theme.of(context).textTheme.labelMedium),
),
);
}
static String _ago(DateTime t) {
final d = DateTime.now().difference(t);
if (d.inSeconds < 60) return '${d.inSeconds}s ago';
if (d.inMinutes < 60) return '${d.inMinutes}m ago';
return '${d.inHours}h ago';
}
}
class _BannerBlock extends StatelessWidget {
final Map<String, dynamic> payload;
const _BannerBlock({required this.payload});
@override
Widget build(BuildContext context) {
final img = payload['image_url']?.toString();
return Card(
clipBehavior: Clip.antiAlias,
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
if (img != null)
Image.network(img,
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox(height: 120)),
Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(payload['text']?.toString() ?? '',
style: Theme.of(context).textTheme.titleLarge),
if (payload['subtitle'] != null)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(payload['subtitle'].toString())),
if (payload['cta'] != null)
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => CtrlApp.instance.track('banner.cta',
props: {'action': payload['action_value']}),
child: Text(payload['cta'].toString())),
),
]),
),
]),
);
}
}
class _PromoCard extends StatelessWidget {
final Map<String, dynamic> payload;
const _PromoCard({required this.payload});
@override
Widget build(BuildContext context) {
final img = payload['image_url']?.toString();
return Card(
clipBehavior: Clip.antiAlias,
child: Row(children: [
if (img != null)
Image.network(img,
width: 96,
height: 96,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox(width: 96, height: 96)),
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child:
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(payload['text']?.toString() ?? '',
style: Theme.of(context).textTheme.titleMedium),
if (payload['subtitle'] != null)
Text(payload['subtitle'].toString(),
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 6),
if (payload['cta'] != null)
FilledButton.tonal(
onPressed: () {},
child: Text(payload['cta'].toString())),
]),
),
),
]),
);
}
}
class _ControlTile extends StatelessWidget {
final MapEntry<String, Map<String, dynamic>> entry;
const _ControlTile({required this.entry});
@override
Widget build(BuildContext context) {
final type = (entry.value['type'] as String?) ?? 'unknown';
final enabled = entry.value['is_enabled'] != false;
final payload = entry.value['payload'];
return Card(
child: ExpansionTile(
leading: CircleAvatar(child: Icon(_iconFor(type), size: 18)),
title: Text(entry.key),
subtitle: Row(children: [
Text(type),
const SizedBox(width: 8),
if (!enabled)
const Chip(
label: Text('disabled'),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero),
]),
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(
const JsonEncoder.withIndent(' ').convert(payload ?? {}),
style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
),
),
),
],
),
);
}
IconData _iconFor(String type) {
switch (type) {
case 'feature_flag':
return Icons.flag;
case 'config_value':
return Icons.tune;
case 'form_schema':
return Icons.dynamic_form;
case 'banner':
return Icons.view_carousel;
case 'button':
return Icons.smart_button;
case 'card':
return Icons.dashboard_customize;
case 'text':
return Icons.text_fields;
case 'image':
return Icons.image;
case 'link':
return Icons.link;
case 'drawer_item':
return Icons.menu_open;
case 'menu_item':
return Icons.menu;
case 'sheet':
return Icons.vertical_split;
case 'modal':
return Icons.open_in_new;
case 'gate':
return Icons.lock;
default:
return Icons.extension;
}
}
}
// ============================================================
// FLAGS PAGE
// ============================================================
class FlagsPage extends StatefulWidget {
const FlagsPage({super.key});
@override
State<FlagsPage> createState() => _FlagsPageState();
}
class _FlagsPageState extends State<FlagsPage> {
final _keyCtrl = TextEditingController(text: 'demo.flag.new_checkout');
final _defaultCtrl = TextEditingController(text: 'off');
String _treatment = '—';
String _boolResult = '—';
Future<void> _evaluate() async {
final v = await CtrlApp.instance
.treatment(_keyCtrl.text, defaultValue: _defaultCtrl.text);
setState(() => _treatment = v);
}
void _evalBool() {
final b = CtrlApp.instance.boolFlag(_keyCtrl.text);
setState(() => _boolResult = b.toString());
}
@override
Widget build(BuildContext context) {
final flags = CtrlApp.instance.controls.entries
.where((e) => e.value['type'] == 'feature_flag')
.toList();
return ListView(padding: const EdgeInsets.all(16), children: [
Text('Feature flags', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
TextField(
controller: _keyCtrl,
decoration: const InputDecoration(
labelText: 'Flag key', prefixIcon: Icon(Icons.key))),
const SizedBox(height: 12),
TextField(
controller: _defaultCtrl,
decoration: const InputDecoration(
labelText: 'Default value',
prefixIcon: Icon(Icons.settings_backup_restore))),
const SizedBox(height: 16),
Row(children: [
Expanded(
child: FilledButton.icon(
onPressed: _evaluate,
icon: const Icon(Icons.play_arrow),
label: const Text('Treatment'))),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: _evalBool,
icon: const Icon(Icons.toggle_on),
label: const Text('boolFlag'))),
]),
]),
),
),
const SizedBox(height: 16),
_ResultCard(label: 'treatment', value: _treatment),
_ResultCard(label: 'boolFlag', value: _boolResult),
const SizedBox(height: 16),
Text('Defined flags', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (flags.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No feature_flag controls yet.')))
else
...flags.map((e) => Card(
child: ListTile(
leading: const Icon(Icons.flag),
title: Text(e.key),
subtitle: Text(
'default: ${(e.value['payload'] as Map?)?['default_treatment'] ?? '—'}'),
onTap: () {
_keyCtrl.text = e.key;
_evaluate();
},
),
)),
]);
}
}
// ============================================================
// CONFIG PAGE
// ============================================================
class ConfigPage extends StatefulWidget {
const ConfigPage({super.key});
@override
State<ConfigPage> createState() => _ConfigPageState();
}
class _ConfigPageState extends State<ConfigPage> {
final _keyCtrl = TextEditingController(text: 'primary_color');
@override
Widget build(BuildContext context) {
final controls = CtrlApp.instance.controls.entries
.where((e) => e.value['type'] == 'config_value')
.toList();
return ListView(padding: const EdgeInsets.all(16), children: [
Text('Remote config', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text('${controls.length} config values available',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
TextField(
controller: _keyCtrl,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Config key',
prefixIcon: Icon(Icons.search))),
const SizedBox(height: 12),
_ResultCard(
label: 'configValue<String>',
value: CtrlApp.instance.configValue<String>(_keyCtrl.text,
defaultValue: '—')),
]),
),
),
const SizedBox(height: 16),
Text('All config values', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (controls.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('No config_value controls yet.')))
else
...controls.map((e) {
final p = e.value['payload'] as Map? ?? {};
return Card(
child: ListTile(
leading: Icon(_iconForConfigType(p['value_type']?.toString())),
title: Text(e.key),
subtitle: Text('${p['value_type'] ?? '—'} · ${p['value'] ?? '—'}'),
),
);
}),
]);
}
IconData _iconForConfigType(String? t) {
switch (t) {
case 'color':
return Icons.palette;
case 'number':
return Icons.pin;
case 'bool':
return Icons.toggle_on;
case 'json':
return Icons.data_object;
case 'url':
return Icons.link;
case 'enum':
return Icons.list;
default:
return Icons.text_fields;
}
}
}
// ============================================================
// FORMS PAGE — fetches a real form from the dashboard by slug
// and submits via CtrlApp.instance.submitForm(...).
// ============================================================
class FormsPage extends StatefulWidget {
const FormsPage({super.key});
@override
State<FormsPage> createState() => _FormsPageState();
}
class _FormsPageState extends State<FormsPage> {
// Slug of the form seeded in the dashboard (Dana Mantk → Forms).
static const String _formSlug = 'app-feedback';
Map<String, dynamic>? _data;
@override
void initState() {
super.initState();
// Instant: read the SDK's cached copy (memory + SharedPreferences).
_data = CtrlApp.instance.cachedForm(_formSlug);
// Kick off a fetch anyway — cache-hit returns instantly and refreshes
// in the background; cache-miss populates it.
CtrlApp.instance.fetchForm(_formSlug).then((v) {
if (mounted && v != null) setState(() => _data = v);
});
}
Future<void> _reload() async {
final v = await CtrlApp.instance.fetchForm(_formSlug, forceRefresh: true);
if (mounted) setState(() => _data = v);
}
@override
Widget build(BuildContext context) {
final data = _data;
return RefreshIndicator(
onRefresh: _reload,
child: data == null
? ListView(children: const [
SizedBox(height: 120),
Center(child: CircularProgressIndicator()),
])
: Builder(builder: (context) {
final schema = (data['schema'] as Map?)?.cast<String, dynamic>() ?? const {};
final settings = (data['settings'] as Map?)?.cast<String, dynamic>() ?? const {};
return ListView(padding: const EdgeInsets.all(16), children: [
Text(data['name']?.toString() ?? 'Form',
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 4),
Text('Loaded from the dashboard via CtrlApp.fetchForm("$_formSlug")',
style: Theme.of(context).textTheme.bodySmall),
const SizedBox(height: 16),
_RemoteForm(slug: _formSlug, schema: schema, settings: settings),
]);
}),
);
}
}
class _RemoteForm extends StatefulWidget {
final String slug;
final Map<String, dynamic> schema;
final Map<String, dynamic> settings;
const _RemoteForm({required this.slug, required this.schema, required this.settings});
@override
State<_RemoteForm> createState() => _RemoteFormState();
}
class _RemoteFormState extends State<_RemoteForm> {
final Map<String, dynamic> _values = {};
bool _submitting = false;
bool _done = false;
Future<void> _submit() async {
final fields = (widget.schema['fields'] as List?) ?? const [];
for (final f in fields) {
if (f is! Map) continue;
if (f['required'] == true) {
final k = f['key']?.toString() ?? '';
final v = _values[k];
if (v == null || (v is String && v.trim().isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Please fill "${f['label'] ?? k}"')));
return;
}
}
}
setState(() => _submitting = true);
final ok = await CtrlApp.instance.submitForm(widget.slug, _values);
if (!mounted) return;
setState(() {
_submitting = false;
_done = ok;
});
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(ok
? (widget.settings['success_message']?.toString() ??
'Thanks! Your response was submitted.')
: 'Submit failed. Try again.'),
));
}
@override
Widget build(BuildContext context) {
final fields = (widget.schema['fields'] as List?) ?? const [];
if (_done) {
return Card(child: Padding(
padding: const EdgeInsets.all(24),
child: Column(children: [
const Icon(Icons.check_circle, size: 48, color: Colors.green),
const SizedBox(height: 12),
Text(widget.settings['success_message']?.toString() ??
'Thanks! Your response was submitted.'),
const SizedBox(height: 12),
TextButton(
onPressed: () => setState(() {
_done = false;
_values.clear();
}),
child: const Text('Submit another'),
),
]),
));
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (widget.schema['title'] != null)
Text(widget.schema['title'].toString(),
style: Theme.of(context).textTheme.titleMedium),
if (widget.schema['subtitle'] != null) ...[
const SizedBox(height: 4),
Text(widget.schema['subtitle'].toString(),
style: Theme.of(context).textTheme.bodySmall),
],
const SizedBox(height: 12),
for (final f in fields) _fieldFor(f as Map<String, dynamic>),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
width: 16, height: 16,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.send),
label: Text(widget.schema['submit_label']?.toString() ?? 'Submit'),
),
]),
),
);
}
Widget _fieldFor(Map<String, dynamic> f) {
final key = f['key']?.toString() ?? '';
final label = f['label']?.toString() ?? key;
final type = f['type']?.toString() ?? 'text';
final required = f['required'] == true;
final lbl = required ? '$label *' : label;
switch (type) {
case 'bool':
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(lbl),
value: (_values[key] as bool?) ?? false,
onChanged: (v) => setState(() => _values[key] = v),
);
case 'select':
final opts = (f['options'] as List?) ?? const [];
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: lbl),
items: opts
.map((o) => DropdownMenuItem(
value: o.toString(), child: Text(o.toString())))
.toList(),
initialValue: _values[key] as String?,
onChanged: (v) => setState(() => _values[key] = v),
),
);
case 'textarea':
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
maxLines: 4,
decoration: InputDecoration(
labelText: lbl, hintText: f['placeholder']?.toString()),
onChanged: (v) => _values[key] = v,
),
);
default:
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
keyboardType: type == 'number'
? TextInputType.number
: type == 'email'
? TextInputType.emailAddress
: TextInputType.text,
decoration: InputDecoration(
labelText: lbl, hintText: f['placeholder']?.toString()),
onChanged: (v) => _values[key] = v,
),
);
}
}
}
// ============================================================
// EVENTS PAGE
// ============================================================
class EventsPage extends StatefulWidget {
const EventsPage({super.key});
@override
State<EventsPage> createState() => _EventsPageState();
}
class _EventsPageState extends State<EventsPage> {
final _eventCtrl = TextEditingController(text: 'demo.button_tap');
final _log = <String>[];
void _push(String s) => setState(
() => _log.insert(0, '${DateTime.now().toIso8601String()} $s'));
@override
Widget build(BuildContext context) {
return ListView(padding: const EdgeInsets.all(16), children: [
Text('Events & attributes',
style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(children: [
TextField(
controller: _eventCtrl,
decoration: const InputDecoration(
labelText: 'Event name',
prefixIcon: Icon(Icons.bolt))),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: () async {
await CtrlApp.instance
.track(_eventCtrl.text, props: {'source': 'demo'});
_push('track "${_eventCtrl.text}"');
},
icon: const Icon(Icons.send),
label: const Text('Send event'),
),
]),
),
),
const SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: [
OutlinedButton.icon(
onPressed: () {
CtrlApp.instance.setAttributes(
{'plan': 'pro', 'country': 'DE', 'beta': true});
_push('setAttributes plan=pro country=DE beta=true');
},
icon: const Icon(Icons.badge),
label: const Text('Update attrs'),
),
OutlinedButton.icon(
onPressed: () async {
await CtrlApp.instance.reportLocation(lat: 37.7749, lng: -122.4194);
_push('reportLocation SF');
},
icon: const Icon(Icons.location_on),
label: const Text('Report location'),
),
OutlinedButton.icon(
onPressed: () async {
await CtrlApp.instance.refresh();
_push('refresh()');
},
icon: const Icon(Icons.sync),
label: const Text('Refresh now'),
),
]),
const SizedBox(height: 24),
Text('Log', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_log.isEmpty)
const Text('No activity yet.')
else
..._log.map((l) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(l,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 11)),
)),
]);
}
}
// ============================================================
// Reusable
// ============================================================
class _ResultCard extends StatelessWidget {
final String label;
final String value;
const _ResultCard({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(label,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12)),
trailing: Text(value,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold)),
),
);
}
}