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

Flutter SDK for capturing structured incident evidence from Flutter apps.

example/lib/main.dart

import "dart:async";

import "package:flutter/material.dart";
import "package:revclust_flutter/revclust_flutter.dart";

const String _sdkKey = String.fromEnvironment("REVCLUST_PROJECT_KEY");
const String _appVersion = String.fromEnvironment("REVCLUST_APP_VERSION");
const String _build = String.fromEnvironment("REVCLUST_BUILD");
const String _gitSha = String.fromEnvironment("REVCLUST_GIT_SHA");

void main() {
  runApp(const RevclustExampleApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: "Revclust SDK Example",
      theme: ThemeData(
        colorSchemeSeed: Colors.teal,
        splashFactory: InkRipple.splashFactory,
      ),
      home: const ExampleHomeScreen(),
    );
  }
}

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

  @override
  State<ExampleHomeScreen> createState() => _ExampleHomeScreenState();
}

class _ExampleHomeScreenState extends State<ExampleHomeScreen> {
  final List<String> _eventLog = <String>[];

  Revclust? _revclust;
  StreamSubscription<RevclustUploadEvent>? _uploadEventsSubscription;
  bool _initializing = false;
  bool _capturing = false;
  String? _activityMessage;
  String? _viewerUrl;

  bool get _hasBuildTimeConfig => _sdkKey.isNotEmpty;

  bool get _canInitialize =>
      _hasBuildTimeConfig && !_initializing && _revclust == null;

  bool get _canCapture => _revclust != null && !_initializing && !_capturing;

  @override
  void dispose() {
    _uploadEventsSubscription?.cancel();
    super.dispose();
  }

  Future<void> _initializeSdk() async {
    if (!_hasBuildTimeConfig) {
      setState(() {
        _activityMessage = _missingConfigMessage;
      });
      return;
    }

    setState(() {
      _initializing = true;
      _activityMessage = null;
    });

    try {
      final Revclust revclust = await Revclust.initialize(
        RevclustConfig(
          projectKey: _sdkKey,
          appVersion: _optionalBuildValue(_appVersion),
          build: _optionalBuildValue(_build),
          gitSha: _optionalBuildValue(_gitSha),
        ),
      );

      revclust.setStateSnapshotProvider(
        () => RevclustStateSnapshot(
          appState: <String, Object?>{
            "screen": "example_home",
            "sdk_status": revclust.status.name,
          },
          dataState: const <String, Object?>{
            "sample_order_ref": "ord_ref_7d82b1",
            "sample_flow": "checkout_confirmation",
          },
        ),
      );

      await _uploadEventsSubscription?.cancel();
      _uploadEventsSubscription = revclust.uploadEvents.listen(
        _handleUploadEvent,
      );

      if (!mounted) {
        return;
      }

      setState(() {
        _revclust = revclust;
        _activityMessage = _initializedMessage(revclust.status);
        _eventLog.insert(
          0,
          "SDK initialized with status ${revclust.status.name}.",
        );
      });
    } catch (_) {
      if (!mounted) {
        return;
      }

      setState(() {
        _activityMessage =
            "SDK initialization failed. Check your Revclust configuration and setup.";
        _eventLog.insert(0, "SDK initialization failed.");
      });
    } finally {
      if (mounted) {
        setState(() {
          _initializing = false;
        });
      }
    }
  }

  String _initializedMessage(RevclustStatus status) {
    return switch (status) {
      RevclustStatus.ready =>
        "SDK is ready. Queue the sample incident to verify explicit capture and upload.",
      RevclustStatus.degraded || RevclustStatus.uploadBlocked =>
        "SDK initialized with status ${status.name}. Local capture can queue, but upload is not ready.",
      RevclustStatus.misconfigured || RevclustStatus.notProvisioned =>
        "SDK initialized with status ${status.name}. Check the SDK key and setup.",
      RevclustStatus.disabled || RevclustStatus.initializing =>
        "SDK initialized with status ${status.name}.",
    };
  }

  Future<void> _queueSampleIncident() async {
    final Revclust? revclust = _revclust;
    if (revclust == null) {
      return;
    }

    setState(() {
      _capturing = true;
      _activityMessage = null;
      _viewerUrl = null;
    });

    try {
      final RevclustCaptureOutcome
      outcome = await revclust.captureInvariantFailure(
        RevclustInvariantFailure(
          failureKind: "checkout_confirmation_mismatch",
          subject: RevclustSubject(kind: "order_ref", value: "ord_ref_7d82b1"),
          expected: const <String, Object?>{"order_status": "confirmed"},
          observed: const <String, Object?>{"order_status": "pending_review"},
        ),
      );

      if (!mounted) {
        return;
      }

      switch (outcome) {
        case RevclustCaptureQueued():
          setState(() {
            _activityMessage =
                "Sample incident queued as ${outcome.captureId}. Waiting for upload events.";
            _eventLog.insert(
              0,
              "Queued sample incident with capture ID ${outcome.captureId}.",
            );
          });
        case RevclustCaptureBlocked():
          setState(() {
            _activityMessage =
                outcome.message == null
                    ? "Sample incident was blocked with status ${outcome.status.name}."
                    : "Sample incident was blocked: ${outcome.message}";
          });
        case RevclustCaptureBuildFailed():
          setState(() {
            _activityMessage =
                outcome.message == null
                    ? "Sample incident could not be built."
                    : "Sample incident build failed: ${outcome.message}";
          });
        case RevclustCapturePersistenceFailed():
          setState(() {
            _activityMessage =
                outcome.message == null
                    ? "Sample incident could not be persisted locally."
                    : "Sample incident persistence failed: ${outcome.message}";
          });
      }
    } catch (_) {
      if (!mounted) {
        return;
      }

      setState(() {
        _activityMessage =
            "Sample incident failed unexpectedly. Retry the capture or check the host logs.";
        _eventLog.insert(0, "Sample incident failed unexpectedly.");
      });
    } finally {
      if (mounted) {
        setState(() {
          _capturing = false;
        });
      }
    }
  }

