uplift_funnel_flutter 0.6.0 copy "uplift_funnel_flutter: ^0.6.0" to clipboard
uplift_funnel_flutter: ^0.6.0 copied to clipboard

Native Flutter onboarding and paywall flows, authored in a dashboard and updated without an app release. Includes A/B experiments and conversion analytics.

example/lib/main.dart

// Demo host for the Uplift Funnel SDK.
//
// Nothing is baked in at build time: type a server URL, a public key
// (`fnl_pk_…` from the dashboard) and a flow key, then hit Start. One build
// points at any flow. The fields persist, so a hot restart keeps them.
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uplift_funnel_flutter/uplift_funnel_flutter.dart';

/// Global navigator so the handlers below can put a dialog on screen — they
/// fire from inside the SDK, which has no `BuildContext` to hand them.
final navigatorKey = GlobalKey<NavigatorState>();

void main() => runApp(const UpliftFunnelExampleApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      navigatorKey: navigatorKey,
      title: 'Uplift Funnel demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFFFF6B35)),
        useMaterial3: true,
      ),
      home: const DemoHome(),
    );
  }
}

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

  @override
  State<DemoHome> createState() => _DemoHomeState();
}

class _DemoHomeState extends State<DemoHome> {
  // Android emulator can't see the host's `localhost` — use 10.0.2.2 there.
  final _serverUrl = TextEditingController(text: 'http://localhost:3000');
  final _apiKey = TextEditingController();
  final _flowKey = TextEditingController(text: 'cal-ai-clone');

  bool _experiment = false;
  bool _forceRefresh = false;
  bool _handlersRegistered = false;
  String _lastResult = '—';

  @override
  void initState() {
    super.initState();
    // Rebuild as the fields change so Start enables/disables with them.
    for (final controller in [_serverUrl, _apiKey, _flowKey]) {
      controller.addListener(() => setState(() {}));
    }
    _restore();
  }

  @override
  void dispose() {
    _serverUrl.dispose();
    _apiKey.dispose();
    _flowKey.dispose();
    super.dispose();
  }

  Future<void> _restore() async {
    final prefs = await SharedPreferences.getInstance();
    _serverUrl.text = prefs.getString('demo.serverUrl') ?? _serverUrl.text;
    _apiKey.text = prefs.getString('demo.apiKey') ?? '';
    _flowKey.text = prefs.getString('demo.flowKey') ?? _flowKey.text;
  }

