crash_heal 0.1.1 copy "crash_heal: ^0.1.1" to clipboard
crash_heal: ^0.1.1 copied to clipboard

Lightweight Flutter SDK for crash capture, deduplication, local persistence, and periodic sync to CrashHeal.

example/lib/main.dart

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:crash_heal/crash_heal.dart';

// Global mock server instance
final mockServer = MockServer();

class MockServer {
  HttpServer? _server;
  final List<Map<String, dynamic>> receivedPayloads = [];
  final StreamController<void> _updateController = StreamController<void>.broadcast();

  Stream<void> get updates => _updateController.stream;
  int get port => _server?.port ?? 0;
  bool get isRunning => _server != null;

  Future<void> start() async {
    try {
      _server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
      _server!.listen((HttpRequest request) async {
        if (request.method == 'POST' && request.uri.path == '/api/v1/errors/ingest') {
          final content = await utf8.decoder.bind(request).join();
          try {
            final data = jsonDecode(content) as Map<String, dynamic>;
            receivedPayloads.insert(0, {
              'timestamp': DateTime.now().toIso8601String(),
              'headers': {
                'x-api-key': request.headers.value('x-api-key') ?? 'None',
                'content-type': request.headers.value('content-type') ?? 'None',
                'user-agent': request.headers.value('user-agent') ?? 'None',
              },
              'body': data,
            });
            _updateController.add(null);

            request.response
              ..statusCode = HttpStatus.created
              ..headers.contentType = ContentType.json
              ..write(jsonEncode({
                'code': 2003,
                'status': true,
                'message': 'Error event ingested',
                'data': {
                  'stage': 'INGESTED',
                  'status': 'PENDING',
                  'occurrenceCount': 1
                }
              }))
              ..close();
          } catch (e) {
            request.response
              ..statusCode = HttpStatus.badRequest
              ..write('Invalid JSON: $e')
              ..close();
          }
        } else {
          request.response
            ..statusCode = HttpStatus.notFound
            ..write('Not Found')
            ..close();
        }
      });
    } catch (e) {
      debugPrint('Error starting mock server: $e');
    }
  }

  Future<void> stop() async {
    await _server?.close(force: true);
    _server = null;
    _updateController.add(null);
  }

  void clearLogs() {
    receivedPayloads.clear();
    _updateController.add(null);
  }
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  // Start the mock server first to bind to a free port
  await mockServer.start();

  // Bootstrap/Initialize the CrashHeal SDK pointing to our local mock server
  try {
    await CrashHeal.init(
      apiKey: 'ch_dev_test_api_key_998877',
      appName: 'Crash Heal Flutter',
      syncInterval: const Duration(hours: 1), // Rapid sync interval for interactive testing
      enableLogging: true,
    );
  } catch (e) {
    debugPrint('Failed to initialize CrashHeal in main(): $e');
  }

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'CrashHeal SDK Testbed',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: const Color(0xFF0F172A), // Slate 900
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6366F1), // Indigo 500
          brightness: Brightness.dark,
          primary: const Color(0xFF6366F1),
          secondary: const Color(0xFF10B981), // Emerald 500
        ),
        cardTheme: CardThemeData(
          color: const Color(0xFF1E293B), // Slate 800
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
          elevation: 4,
        ),
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF0F172A),
          elevation: 0,
        ),
      ),
      home: const DashboardScreen(),
    );
  }
}

class DashboardScreen extends StatefulWidget {

  const DashboardScreen({super.key});

  @override
  State<DashboardScreen> createState() => _DashboardScreenState();
}

class _DashboardScreenState extends State<DashboardScreen> with SingleTickerProviderStateMixin {
  late TabController _tabController;
  List<CrashRecord> _pendingCrashes = [];
  Timer? _refreshTimer;
  StreamSubscription? _syncSubscription;
  StreamSubscription? _mockServerSubscription;
  String _sdkStatusMsg = 'Running';
  bool _sdkInitialized = true;

