synheart_behavior 0.1.0 copy "synheart_behavior: ^0.1.0" to clipboard
synheart_behavior: ^0.1.0 copied to clipboard

A lightweight, privacy-preserving mobile SDK that collects digital behavioral signals from smartphones.

example/lib/main.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:synheart_behavior/synheart_behavior.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Synheart Behavior Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const BehaviorDemoPage(),
    );
  }
}

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

  @override
  State<BehaviorDemoPage> createState() => _BehaviorDemoPageState();
}

class _BehaviorDemoPageState extends State<BehaviorDemoPage>
    with WidgetsBindingObserver {
  // Constants
  static const int _maxEventsToKeep = 50;
  static const int _eventRetentionMinutes = 5;
  static const int _backgroundSessionTimeoutMinutes = 1; // 1 minute

  SynheartBehavior? _behavior;
  BehaviorSession? _currentSession;
  BehaviorStats? _currentStats;
  List<BehaviorEvent> _events = [];
  List<BehaviorEvent> _sessionEvents =
      []; // Events collected during current session
  bool _isInitialized = false;
  bool _isSessionActive = false;
  bool _sessionAutoEnded = false; // Track if session was auto-ended
  BehaviorSessionSummary? _autoEndedSummary; // Store summary if auto-ended
  List<BehaviorEvent> _autoEndedEvents = []; // Store events if auto-ended
  Timer? _backgroundTimer;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _initializeSDK();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    super.didChangeAppLifecycleState(state);

    if (state == AppLifecycleState.paused ||
        state == AppLifecycleState.inactive) {
      // App went to background
      _onAppBackgrounded();
    } else if (state == AppLifecycleState.resumed) {
      // App returned to foreground
      _onAppForegrounded();
    }
  }

  void _onAppBackgrounded() {
    // End any active typing sessions when app goes to background
    BehaviorTextField.endAllTypingSessions();

    if (!_isSessionActive || _currentSession == null) return;

    print(
        'App went to background. Starting ${_backgroundSessionTimeoutMinutes} minute timer...');

    // Cancel any existing timer
    _backgroundTimer?.cancel();

    // Start timer to auto-end session after 1 minute
    _backgroundTimer = Timer(
      Duration(minutes: _backgroundSessionTimeoutMinutes),
      () {
        if (_isSessionActive && _currentSession != null) {
          print('Background timeout reached. Auto-ending session...');
          _endSession(autoEnded: true);
        }
      },
    );
  }

  void _onAppForegrounded() {
    // Cancel the timer if app returned before timeout
    _backgroundTimer?.cancel();

    // If session was auto-ended while in background, show results
    if (_sessionAutoEnded && _autoEndedSummary != null && mounted) {
      print(
          'App returned to foreground. Session was auto-ended. Showing results...');

      // Store values in local variables to avoid null issues in callback
      final summary = _autoEndedSummary!;
      final events = List<BehaviorEvent>.from(_autoEndedEvents);

      // Reset auto-ended flags immediately
      _sessionAutoEnded = false;
      _autoEndedSummary = null;
      _autoEndedEvents = [];

      // Delay slightly to ensure widget is fully mounted
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (mounted && context.mounted) {
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: (context) => SessionResultsScreen(
                summary: summary,
                events: events,
              ),
            ),
          );
        }
      });
    }
  }

  Future<void> _initializeSDK() async {
    try {
      final behavior = await SynheartBehavior.initialize(
        config: const BehaviorConfig(
          enableInputSignals: true,
          enableAttentionSignals: true,
          enableMotionLite: true, // Enable motion data collection for ML
        ),
      );

      // Listen to raw events
      behavior.onEvent.listen((event) {
        setState(() {
          _events.insert(0, event);
          // Keep only recent events within retention period
          final now = DateTime.now().millisecondsSinceEpoch;
          final cutoffTime = now - (_eventRetentionMinutes * 60 * 1000);
          _events = _events
              .where((e) {
                try {
                  final eventTime =
                      DateTime.parse(e.timestamp).millisecondsSinceEpoch;
                  return eventTime >= cutoffTime;
                } catch (e) {
                  return false; // Remove events with invalid timestamps
                }
              })
              .take(_maxEventsToKeep)
              .toList();

          // Store events for current session (if session is active)
          if (_isSessionActive && _currentSession != null) {
            if (event.sessionId == _currentSession!.sessionId) {
              _sessionEvents.add(event);
            }
          }
        });
      });

      // Check and request notification permission
      await _checkAndRequestNotificationPermission(behavior);

      // Auto-start a session for testing
      final session = await behavior.startSession();

      setState(() {
        _behavior = behavior;
        _currentSession = session;
        _isInitialized = true;
        _isSessionActive = true;
      });
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Failed to initialize SDK: $e')),
        );
      }
    }
  }

  Future<void> _startSession() async {
    if (_behavior == null) return;

    try {
      final session = await _behavior!.startSession();
      setState(() {
        _currentSession = session;
        _isSessionActive = true;
        _sessionEvents = []; // Clear previous session events
      });
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Failed to start session: $e')),
        );
      }
    }
  }

  Future<void> _endSession({bool autoEnded = false}) async {
    print('_endSession called (autoEnded: $autoEnded)');
    print('_currentSession: $_currentSession');
    print('_isSessionActive: $_isSessionActive');

    // End any active typing sessions (keyboard might still be open)
    BehaviorTextField.endAllTypingSessions();

    if (_currentSession == null) {
      print('ERROR: _currentSession is null!');
      if (mounted && !autoEnded) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text('No active session to end')),
        );
      }
      return;
    }

    // Cancel background timer if it exists
    _backgroundTimer?.cancel();
    _backgroundTimer = null;

    try {
      print('Calling _currentSession!.end()...');
      print('Session ID being ended: ${_currentSession!.sessionId}');
      final summary = await _currentSession!.end().timeout(
        const Duration(seconds: 15),
        onTimeout: () {
          throw Exception('Session end timed out after 15 seconds');
        },
      );
      print('Session ended successfully. Summary: ${summary.sessionId}');
      final sessionEvents = List<BehaviorEvent>.from(_sessionEvents);
      print('Session events count: ${sessionEvents.length}');

      setState(() {
        _currentSession = null;
        _isSessionActive = false;
      });

      if (autoEnded) {
        // Store summary and events to show when app returns to foreground
        _sessionAutoEnded = true;
        _autoEndedSummary = summary;
        _autoEndedEvents = sessionEvents;
        print(
            'Session auto-ended. Will show results when app returns to foreground.');
      } else if (mounted) {
        print('Navigating to SessionResultsScreen...');
        // Navigate to session results screen immediately
        Navigator.of(context).push(
          MaterialPageRoute(
            builder: (context) => SessionResultsScreen(
              summary: summary,
              events: sessionEvents,
            ),
          ),
        );
      }
    } catch (e, stackTrace) {
      print('ERROR ending session: $e');
      print('Stack trace: $stackTrace');
      if (mounted && !autoEnded) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Failed to end session: $e')),
        );
      }
    }
  }

  Future<void> _refreshStats() async {
    if (_behavior == null) return;

    try {
      final stats = await _behavior!.getCurrentStats();
      setState(() {
        _currentStats = stats;
      });
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Failed to get stats: $e')),
        );
      }
    }
  }

  Future<void> _checkAndRequestNotificationPermission(
      [SynheartBehavior? behavior]) async {
    final sdk = behavior ?? _behavior;
    if (sdk == null) return;

    try {
      final hasPermission = await sdk.checkNotificationPermission();
      if (!hasPermission) {
        if (mounted) {
          final shouldRequest = await showDialog<bool>(
            context: context,
            builder: (context) => AlertDialog(
              title: const Text('Notification Permission'),
              content: const Text(
                'To track notification events, please enable notification access.\n\n'
                'On Android: You will be taken to system settings to enable notification access.\n'
                'On iOS: A permission dialog will appear.',
              ),
              actions: [
                TextButton(
                  onPressed: () => Navigator.of(context).pop(false),
                  child: const Text('Cancel'),
                ),
                TextButton(
                  onPressed: () => Navigator.of(context).pop(true),
                  child: const Text('Enable'),
                ),
              ],
            ),
          );

          if (shouldRequest == true) {
            await sdk.requestNotificationPermission();
            if (mounted) {
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Text(
                    'Please enable notification access in settings if prompted.',
                  ),
                ),
              );
            }
          }
        }
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
              content: Text('Failed to check notification permission: $e')),
        );
      }
    }
  }

  Future<void> _checkAndRequestCallPermission(
      [SynheartBehavior? behavior]) async {
    final sdk = behavior ?? _behavior;
    if (sdk == null) return;

    try {
      final hasPermission = await sdk.checkCallPermission();
      if (!hasPermission) {
        if (mounted) {
          final shouldRequest = await showDialog<bool>(
            context: context,
            builder: (context) => AlertDialog(
              title: const Text('Call Permission'),
              content: const Text(
                'To track call events, please enable phone state access.\n\n'
                'On Android: A permission dialog will appear.\n'
                'On iOS: Call monitoring works automatically.',
              ),
              actions: [
                TextButton(
                  onPressed: () => Navigator.of(context).pop(false),
                  child: const Text('Cancel'),
                ),
                TextButton(
                  onPressed: () => Navigator.of(context).pop(true),
                  child: const Text('Enable'),
                ),
              ],
            ),
          );

          if (shouldRequest == true) {
            await sdk.requestCallPermission();
            if (mounted) {
              ScaffoldMessenger.of(context).showSnackBar(
                const SnackBar(
                  content: Text(
                    'Please grant phone state permission if prompted.',
                  ),
                ),
              );
            }
          }
        }
      } else {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(
              content: Text('Call permission already granted.'),
            ),
          );
        }
      }
    } catch (e) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text('Failed to check call permission: $e')),
        );
      }
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _backgroundTimer?.cancel();
    _behavior?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    Widget content = BehaviorGestureDetector(
      behavior: _behavior,
      child: Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: const Text('Synheart Behavior Demo'),
        ),
        body: GestureDetector(
          // Dismiss keyboard when tapping outside text fields
          onTap: () {
            FocusScope.of(context).unfocus();
          },
          child: SingleChildScrollView(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // Status Card
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          'SDK Status',
                          style: Theme.of(context).textTheme.titleLarge,
                        ),
                        const SizedBox(height: 8),
                        Row(
                          children: [
                            Icon(
                              _isInitialized ? Icons.check_circle : Icons.error,
                              color: _isInitialized ? Colors.green : Colors.red,
                            ),
                            const SizedBox(width: 8),
                            Text(
                              _isInitialized
                                  ? 'Initialized'
                                  : 'Not Initialized',
                            ),
                          ],
                        ),
                        if (_isSessionActive) ...[
                          const SizedBox(height: 8),
                          Row(
                            children: [
                              const Icon(
                                Icons.play_circle,
                                color: Colors.blue,
                              ),
                              const SizedBox(width: 8),
                              Text(
                                  'Session Active: ${_currentSession?.sessionId}'),
                            ],
                          ),
                        ],
                      ],
                    ),
                  ),
                ),

                const SizedBox(height: 16),

                // Controls
                Row(
                  children: [
                    Expanded(
                      child: ElevatedButton(
                        onPressed: _isInitialized && !_isSessionActive
                            ? () {
                                print('Start Session button clicked!');
                                _startSession();
                              }
                            : null,
                        child: const Text('Start Session'),
                      ),
                    ),
                    const SizedBox(width: 8),
                    Expanded(
                      child: Builder(
                        builder: (context) => ElevatedButton(
                          onPressed: _isInitialized && _isSessionActive
                              ? () {
                                  print('=== END SESSION BUTTON CLICKED ===');
                                  print('_isInitialized: $_isInitialized');
                                  print('_isSessionActive: $_isSessionActive');
                                  print('_currentSession: $_currentSession');
                                  print(
                                      '_currentSession?.sessionId: ${_currentSession?.sessionId}');
                                  _endSession();
                                }
                              : () {
                                  print(
                                      '=== END SESSION BUTTON CLICKED (DISABLED) ===');
                                  print('_isInitialized: $_isInitialized');
                                  print('_isSessionActive: $_isSessionActive');
                                  ScaffoldMessenger.of(context).showSnackBar(
                                    SnackBar(
                                      content: Text(
                                        'Button disabled: initialized=$_isInitialized, active=$_isSessionActive',
                                      ),
                                    ),
                                  );
                                },
                          style: ElevatedButton.styleFrom(
                            backgroundColor: _isInitialized && _isSessionActive
                                ? Colors.red
                                : Colors.grey,
                            foregroundColor: Colors.white,
                          ),
                          child: Text(
                            _isInitialized && _isSessionActive
                                ? 'End Session'
                                : 'End Session (Disabled)',
                          ),
                        ),
                      ),
                    ),
                  ],
                ),

                const SizedBox(height: 8),

                ElevatedButton(
                  onPressed: _isInitialized ? _refreshStats : null,
                  child: const Text('Refresh Stats'),
                ),

                const SizedBox(height: 8),

                ElevatedButton(
                  onPressed: _isInitialized && _behavior != null
                      ? () => _checkAndRequestNotificationPermission(_behavior!)
                      : null,
                  child: const Text('Request Notification Permission'),
                ),

                const SizedBox(height: 8),

                ElevatedButton(
                  onPressed: _isInitialized && _behavior != null
                      ? () => _checkAndRequestCallPermission(_behavior!)
                      : null,
                  child: const Text('Request Call Permission'),
                ),

                const SizedBox(height: 16),
                if (!_isSessionActive) ...[
                  // Stats Card
                  if (_currentStats != null)
                    Card(
                      child: Padding(
                        padding: const EdgeInsets.all(16.0),
                        child: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text(
                              'Current Stats',
                              style: Theme.of(context).textTheme.titleLarge,
                            ),
                            const SizedBox(height: 8),
                            // _buildStatRow('Typing Cadence',
                            //     _currentStats!.typingCadence?.toStringAsFixed(2)),
                            _buildStatRow(
                                'Scroll Velocity',
                                _currentStats!.scrollVelocity
                                    ?.toStringAsFixed(2)),
                            _buildStatRow('App Switches/min',
                                _currentStats!.appSwitchesPerMinute.toString()),
                            _buildStatRow(
                                'Stability Index',
                                _currentStats!.stabilityIndex
                                    ?.toStringAsFixed(2)),
                          ],
                        ),
                      ),
                    ),

                  const SizedBox(height: 16),
                ],

                // Typing Test Field
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(16.0),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Text(
                          'Typing Test',
                          style: Theme.of(context).textTheme.titleMedium,
                        ),
                        const SizedBox(height: 8),
                        BehaviorTextField(
                          decoration: const InputDecoration(
                            hintText: 'Type here to test typing events...',
                            border: OutlineInputBorder(),
                            labelText: 'Text Input',
                          ),
                          maxLines: 3,
                          onTypingEvent: (event) async {
                            // Send typing event to SDK
                            if (_behavior != null) {
                              await _behavior!.sendEvent(event);
                            }
                          },
                        ),
                        const SizedBox(height: 8),
                        Text(
                          'Typing events will appear in the event stream above',
                          style:
                              Theme.of(context).textTheme.bodySmall?.copyWith(
                                    color: Colors.grey[600],
                                  ),
                        ),
                      ],
                    ),
                  ),
                ),

                const SizedBox(height: 16),

                // Interactive Test Area - Always visible, especially during session
                const SizedBox(height: 16),
                // Test list - removed for now to avoid blocking interactions
                ListView.builder(
                  shrinkWrap: true,
                  physics: const NeverScrollableScrollPhysics(),
                  itemCount: 10,
                  itemBuilder: (context, index) {
                    return Card(
                        child: Padding(
                            padding: const EdgeInsets.all(16.0),
                            child: Text('Item $index')));
                  },
                ),
                const SizedBox(height: 100),
                const Text('Scroll down to see more content'),
              ],
            ),
          ),
        ),
      ),
    );

    // BehaviorGestureDetector already wraps the Scaffold above
    return content;
  }

  Widget _buildStatRow(String label, String? value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(label),
          Text(
            value ?? 'N/A',
            style: const TextStyle(fontWeight: FontWeight.bold),
          ),
        ],
      ),
    );
  }

  // Color _getEventTypeColor(BehaviorEventType eventType) {
  //   switch (eventType) {
  //     case BehaviorEventType.scroll:
  //       return Colors.blue;
  //     case BehaviorEventType.tap:
  //       return Colors.green;
  //     case BehaviorEventType.swipe:
  //       return Colors.orange;
  //     case BehaviorEventType.call:
  //       return Colors.red;
  //     case BehaviorEventType.notification:
  //       return Colors.purple;
  //   }
  // }
}

