Skip to main content

ctrlapp 0.1.0 copy "ctrlapp: ^0.1.0" to clipboard
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)),
      ),
    );
  }
}
1
likes
0
points
448
downloads

Documentation

Documentation

Publisher

verified publisherctrlapp.dev

Weekly Downloads

Remote kill-switch, feature flags, forced update, forms and inbox for Flutter, plus diagnostics, device-integrity checks, push permission and hosted login.

Homepage

Topics

#feature-flags #kill-switch #remote-config #forced-update #screen-capture

License

unknown (license)

Dependencies

device_info_plus, flutter, http, package_info_plus, shared_preferences

More

Packages that depend on ctrlapp

Packages that implement ctrlapp