dicom_flutter_radiology_kit 0.0.1 copy "dicom_flutter_radiology_kit: ^0.0.1" to clipboard
dicom_flutter_radiology_kit: ^0.0.1 copied to clipboard

Drop-in medical imaging kit for Flutter Web & Desktop featuring WADO-RS streaming, Web Worker WASM decoding, dynamic 16-bit scalar VOI LUT rendering, and clinical viewports.

example/lib/main.dart

import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:dicom_flutter_radiology_kit/dicom_flutter_radiology_kit.dart';
import 'fixtures/synthetic_patterns.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'DICOM Flutter Radiology Kit Test Viewer',
      debugShowCheckedModeBanner: false,
      theme: ThemeData.dark().copyWith(
        scaffoldBackgroundColor: const Color(0xFF0F1218),
        cardColor: const Color(0xFF181D26),
        colorScheme: const ColorScheme.dark(
          primary: Color(0xFF388BFD),
          secondary: Color(0xFF58A6FF),
          surface: Color(0xFF161B22),
        ),
        appBarTheme: const AppBarTheme(
          backgroundColor: Color(0xFF161B22),
          elevation: 0,
          titleTextStyle: TextStyle(
            color: Colors.white,
            fontSize: 16,
            fontWeight: FontWeight.w600,
            letterSpacing: 0.5,
          ),
        ),
      ),
      home: const DicomViewerWorkbench(),
    );
  }
}

/// Clinical viewport grid layout options: 1 on 1, 2 on 1, 4 on 1, 9 on 1.
enum ViewportLayout {
  oneOnOne('1 on 1 (1×1)', 1, 1),
  twoOnOne('2 on 1 (1×2)', 1, 2),
  fourOnOne('4 on 1 (2×2)', 2, 2),
  nineOnOne('9 on 1 (3×3)', 3, 3);

  final String label;
  final int rows;
  final int cols;
  const ViewportLayout(this.label, this.rows, this.cols);

  int get count => rows * cols;
}

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

  @override
  State<DicomViewerWorkbench> createState() => _DicomViewerWorkbenchState();
}

class _DicomViewerWorkbenchState extends State<DicomViewerWorkbench> {
  // Pool of 9 controllers for up to 3x3 layout
  late final List<ViewportController> _controllers;
  ViewportLayout _layout = ViewportLayout.oneOnOne;

  bool _showOverlay = true;
  String _selectedFixture = 'CT Phantom';
  bool _sidebarExpanded = true;

  DicomSeriesBuffer? _loadedSeries;
  int _currentFrameIndex = 0;
  bool _isLoadingFrame = false;
  late String _activeServerUrl;

  // In-memory persistent presentation states per frame index
  final Map<int, DicomPresentationState> _framePresentationCache = {};

  ViewportController get _primaryController => _controllers[0];

  @override
  void initState() {
    super.initState();
    _activeServerUrl = DicomServerUrlStore.getLastUsedUrl();

    _controllers = List.generate(9, (index) {
      final controller = ViewportController();
      controller.onSliceStep = (direction) {
        if (_loadedSeries != null && _loadedSeries!.frameCount > 1) {
          final nextIdx = (_currentFrameIndex + direction)
              .clamp(0, _loadedSeries!.frameCount - 1);
          if (nextIdx != _currentFrameIndex) {
            _goToFrame(nextIdx);
          }
        }
      };

      // Live propagation hook: save presentation state per frame
      controller.onPresentationChanged = (state) {
        final frameNum = controller.frameIndex;
        if (frameNum != null && frameNum > 0) {
          _framePresentationCache[frameNum - 1] = state;
        }
      };

      return controller;
    });

    _loadCtPhantom();
  }

  @override
  void dispose() {
    _loadedSeries?.dispose();
    for (final c in _controllers) {
      c.dispose();
    }
    super.dispose();
  }