  // SDK Configuration state variables
  final _apiKeyController = TextEditingController(text: 'ch_dev_test_api_key_998877');
  final _endpointController = TextEditingController(text: 'https://api.dafuu.uk');
  final _envController = TextEditingController(text: 'development');
  final _appNameController = TextEditingController(text: 'Crash Heal Flutter');
  final _releaseController = TextEditingController(text: '1.0.0+1');
  int _syncIntervalSeconds = 5;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(length: 2, vsync: this);
    _loadPendingCrashes();

    // Periodically check local DB for pending crashes
    _refreshTimer = Timer.periodic(const Duration(seconds: 2), (_) {
      _loadPendingCrashes();
    });

    // Listen for sync states to update UI
    _syncSubscription = CrashHeal.syncStateStream.listen((state) {
      if (mounted) {
        setState(() {}); // Trigger refresh of sync status UI
      }
    });

    // Listen to mock server events to reload UI when a request lands
    _mockServerSubscription = mockServer.updates.listen((_) {
      if (mounted) {
        setState(() {});
      }
    });
  }

  @override
  void dispose() {
    _refreshTimer?.cancel();
    _syncSubscription?.cancel();
    _mockServerSubscription?.cancel();
    _tabController.dispose();
    _apiKeyController.dispose();
    _endpointController.dispose();
    _envController.dispose();
    _appNameController.dispose();
    _releaseController.dispose();
    super.dispose();
  }

  Future<void> _loadPendingCrashes() async {
    if (!_sdkInitialized) return;
    try {
      final crashes = await CrashHeal.getPendingCrashes();
      if (mounted) {
        setState(() {
          _pendingCrashes = crashes;
        });
      }
    } catch (e) {
      debugPrint('Error loading pending crashes: $e');
    }
  }

  Future<void> _restartSdk() async {
    try {
      await CrashHeal.shutdown();
      await CrashHeal.init(
        apiKey: _apiKeyController.text,
        endpoint: _endpointController.text,
        environment: _envController.text,
        appName: _appNameController.text,
        release: _releaseController.text,
        syncInterval: Duration(seconds: _syncIntervalSeconds),
        enableLogging: true,
      );
      setState(() {
        _sdkInitialized = true;
        _sdkStatusMsg = 'Reinitialized';
      });
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('SDK Reinitialized Successfully')),
      );
      _loadPendingCrashes();
    } catch (e) {
      setState(() {
        _sdkInitialized = false;
        _sdkStatusMsg = 'Failed to Init: $e';
      });
    }
  }

  // Simulations
  void _triggerHandledException() async {
    try {
      throw StateError('Simulated StateError representing a handled validation issue.');
    } catch (error, stack) {
      await CrashHeal.captureException(
        error,
        stack,
        handled: true,
        tags: {
          'user_role': 'tester',
          'action': 'click_handled_simulation',
          'priority': 'low',
        },
      );
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Handled Exception Captured locally.')),
      );
      _loadPendingCrashes();
    }
  }

  void _triggerUnhandledAsyncException() {
    // Thrown in microtask so it bypasses sync call stack and triggers PlatformDispatcher
    Future.microtask(() {
      throw RangeError.index(5, [1, 2, 3], 'list', 'Index out of bounds during simulation.');
    });
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Unhandled Async Exception thrown. Check local storage soon.')),
    );
  }

  void _triggerZonedGuardedException() {
    CrashHeal.runGuarded(() {
      throw ArgumentError.value(
        'invalid_payload',
        'payload',
        'Zoned exception thrown inside CrashHeal.runGuarded block.',
      );
    });
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('Zoned Exception thrown. Check local storage soon.')),
    );
  }

  void _triggerFlutterBuildException() {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => Scaffold(
          appBar: AppBar(title: const Text('Widget Build Crash Test')),
          body: Center(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                const Padding(
                  padding: EdgeInsets.all(16.0),
                  child: Text(
                    'The widget below will fail its build method, producing a FlutterError.',
                    textAlign: TextAlign.center,
                  ),
                ),
                ElevatedButton(
                  onPressed: () => Navigator.pop(context),
                  child: const Text('Go Back'),
                ),
                const SizedBox(height: 20),
                const Expanded(
                  child: BuggyWidget(),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    final syncState = _sdkInitialized ? CrashHeal.currentSyncState : null;
    String syncStateStr = 'SDK Off';
    Color syncColor = Colors.grey;
    bool isSyncing = false;

    if (syncState != null) {
      if (syncState is CrashSyncIdle) {
        syncStateStr = 'Idle (Pending: ${_pendingCrashes.length})';
        syncColor = Colors.blue;
      } else if (syncState is CrashSyncRunning) {
        syncStateStr = 'Syncing...';
        syncColor = Colors.amber;
        isSyncing = true;
      } else if (syncState is CrashSyncSuccess) {
        syncStateStr = 'Success';
        syncColor = Colors.green;
      } else if (syncState is CrashSyncHttpFailure) {
        syncStateStr = 'HTTP Error';

        syncColor = Colors.red;
      } else if (syncState is CrashSyncException) {
        syncStateStr = 'Exception';
        syncColor = Colors.deepOrange;
      }
    }

    final isMobile = MediaQuery.of(context).size.width < 600;

    return Scaffold(
      appBar: AppBar(
        title: isMobile
            ? const Text(
                'CH Sandbox',
                style: TextStyle(fontWeight: FontWeight.bold),
              )
            : Row(
                children: [
                  Container(
                    padding: const EdgeInsets.all(6),
                    decoration: BoxDecoration(
                      color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
                      shape: BoxShape.circle,
                    ),
                    child: const Icon(Icons.healing, color: Color(0xFF6366F1), size: 24),
                  ),
                  const SizedBox(width: 10),
                  const Text(
                    'CrashHeal Sandbox',
                    style: TextStyle(fontWeight: FontWeight.bold, letterSpacing: 0.5),
                  ),
                ],
              ),
        actions: [
          Container(
            margin: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
            padding: const EdgeInsets.symmetric(horizontal: 8),
            decoration: BoxDecoration(
              color: mockServer.isRunning ? Colors.green.withOpacity(0.15) : Colors.red.withOpacity(0.15),
              borderRadius: BorderRadius.circular(20),
              border: Border.all(
                color: mockServer.isRunning ? Colors.green : Colors.red,
                width: 1,
              ),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Container(
                  width: 8,
                  height: 8,
                  decoration: BoxDecoration(
                    color: mockServer.isRunning ? Colors.green : Colors.red,
                    shape: BoxShape.circle,
                  ),
                ),
                const SizedBox(width: 6),
                Text(
                  mockServer.isRunning ? (isMobile ? ':${mockServer.port}' : 'Mock: :${mockServer.port}') : 'Offline',
                  style: TextStyle(
                    color: mockServer.isRunning ? Colors.green[200] : Colors.red[200],
                    fontSize: 11,
                    fontWeight: FontWeight.w600,
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(16.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              // 1. SDK config & control panel
              _buildConfigCard(syncStateStr, syncColor, isSyncing),
              const SizedBox(height: 16),

              // 2. Exception simulation area
              _buildSimulationCard(),
              const SizedBox(height: 16),

              // 3. Tabbed log view (Local database vs server-received requests)
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(12.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: [
                      TabBar(
                        controller: _tabController,
                        tabs: [
                          Tab(
                            child: Row(
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: [
                                if (!isMobile) ...[
                                  const Icon(Icons.storage, size: 16),
                                  const SizedBox(width: 6),
                                ],
                                Text(isMobile ? 'Cache (${_pendingCrashes.length})' : 'Local Cache (${_pendingCrashes.length})'),
                              ],
                            ),
                          ),
                          Tab(
                            child: Row(
                              mainAxisAlignment: MainAxisAlignment.center,
                              children: [
                                if (!isMobile) ...[
                                  const Icon(Icons.cloud_upload, size: 16),
                                  const SizedBox(width: 6),
                                ],
                                Text(isMobile ? 'Server (${mockServer.receivedPayloads.length})' : 'Server Ingestion (${mockServer.receivedPayloads.length})'),
                              ],
                            ),
                          ),
                        ],
                        indicatorColor: Theme.of(context).colorScheme.primary,
                        labelColor: Colors.white,
                        unselectedLabelColor: Colors.grey[400],
                      ),
                      const SizedBox(height: 12),
                      SizedBox(
                        height: 450,
                        child: TabBarView(
                          controller: _tabController,
                          children: [
                            _buildLocalCrashesTab(),
                            _buildServerReceivedTab(),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildConfigCard(String syncStateStr, Color syncColor, bool isSyncing) {
    final isMobile = MediaQuery.of(context).size.width < 600;

    final configInputs = Column(
      children: [
        _buildTextField(_apiKeyController, 'API Key'),
        const SizedBox(height: 10),
        _buildTextField(_endpointController, 'Ingestion Endpoint'),
        const SizedBox(height: 10),
        Row(
          children: [
            Expanded(child: _buildTextField(_envController, 'Environment')),
            const SizedBox(width: 10),
            Expanded(
              child: DropdownButtonFormField<int>(
                value: _syncIntervalSeconds,
                decoration: const InputDecoration(
                  labelText: 'Sync Period',
                  contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10),
                  border: OutlineInputBorder(),
                ),
                items: const [
                  DropdownMenuItem(value: 5, child: Text('5 Sec')),
                  DropdownMenuItem(value: 10, child: Text('10 Sec')),
                  DropdownMenuItem(value: 30, child: Text('30 Sec')),
                  DropdownMenuItem(value: 60, child: Text('1 Min')),
                ],
                onChanged: (val) {
                  if (val != null) {
                    setState(() {
                      _syncIntervalSeconds = val;
                    });
                  }
                },
              ),
            ),
          ],
        ),
      ],
    );

    final syncStatusBox = Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(0.2),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(color: Colors.white.withOpacity(0.05)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          const Text(
            'SYNC STATUS',
            style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.grey),
          ),
          const SizedBox(height: 8),
          Row(
            children: [
              Container(
                width: 10,
                height: 10,
                decoration: BoxDecoration(color: syncColor, shape: BoxShape.circle),
              ),
              const SizedBox(width: 8),
              Expanded(
                child: Text(
                  syncStateStr,
                  style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
                ),
              ),
              if (isSyncing)
                const SizedBox(
                  width: 14,
                  height: 14,
                  child: CircularProgressIndicator(strokeWidth: 2),
                ),
            ],
          ),
          const SizedBox(height: 16),
          ElevatedButton.icon(
            onPressed: _sdkInitialized
                ? () async {
                    await CrashHeal.syncNow();
                    _loadPendingCrashes();
                  }
                : null,
            icon: const Icon(Icons.sync, size: 16),
            label: const Text('Sync Now', style: TextStyle(fontSize: 12)),
            style: ElevatedButton.styleFrom(
              backgroundColor: const Color(0xFF6366F1),
              foregroundColor: Colors.white,
            ),
          ),
          const SizedBox(height: 8),
          OutlinedButton.icon(
            onPressed: () {
              throw StateError('Simulated crash triggered by clicking Apply Changes button.');
            },
            icon: const Icon(Icons.restart_alt, size: 16),
            label: const Text('Apply Changes', style: TextStyle(fontSize: 12)),
            style: OutlinedButton.styleFrom(
              foregroundColor: Colors.grey[200],
            ),
          ),
        ],
      ),
    );

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text(
                  'SDK CONFIGURATION',
                  style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.bold,
                    letterSpacing: 1.0,
                    color: Colors.grey,
                  ),
                ),
                Container(
                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  decoration: BoxDecoration(
                    color: _sdkInitialized ? Colors.green.withOpacity(0.15) : Colors.red.withOpacity(0.15),
                    borderRadius: BorderRadius.circular(6),
                  ),
                  child: Text(
                    _sdkStatusMsg.toUpperCase(),
                    style: TextStyle(
                      color: _sdkInitialized ? Colors.green : Colors.red,
                      fontSize: 10,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ],
            ),
            const Divider(height: 20),
            if (isMobile) ...[
              configInputs,
              const SizedBox(height: 16),
              syncStatusBox,
            ] else ...[
              Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Expanded(
                    flex: 3,
                    child: configInputs,
                  ),
                  const SizedBox(width: 16),
                  Expanded(
                    flex: 2,
                    child: syncStatusBox,
                  ),
                ],
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _buildTextField(TextEditingController controller, String label) {
    return TextField(
      controller: controller,
      decoration: InputDecoration(
        labelText: label,
        labelStyle: TextStyle(color: Colors.grey[400], fontSize: 12),
        contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
        border: const OutlineInputBorder(),
        focusedBorder: const OutlineInputBorder(
          borderSide: BorderSide(color: Color(0xFF6366F1), width: 1.5),
        ),
      ),
      style: const TextStyle(fontSize: 13),
    );
  }

  Widget _buildSimulationCard() {
    final isMobile = MediaQuery.of(context).size.width < 600;
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'SIMULATE EXCEPTIONS',
              style: TextStyle(
                fontSize: 14,
                fontWeight: FontWeight.bold,
                letterSpacing: 1.0,
                color: Colors.grey,
              ),
            ),
            const Divider(height: 20),
            GridView.count(
              shrinkWrap: true,
              physics: const NeverScrollableScrollPhysics(),
              crossAxisCount: isMobile ? 1 : 2,
              childAspectRatio: isMobile ? 3.5 : 2.8,
              crossAxisSpacing: 12,
              mainAxisSpacing: 12,
              children: [
                _buildSimButton(
                  label: 'Handled Exception',
                  icon: Icons.check_circle,
                  color: const Color(0xFF10B981), // Emerald
                  onPressed: _triggerHandledException,
                  description: 'Manually caught & sent with custom tags',
                ),
                _buildSimButton(
                  label: 'Unhandled Async',
                  icon: Icons.bolt,
                  color: const Color(0xFFF43F5E), // Rose
                  onPressed: _triggerUnhandledAsyncException,
                  description: 'Caught via PlatformDispatcher',
                ),
                _buildSimButton(
                  label: 'Zoned Guarded',
                  icon: Icons.shield,
                  color: const Color(0xFFF59E0B), // Amber
                  onPressed: _triggerZonedGuardedException,
                  description: 'Caught inside runGuarded zone',
                ),
                _buildSimButton(
                  label: 'Widget Build Error',
                  icon: Icons.layers,
                  color: const Color(0xFF8B5CF6), // Violet
                  onPressed: _triggerFlutterBuildException,
                  description: 'Throws inside widget build method',
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildSimButton({
    required String label,
    required IconData icon,
    required Color color,
    required VoidCallback onPressed,
    required String description,
  }) {
    return InkWell(
      onTap: onPressed,
      borderRadius: BorderRadius.circular(12),
      child: Container(
        padding: const EdgeInsets.all(8),
        decoration: BoxDecoration(
          color: color.withOpacity(0.08),
          borderRadius: BorderRadius.circular(12),
          border: Border.all(color: color.withOpacity(0.2), width: 1),
        ),
        child: Row(
          children: [
            Container(
              padding: const EdgeInsets.all(6),
              decoration: BoxDecoration(color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(8)),
              child: Icon(icon, color: color, size: 20),
            ),
            const SizedBox(width: 10),
            Expanded(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    label,
                    style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
                  ),
                  const SizedBox(height: 2),
                  Text(
                    description,
                    style: TextStyle(color: Colors.grey[400], fontSize: 10),
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildLocalCrashesTab() {
    if (!_sdkInitialized) {
      return const Center(child: Text('SDK is not initialized.'));
    }
    if (_pendingCrashes.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.storage, size: 48, color: Colors.grey[700]),
            const SizedBox(height: 12),
            const Text(
              'No Pending Crashes in Hive Storage',
              style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
            ),
            const SizedBox(height: 4),
            Text(
              'Trigger some simulations above to record logs.',
              style: TextStyle(color: Colors.grey[500], fontSize: 12),
            ),
          ],
        ),
      );
    }

    return Column(
      children: [
        Align(
          alignment: Alignment.centerRight,
          child: TextButton.icon(
            onPressed: () async {
              await CrashHeal.clearLocalStorage();
              _loadPendingCrashes();
            },
            icon: const Icon(Icons.delete_outline, size: 16, color: Colors.red),
            label: const Text('Clear local db', style: TextStyle(color: Colors.red, fontSize: 12)),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: _pendingCrashes.length,
            itemBuilder: (context, index) {
              final record = _pendingCrashes[index];
              final event = record.event;
              final isHandled = event.crash.handled;

              return Card(
                color: const Color(0xFF0F172A),
                margin: const EdgeInsets.only(bottom: 10),
                child: ExpansionTile(
                  leading: CircleAvatar(
                    backgroundColor: isHandled ? Colors.green.withOpacity(0.2) : Colors.red.withOpacity(0.2),
                    child: Icon(
                      isHandled ? Icons.check : Icons.warning,
                      color: isHandled ? Colors.green : Colors.red,
                      size: 20,
                    ),
                  ),
                  title: Text(
                    event.crash.type,
                    style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
                  ),
                  subtitle: Text(
                    event.crash.message,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: TextStyle(color: Colors.grey[400], fontSize: 12),
                  ),
                  trailing: Container(
                    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                    decoration: BoxDecoration(
                      color: isHandled ? Colors.green.withOpacity(0.15) : Colors.red.withOpacity(0.15),
                      borderRadius: BorderRadius.circular(4),
                    ),
                    child: Text(
                      isHandled ? 'HANDLED' : 'UNHANDLED',
                      style: TextStyle(
                        color: isHandled ? Colors.green : Colors.red,
                        fontWeight: FontWeight.bold,
                        fontSize: 10,
                      ),
                    ),
                  ),
                  children: [
                    Padding(
                      padding: const EdgeInsets.all(12.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          _buildDetailRow('Occurrences', '${record.occurrenceCount} times'),
                          _buildDetailRow('First Seen', record.firstSeenAt.toLocal().toString()),
                          _buildDetailRow('Last Seen', record.lastSeenAt.toLocal().toString()),
                          _buildDetailRow('Fingerprint', record.fingerprint),
                          _buildDetailRow('OS / Device', '${event.device.manufacturer} ${event.device.model} (OS: ${event.device.osVersion})'),
                          if (event.context.tags.isNotEmpty)
                            _buildDetailRow('Tags', event.context.tags.toString()),
                          const SizedBox(height: 10),
                          const Text(
                            'Stack Trace:',
                            style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Color(0xFF8B5CF6)),
                          ),
                          const SizedBox(height: 4),
                          Container(
                            width: double.infinity,
                            padding: const EdgeInsets.all(8),
                            decoration: BoxDecoration(
                              color: Colors.black.withOpacity(0.3),
                              borderRadius: BorderRadius.circular(6),
                            ),
                            child: SingleChildScrollView(
                              scrollDirection: Axis.horizontal,
                              child: Text(
                                event.crash.stacktrace.frames.map((frame) {
                                  return 'at ${frame.className ?? ""}.${frame.function ?? "anonymous"} (${frame.file ?? "unknown"}:${frame.line ?? 0}:${frame.column ?? 0})';
                                }).join('\n'),
                                style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
        ),
      ],
    );
  }

  Widget _buildServerReceivedTab() {
    if (mockServer.receivedPayloads.isEmpty) {
      return Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.cloud_off, size: 48, color: Colors.grey[700]),
            const SizedBox(height: 12),
            const Text(
              'No Ingested Payloads Yet',
              style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
            ),
            const SizedBox(height: 4),
            Text(
              'When the SDK synchronizes, JSON requests will display here.',
              style: TextStyle(color: Colors.grey[500], fontSize: 12),
            ),
          ],
        ),
      );
    }

    return Column(
      children: [
        Align(
          alignment: Alignment.centerRight,
          child: TextButton.icon(
            onPressed: () {
              mockServer.clearLogs();
            },
            icon: const Icon(Icons.clear_all, size: 16, color: Colors.orange),
            label: const Text('Clear received logs', style: TextStyle(color: Colors.orange, fontSize: 12)),
          ),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: mockServer.receivedPayloads.length,
            itemBuilder: (context, index) {
              final log = mockServer.receivedPayloads[index];
              final body = log['body'] as Map<String, dynamic>;
              final exceptionType = body['exceptionType'] as String? ?? 'Crash';
              final errorMessage = body['errorMessage'] as String? ?? '';

              return Card(
                color: const Color(0xFF0F172A),
                margin: const EdgeInsets.only(bottom: 10),
                child: ExpansionTile(
                  leading: const CircleAvatar(
                    backgroundColor: Color(0xFF6366F1),
                    child: Icon(Icons.file_download, color: Colors.white, size: 20),
                  ),
                  title: Text(
                    'POST /errors/ingest ($exceptionType)',
                    style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
                  ),
                  subtitle: Text(
                    'Time: ${DateTime.parse(log['timestamp']).toLocal().toString().split('.')[0]} - $errorMessage',
                    style: TextStyle(color: Colors.grey[400], fontSize: 11),
                  ),
                  children: [
                    Padding(
                      padding: const EdgeInsets.all(12.0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          const Text(
                            'Request Headers:',
                            style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.blue),
                          ),
                          const SizedBox(height: 4),
                          Container(
                            width: double.infinity,
                            padding: const EdgeInsets.all(8),
                            decoration: BoxDecoration(
                              color: Colors.black.withOpacity(0.3),
                              borderRadius: BorderRadius.circular(6),
                            ),
                            child: Text(
                              const JsonEncoder.withIndent('  ').convert(log['headers']),
                              style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
                            ),
                          ),
                          const SizedBox(height: 10),
                          const Text(
                            'JSON Payload Body:',
                            style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12, color: Colors.green),
                          ),
                          const SizedBox(height: 4),
                          Container(
                            width: double.infinity,
                            padding: const EdgeInsets.all(8),
                            decoration: BoxDecoration(
                              color: Colors.black.withOpacity(0.3),
                              borderRadius: BorderRadius.circular(6),
                            ),
                            child: SingleChildScrollView(
                              scrollDirection: Axis.horizontal,
                              child: Text(
                                const JsonEncoder.withIndent('  ').convert(body),
                                style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
                              ),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ],
                ),
              );
            },
          ),
        ),
      ],
    );
  }

  Widget _buildDetailRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          SizedBox(
            width: 100,
            child: Text(
              '$label:',
              style: TextStyle(color: Colors.grey[400], fontWeight: FontWeight.bold, fontSize: 12),
            ),
          ),
          Expanded(
            child: Text(
              value,
              style: const TextStyle(fontSize: 12),
            ),
          ),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    // Intentionally trigger a layout/rendering exception during widget build time
    throw StateError('This error is generated during BuggyWidget building! Caught via FlutterError.onError.');
  }
}
4
likes
130
points
143
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Lightweight Flutter SDK for crash capture, deduplication, local persistence, and periodic sync to CrashHeal.

Homepage

License

MIT (license)

Dependencies

bloc, crypto, dio, flutter, flutter_bloc, hive, package_info_plus, path, path_provider, uuid

More

Packages that depend on crash_heal

Packages that implement crash_heal