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

A Flutter package for camera-based PPG (Photoplethysmography) signal processing. Extracts RR intervals from camera frames for heart rate and HRV analysis.

example/lib/main.dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:camera/camera.dart';
import 'package:flutter_ppg/flutter_ppg.dart';
import 'waveform_painter.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(brightness: Brightness.dark, primarySwatch: Colors.red, useMaterial3: true),
      home: const PPGExamplePage(),
    );
  }
}

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

  @override
  State<PPGExamplePage> createState() => _PPGExamplePageState();
}

class _PPGExamplePageState extends State<PPGExamplePage> {
  CameraController? _controller;
  final _ppgService = FlutterPPGService();

  // State for UI
  PPGSignal? _currentSignal;
  String _status = 'Initializing...';
  bool _isScanning = false;
  bool _isTransitioning = false; // Guard against rapid START/STOP
  int _timeLeft = 30;
  Timer? _timer;
  Timer? _uiUpdateTimer; // Throttle UI updates

  // Data buffers for visualization
  final List<double> _rawHistory = [];
  final List<double> _filteredHistory = [];
  final List<double> _rrHistory = [];
  List<int> _currentPeakIndices = [];
  static const int _historyLimit = 150;

  // Pending signal for throttled update
  PPGSignal? _pendingSignal;

  StreamController<CameraImage>? _imageStreamController;
  StreamSubscription<PPGSignal>? _ppgSubscription;

  @override
  void initState() {
    super.initState();
    _initializeCamera();
  }

  Future<void> _initializeCamera() async {
    try {
      final cameras = await availableCameras();
      if (cameras.isEmpty) {
        if (mounted) setState(() => _status = 'No cameras found.');
        return;
      }
      final camera = cameras.firstWhere((c) => c.lensDirection == CameraLensDirection.back, orElse: () => cameras.first);

      _controller = CameraController(camera, ResolutionPreset.low, enableAudio: false, imageFormatGroup: ImageFormatGroup.yuv420);

      await _controller!.initialize();
      if (mounted) {
        setState(() => _status = 'Ready. Press Start to measure.');
      }
    } catch (e) {
      if (mounted) {
        setState(() => _status = 'Camera error: $e');
      }
    }
  }

  void _toggleScanning() {
    if (_isTransitioning) return; // Guard against rapid clicks
    if (_isScanning) {
      _stopProcessing();
    } else {
      _startProcessing();
    }
  }