  void _handleUploadEvent(RevclustUploadEvent event) {
    if (!mounted) {
      return;
    }

    switch (event) {
      case RevclustUploadStarted():
        setState(() {
          _eventLog.insert(0, "Upload started for capture ${event.captureId}.");
        });
      case RevclustUploadAccepted():
        setState(() {
          _viewerUrl = event.result.viewerUrl?.toString();
          _eventLog.insert(
            0,
            "Incident ${event.result.packId} accepted for capture ${event.captureId}.",
          );
          _activityMessage =
              _viewerUrl == null
                  ? "Sample incident was accepted."
                  : "Sample incident was accepted. Open the viewer URL below.";
        });
      case RevclustUploadRejected():
        setState(() {
          _eventLog.insert(
            0,
            "Upload rejected for ${event.captureId} with code ${event.code.name}.",
          );
          _activityMessage =
              event.message == null
                  ? "Sample incident was rejected with code ${event.code.name}."
                  : "Sample incident was rejected: ${event.message}";
        });
      case RevclustTransportFailure():
        setState(() {
          _eventLog.insert(
            0,
            "Transport failure for ${event.captureId}${event.statusCode == null ? "" : " (HTTP ${event.statusCode})"}.",
          );
          _activityMessage =
              event.message == null
                  ? "Sample incident upload failed before acceptance."
                  : "Sample incident upload failed: ${event.message}";
        });
    }
  }

  @override
  Widget build(BuildContext context) {
    final Revclust? revclust = _revclust;
    final ThemeData theme = Theme.of(context);

    return Scaffold(
      appBar: AppBar(title: const Text("Revclust SDK Example")),
      body: ListView(
        padding: const EdgeInsets.all(24),
        children: <Widget>[
          Text(
            "Initialize the SDK, register the state snapshot provider, then queue one sample incident to verify explicit capture on mobile.",
            style: theme.textTheme.titleMedium,
          ),
          const SizedBox(height: 16),
          _Section(
            title: "Configuration",
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  "SDK key: ${_sdkKey.isEmpty ? "missing" : _maskSdkKey(_sdkKey)}",
                ),
                if (!_hasBuildTimeConfig) ...<Widget>[
                  const SizedBox(height: 12),
                  Text(_missingConfigMessage),
                ],
              ],
            ),
          ),
          const SizedBox(height: 16),
          _Section(
            title: "SDK status",
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: <Widget>[
                Text(
                  "Current status: ${revclust?.status.name ?? RevclustStatus.disabled.name}",
                ),
                const SizedBox(height: 8),
                Text(
                  _activityMessage ??
                      "Initialize the SDK to begin the example flow.",
                ),
              ],
            ),
          ),
          const SizedBox(height: 16),
          Wrap(
            spacing: 12,
            runSpacing: 12,
            children: <Widget>[
              FilledButton(
                onPressed: _canInitialize ? _initializeSdk : null,
                child: Text(
                  _initializing ? "Initializing..." : "Initialize SDK",
                ),
              ),
              OutlinedButton(
                onPressed: _canCapture ? _queueSampleIncident : null,
                child: Text(
                  _capturing ? "Queueing..." : "Queue Sample Incident",
                ),
              ),
            ],
          ),
          if (_viewerUrl != null) ...<Widget>[
            const SizedBox(height: 16),
            _Section(title: "Viewer URL", child: SelectableText(_viewerUrl!)),
          ],
          const SizedBox(height: 16),
          _Section(
            title: "Upload event log",
            child:
                _eventLog.isEmpty
                    ? const Text(
                      "No upload events yet. Initialize the SDK and queue the sample incident first.",
                    )
                    : Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: _eventLog
                          .take(6)
                          .map(
                            (String event) => Padding(
                              padding: const EdgeInsets.only(bottom: 8),
                              child: Text(event),
                            ),
                          )
                          .toList(growable: false),
                    ),
          ),
        ],
      ),
    );
  }
}

class _Section extends StatelessWidget {
  const _Section({required this.title, required this.child});

  final String title;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
        borderRadius: BorderRadius.circular(12),
      ),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text(title, style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 12),
            child,
          ],
        ),
      ),
    );
  }
}

String get _missingConfigMessage =>
    "Provide REVCLUST_PROJECT_KEY via --dart-define to enable the example flow.";

String? _optionalBuildValue(String value) {
  if (value.isEmpty) {
    return null;
  }
  return value;
}

String _maskSdkKey(String value) {
  if (value.length <= 12) {
    return value;
  }

  return "${value.substring(0, 8)}...${value.substring(value.length - 4)}";
}