  Future<void> _persist() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('demo.serverUrl', _serverUrl.text.trim());
    await prefs.setString('demo.apiKey', _apiKey.text.trim());
    await prefs.setString('demo.flowKey', _flowKey.text.trim());
  }

  bool get _canStart =>
      _apiKey.text.trim().isNotEmpty && _flowKey.text.trim().isNotEmpty;

  Future<void> _start() async {
    await _persist();
    await _configureSdk();
    if (!mounted) return;
    await Navigator.of(context).push(
      MaterialPageRoute<void>(
        builder: (_) => _FlowScreen(
          flowKey: _flowKey.text.trim(),
          experiment: _experiment,
          forceRefresh: _forceRefresh,
          onCompleted: _showResult,
        ),
      ),
    );
  }

  void _showResult(UpliftFunnelFlowResult result) {
    final variables = result.variables.entries
        .map((e) => '${e.key}=${e.value}')
        .toList()
      ..sort();
    setState(() {
      _lastResult = [
        '${result.endReason} · ${result.source.name}',
        if (result.experiment != null)
          'experiment: ${result.experiment!.experimentId}:'
              '${result.experiment!.variantName ?? result.experiment!.variantId}',
        ...variables,
      ].join('\n');
    });
    navigatorKey.currentState?.pop();
  }

  Future<void> _configureSdk() async {
    final serverUrl = _serverUrl.text.trim();
    await UpliftFunnel.configure(
      apiKey: _apiKey.text.trim(),
      // Empty means "use the default production host".
      serverUrl: serverUrl.isEmpty ? null : serverUrl,
      appVersion: '1.0.0-example',
    );
    if (_handlersRegistered) return;
    _handlersRegistered = true;

    // ── Native handoffs ──────────────────────────────────────────────────
    // The flow JSON declares WHAT happens (a signin gate, a permission ask, a
    // paywall CTA, a Terms link); these handlers are HOW your app does it.
    // Every one is optional — wire them one at a time.
    //
    // This demo answers each with a confirm dialog rather than faking success,
    // so the deny/cancel branches of a flow are walkable before you've wired a
    // real integration. In a real app you'd call the commented-out API.

    UpliftFunnel.registerSignInHandler((provider) async {
      // e.g. SignInWithApple.getAppleIDCredential(…) when provider == 'apple'
      return _confirm('Sign in with $provider?');
    });

    UpliftFunnel.registerPermissionHandler((permission) async {
      // e.g. (await Permission.notification.request()).isGranted
      return _confirm('Grant $permission permission?');
    });

    UpliftFunnel.registerPurchaseHandler((request) async {
      // e.g. Purchases.purchaseStoreProduct(…) — map cancel/failure onto the
      // matching PurchaseResult so the user stays on the paywall and the
      // drop-off is recorded.
      final bought = await _confirm(
        'Purchase "${request.productId ?? request.planId ?? '—'}" '
        '(plan ${request.planId ?? '—'})?',
        confirmLabel: 'Buy',
      );
      return bought ? PurchaseResult.purchased : PurchaseResult.cancelled;
    });

    UpliftFunnel.registerRestoreHandler(() async {
      // e.g. Purchases.restorePurchases() — return whether an active
      // entitlement actually came back.
      return _confirm('Restore purchases?', confirmLabel: 'Restore');
    });

    UpliftFunnel.registerPhotoUploadHandler((request) async {
      // e.g. (await ImagePicker().pickImage(source: …))?.path
      final picked = await _confirm(
        'Pick a photo (source ${request.source}, ${request.shape})?',
        confirmLabel: 'Pick',
      );
      return picked ? 'demo://photo' : null;
    });

    UpliftFunnel.registerLinkHandler((url) {
      // e.g. launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication).
      // The demo only reports it, so tapping Terms mid-flow doesn't kick you
      // out of the app.
      final context = navigatorKey.currentContext;
      if (context == null) return;
      ScaffoldMessenger.maybeOf(context)
          ?.showSnackBar(SnackBar(content: Text('Would open $url')));
    });

    // Fake catalog so {{price.…}} interpolation and plan_picker auto-binding
    // light up. A real app maps its billing SDK's offerings here.
    UpliftFunnel.setProducts(const [
      UpliftFunnelProduct(
        id: 'yearly_pro',
        price: r'$59.99',
        priceAmount: 59.99,
        period: ProductPeriod.year,
        trialDays: 7,
        trialEligible: true,
      ),
      UpliftFunnelProduct(
        id: 'monthly_pro',
        price: r'$9.99',
        priceAmount: 9.99,
        period: ProductPeriod.month,
      ),
    ]);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Uplift Funnel')),
      body: SafeArea(
        child: ListView(
          padding: const EdgeInsets.all(20),
          children: [
            _label('SERVER'),
            _field(_serverUrl, 'Server URL'),
            const SizedBox(height: 12),
            _field(_apiKey, 'API key (fnl_pk_…)'),
            const SizedBox(height: 24),
            _label('FLOW'),
            _field(_flowKey, 'Flow key'),
            SwitchListTile(
              contentPadding: EdgeInsets.zero,
              title: const Text('Experiment'),
              subtitle: const Text('Route through UpliftFunnelFlow.experiment'),
              value: _experiment,
              onChanged: (v) => setState(() => _experiment = v),
            ),
            SwitchListTile(
              contentPadding: EdgeInsets.zero,
              title: const Text('Force refresh'),
              subtitle: const Text('Bypass the cache and refetch'),
              value: _forceRefresh,
              onChanged: (v) => setState(() => _forceRefresh = v),
            ),
            const SizedBox(height: 12),
            FilledButton.icon(
              onPressed: _canStart ? _start : null,
              icon: const Icon(Icons.play_arrow),
              label: const Text('Start flow'),
            ),
            const SizedBox(height: 24),
            _label('LAST RESULT'),
            Text(
              _lastResult,
              style: const TextStyle(fontFamily: 'monospace', fontSize: 12),
            ),
          ],
        ),
      ),
    );
  }

  Widget _label(String text) => Padding(
        padding: const EdgeInsets.only(bottom: 8),
        child: Text(
          text,
          style: const TextStyle(
            fontWeight: FontWeight.w700,
            letterSpacing: 1.1,
            fontSize: 12,
          ),
        ),
      );

  Widget _field(TextEditingController controller, String label) => TextField(
        controller: controller,
        autocorrect: false,
        enableSuggestions: false,
        textCapitalization: TextCapitalization.none,
        decoration: InputDecoration(
          labelText: label,
          border: const OutlineInputBorder(),
          isDense: true,
        ),
      );
}

/// Stand-in for a real OS dialog / auth sheet / purchase sheet. Suspends until
/// the user answers, so both branches of a flow are reachable.
Future<bool> _confirm(String message, {String confirmLabel = 'Allow'}) async {
  final context = navigatorKey.currentContext;
  if (context == null) return false;
  final ok = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      content: Text(message),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(false),
          child: const Text("Don't Allow"),
        ),
        FilledButton(
          onPressed: () => Navigator.of(context).pop(true),
          child: Text(confirmLabel),
        ),
      ],
    ),
  );
  return ok ?? false;
}

/// Hosts the flow full-screen. Its own screen so each run starts a fresh
/// session.
class _FlowScreen extends StatelessWidget {
  const _FlowScreen({
    required this.flowKey,
    required this.experiment,
    required this.forceRefresh,
    required this.onCompleted,
  });

  final String flowKey;
  final bool experiment;
  final bool forceRefresh;
  final void Function(UpliftFunnelFlowResult result) onCompleted;

  @override
  Widget build(BuildContext context) {
    // UpliftFunnelFlow is full-bleed: it paints its own background edge-to-edge
    // and runs its own SafeArea. Don't wrap it in SafeArea or padding — that
    // insets the flow and exposes the Scaffold background as strips.
    return Scaffold(
      body: experiment
          ? UpliftFunnelFlow.experiment(
              flowKey,
              forceRefresh: forceRefresh,
              onCompleted: onCompleted,
            )
          : UpliftFunnelFlow(
              flowKey,
              forceRefresh: forceRefresh,
              onCompleted: onCompleted,
            ),
    );
  }
}
1
likes
0
points
530
downloads

Publisher

verified publisherupliftfunnel.com

Weekly Downloads

Native Flutter onboarding and paywall flows, authored in a dashboard and updated without an app release. Includes A/B experiments and conversion analytics.

Repository (GitHub)
View/report issues

Topics

#onboarding #funnel #sdk #oaas

License

unknown (license)

Dependencies

flutter, google_fonts, http, meta, shared_preferences, video_player

More

Packages that depend on uplift_funnel_flutter