  void _loadCtPhantom() {
    _loadedSeries?.dispose();
    _loadedSeries = null;
    _framePresentationCache.clear();
    final frame = SyntheticPatterns.generateCtPhantom();

    for (int i = 0; i < _controllers.length; i++) {
      if (i == 0) {
        _controllers[i].resetZoomPan(notify: false);
        _controllers[i].setFrame(frame);
        _controllers[i].applyPreset(WindowPresets.softTissue);
        _controllers[i].updateMetadata(
          patientName: 'DOE^JOHN',
          patientId: 'SYN-CT-90210',
          studyDescription: 'CT THORAX W/ CONTRAST',
          seriesDescription: 'AXIAL 5.0mm SOFT TISSUE',
          frameIndex: 1,
          totalFrames: 1,
        );
      } else {
        _controllers[i].clear();
      }
    }
    setState(() {
      _currentFrameIndex = 0;
      _selectedFixture = 'CT Phantom';
    });
  }

  void _loadTg18Qc() {
    _loadedSeries?.dispose();
    _loadedSeries = null;
    _framePresentationCache.clear();
    final frame = SyntheticPatterns.generateTg18QcPattern();

    for (int i = 0; i < _controllers.length; i++) {
      if (i == 0) {
        _controllers[i].resetZoomPan(notify: false);
        _controllers[i].setFrame(frame);
        _controllers[i].setWindowLevel(2048, 4096);
        _controllers[i].updateMetadata(
          patientName: 'QUALITY^CONTROL',
          patientId: 'QC-TG18-001',
          studyDescription: 'TG18-QC DISPLAY CALIBRATION',
          seriesDescription: 'SMPTE DYNAMIC RANGE TEST',
          frameIndex: 1,
          totalFrames: 1,
        );
      } else {
        _controllers[i].clear();
      }
    }
    setState(() {
      _currentFrameIndex = 0;
      _selectedFixture = 'TG18-QC Test Pattern';
    });
  }

  void _loadDynamicRamp() {
    _loadedSeries?.dispose();
    _loadedSeries = null;
    _framePresentationCache.clear();
    final frame = SyntheticPatterns.generateDynamicRamp();

    for (int i = 0; i < _controllers.length; i++) {
      if (i == 0) {
        _controllers[i].resetZoomPan(notify: false);
        _controllers[i].setFrame(frame);
        _controllers[i].setWindowLevel(250, 2500);
        _controllers[i].updateMetadata(
          patientName: 'CALIBRATION^RAMP',
          patientId: 'RAMP-16BIT-002',
          studyDescription: 'CONTINUOUS 16-BIT HU GRADIENT',
          seriesDescription: '-1000 HU TO +2500 HU RAMP',
          frameIndex: 1,
          totalFrames: 1,
        );
      } else {
        _controllers[i].clear();
      }
    }
    setState(() {
      _currentFrameIndex = 0;
      _selectedFixture = 'Dynamic Ramp';
    });
  }