/// Screen to display session results with events timeline and behavior metrics
class SessionResultsScreen extends StatelessWidget {
  final BehaviorSessionSummary summary;
  final List<BehaviorEvent> events;

  const SessionResultsScreen({
    super.key,
    required this.summary,
    required this.events,
  });

  @override
  Widget build(BuildContext context) {
    // Sort events by timestamp (oldest first)
    final sortedEvents = List<BehaviorEvent>.from(events)
      ..sort((a, b) {
        try {
          final timeA = DateTime.parse(a.timestamp);
          final timeB = DateTime.parse(b.timestamp);
          return timeA.compareTo(timeB);
        } catch (e) {
          return 0;
        }
      });

    // Calculate relative time from session start
    final sessionStart = DateTime.parse(summary.startAt);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Session Results'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // Session Info Card
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Session Information',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 12),
                    _buildInfoRow('Session ID', summary.sessionId),
                    _buildInfoRow(
                        'Start Time', _formatDateTime(summary.startAt)),
                    _buildInfoRow('End Time', _formatDateTime(summary.endAt)),
                    _buildInfoRow('Duration', _formatMs(summary.durationMs)),
                    _buildInfoRow(
                        'Micro Session', summary.microSession ? 'Yes' : 'No'),
                    _buildInfoRow('OS', summary.os),
                    if (summary.appId != null)
                      _buildInfoRow('App ID', summary.appId!),
                    if (summary.appName != null)
                      _buildInfoRow('App Name', summary.appName!),
                    _buildInfoRow(
                        'Session Spacing', _formatMs(summary.sessionSpacing)),
                    _buildInfoRow('Total Events', '${sortedEvents.length}'),
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Motion Data Debug Card (temporary for debugging)
            Card(
              color: Colors.orange.withOpacity(0.1),
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Motion Data Debug',
                      style: Theme.of(context).textTheme.titleMedium?.copyWith(
                            color: Colors.orange[900],
                          ),
                    ),
                    const SizedBox(height: 8),
                    _buildInfoRow('Motion Data Available',
                        summary.motionData != null ? 'Yes' : 'No'),
                    _buildInfoRow('Motion Data Count',
                        '${summary.motionData?.length ?? 0} windows'),
                    _buildInfoRow('Motion State Available',
                        summary.motionState != null ? 'Yes' : 'No'),
                    if (summary.motionData != null &&
                        summary.motionData!.isNotEmpty) ...[
                      const SizedBox(height: 8),
                      Text(
                        'First window sample (first 5 features):',
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                      const SizedBox(height: 4),
                      Text(
                        summary.motionData!.first.features.entries
                            .take(5)
                            .map((e) =>
                                '${e.key}: ${e.value.toStringAsFixed(4)}')
                            .join('\n'),
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(
                              fontFamily: 'monospace',
                              fontSize: 10,
                            ),
                      ),
                    ],
                  ],
                ),
              ),
            ),
            const SizedBox(height: 16),

            // Motion State Card
            if (summary.motionState != null) ...[
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Motion State',
                        style: Theme.of(context).textTheme.titleLarge,
                      ),
                      const SizedBox(height: 12),
                      _buildInfoRow(
                          'Major State', summary.motionState!.majorState),
                      _buildInfoRow('Major State %',
                          '${(summary.motionState!.majorStatePct * 100).toStringAsFixed(1)}%'),
                      _buildInfoRow('ML Model', summary.motionState!.mlModel),
                      _buildInfoRow('Confidence',
                          summary.motionState!.confidence.toStringAsFixed(2)),
                      const SizedBox(height: 12),
                      const Divider(),
                      const SizedBox(height: 8),
                      Text(
                        'State Array (${summary.motionState!.state.length} windows):',
                        style: Theme.of(context).textTheme.titleSmall,
                      ),
                      const SizedBox(height: 8),
                      // Display as JSON-like array format
                      Container(
                        padding: const EdgeInsets.all(12.0),
                        decoration: BoxDecoration(
                          color: Colors.grey[100],
                          borderRadius: BorderRadius.circular(8),
                        ),
                        child: SelectableText(
                          '[${summary.motionState!.state.map((s) => '"$s"').join(', ')}]',
                          style:
                              Theme.of(context).textTheme.bodySmall?.copyWith(
                                    fontFamily: 'monospace',
                                    fontSize: 12,
                                  ),
                        ),
                      ),
                      const SizedBox(height: 8),
                      // Also show as readable list
                      Wrap(
                        spacing: 8,
                        runSpacing: 4,
                        children: summary.motionState!.state
                            .asMap()
                            .entries
                            .map((entry) {
                          return Chip(
                            label: Text(
                              '${entry.key + 1}: ${entry.value}',
                              style: const TextStyle(fontSize: 11),
                            ),
                            padding: const EdgeInsets.symmetric(horizontal: 4),
                            materialTapTargetSize:
                                MaterialTapTargetSize.shrinkWrap,
                            visualDensity: VisualDensity.compact,
                          );
                        }).toList(),
                      ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 16),
            ],

            // Motion Data Card (ML Features)
            if (summary.motionData != null &&
                summary.motionData!.isNotEmpty) ...[
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        'Motion Data (ML Features)',
                        style: Theme.of(context).textTheme.titleLarge,
                      ),
                      const SizedBox(height: 12),
                      _buildInfoRow(
                        'Data Points',
                        '${summary.motionData!.length} time windows',
                      ),
                      _buildInfoRow(
                        'Time Window',
                        '5 seconds per window',
                      ),
                      _buildInfoRow(
                        'Features per Window',
                        '561 ML features',
                      ),
                      const SizedBox(height: 8),
                      Text(
                        'Sample Features (First window):',
                        style: Theme.of(context).textTheme.titleSmall,
                      ),
                      const SizedBox(height: 8),
                      ...summary.motionData!.take(1).map((dataPoint) {
                        return ExpansionTile(
                          title: Text(
                            'Window: ${dataPoint.timestamp}',
                            style: const TextStyle(fontSize: 12),
                          ),
                          children: [
                            Padding(
                              padding: const EdgeInsets.all(8.0),
                              child: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: [
                                  Text(
                                    'Total Features: ${dataPoint.features.length}',
                                    style: const TextStyle(
                                        fontWeight: FontWeight.bold),
                                  ),
                                  const SizedBox(height: 8),
                                  // Show first 20 features as examples
                                  ...dataPoint.features.entries
                                      .take(20)
                                      .map((entry) {
                                    return Padding(
                                      padding:
                                          const EdgeInsets.only(bottom: 4.0),
                                      child: Row(
                                        crossAxisAlignment:
                                            CrossAxisAlignment.start,
                                        children: [
                                          Expanded(
                                            flex: 2,
                                            child: Text(
                                              entry.key,
                                              style: const TextStyle(
                                                  fontSize: 10,
                                                  fontFamily: 'monospace'),
                                            ),
                                          ),
                                          Expanded(
                                            flex: 1,
                                            child: Text(
                                              entry.value.toStringAsFixed(4),
                                              style: const TextStyle(
                                                  fontSize: 10,
                                                  fontFamily: 'monospace'),
                                              textAlign: TextAlign.right,
                                            ),
                                          ),
                                        ],
                                      ),
                                    );
                                  }),
                                  if (dataPoint.features.length > 20)
                                    Padding(
                                      padding: const EdgeInsets.only(top: 8.0),
                                      child: Text(
                                        '... and ${dataPoint.features.length - 20} more features',
                                        style: Theme.of(context)
                                            .textTheme
                                            .bodySmall,
                                      ),
                                    ),
                                ],
                              ),
                            ),
                          ],
                        );
                      }),
                      if (summary.motionData!.length > 1)
                        Padding(
                          padding: const EdgeInsets.all(8.0),
                          child: Text(
                            '... and ${summary.motionData!.length - 1} more windows',
                            style: Theme.of(context).textTheme.bodySmall,
                          ),
                        ),
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 16),
            ],

            // Device Context Card
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Device Context',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 12),
                    _buildInfoRow(
                        'Avg Screen Brightness',
                        summary.deviceContext.avgScreenBrightness
                            .toStringAsFixed(3)),
                    _buildInfoRow('Start Orientation',
                        summary.deviceContext.startOrientation),
                    _buildInfoRow('Orientation Changes',
                        '${summary.deviceContext.orientationChanges}'),
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Activity Summary Card
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Activity Summary',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 12),
                    _buildInfoRow('Total Events',
                        '${summary.activitySummary.totalEvents}'),
                    _buildInfoRow('App Switch Count',
                        '${summary.activitySummary.appSwitchCount}'),
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Behavior Metrics Card
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Behavior Metrics',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 12),
                    _buildInfoRow(
                        'Interaction Intensity',
                        summary.behavioralMetrics.interactionIntensity
                            .toStringAsFixed(3)),
                    _buildInfoRow(
                        'Task Switch Rate',
                        summary.behavioralMetrics.taskSwitchRate
                            .toStringAsFixed(3)),
                    _buildInfoRow('Task Switch Cost',
                        _formatMs(summary.behavioralMetrics.taskSwitchCost)),
                    _buildInfoRow(
                        'Idle Time Ratio',
                        summary.behavioralMetrics.idleTimeRatio
                            .toStringAsFixed(3)),
                    _buildInfoRow(
                        'Active Time Ratio',
                        summary.behavioralMetrics.activeTimeRatio
                            .toStringAsFixed(3)),
                    _buildInfoRow(
                        'Notification Load',
                        summary.behavioralMetrics.notificationLoad
                            .toStringAsFixed(3)),
                    _buildInfoRow(
                        'Burstiness',
                        summary.behavioralMetrics.burstiness
                            .toStringAsFixed(3)),
                    _buildInfoRow(
                        'Distraction Score',
                        summary.behavioralMetrics.behavioralDistractionScore
                            .toStringAsFixed(3)),
                    _buildInfoRow('Focus Hint',
                        summary.behavioralMetrics.focusHint.toStringAsFixed(3)),
                    _buildInfoRow(
                        'Fragmented Idle Ratio',
                        summary.behavioralMetrics.fragmentedIdleRatio
                            .toStringAsFixed(3)),
                    _buildInfoRow(
                        'Scroll Jitter Rate',
                        summary.behavioralMetrics.scrollJitterRate
                            .toStringAsFixed(3)),
                    _buildInfoRow('Deep Focus Blocks',
                        '${summary.behavioralMetrics.deepFocusBlocks.length}'),
                    if (summary
                        .behavioralMetrics.deepFocusBlocks.isNotEmpty) ...[
                      const SizedBox(height: 8),
                      const Text(
                        'Deep Focus Block Details:',
                        style: TextStyle(
                          fontWeight: FontWeight.bold,
                          fontSize: 14,
                        ),
                      ),
                      const SizedBox(height: 4),
                      ...summary.behavioralMetrics.deepFocusBlocks
                          .asMap()
                          .entries
                          .map((entry) {
                        final index = entry.key;
                        final block = entry.value;
                        return Padding(
                          padding: const EdgeInsets.only(left: 8, top: 4),
                          child: Text(
                            'Block ${index + 1}: ${_formatDateTime(block.startAt)} - ${_formatDateTime(block.endAt)} (${_formatMs(block.durationMs)})',
                            style: Theme.of(context).textTheme.bodySmall,
                          ),
                        );
                      }),
                    ],
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Notification Summary Card
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Notification Summary',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 12),
                    _buildInfoRow('Notification Count',
                        '${summary.notificationSummary.notificationCount}'),
                    _buildInfoRow('Notifications Ignored',
                        '${summary.notificationSummary.notificationIgnored}'),
                    _buildInfoRow(
                        'Ignore Rate',
                        summary.notificationSummary.notificationIgnoreRate
                            .toStringAsFixed(3)),
                    _buildInfoRow(
                        'Clustering Index',
                        summary.notificationSummary.notificationClusteringIndex
                            .toStringAsFixed(3)),
                    _buildInfoRow('Call Count',
                        '${summary.notificationSummary.callCount}'),
                    _buildInfoRow('Calls Ignored',
                        '${summary.notificationSummary.callIgnored}'),
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Typing Session Summary Card
            if (summary.typingSessionSummary != null) ...[
              Card(
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        children: [
                          Icon(Icons.keyboard, color: Colors.teal),
                          const SizedBox(width: 8),
                          Text(
                            'Typing Session Summary',
                            style: Theme.of(context).textTheme.titleLarge,
                          ),
                        ],
                      ),
                      const SizedBox(height: 12),
                      _buildInfoRow('Typing Sessions',
                          '${summary.typingSessionSummary!.typingSessionCount}'),
                      _buildInfoRow(
                          'Avg Keystrokes/Session',
                          summary
                              .typingSessionSummary!.averageKeystrokesPerSession
                              .toStringAsFixed(1)),
                      _buildInfoRow(
                          'Avg Session Duration',
                          _formatMs((summary.typingSessionSummary!
                                      .averageTypingSessionDuration *
                                  1000)
                              .round())),
                      _buildInfoRow('Avg Typing Speed',
                          '${summary.typingSessionSummary!.averageTypingSpeed.toStringAsFixed(2)} taps/s'),
                      _buildInfoRow(
                          'Avg Typing Gap',
                          _formatMs(summary
                              .typingSessionSummary!.averageTypingGap
                              .round())),
                      _buildInfoRow(
                          'Average Inter-tap Interval',
                          _formatMs(summary
                              .typingSessionSummary!.averageInterTapInterval
                              .round())),
                      _buildInfoRow(
                          'Cadence Stability',
                          summary.typingSessionSummary!.typingCadenceStability
                              .toStringAsFixed(3)),
                      _buildInfoRow(
                          'Burstiness',
                          summary.typingSessionSummary!.burstinessOfTyping
                              .toStringAsFixed(3)),
                      _buildInfoRow(
                          'Total Typing Duration',
                          _formatMs(summary
                                  .typingSessionSummary!.totalTypingDuration *
                              1000)),
                      _buildInfoRow(
                          'Active Typing Ratio',
                          summary.typingSessionSummary!.activeTypingRatio
                              .toStringAsFixed(3)),
                      _buildInfoRow(
                          'Typing Contribution to Intensity',
                          summary.typingSessionSummary!
                              .typingContributionToInteractionIntensity
                              .toStringAsFixed(3)),
                      _buildInfoRow('Deep Typing Blocks',
                          '${summary.typingSessionSummary!.deepTypingBlocks}'),
                      _buildInfoRow(
                          'Typing Fragmentation',
                          summary.typingSessionSummary!.typingFragmentation
                              .toStringAsFixed(3)),
                      if (summary.typingSessionSummary!.individualTypingSessions
                          .isNotEmpty) ...[
                        const SizedBox(height: 16),
                        const Divider(),
                        const SizedBox(height: 8),
                        Text(
                          'Individual Typing Sessions (${summary.typingSessionSummary!.individualTypingSessions.length})',
                          style: const TextStyle(
                            fontWeight: FontWeight.bold,
                            fontSize: 14,
                          ),
                        ),
                        const SizedBox(height: 8),
                        ...summary
                            .typingSessionSummary!.individualTypingSessions
                            .asMap()
                            .entries
                            .map((entry) {
                          final index = entry.key;
                          final session = entry.value;
                          return ExpansionTile(
                            title: Text(
                              'Session ${index + 1}${session.deepTyping ? " (Deep Typing)" : ""}',
                              style: const TextStyle(fontSize: 14),
                            ),
                            subtitle: Text(
                              '${_formatMs(session.duration * 1000)} • ${session.typingTapCount} keystrokes',
                              style: const TextStyle(fontSize: 12),
                            ),
                            children: [
                              Padding(
                                padding: const EdgeInsets.all(16.0),
                                child: Column(
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: [
                                    _buildInfoRow('Start Time',
                                        _formatDateTime(session.startAt)),
                                    _buildInfoRow('End Time',
                                        _formatDateTime(session.endAt)),
                                    _buildInfoRow('Duration',
                                        _formatMs(session.duration * 1000)),
                                    _buildInfoRow('Deep Typing',
                                        session.deepTyping ? 'Yes' : 'No'),
                                    _buildInfoRow('Keystrokes',
                                        '${session.typingTapCount}'),
                                    _buildInfoRow('Typing Speed',
                                        '${session.typingSpeed.toStringAsFixed(2)} taps/s'),
                                    _buildInfoRow(
                                        'Mean Inter-Tap Interval',
                                        _formatMs(session.meanInterTapIntervalMs
                                            .round())),
                                    _buildInfoRow(
                                        'Cadence Variability',
                                        _formatMs(session
                                            .typingCadenceVariability
                                            .round())),
                                    _buildInfoRow(
                                        'Cadence Stability',
                                        session.typingCadenceStability
                                            .toStringAsFixed(3)),
                                    _buildInfoRow('Gap Count',
                                        '${session.typingGapCount}'),
                                    _buildInfoRow(
                                        'Gap Ratio',
                                        session.typingGapRatio
                                            .toStringAsFixed(3)),
                                    _buildInfoRow(
                                        'Burstiness',
                                        session.typingBurstiness
                                            .toStringAsFixed(3)),
                                    _buildInfoRow(
                                        'Activity Ratio',
                                        session.typingActivityRatio
                                            .toStringAsFixed(3)),
                                    _buildInfoRow(
                                        'Interaction Intensity',
                                        session.typingInteractionIntensity
                                            .toStringAsFixed(3)),
                                  ],
                                ),
                              ),
                            ],
                          );
                        }),
                      ],
                    ],
                  ),
                ),
              ),
              const SizedBox(height: 16),
            ],

            // System State Card
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'System State',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 12),
                    _buildInfoRow(
                        'Internet',
                        summary.systemState.internetState
                            ? 'Connected'
                            : 'Disconnected'),
                    _buildInfoRow('Do Not Disturb',
                        summary.systemState.doNotDisturb ? 'On' : 'Off'),
                    _buildInfoRow('Charging',
                        summary.systemState.charging ? 'Yes' : 'No'),
                  ],
                ),
              ),
            ),

            const SizedBox(height: 16),

            // Events Timeline
            Card(
              child: Padding(
                padding: const EdgeInsets.all(16.0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'Events Timeline (${sortedEvents.length} events)',
                      style: Theme.of(context).textTheme.titleLarge,
                    ),
                    const SizedBox(height: 12),
                    if (sortedEvents.isEmpty)
                      const Padding(
                        padding: EdgeInsets.all(16.0),
                        child: Center(
                          child:
                              Text('No events collected during this session.'),
                        ),
                      )
                    else
                      ...sortedEvents.asMap().entries.map((entry) {
                        final index = entry.key;
                        final event = entry.value;
                        final eventTime = DateTime.parse(event.timestamp);
                        final relativeTime = eventTime.difference(sessionStart);
                        final relativeTimeMs = relativeTime.inMilliseconds;

                        return _buildEventTimelineItem(
                          context,
                          event,
                          index + 1,
                          relativeTimeMs,
                        );
                      }),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildInfoRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 4.0),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(
            label,
            style: const TextStyle(fontWeight: FontWeight.w500),
          ),
          Text(
            value,
            style: const TextStyle(fontWeight: FontWeight.bold),
          ),
        ],
      ),
    );
  }

  Widget _buildEventTimelineItem(
    BuildContext context,
    BehaviorEvent event,
    int eventNumber,
    int relativeTimeMs,
  ) {
    return Container(
      margin: const EdgeInsets.only(bottom: 12.0),
      padding: const EdgeInsets.all(12.0),
      decoration: BoxDecoration(
        border: Border.all(color: Colors.grey[300]!),
        borderRadius: BorderRadius.circular(8),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              Container(
                padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                decoration: BoxDecoration(
                  color: _getEventTypeColor(event.eventType),
                  borderRadius: BorderRadius.circular(4),
                ),
                child: Text(
                  event.eventType.name.toUpperCase(),
                  style: const TextStyle(
                    color: Colors.white,
                    fontSize: 12,
                    fontWeight: FontWeight.bold,
                  ),
                ),
              ),
              Text(
                '+${_formatMs(relativeTimeMs)}',
                style: Theme.of(context).textTheme.bodySmall?.copyWith(
                      fontFamily: 'monospace',
                      fontWeight: FontWeight.bold,
                    ),
              ),
            ],
          ),
          const SizedBox(height: 8),
          Text(
            'Time: ${_formatDateTime(event.timestamp)}',
            style: Theme.of(context).textTheme.bodySmall,
          ),
          const SizedBox(height: 8),
          const Text(
            'Metrics:',
            style: TextStyle(
              fontWeight: FontWeight.bold,
              fontSize: 12,
            ),
          ),
          const SizedBox(height: 4),
          ...event.metrics.entries.map((entry) => Padding(
                padding: const EdgeInsets.only(left: 8, top: 2),
                child: Row(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Flexible(
                      child: Text(
                        '${entry.key}: ',
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(
                              fontWeight: FontWeight.w500,
                              fontFamily: 'monospace',
                            ),
                      ),
                    ),
                    Flexible(
                      flex: 2,
                      child: Text(
                        entry.value.toString(),
                        style: Theme.of(context).textTheme.bodySmall?.copyWith(
                              fontFamily: 'monospace',
                            ),
                        overflow: TextOverflow.ellipsis,
                        maxLines: 2,
                      ),
                    ),
                  ],
                ),
              )),
        ],
      ),
    );
  }

  Color _getEventTypeColor(BehaviorEventType eventType) {
    switch (eventType) {
      case BehaviorEventType.scroll:
        return Colors.blue;
      case BehaviorEventType.tap:
        return Colors.green;
      case BehaviorEventType.swipe:
        return Colors.orange;
      case BehaviorEventType.call:
        return Colors.red;
      case BehaviorEventType.notification:
        return Colors.purple;
      case BehaviorEventType.typing:
        return Colors.teal;
    }
  }

  String _formatDateTime(String isoString) {
    try {
      final dateTime = DateTime.parse(isoString);
      return '${dateTime.hour.toString().padLeft(2, '0')}:'
          '${dateTime.minute.toString().padLeft(2, '0')}:'
          '${dateTime.second.toString().padLeft(2, '0')}.'
          '${(dateTime.millisecond ~/ 100).toString()}';
    } catch (e) {
      return isoString;
    }
  }

  String _formatMs(int milliseconds) {
    if (milliseconds < 1000) {
      return '${milliseconds}ms';
    } else if (milliseconds < 60000) {
      return '${(milliseconds / 1000).toStringAsFixed(1)}s';
    } else {
      return '${(milliseconds / 60000).toStringAsFixed(1)}m';
    }
  }
}
0
likes
140
points
39
downloads

Publisher

verified publishersynheart.ai

Weekly Downloads

A lightweight, privacy-preserving mobile SDK that collects digital behavioral signals from smartphones.

Repository (GitHub)
View/report issues
Contributing

Topics

#behavior #digital-phenotyping #mobile-sensing #flutter #privacy

Documentation

API reference

License

unknown (license)

Dependencies

flutter, onnxruntime

More

Packages that depend on synheart_behavior

Packages that implement synheart_behavior