  Future<void> _startProcessing() async {
    if (_controller == null || !_controller!.value.isInitialized) return;
    if (_isTransitioning) return;

    _isTransitioning = true;

    // Reset State
    setState(() {
      _isScanning = true;
      _timeLeft = 30;
      _rawHistory.clear();
      _filteredHistory.clear();
      _rrHistory.clear();
      _currentPeakIndices = [];
      _currentSignal = null;
      _pendingSignal = null;
      _status = 'Starting...';
    });

    try {
      await _controller!.setFlashMode(FlashMode.torch);
    } catch (e) {
      debugPrint('Flash error: $e');
    }

    // Start countdown timer
    _timer = Timer.periodic(const Duration(seconds: 1), (timer) {
      if (mounted && _isScanning) {
        setState(() => _timeLeft--);
        if (_timeLeft <= 0) {
          _stopProcessing();
        }
      }
    });

    // Start UI update timer (throttled to ~10fps instead of 30fps)
    _uiUpdateTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
      if (mounted && _pendingSignal != null) {
        _applyPendingSignal();
      }
    });

    // Create stream controller
    _imageStreamController = StreamController<CameraImage>();

    try {
      await _controller!.startImageStream((image) {
        if (_imageStreamController != null && !_imageStreamController!.isClosed) {
          _imageStreamController!.add(image);
        }
      });
    } catch (e) {
      debugPrint('Image stream error: $e');
      _isTransitioning = false;
      await _stopProcessing();
      return;
    }

    // Subscribe to PPG service (NO setState here - just buffer the signal)
    _ppgSubscription = _ppgService.processImageStream(_imageStreamController!.stream).listen((signal) {
      _pendingSignal = signal;
      // Data buffering happens here (lightweight, no setState)
      _rawHistory.add(signal.rawIntensity);
      _filteredHistory.add(signal.filteredIntensity);
      if (_rawHistory.length > _historyLimit) _rawHistory.removeAt(0);
      if (_filteredHistory.length > _historyLimit) _filteredHistory.removeAt(0);
      _currentPeakIndices = signal.peakIndices;
      for (final rr in signal.rrIntervals) {
        _rrHistory.add(rr);
        if (_rrHistory.length > 20) _rrHistory.removeAt(0);
      }
    });

    _isTransitioning = false;
  }

  void _applyPendingSignal() {
    if (_pendingSignal == null) return;
    final signal = _pendingSignal!;
    _pendingSignal = null;

    setState(() {
      _currentSignal = signal;
      if (signal.quality == SignalQuality.good) {
        _status = 'Signal Good - Detecting...';
      } else if (signal.quality == SignalQuality.fair) {
        _status = 'Signal Fair - Keep steady';
      } else {
        _status = 'Signal Poor - Cover camera';
      }
    });
  }

  Future<void> _stopProcessing() async {
    if (_isTransitioning) return;
    _isTransitioning = true;

    _timer?.cancel();
    _timer = null;
    _uiUpdateTimer?.cancel();
    _uiUpdateTimer = null;

    await _ppgSubscription?.cancel();
    _ppgSubscription = null;

    await _imageStreamController?.close();
    _imageStreamController = null;

    if (_controller != null && _controller!.value.isInitialized) {
      try {
        if (_controller!.value.isStreamingImages) {
          await _controller!.stopImageStream();
        }
        await _controller!.setFlashMode(FlashMode.off);
      } catch (e) {
        debugPrint('Stop camera error: $e');
      }
    }

    if (mounted) {
      setState(() {
        _isScanning = false;
        _status = 'Measurement Complete.';
      });
    }

    _isTransitioning = false;
  }

  @override
  void dispose() {
    _timer?.cancel();
    _uiUpdateTimer?.cancel();
    _ppgSubscription?.cancel();
    _imageStreamController?.close();
    _controller?.dispose();
    _ppgService.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    String hrDisplay = '--';
    if (_rrHistory.isNotEmpty) {
      final rr = _rrHistory.last;
      if (rr > 0) {
        hrDisplay = '${(60000 / rr).round()}';
      }
    }

    return Scaffold(
      appBar: AppBar(title: const Text('Flutter PPG Demo')),
      body: SingleChildScrollView(
        child: Padding(
          padding: const EdgeInsets.all(12.0),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Row(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  _buildCameraPreview(),
                  const SizedBox(width: 16),
                  Expanded(child: _buildStatsPanel(hrDisplay)),
                ],
              ),
              const SizedBox(height: 16),
              _buildWaveformCard(title: 'Raw Signal (Red Channel)', data: _rawHistory, color: Colors.red.shade300, peakIndices: []),
              const SizedBox(height: 12),
              _buildWaveformCard(title: 'Filtered Signal (Bandpass)', data: _filteredHistory, color: Colors.greenAccent, peakIndices: _currentPeakIndices),
              const SizedBox(height: 12),
              _buildRRHistoryCard(),
            ],
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _isTransitioning ? null : _toggleScanning,
        backgroundColor: _isTransitioning ? Colors.grey : (_isScanning ? Colors.red : Colors.green),
        icon: Icon(_isScanning ? Icons.stop : Icons.play_arrow),
        label: Text(_isScanning ? 'STOP' : 'START'),
      ),
      floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
    );
  }

  Widget _buildCameraPreview() {
    if (_controller == null || !_controller!.value.isInitialized) {
      return Container(
        height: 100,
        width: 100,
        decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.grey.shade800),
        child: const Center(child: CircularProgressIndicator()),
      );
    }

    return Container(
      height: 100,
      width: 100,
      decoration: BoxDecoration(
        shape: BoxShape.circle,
        border: Border.all(color: _getQualityColor(), width: 3),
      ),
      clipBehavior: Clip.antiAlias,
      child: CameraPreview(_controller!),
    );
  }

  Widget _buildStatsPanel(String hrDisplay) {
    final snr = _currentSignal?.snr ?? 0.0;
    final quality = _currentSignal?.quality ?? SignalQuality.poor;
    final peakCount = _currentPeakIndices.length;

    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  '$hrDisplay BPM',
                  style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: _getQualityColor()),
                ),
                if (_isScanning)
                  Container(
                    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                    decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(12)),
                    child: Text('$_timeLeft s'),
                  ),
              ],
            ),
            const SizedBox(height: 8),
            Text(_status, style: TextStyle(color: _getQualityColor())),
            const SizedBox(height: 8),
            Text('Quality: ${quality.name.toUpperCase()} | SNR: ${snr.toStringAsFixed(1)} dB', style: const TextStyle(fontSize: 12, color: Colors.grey)),
            Text('Peaks: $peakCount', style: const TextStyle(fontSize: 12, color: Colors.grey)),
          ],
        ),
      ),
    );
  }

  Widget _buildWaveformCard({required String title, required List<double> data, required Color color, required List<int> peakIndices}) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey)),
            const SizedBox(height: 8),
            SizedBox(
              height: 100,
              child: CustomPaint(
                painter: WaveformPainter(signalData: data, color: color, peakIndices: peakIndices),
                child: Container(),
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildRRHistoryCard() {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('RR Intervals (ms)', style: TextStyle(fontSize: 12, color: Colors.grey)),
            const SizedBox(height: 8),
            if (_rrHistory.isEmpty)
              const Text('Waiting for data...', style: TextStyle(color: Colors.white54))
            else
              Wrap(
                spacing: 8,
                runSpacing: 4,
                children: _rrHistory.map((rr) {
                  final isOutlier = rr < 400 || rr > 1500;
                  return Chip(
                    label: Text('${rr.round()}'),
                    backgroundColor: isOutlier ? Colors.orange.shade800 : Colors.green.shade800,
                    labelStyle: const TextStyle(fontSize: 11),
                    visualDensity: VisualDensity.compact,
                  );
                }).toList(),
              ),
          ],
        ),
      ),
    );
  }

  Color _getQualityColor() {
    if (!_isScanning) return Colors.grey;
    if (_currentSignal == null) return Colors.grey;
    switch (_currentSignal!.quality) {
      case SignalQuality.good:
        return Colors.greenAccent;
      case SignalQuality.fair:
        return Colors.orangeAccent;
      case SignalQuality.poor:
        return Colors.redAccent;
    }
  }
}
1
likes
0
points
0
downloads

Publisher

unverified uploader

Weekly Downloads

A Flutter package for camera-based PPG (Photoplethysmography) signal processing. Extracts RR intervals from camera frames for heart rate and HRV analysis.

Repository (GitHub)
View/report issues

Topics

#ppg #heart-rate #hrv #camera #health

License

unknown (license)

Dependencies

camera, flutter

More

Packages that depend on flutter_ppg