  Future<void> _openQidoBrowser() async {
    await QidoBrowserDialog.show(
      context,
      onStudySelected: (study) {
        _loadedSeries?.dispose();
        _loadedSeries = null;
        _framePresentationCache.clear();
        for (final c in _controllers) {
          c.clear();
        }
        setState(() {
          _currentFrameIndex = 0;
          _selectedFixture = 'Loading: ${study.patientName}';
        });
      },
      onSeriesLoaded: (seriesBuffer, initialFrame) {
        _loadedSeries?.dispose();
        _framePresentationCache.clear();
        setState(() {
          _loadedSeries = seriesBuffer;
          _currentFrameIndex = 0;
          _selectedFixture = 'QIDO: ${seriesBuffer.series.seriesDescription}';
        });

        _syncGridLayoutFrames();

        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              'Buffered ${seriesBuffer.frameCount} frame(s) into 16-bit memory.',
            ),
            backgroundColor: const Color(0xFF238636),
            duration: const Duration(seconds: 3),
          ),
        );
      },
    );
    if (mounted) {
      setState(() {
        _activeServerUrl = DicomServerUrlStore.getLastUsedUrl();
      });
    }
  }

  Map<String, dynamic> _getActiveSliceMetadata() {
    if (_loadedSeries != null && _loadedSeries!.frames.isNotEmpty) {
      final idx = _currentFrameIndex.clamp(0, _loadedSeries!.frames.length - 1);
      return _loadedSeries!.frames[idx].metadata.rawJson;
    }
    // Synthetic fallback metadata for test pattern fixtures
    return {
      '00080016': {
        'vr': 'UI',
        'Value': ['1.2.840.10008.5.1.4.1.1.7']
      },
      '00080018': {
        'vr': 'UI',
        'Value': ['1.2.826.0.1.3680043.9.7133.1.1']
      },
      '00080020': {
        'vr': 'DA',
        'Value': ['20260908']
      },
      '00080030': {
        'vr': 'TM',
        'Value': ['120000']
      },
      '00080060': {
        'vr': 'CS',
        'Value': ['OT']
      },
      '00080070': {
        'vr': 'LO',
        'Value': ['RadiologyKit']
      },
      '00080080': {
        'vr': 'LO',
        'Value': ['Radiology Department']
      },
      '00081030': {
        'vr': 'LO',
        'Value': [_selectedFixture]
      },
      '0008103E': {
        'vr': 'LO',
        'Value': ['Synthetic Calibration Test Pattern']
      },
      '00100010': {
        'vr': 'PN',
        'Value': [
          {
            'Alphabetic': _selectedFixture.contains('TG18')
                ? 'QUALITY^CONTROL'
                : 'CALIBRATION^RAMP'
          }
        ]
      },
      '00100020': {
        'vr': 'LO',
        'Value': [
          _selectedFixture.contains('TG18') ? 'QC-TG18-001' : 'RAMP-16BIT-002'
        ]
      },
      '00100040': {
        'vr': 'CS',
        'Value': ['O']
      },
      '0020000D': {
        'vr': 'UI',
        'Value': ['1.2.826.0.1.3680043.9.7133.1']
      },
      '0020000E': {
        'vr': 'UI',
        'Value': ['1.2.826.0.1.3680043.9.7133.1.1']
      },
      '00200013': {
        'vr': 'IS',
        'Value': ['1']
      },
      '00280002': {
        'vr': 'US',
        'Value': [1]
      },
      '00280004': {
        'vr': 'CS',
        'Value': ['MONOCHROME2']
      },
      '00280010': {
        'vr': 'US',
        'Value': [512]
      },
      '00280011': {
        'vr': 'US',
        'Value': [512]
      },
      '00280100': {
        'vr': 'US',
        'Value': [16]
      },
      '00280101': {
        'vr': 'US',
        'Value': [12]
      },
      '00280102': {
        'vr': 'US',
        'Value': [11]
      },
      '00280103': {
        'vr': 'US',
        'Value': [0]
      },
      '00281050': {
        'vr': 'DS',
        'Value': [_primaryController.windowCenter.toStringAsFixed(0)]
      },
      '00281051': {
        'vr': 'DS',
        'Value': [_primaryController.windowWidth.toStringAsFixed(0)]
      },
    };
  }

  void _openDicomDumpModal() {
    final metadata = _getActiveSliceMetadata();
    final sliceNum = _loadedSeries != null ? _currentFrameIndex + 1 : 1;
    final total = _loadedSeries != null ? _loadedSeries!.frameCount : 1;
    DicomDumpDialog.show(
      context,
      metadataJson: metadata,
      title: 'DICOM Dump — Slice $sliceNum of $total ($selectedFixtureName)',
    );
  }

  String get selectedFixtureName {
    if (_loadedSeries != null) {
      return _loadedSeries!.series.seriesDescription.isNotEmpty
          ? _loadedSeries!.series.seriesDescription
          : 'Series';
    }
    return _selectedFixture;
  }

  Future<void> _goToFrame(int index) async {
    if (_loadedSeries == null || _isLoadingFrame) return;
    if (index < 0 || index >= _loadedSeries!.frameCount) return;

    setState(() {
      _isLoadingFrame = true;
      _currentFrameIndex = index;
    });

    try {
      await _syncGridLayoutFrames();
    } finally {
      if (mounted) {
        setState(() => _isLoadingFrame = false);
      }
    }
  }

  /// Synchronizes frame decoding and presentation states across all active grid slots.
  Future<void> _syncGridLayoutFrames() async {
    if (_loadedSeries == null) return;

    final totalCount = _loadedSeries!.frameCount;
    final activeSlots = _layout.count;

    for (int slot = 0; slot < 9; slot++) {
      final controller = _controllers[slot];
      if (slot < activeSlots) {
        final frameIdx = _currentFrameIndex + slot;
        if (frameIdx < totalCount) {
          final pixelFrame = await _loadedSeries!.getPixelFrame(frameIdx);
          if (pixelFrame != null && mounted) {
            // Apply or restore per-frame presentation state
            final cachedState = _framePresentationCache[frameIdx];
            if (cachedState != null) {
              controller.applyPresentationState(cachedState, notify: false);
              controller.setFrame(pixelFrame,
                  updateWindowLevelFromFrame: false);
            } else {
              controller.resetZoomPan(notify: false);
              final is8Bit = pixelFrame.bitsAllocated <= 8 ||
                  pixelFrame.rawPixels is Uint8List;
              if (is8Bit) {
                controller.setWindowLevel(128.0, 256.0);
                controller.setFrame(pixelFrame,
                    updateWindowLevelFromFrame: false);
              } else {
                final metaCenter = _loadedSeries!.frames.isNotEmpty
                    ? _loadedSeries!
                        .frames[frameIdx < _loadedSeries!.frames.length
                            ? frameIdx
                            : 0]
                        .metadata
                        .windowCenter
                    : null;
                final metaWidth = _loadedSeries!.frames.isNotEmpty
                    ? _loadedSeries!
                        .frames[frameIdx < _loadedSeries!.frames.length
                            ? frameIdx
                            : 0]
                        .metadata
                        .windowWidth
                    : null;
                if (metaCenter != null &&
                    metaWidth != null &&
                    metaWidth > 1.0) {
                  controller.setWindowLevel(metaCenter, metaWidth);
                  controller.setFrame(pixelFrame,
                      updateWindowLevelFromFrame: false);
                } else {
                  controller.setFrame(pixelFrame,
                      updateWindowLevelFromFrame: true);
                }
              }
            }

            controller.updateMetadata(
              patientName: _loadedSeries!.study?.patientName ?? 'Anonymous',
              patientId: _loadedSeries!.study?.patientId ?? '-',
              studyDescription:
                  _loadedSeries!.study?.studyDescription ?? 'DICOM Study',
              seriesDescription: _loadedSeries!.series.seriesDescription,
              frameIndex: frameIdx + 1,
              totalFrames: totalCount,
            );
          }
        } else {
          controller.clear();
        }
      } else {
        controller.clear();
      }
    }
  }

  void _setLayout(ViewportLayout layout) {
    setState(() {
      _layout = layout;
    });
    _syncGridLayoutFrames();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Row(
          children: [
            Icon(Icons.medical_services_outlined,
                color: Color(0xFF388BFD), size: 20),
            SizedBox(width: 10),
            Text('DICOM Flutter Radiology Kit'),
            SizedBox(width: 8),
            Chip(
              label: Text('16-bit VOI LUT',
                  style: TextStyle(fontSize: 10, color: Colors.white)),
              backgroundColor: Color(0xFF238636),
              padding: EdgeInsets.zero,
              materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
            ),
          ],
        ),
        actions: [
          ElevatedButton.icon(
            onPressed: _openQidoBrowser,
            icon: const Icon(Icons.manage_search_rounded, size: 16),
            label: const Text('QIDO Patient Browser',
                style: TextStyle(fontSize: 12)),
            style: ElevatedButton.styleFrom(
              backgroundColor: const Color(0xFF1F6FEB),
              foregroundColor: Colors.white,
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
            ),
          ),
          const SizedBox(width: 8),

          // Layout Grid Selector Menu
          PopupMenuButton<ViewportLayout>(
            icon: const Icon(Icons.grid_view_rounded, color: Color(0xFF58A6FF)),
            tooltip: 'Layout Grid ([1, 2, 4, 9] on 1)',
            onSelected: _setLayout,
            itemBuilder: (context) => [
              _buildLayoutMenuItem(
                  ViewportLayout.oneOnOne, Icons.crop_square_rounded),
              _buildLayoutMenuItem(
                  ViewportLayout.twoOnOne, Icons.view_agenda_rounded),
              _buildLayoutMenuItem(
                  ViewportLayout.fourOnOne, Icons.grid_view_rounded),
              _buildLayoutMenuItem(
                  ViewportLayout.nineOnOne, Icons.apps_rounded),
            ],
          ),

          IconButton(
            icon: Icon(_showOverlay ? Icons.layers : Icons.layers_clear),
            tooltip: 'Toggle HUD Overlays',
            onPressed: () => setState(() => _showOverlay = !_showOverlay),
          ),
          IconButton(
            icon: const Icon(Icons.zoom_in),
            tooltip: 'Zoom In Primary',
            onPressed: () =>
                _primaryController.setZoom(_primaryController.zoom + 0.25),
          ),
          IconButton(
            icon: const Icon(Icons.zoom_out),
            tooltip: 'Zoom Out Primary',
            onPressed: () =>
                _primaryController.setZoom(_primaryController.zoom - 0.25),
          ),
          IconButton(
            icon: const Icon(Icons.restart_alt),
            tooltip: 'Reset View & Presets',
            onPressed: () {
              for (final c in _controllers) {
                c.resetView();
              }
            },
          ),
          const SizedBox(width: 8),
          ElevatedButton.icon(
            onPressed: _openDicomDumpModal,
            icon: const Icon(Icons.data_object_rounded, size: 16),
            label: const Text('DICOM Dump', style: TextStyle(fontSize: 12)),
            style: ElevatedButton.styleFrom(
              backgroundColor: const Color(0xFF21262D),
              foregroundColor: const Color(0xFF58A6FF),
              side: const BorderSide(color: Color(0xFF30363D)),
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
            ),
          ),
          const SizedBox(width: 8),
          IconButton(
            icon: Icon(_sidebarExpanded
                ? Icons.view_sidebar
                : Icons.view_sidebar_outlined),
            tooltip: 'Toggle Control Panel',
            onPressed: () =>
                setState(() => _sidebarExpanded = !_sidebarExpanded),
          ),
          const SizedBox(width: 12),
        ],
      ),
      body: Row(
        children: [
          // Main Viewport Area: 1, 2, 4, or 9 on 1 Grid
          Expanded(
            child: _buildViewportGrid(),
          ),

          // Control & Inspector Sidebar
          if (_sidebarExpanded)
            Container(
              width: 320,
              decoration: const BoxDecoration(
                color: Color(0xFF161B22),
                border: Border(left: BorderSide(color: Color(0xFF30363D))),
              ),
              child: _buildSidebar(),
            ),
        ],
      ),
    );
  }

  PopupMenuItem<ViewportLayout> _buildLayoutMenuItem(
      ViewportLayout layout, IconData icon) {
    final isSelected = _layout == layout;
    return PopupMenuItem<ViewportLayout>(
      value: layout,
      child: Row(
        children: [
          Icon(icon,
              size: 18,
              color: isSelected
                  ? const Color(0xFF58A6FF)
                  : const Color(0xFF8B949E)),
          const SizedBox(width: 10),
          Text(
            layout.label,
            style: TextStyle(
              fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
              color: isSelected ? const Color(0xFF58A6FF) : Colors.white,
            ),
          ),
          if (isSelected) ...[
            const Spacer(),
            const Icon(Icons.check, size: 16, color: Color(0xFF58A6FF)),
          ],
        ],
      ),
    );
  }

  Widget _buildViewportGrid() {
    switch (_layout) {
      case ViewportLayout.oneOnOne:
        return DicomViewport(
          controller: _controllers[0],
          showOverlay: _showOverlay,
        );

      case ViewportLayout.twoOnOne:
        return Row(
          children: [
            Expanded(
              child: DicomViewport(
                controller: _controllers[0],
                showOverlay: _showOverlay,
              ),
            ),
            const VerticalDivider(
                width: 2, thickness: 2, color: Color(0xFF30363D)),
            Expanded(
              child: DicomViewport(
                controller: _controllers[1],
                showOverlay: _showOverlay,
              ),
            ),
          ],
        );

      case ViewportLayout.fourOnOne:
        return Column(
          children: [
            Expanded(
              child: Row(
                children: [
                  Expanded(
                    child: DicomViewport(
                      controller: _controllers[0],
                      showOverlay: _showOverlay,
                    ),
                  ),
                  const VerticalDivider(
                      width: 2, thickness: 2, color: Color(0xFF30363D)),
                  Expanded(
                    child: DicomViewport(
                      controller: _controllers[1],
                      showOverlay: _showOverlay,
                    ),
                  ),
                ],
              ),
            ),
            const Divider(height: 2, thickness: 2, color: Color(0xFF30363D)),
            Expanded(
              child: Row(
                children: [
                  Expanded(
                    child: DicomViewport(
                      controller: _controllers[2],
                      showOverlay: _showOverlay,
                    ),
                  ),
                  const VerticalDivider(
                      width: 2, thickness: 2, color: Color(0xFF30363D)),
                  Expanded(
                    child: DicomViewport(
                      controller: _controllers[3],
                      showOverlay: _showOverlay,
                    ),
                  ),
                ],
              ),
            ),
          ],
        );

      case ViewportLayout.nineOnOne:
        return Column(
          children: List.generate(3, (row) {
            return Expanded(
              child: Column(
                children: [
                  if (row > 0)
                    const Divider(
                        height: 2, thickness: 2, color: Color(0xFF30363D)),
                  Expanded(
                    child: Row(
                      children: List.generate(3, (col) {
                        final slot = row * 3 + col;
                        return Expanded(
                          child: Row(
                            children: [
                              if (col > 0)
                                const VerticalDivider(
                                    width: 2,
                                    thickness: 2,
                                    color: Color(0xFF30363D)),
                              Expanded(
                                child: DicomViewport(
                                  controller: _controllers[slot],
                                  showOverlay: _showOverlay,
                                ),
                              ),
                            ],
                          ),
                        );
                      }),
                    ),
                  ),
                ],
              ),
            );
          }),
        );
    }
  }

  Widget _buildSidebar() {
    return ListenableBuilder(
      listenable: _primaryController,
      builder: (context, _) {
        final frame = _primaryController.currentFrame;
        return ListView(
          padding: const EdgeInsets.all(16),
          children: [
            // Fixture Selector
            _buildSectionHeader('STUDIES & FIXTURES'),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _buildFixtureChip('CT Phantom',
                    _selectedFixture == 'CT Phantom', _loadCtPhantom),
                _buildFixtureChip('TG18-QC',
                    _selectedFixture == 'TG18-QC Test Pattern', _loadTg18Qc),
                _buildFixtureChip('Dynamic Ramp',
                    _selectedFixture == 'Dynamic Ramp', _loadDynamicRamp),
              ],
            ),
            const SizedBox(height: 12),
            OutlinedButton.icon(
              onPressed: _openQidoBrowser,
              icon: const Icon(Icons.cloud_sync_outlined, size: 16),
              label: const Text('QIDO Studies & Series Browser',
                  style: TextStyle(fontSize: 12)),
              style: OutlinedButton.styleFrom(
                foregroundColor: const Color(0xFF58A6FF),
                side: const BorderSide(color: Color(0xFF388BFD)),
              ),
            ),
            Padding(
              padding: const EdgeInsets.only(top: 6, bottom: 4),
              child: Row(
                children: [
                  const Icon(Icons.dns_outlined,
                      size: 12, color: Color(0xFF58A6FF)),
                  const SizedBox(width: 6),
                  Expanded(
                    child: Text(
                      'Root: $_activeServerUrl',
                      style: const TextStyle(
                          fontSize: 10,
                          color: Color(0xFF8B949E),
                          fontFamily: 'monospace'),
                      overflow: TextOverflow.ellipsis,
                    ),
                  ),
                ],
              ),
            ),

            if (_loadedSeries != null && _loadedSeries!.frameCount > 1) ...[
              const Divider(height: 32, color: Color(0xFF30363D)),
              _buildSectionHeader('SERIES SLICE NAVIGATION'),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text(
                    'Base Frame ${_currentFrameIndex + 1} / ${_loadedSeries!.frameCount}',
                    style: const TextStyle(
                        fontSize: 12,
                        fontWeight: FontWeight.bold,
                        color: Color(0xFF58A6FF)),
                  ),
                  if (_isLoadingFrame)
                    const SizedBox(
                      width: 14,
                      height: 14,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    ),
                ],
              ),
              Slider(
                value: _currentFrameIndex.toDouble(),
                min: 0.0,
                max: (_loadedSeries!.frameCount - 1).toDouble(),
                divisions: _loadedSeries!.frameCount > 1
                    ? _loadedSeries!.frameCount - 1
                    : 1,
                onChanged: (val) => _goToFrame(val.round()),
              ),
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  IconButton(
                    icon: const Icon(Icons.skip_previous, size: 20),
                    onPressed:
                        _currentFrameIndex > 0 ? () => _goToFrame(0) : null,
                    tooltip: 'First Slice',
                  ),
                  IconButton(
                    icon: const Icon(Icons.chevron_left, size: 20),
                    onPressed: _currentFrameIndex > 0
                        ? () => _goToFrame(_currentFrameIndex - 1)
                        : null,
                    tooltip: 'Previous Slice',
                  ),
                  IconButton(
                    icon: const Icon(Icons.chevron_right, size: 20),
                    onPressed:
                        _currentFrameIndex < _loadedSeries!.frameCount - 1
                            ? () => _goToFrame(_currentFrameIndex + 1)
                            : null,
                    tooltip: 'Next Slice',
                  ),
                  IconButton(
                    icon: const Icon(Icons.skip_next, size: 20),
                    onPressed:
                        _currentFrameIndex < _loadedSeries!.frameCount - 1
                            ? () => _goToFrame(_loadedSeries!.frameCount - 1)
                            : null,
                    tooltip: 'Last Slice',
                  ),
                ],
              ),
            ],

            const Divider(height: 32, color: Color(0xFF30363D)),

            // Viewport Grid Layout Selector
            _buildSectionHeader('VIEWPORT LAYOUT'),
            Wrap(
              spacing: 8,
              children: ViewportLayout.values.map((layout) {
                final isSelected = _layout == layout;
                return ChoiceChip(
                  label:
                      Text(layout.label, style: const TextStyle(fontSize: 11)),
                  selected: isSelected,
                  onSelected: (selected) {
                    if (selected) _setLayout(layout);
                  },
                  selectedColor: const Color(0xFF1F6FEB),
                  backgroundColor: const Color(0xFF21262D),
                  labelStyle: TextStyle(
                    color: isSelected ? Colors.white : const Color(0xFFC9D1D9),
                  ),
                );
              }).toList(),
            ),

            const Divider(height: 32, color: Color(0xFF30363D)),

            // Clinical Window Presets
            _buildSectionHeader('PRIMARY CLINICAL PRESETS'),
            Wrap(
              spacing: 6,
              runSpacing: 6,
              children: WindowPresets.all.map((preset) {
                final isSelected = _primaryController.activePreset == preset;
                return ChoiceChip(
                  label:
                      Text(preset.name, style: const TextStyle(fontSize: 11)),
                  selected: isSelected,
                  onSelected: (selected) {
                    if (selected) _primaryController.applyPreset(preset);
                  },
                  selectedColor: const Color(0xFF1F6FEB),
                  backgroundColor: const Color(0xFF21262D),
                  labelStyle: TextStyle(
                    color: isSelected ? Colors.white : const Color(0xFFC9D1D9),
                  ),
                );
              }).toList(),
            ),

            const Divider(height: 32, color: Color(0xFF30363D)),

            // Dynamic Window Level Controls
            _buildSectionHeader('PRIMARY WINDOW & LEVEL (HU)'),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text('Center (C):',
                    style: TextStyle(fontSize: 12, color: Colors.grey)),
                Text('${_primaryController.windowCenter.toStringAsFixed(1)} HU',
                    style: const TextStyle(
                        fontSize: 12, fontWeight: FontWeight.bold)),
              ],
            ),
            Slider(
              value: _primaryController.windowCenter.clamp(-1000.0, 3000.0),
              min: -1000.0,
              max: 3000.0,
              onChanged: (val) {
                _primaryController.setWindowLevel(
                    val, _primaryController.windowWidth);
              },
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                const Text('Width (W):',
                    style: TextStyle(fontSize: 12, color: Colors.grey)),
                Text('${_primaryController.windowWidth.toStringAsFixed(1)} HU',
                    style: const TextStyle(
                        fontSize: 12, fontWeight: FontWeight.bold)),
              ],
            ),
            Slider(
              value: _primaryController.windowWidth.clamp(1.0, 4000.0),
              min: 1.0,
              max: 4000.0,
              onChanged: (val) {
                _primaryController.setWindowLevel(
                    _primaryController.windowCenter, val);
              },
            ),

            const Divider(height: 32, color: Color(0xFF30363D)),

            // Zoom & Transform Inspector
            _buildSectionHeader('PRIMARY VIEWPORT TRANSFORM'),
            _buildInfoRow('Zoom Scale',
                '${(_primaryController.zoom * 100).toStringAsFixed(0)}%'),
            _buildInfoRow('Pan Offset',
                '(${_primaryController.panOffset.dx.toStringAsFixed(1)}, ${_primaryController.panOffset.dy.toStringAsFixed(1)})'),

            const Divider(height: 32, color: Color(0xFF30363D)),

            // DICOM Frame Metadata
            _buildSectionHeader('PRIMARY FRAME METADATA'),
            if (frame != null) ...[
              _buildInfoRow('Dimensions', '${frame.width} × ${frame.height}'),
              _buildInfoRow('Photometric', frame.photometricInterpretation),
              _buildInfoRow('Bits Allocated', '${frame.bitsAllocated}'),
              _buildInfoRow('Bits Stored', '${frame.bitsStored}'),
              _buildInfoRow('Pixel Representation',
                  frame.isSigned ? 'Signed (Int16)' : 'Unsigned (Uint16)'),
              _buildInfoRow('Rescale Slope', '${frame.rescaleSlope}'),
              _buildInfoRow('Rescale Intercept', '${frame.rescaleIntercept}'),
            ] else
              const Text('No active frame loaded',
                  style: TextStyle(fontSize: 12, color: Colors.grey)),
          ],
        );
      },
    );
  }

  Widget _buildSectionHeader(String title) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 8),
      child: Text(
        title,
        style: const TextStyle(
          fontSize: 11,
          fontWeight: FontWeight.bold,
          letterSpacing: 1.0,
          color: Color(0xFF8B949E),
        ),
      ),
    );
  }

  Widget _buildFixtureChip(
      String title, bool isSelected, VoidCallback onSelected) {
    return FilterChip(
      label: Text(title, style: const TextStyle(fontSize: 11)),
      selected: isSelected,
      onSelected: (_) => onSelected(),
      selectedColor: const Color(0xFF238636),
      backgroundColor: const Color(0xFF21262D),
      labelStyle:
          TextStyle(color: isSelected ? Colors.white : const Color(0xFFC9D1D9)),
    );
  }

  Widget _buildInfoRow(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 3),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        children: [
          Text(label,
              style: const TextStyle(fontSize: 11, color: Color(0xFF8B949E))),
          Text(value,
              style: const TextStyle(
                  fontSize: 11, fontFamily: 'monospace', color: Colors.white)),
        ],
      ),
    );
  }
}
1
likes
130
points
328
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Drop-in medical imaging kit for Flutter Web & Desktop featuring WADO-RS streaming, Web Worker WASM decoding, dynamic 16-bit scalar VOI LUT rendering, and clinical viewports.

Repository (GitHub)
View/report issues

Topics

#dicom #medical-imaging #radiology #wasm #viewer

License

unknown (license)

Dependencies

flutter, http, typed_data, web

More

Packages that depend on dicom_flutter_radiology_kit