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

Flutter SDK for Synheart Session — stream-based session API with typed events for HR metrics and behavioral signals.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:synheart_session/synheart_session.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Synheart Session',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: const Color(0xFF6C3CE1),
        useMaterial3: true,
        brightness: Brightness.dark,
      ),
      home: const SessionPage(),
    );
  }
}

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

  @override
  State<SessionPage> createState() => _SessionPageState();
}

class _SessionPageState extends State<SessionPage> {
  SynheartSession? _session;
  StreamSubscription<SessionEvent>? _subscription;
  SessionMode _mode = SessionMode.focus;
  int _durationSec = 30;

  // Session state
  bool _running = false;
  SessionConfig? _activeConfig;
  int _currentSeq = 0;
  double _lastHr = 0;
  double _lastSdnn = 0;
  double _lastRmssd = 0;
  double? _lastStability;
  double? _lastFragmentation;
  final List<_LogEntry> _log = [];
  final List<double> _hrHistory = [];
  final _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();
    _session = SynheartSession.mock(
      seed: 42,
      behaviorProvider: MockBehaviorProvider(),
    );
    _logWatchStatus();
  }

  @override
  void dispose() {
    _subscription?.cancel();
    _session?.dispose();
    _scrollController.dispose();
    super.dispose();
  }

  /// Query watch connectivity on init to demonstrate getWatchStatus() / WatchStatus.
  Future<void> _logWatchStatus() async {
    final ws = await _session!.getWatchStatus();
    if (ws != null) {
      _addLog(
        'Watch: supported=${ws.supported} reachable=${ws.reachable} '
        'paired=${ws.paired} installed=${ws.installed}',
        _LogType.info,
      );
    } else {
      _addLog('Watch: not available (mock mode)', _LogType.info);
    }
  }

  void _start() {
    final config = SessionConfig(
      mode: _mode,
      durationSec: _durationSec,
      windowLabel: '${_mode.value}_session',
      profile: ComputeProfile(
        windowSec: 10,
        emitIntervalSec: 3,
        rawEmitIntervalSec: 2,
      ),
      includeRawSamples: true,
    );

    setState(() {
      _activeConfig = config;
      _running = true;
      _currentSeq = 0;
      _lastHr = 0;
      _lastSdnn = 0;
      _lastRmssd = 0;
      _lastStability = null;
      _lastFragmentation = null;
      _log.clear();
      _hrHistory.clear();
    });

    _addLog('Starting session (label=${config.windowLabel})...', _LogType.info);

    try {
      _subscription = _session!
          .startSession(config)
          .listen(
            _handleEvent,
            onDone: () {
              setState(() => _running = false);
              _addLog('Stream closed', _LogType.info);
            },
            onError: (Object e) {
              if (e is SessionInvalidStateError) {
                _addLog('Invalid state: ${e.message}', _LogType.error);
              } else if (e is SessionPermissionDeniedError) {
                _addLog('Permission denied: ${e.message}', _LogType.error);
              } else if (e is SessionSensorUnavailableError) {
                _addLog('Sensor unavailable: ${e.message}', _LogType.error);
              } else if (e is SynheartSessionError) {
                _addLog('Session error: ${e.message}', _LogType.error);
              } else {
                _addLog('Stream error: $e', _LogType.error);
              }
              setState(() => _running = false);
            },
          );
    } on SessionInvalidStateError catch (e) {
      _addLog('Cannot start: ${e.message}', _LogType.error);
      setState(() => _running = false);
    }
  }

  void _handleEvent(SessionEvent event) {
    // Demonstrate toJson() serialization on every event
    final json = event.toJson();
    assert(json['type'] is String, 'toJson() must include type');

    switch (event) {
      case SessionStarted():
        _addLog(
          'Session started (${_activeConfig!.mode.value}) '
          'at ${DateTime.fromMillisecondsSinceEpoch(event.startedAtMs).toIso8601String()}',
          _LogType.success,
        );
        _queryStatus();

      case SessionFrame():
        _handleSessionFrame(event);

      case BiosignalFrame():
        _handleBiosignalFrame(event);

      case SessionSummary():
        _handleSessionSummary(event);

      case SessionError():
        _handleSessionError(event);
    }
  }

  Future<void> _queryStatus() async {
    final status = await _session!.getStatus();
    if (status != null) {
      _addLog(
        'Status: id=${status.sessionId.substring(0, 8)}... '
        'active=${status.active} seq=${status.lastSeq}',
        _LogType.info,
      );
    }
  }

  void _handleSessionFrame(SessionFrame frame) {
    final m = frame.metrics;
    final hr = (m['hr_mean_bpm'] as num?)?.toDouble() ?? 0;
    final sdnn = (m['hr_sdnn_ms'] as num?)?.toDouble() ?? 0;
    final rmssd = (m['rmssd_ms'] as num?)?.toDouble() ?? 0;

    // Extract behavioral signals beyond just stability_index
    final behavior = frame.behavior;
    final stability = (behavior?['stability_index'] as num?)?.toDouble();
    final fragmentation = (behavior?['fragmentation_index'] as num?)
        ?.toDouble();
    final appSwitches = behavior?['app_switches_per_minute'] as int?;

    setState(() {
      _currentSeq = frame.seq;
      _lastHr = hr;
      _lastSdnn = sdnn;
      _lastRmssd = rmssd;
      if (stability != null) _lastStability = stability;
      if (fragmentation != null) _lastFragmentation = fragmentation;
      _hrHistory.add(hr);
      if (_hrHistory.length > 60) _hrHistory.removeAt(0);
    });

    // Show emittedAtMs, encoding, and behavioral fields
    final emittedAt = DateTime.fromMillisecondsSinceEpoch(frame.emittedAtMs);
    final encodingStr = frame.encoding.value;
    _addLog(
      '#${frame.seq}  HR ${hr.toStringAsFixed(1)} bpm  '
      'SDNN ${sdnn.toStringAsFixed(1)} ms  '
      'enc=$encodingStr  '
      'at ${emittedAt.hour}:${emittedAt.minute.toString().padLeft(2, '0')}:${emittedAt.second.toString().padLeft(2, '0')}'
      '${stability != null ? '  stab=${stability.toStringAsFixed(2)}' : ''}'
      '${fragmentation != null ? '  frag=${fragmentation.toStringAsFixed(2)}' : ''}'
      '${appSwitches != null ? '  appSw=$appSwitches' : ''}',
      _LogType.data,
    );
  }

  void _handleBiosignalFrame(BiosignalFrame frame) {
    if (frame.samples.isEmpty) return;
    final latest = frame.samples.last;

    setState(() {
      _lastHr = latest.bpm;
      _hrHistory.add(latest.bpm);
      if (_hrHistory.length > 60) _hrHistory.removeAt(0);
    });

    final accelMag = latest.accelerometer != null
        ? _sqrt(
            latest.accelerometer!.x * latest.accelerometer!.x +
                latest.accelerometer!.y * latest.accelerometer!.y +
                latest.accelerometer!.z * latest.accelerometer!.z,
          )
        : 0.0;

    // Show all BiosignalSample fields: timestampMs, bpm, rrIntervalMs, accelerometer, spo2
    // Also show BiosignalFrame.emittedAtMs
    _addLog(
      'BIO #${frame.seq}  '
      'ts=${latest.timestampMs}  '
      'BPM ${latest.bpm.toStringAsFixed(1)}  '
      'RR ${latest.rrIntervalMs?.toStringAsFixed(0) ?? "-"} ms  '
      'Accel ${accelMag.toStringAsFixed(2)}  '
      'SpO2 ${latest.spo2?.toStringAsFixed(0) ?? "-"}  '
      'emitted=${frame.emittedAtMs}',
      _LogType.data,
    );
  }

  void _handleSessionSummary(SessionSummary event) {
    // Show summary metrics, encoding, and behavioral summary
    final sm = event.metrics;
    final summaryHr = (sm['hr_mean_bpm'] as num?)?.toDouble();
    final summarySdnn = (sm['hr_sdnn_ms'] as num?)?.toDouble();
    final summaryRmssd = (sm['rmssd_ms'] as num?)?.toDouble();
    final sampleCount = sm['sample_count'] as int?;

    final behavior = event.behavior;
    final stability = (behavior?['stability_index'] as num?)?.toDouble();
    final fragmentation = (behavior?['fragmentation_index'] as num?)
        ?.toDouble();
    final typingCadence = (behavior?['typing_cadence'] as num?)?.toDouble();
    final scrollVelocity = (behavior?['scroll_velocity'] as num?)?.toDouble();
    final idleGap = (behavior?['idle_gap_seconds'] as num?)?.toDouble();

    _addLog(
      'Session complete: ${event.durationActualSec}s  '
      'enc=${event.encoding.value}',
      _LogType.success,
    );

    if (summaryHr != null) {
      _addLog(
        '  Metrics: HR=${summaryHr.toStringAsFixed(1)} '
        'SDNN=${summarySdnn?.toStringAsFixed(1) ?? "-"} '
        'RMSSD=${summaryRmssd?.toStringAsFixed(1) ?? "-"} '
        'samples=$sampleCount',
        _LogType.success,
      );
    }

    if (behavior != null) {
      _addLog(
        '  Behavior: stab=${stability?.toStringAsFixed(2) ?? "-"} '
        'frag=${fragmentation?.toStringAsFixed(2) ?? "-"} '
        'typing=${typingCadence?.toStringAsFixed(1) ?? "-"}/s '
        'scroll=${scrollVelocity?.toStringAsFixed(1) ?? "-"}px/s '
        'idle=${idleGap?.toStringAsFixed(1) ?? "-"}s',
        _LogType.success,
      );
    }
  }

  void _handleSessionError(SessionError event) {
    // Use SessionErrorCode enum directly (not string matching)
    final String msg;
    switch (event.code) {
      case SessionErrorCode.permissionDenied:
        msg = 'Permission denied: ${event.message}';
      case SessionErrorCode.sensorUnavailable:
        msg = 'Sensor unavailable: ${event.message}';
      case SessionErrorCode.lowBattery:
        msg = 'Low battery: ${event.message}';
      case SessionErrorCode.osTerminated:
        msg = 'OS terminated session: ${event.message}';
      case SessionErrorCode.invalidState:
        msg = 'Invalid state: ${event.message}';
    }
    _addLog(msg, _LogType.error);
  }

  static double _sqrt(double v) {
    if (v <= 0) return 0;
    var x = v;
    for (var i = 0; i < 20; i++) {
      x = (x + v / x) / 2;
    }
    return x;
  }

  Future<void> _stop() async {
    if (_activeConfig == null) return;
    _addLog('Stopping session...', _LogType.info);
    await _session!.stopSession(_activeConfig!.sessionId);
  }

  void _addLog(String message, _LogType type) {
    final now = DateTime.now();
    final ts =
        '${now.hour.toString().padLeft(2, '0')}:'
        '${now.minute.toString().padLeft(2, '0')}:'
        '${now.second.toString().padLeft(2, '0')}';
    setState(() => _log.add(_LogEntry(time: ts, message: message, type: type)));

    WidgetsBinding.instance.addPostFrameCallback((_) {
      if (_scrollController.hasClients) {
        _scrollController.animateTo(
          _scrollController.position.maxScrollExtent,
          duration: const Duration(milliseconds: 200),
          curve: Curves.easeOut,
        );
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;

    return Scaffold(
      appBar: AppBar(title: const Text('Synheart Session')),
      body: Column(
        children: [
          // ---- Live stats cards ----
          if (_running || _lastHr > 0)
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
              child: Row(
                children: [
                  _StatCard(
                    label: 'HR',
                    value: _lastHr.toStringAsFixed(1),
                    unit: 'bpm',
                    color: cs.primary,
                  ),
                  const SizedBox(width: 8),
                  _StatCard(
                    label: 'SDNN',
                    value: _lastSdnn.toStringAsFixed(1),
                    unit: 'ms',
                    color: cs.tertiary,
                  ),
                  const SizedBox(width: 8),
                  _StatCard(
                    label: 'RMSSD',
                    value: _lastRmssd.toStringAsFixed(1),
                    unit: 'ms',
                    color: cs.secondary,
                  ),
                  const SizedBox(width: 8),
                  _StatCard(
                    label: 'Stability',
                    value: _lastStability?.toStringAsFixed(2) ?? '--',
                    unit: '',
                    color: Colors.tealAccent,
                  ),
                ],
              ),
            ),

          // ---- Fragmentation indicator ----
          if (_lastFragmentation != null)
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
              child: Row(
                children: [
                  Text(
                    'Fragmentation: ${_lastFragmentation!.toStringAsFixed(2)}',
                    style: TextStyle(fontSize: 11, color: cs.outline),
                  ),
                ],
              ),
            ),

          // ---- HR sparkline ----
          if (_hrHistory.length > 1)
            Padding(
              padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
              child: SizedBox(
                height: 80,
                child: CustomPaint(
                  size: const Size(double.infinity, 80),
                  painter: _SparklinePainter(
                    values: _hrHistory,
                    color: cs.primary,
                  ),
                ),
              ),
            ),

          // ---- Controls ----
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                // Mode selector
                Flexible(
                  child: DropdownButton<SessionMode>(
                    value: _mode,
                    isExpanded: false,
                    onChanged: _running
                        ? null
                        : (v) => setState(() => _mode = v!),
                    items: const [
                      DropdownMenuItem(
                        value: SessionMode.focus,
                        child: Text('Focus'),
                      ),
                      DropdownMenuItem(
                        value: SessionMode.breathing,
                        child: Text('Breathe'),
                      ),
                    ],
                  ),
                ),
                const SizedBox(width: 8),
                // Duration selector
                DropdownButton<int>(
                  value: _durationSec,
                  onChanged: _running
                      ? null
                      : (v) => setState(() => _durationSec = v!),
                  items: const [
                    DropdownMenuItem(value: 15, child: Text('15s')),
                    DropdownMenuItem(value: 30, child: Text('30s')),
                    DropdownMenuItem(value: 60, child: Text('60s')),
                    DropdownMenuItem(value: 300, child: Text('5m')),
                  ],
                ),
                const Spacer(),
                // Start/Stop
                IconButton.filled(
                  onPressed: _running ? null : _start,
                  icon: const Icon(Icons.play_arrow),
                ),
                const SizedBox(width: 8),
                IconButton.outlined(
                  onPressed: _running ? _stop : null,
                  icon: const Icon(Icons.stop),
                ),
              ],
            ),
          ),

          // ---- Status bar ----
          if (_running)
            Container(
              width: double.infinity,
              padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
              color: cs.primaryContainer,
              child: Text(
                'Session ${_activeConfig?.sessionId.substring(0, 8) ?? ''}'
                '...  |  Frame #$_currentSeq  |  '
                '${_mode.value.toUpperCase()}',
                style: TextStyle(
                  fontSize: 12,
                  color: cs.onPrimaryContainer,
                  fontFamily: 'monospace',
                ),
              ),
            ),

          // ---- Event log ----
          Expanded(
            child: ListView.builder(
              controller: _scrollController,
              padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
              itemCount: _log.length,
              itemBuilder: (_, i) {
                final entry = _log[i];
                return Padding(
                  padding: const EdgeInsets.symmetric(vertical: 1),
                  child: RichText(
                    text: TextSpan(
                      style: const TextStyle(
                        fontFamily: 'monospace',
                        fontSize: 12,
                      ),
                      children: [
                        TextSpan(
                          text: '${entry.time}  ',
                          style: TextStyle(color: cs.outline),
                        ),
                        TextSpan(
                          text: entry.message,
                          style: TextStyle(color: entry.type.color(cs)),
                        ),
                      ],
                    ),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

// ---- Helpers ----

enum _LogType { info, success, data, error }

extension on _LogType {
  Color color(ColorScheme cs) => switch (this) {
    _LogType.info => cs.outline,
    _LogType.success => Colors.greenAccent,
    _LogType.data => cs.onSurface,
    _LogType.error => cs.error,
  };
}

class _LogEntry {
  const _LogEntry({
    required this.time,
    required this.message,
    required this.type,
  });
  final String time;
  final String message;
  final _LogType type;
}

class _StatCard extends StatelessWidget {
  const _StatCard({
    required this.label,
    required this.value,
    required this.unit,
    required this.color,
  });

  final String label;
  final String value;
  final String unit;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Card(
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(label, style: TextStyle(fontSize: 11, color: color)),
              const SizedBox(height: 2),
              Row(
                crossAxisAlignment: CrossAxisAlignment.baseline,
                textBaseline: TextBaseline.alphabetic,
                children: [
                  Text(
                    value,
                    style: TextStyle(
                      fontSize: 22,
                      fontWeight: FontWeight.bold,
                      color: color,
                    ),
                  ),
                  const SizedBox(width: 4),
                  Text(
                    unit,
                    style: TextStyle(
                      fontSize: 11,
                      color: color.withValues(alpha: 0.7),
                    ),
                  ),
                ],
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _SparklinePainter extends CustomPainter {
  _SparklinePainter({required this.values, required this.color});

  final List<double> values;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    if (values.length < 2) return;

    final minV = values.reduce((a, b) => a < b ? a : b) - 2;
    final maxV = values.reduce((a, b) => a > b ? a : b) + 2;
    final range = maxV - minV;
    if (range == 0) return;

    final paint = Paint()
      ..color = color
      ..strokeWidth = 2
      ..style = PaintingStyle.stroke
      ..strokeCap = StrokeCap.round;

    final path = Path();
    for (var i = 0; i < values.length; i++) {
      final x = (i / (values.length - 1)) * size.width;
      final y = size.height - ((values[i] - minV) / range) * size.height;
      if (i == 0) {
        path.moveTo(x, y);
      } else {
        path.lineTo(x, y);
      }
    }
    canvas.drawPath(path, paint);

    // Fill gradient under the line
    final fillPath = Path.from(path)
      ..lineTo(size.width, size.height)
      ..lineTo(0, size.height)
      ..close();

    final fillPaint = Paint()
      ..shader = LinearGradient(
        begin: Alignment.topCenter,
        end: Alignment.bottomCenter,
        colors: [color.withValues(alpha: 0.3), color.withValues(alpha: 0.0)],
      ).createShader(Rect.fromLTWH(0, 0, size.width, size.height));

    canvas.drawPath(fillPath, fillPaint);
  }

  @override
  bool shouldRepaint(covariant _SparklinePainter old) =>
      values.length != old.values.length ||
      (values.isNotEmpty &&
          old.values.isNotEmpty &&
          values.last != old.values.last);
}
0
likes
130
points
120
downloads

Publisher

verified publishersynheart.ai

Weekly Downloads

Flutter SDK for Synheart Session — stream-based session API with typed events for HR metrics and behavioral signals.

Repository (GitHub)
View/report issues

Topics

#session #biosignals #wearable #flutter #health

Documentation

API reference

License

unknown (license)

Dependencies

flutter, meta, synheart_behavior, synheart_wear, uuid

More

Packages that depend on synheart_session

Packages that implement synheart_session