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

Platformweb

Drop-in medical imaging kit for Flutter Web 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>
    with WidgetsBindingObserver {
  // 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 study/series key and frame index
  final Map<String, Map<int, DicomPresentationState>> _seriesPresentationCache = {};

  Map<int, DicomPresentationState> get _framePresentationCache =>
      _seriesPresentationCache.putIfAbsent(_computeSeriesKey(), () => {});
  final Map<int, String> _framePrSopUidMap = {};

  // Annotation controllers pool for up to 9 slots
  late final List<AnnotationController> _annotationControllers;
  AnnotationTool _activeTool = AnnotationTool.none;
  String? _collaboratorFilter;

  // Cache of annotations per study/series key and frame index:
  // Map<seriesKey, Map<frameIndex, List<DicomAnnotation>>>
  final Map<String, Map<int, List<DicomAnnotation>>> _seriesAnnotationCache = {};
  String? _activeSeriesKey;
  final List<int?> _slotActiveFrameIndex = List.filled(9, null);

  // Dirty tracking for STOW-RS persistence
  final Set<int> _dirtyFrameIndices = {};
  bool _isSavingAnnotations = false;
  bool _isSyncingFrames = false;
  String? _lastSaveStatus;

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive ||
        state == AppLifecycleState.hidden ||
        state == AppLifecycleState.detached) {
      _persistDirtyFrames();
    }
  }

  Future<void> _persistDirtyFrames() async {
    if (_loadedSeries == null || _dirtyFrameIndices.isEmpty || _isSavingAnnotations) {
      return;
    }
    _saveCurrentSlotAnnotations();
    final framesToSave = Set<int>.from(_dirtyFrameIndices);
    if (framesToSave.isEmpty) return;

    final studyUid = _loadedSeries!.study?.studyInstanceUID ??
        _loadedSeries!.series.studyInstanceUID;
    final seriesUid = _loadedSeries!.series.seriesInstanceUID;
    if (studyUid.isEmpty) return;

    final client = DicomWebClient(baseUrl: _activeServerUrl);
    final cache = _seriesAnnotationCache[_computeSeriesKey()] ?? {};

    setState(() {
      _isSavingAnnotations = true;
    });

    try {
      final frameStatesList = <GspsFrameState>[];

      for (final frameIdx in framesToSave) {
        final annotations = cache[frameIdx] ?? const <DicomAnnotation>[];
        final frameBuffer = frameIdx < _loadedSeries!.frames.length
            ? _loadedSeries!.frames[frameIdx]
            : null;
        final sopInstanceUid = frameBuffer?.sopInstanceUID ?? 'sop_frame_$frameIdx';

        final activeSlotIdx = _slotActiveFrameIndex.indexOf(frameIdx);
        final presState = _framePresentationCache[frameIdx] ??
            (activeSlotIdx != -1
                ? _controllers[activeSlotIdx].toPresentationState()
                : null);

        frameStatesList.add(GspsFrameState(
          referencedSopInstanceUid: sopInstanceUid,
          referencedFrameNumber: frameIdx + 1,
          windowCenter: presState?.windowCenter,
          windowWidth: presState?.windowWidth,
          windowCenterWidthExplanation: presState?.presetName,
          zoom: presState?.zoom,
          panOffset: presState?.panOffset,
          annotations: annotations,
        ));
      }

      if (frameStatesList.isEmpty) return;

      final firstSopUid = frameStatesList.first.referencedSopInstanceUid;
      final prSopUid = GspsDicomEncoder.generateDicomUid();

      final gsps = GspsPresentationState(
        sopInstanceUid: prSopUid,
        contentLabel: 'SERIES_GSPS_PERSIST',
        contentDescription: 'Clinical review marks for series',
        contentCreatorName: _annotationControllers[0].activeCreator ?? 'Dr. Radiologist',
        studyInstanceUid: studyUid,
        seriesInstanceUid: seriesUid,
        referencedSeriesUid: seriesUid,
        referencedSopInstanceUid: firstSopUid,
        frameStates: frameStatesList,
        annotations: frameStatesList.expand((fs) => fs.annotations).toList(),
      );

      final part10Bytes = gsps.toDicomPart10Bytes(
        presentationSopUid: prSopUid,
        studyInstanceUid: studyUid,
        seriesInstanceUid: seriesUid,
        sopInstanceUid: firstSopUid,
        patientName: _loadedSeries!.study?.patientName,
        patientId: _loadedSeries!.study?.patientId,
        accessionNumber: _loadedSeries!.study?.accessionNumber,
      );

      await client.storePresentationState(
        studyInstanceUID: studyUid,
        dicomPart10Bytes: part10Bytes,
        sopInstanceUID: prSopUid,
      );

      for (final f in framesToSave) {
        _dirtyFrameIndices.remove(f);
      }
      for (final ac in _annotationControllers) {
        ac.markClean();
      }

      if (mounted) {
        setState(() {
          _lastSaveStatus = 'Saved single GSPS file for series (${frameStatesList.length} frame(s)) to STOW-RS';
        });
      }
    } catch (e) {
      print('[WORKBENCH] Failed to persist GSPS: $e');
      if (mounted) {
        setState(() {
          _lastSaveStatus = 'Save failed: $e';
        });
      }
    } finally {
      client.dispose();
      if (mounted) {
        setState(() {
          _isSavingAnnotations = false;
        });
      }
    }
  }

  Future<void> _fetchServerPresentationStates(String studyUid) async {
    final client = DicomWebClient(baseUrl: _activeServerUrl);
    try {
      final states = await client.fetchPresentationStates(studyInstanceUID: studyUid);
      if (states.isEmpty) return;

      final seriesKey = _computeSeriesKey();
      final cache = _seriesAnnotationCache.putIfAbsent(seriesKey, () => {});

      int restoredCount = 0;
      for (final gsps in states) {
        if (gsps.frameStates.isNotEmpty) {
          for (final fs in gsps.frameStates) {
            int targetFrame = fs.referencedFrameNumber > 0 ? fs.referencedFrameNumber - 1 : 0;
            if (fs.referencedSopInstanceUid.isNotEmpty && _loadedSeries != null) {
              final idx = _loadedSeries!.frames.indexWhere(
                (f) => f.sopInstanceUID == fs.referencedSopInstanceUid,
              );
              if (idx != -1) targetFrame = idx;
            }
            if (gsps.sopInstanceUid != null && gsps.sopInstanceUid!.isNotEmpty) {
              _framePrSopUidMap[targetFrame] = gsps.sopInstanceUid!;
            }

            cache[targetFrame] = List<DicomAnnotation>.from(fs.annotations);
            final presState = fs.toDicomPresentationState();
            _framePresentationCache[targetFrame] = presState;
            restoredCount += fs.annotations.length;

            final slotIdx = _slotActiveFrameIndex.indexOf(targetFrame);
            if (slotIdx != -1) {
              _controllers[slotIdx].applyPresentationState(presState);
            }
          }
        } else {
          int targetFrame = 0;
          if (gsps.referencedFrameNumber != null && gsps.referencedFrameNumber! > 0) {
            targetFrame = gsps.referencedFrameNumber! - 1;
          } else if (gsps.referencedSopInstanceUid != null && _loadedSeries != null) {
            final idx = _loadedSeries!.frames.indexWhere(
              (f) => f.sopInstanceUID == gsps.referencedSopInstanceUid,
            );
            if (idx != -1) targetFrame = idx;
          }

          if (gsps.sopInstanceUid != null && gsps.sopInstanceUid!.isNotEmpty) {
            _framePrSopUidMap[targetFrame] = gsps.sopInstanceUid!;
          }

          cache[targetFrame] = List<DicomAnnotation>.from(gsps.annotations);
          final presState = gsps.toDicomPresentationState();
          _framePresentationCache[targetFrame] = presState;
          restoredCount += gsps.annotations.length;

          final slotIdx = _slotActiveFrameIndex.indexOf(targetFrame);
          if (slotIdx != -1) {
            _controllers[slotIdx].applyPresentationState(presState);
          }
        }
      }

      if (mounted) {
        _restoreSlotAnnotations(seriesKey);
        setState(() {
          _lastSaveStatus = 'WADO-RS: Loaded $restoredCount annotation(s)';
        });
      }
    } catch (e) {
      print('[WORKBENCH] Error fetching presentation states: $e');
    } finally {
      client.dispose();
    }
  }

  String _computeSeriesKey() {
    if (_loadedSeries != null) {
      final studyUid = _loadedSeries!.study?.studyInstanceUID ?? 'unknown_study';
      final seriesUid = _loadedSeries!.series.seriesInstanceUID;
      return '$studyUid/$seriesUid';
    }
    return 'fixture:$_selectedFixture';
  }

  void _saveCurrentSlotAnnotations() {
    if (_activeSeriesKey == null) return;
    final cache = _seriesAnnotationCache.putIfAbsent(_activeSeriesKey!, () => {});
    for (int s = 0; s < 9; s++) {
      final frameIdx = _slotActiveFrameIndex[s];
      if (frameIdx != null) {
        cache[frameIdx] =
            List<DicomAnnotation>.from(_annotationControllers[s].allAnnotations);
        _framePresentationCache[frameIdx] = _controllers[s].toPresentationState();
      }
    }
  }

  void _restoreSlotAnnotations(String newSeriesKey) {
    _activeSeriesKey = newSeriesKey;
    final cache = _seriesAnnotationCache[newSeriesKey] ?? {};
    final activeSlots = _layout.count;
    final totalFrames = _loadedSeries?.frameCount ?? 1;

    for (int s = 0; s < 9; s++) {
      if (s < activeSlots &&
          (_loadedSeries != null
              ? (_currentFrameIndex + s < totalFrames)
              : s == 0)) {
        final frameIdx = _loadedSeries != null ? (_currentFrameIndex + s) : 0;
        _slotActiveFrameIndex[s] = frameIdx;
        final saved = cache[frameIdx] ?? const <DicomAnnotation>[];
        _annotationControllers[s].setAnnotations(saved);
        _annotationControllers[s].setActiveTool(_activeTool);
        _annotationControllers[s].setFilterCreator(_collaboratorFilter);

        final presState = _framePresentationCache[frameIdx];
        if (presState != null) {
          _controllers[s].applyPresentationState(presState, notify: false);
        }
      } else {
        _slotActiveFrameIndex[s] = null;
        _annotationControllers[s].setAnnotations(const <DicomAnnotation>[]);
        _annotationControllers[s].setActiveTool(_activeTool);
        _annotationControllers[s].setFilterCreator(_collaboratorFilter);
      }
    }
  }

  ViewportController get _primaryController => _controllers[0];
  AnnotationController get _primaryAnnotationController =>
      _annotationControllers[0];

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _activeServerUrl = DicomServerUrlStore.getLastUsedUrl();

    _annotationControllers = List.generate(9, (index) {
      return AnnotationController(
        initialTool: _activeTool,
        activeCreator: 'Dr. Radiologist',
      );
    });

    for (int s = 0; s < 9; s++) {
      final slot = s;
      final ac = _annotationControllers[slot];
      ac.addListener(() {
        final frameIdx = _slotActiveFrameIndex[slot];
        if (frameIdx != null && ac.isDirty) {
          if (!_dirtyFrameIndices.contains(frameIdx)) {
            setState(() {
              _dirtyFrameIndices.add(frameIdx);
            });
          }
        }
      });
    }

    _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) {
        if (_isSyncingFrames) return;
        final frameNum = controller.frameIndex;
        if (frameNum != null && frameNum > 0) {
          final frameIdx = frameNum - 1;
          _framePresentationCache[frameIdx] = state;
          if (!_dirtyFrameIndices.contains(frameIdx)) {
            setState(() {
              _dirtyFrameIndices.add(frameIdx);
            });
          }
        }
      };

      return controller;
    });

    _loadCtPhantom();
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _loadedSeries?.dispose();
    for (final c in _controllers) {
      c.dispose();
    }
    for (final ac in _annotationControllers) {
      ac.dispose();
    }
    super.dispose();
  }

  void _loadCtPhantom() {
    _persistDirtyFrames();
    _saveCurrentSlotAnnotations();
    _loadedSeries?.dispose();
    _loadedSeries = null;
    _framePrSopUidMap.clear();
    _dirtyFrameIndices.clear();
    _isSyncingFrames = true;
    try {
      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';
      });
      _restoreSlotAnnotations('fixture:CT Phantom');
    } finally {
      _isSyncingFrames = false;
    }
  }

  void _loadTg18Qc() {
    _persistDirtyFrames();
    _saveCurrentSlotAnnotations();
    _loadedSeries?.dispose();
    _loadedSeries = null;
    _framePrSopUidMap.clear();
    _dirtyFrameIndices.clear();
    _isSyncingFrames = true;
    try {
      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';
      });
      _restoreSlotAnnotations('fixture:TG18-QC Test Pattern');
    } finally {
      _isSyncingFrames = false;
    }
  }

  void _loadDynamicRamp() {
    _persistDirtyFrames();
    _saveCurrentSlotAnnotations();
    _loadedSeries?.dispose();
    _loadedSeries = null;
    _framePrSopUidMap.clear();
    _dirtyFrameIndices.clear();
    _isSyncingFrames = true;
    try {
      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';
      });
      _restoreSlotAnnotations('fixture:Dynamic Ramp');
    } finally {
      _isSyncingFrames = false;
    }
  }

  Future<void> _openQidoBrowser() async {
    await QidoBrowserDialog.show(
      context,
      onStudySelected: (study) async {
        if (_loadedSeries != null && _dirtyFrameIndices.isNotEmpty) {
          await _persistDirtyFrames();
        }
        _saveCurrentSlotAnnotations();
      },
      onSeriesLoaded: (seriesBuffer, initialFrame) async {
        if (_loadedSeries != null && _dirtyFrameIndices.isNotEmpty) {
          await _persistDirtyFrames();
        }
        _saveCurrentSlotAnnotations();
        if (_loadedSeries != null && _loadedSeries != seriesBuffer) {
          _loadedSeries!.dispose();
        }
        _framePresentationCache.clear();
        _framePrSopUidMap.clear();
        _dirtyFrameIndices.clear();
        setState(() {
          _loadedSeries = seriesBuffer;
          _currentFrameIndex = 0;
          _selectedFixture = 'QIDO: ${seriesBuffer.series.seriesDescription}';
        });

        await _syncGridLayoutFrames();
        _restoreSlotAnnotations(_computeSeriesKey());

        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),
          ),
        );

        // Retrieve existing presentation states for this study via WADO-RS
        final studyUid = seriesBuffer.study?.studyInstanceUID ??
            seriesBuffer.series.studyInstanceUID;
        if (studyUid.isNotEmpty) {
          await _fetchServerPresentationStates(studyUid);
        }
      },
      currentLoadedSeries: _loadedSeries,
      onPresentationStateLoaded: (states, {loadedBuffer, initialFrame}) async {
        if (loadedBuffer != null) {
          if (_loadedSeries != null && _dirtyFrameIndices.isNotEmpty) {
            await _persistDirtyFrames();
          }
          _saveCurrentSlotAnnotations();
          if (_loadedSeries != null && _loadedSeries != loadedBuffer) {
            _loadedSeries!.dispose();
          }
          _framePresentationCache.clear();
          _framePrSopUidMap.clear();
          _dirtyFrameIndices.clear();
          setState(() {
            _loadedSeries = loadedBuffer;
            _currentFrameIndex = 0;
            _selectedFixture = 'QIDO: ${loadedBuffer.series.seriesDescription}';
          });
          await _syncGridLayoutFrames();
        }

        final seriesKey = _computeSeriesKey();
        final cache = _seriesAnnotationCache.putIfAbsent(seriesKey, () => {});
        int restoredCount = 0;
        int? firstTargetFrame;
        for (final gsps in states) {
          if (gsps.frameStates.isNotEmpty) {
            for (final fs in gsps.frameStates) {
              int targetFrame = fs.referencedFrameNumber > 0
                  ? fs.referencedFrameNumber - 1
                  : 0;
              if (fs.referencedSopInstanceUid.isNotEmpty &&
                  _loadedSeries != null) {
                final idx = _loadedSeries!.frames.indexWhere(
                  (f) => f.sopInstanceUID == fs.referencedSopInstanceUid,
                );
                if (idx != -1) targetFrame = idx;
              }
              firstTargetFrame ??= targetFrame;

              if (gsps.sopInstanceUid != null &&
                  gsps.sopInstanceUid!.isNotEmpty) {
                _framePrSopUidMap[targetFrame] = gsps.sopInstanceUid!;
              }

              cache[targetFrame] = List<DicomAnnotation>.from(fs.annotations);
              final presState = fs.toDicomPresentationState();
              _framePresentationCache[targetFrame] = presState;
              restoredCount += fs.annotations.length;

              final slotIdx = _slotActiveFrameIndex.indexOf(targetFrame);
              if (slotIdx != -1) {
                _controllers[slotIdx].applyPresentationState(presState);
              }
            }
          } else {
            int targetFrame = 0;
            if (gsps.referencedFrameNumber != null &&
                gsps.referencedFrameNumber! > 0) {
              targetFrame = gsps.referencedFrameNumber! - 1;
            } else if (gsps.referencedSopInstanceUid != null &&
                _loadedSeries != null) {
              final idx = _loadedSeries!.frames.indexWhere(
                (f) => f.sopInstanceUID == gsps.referencedSopInstanceUid,
              );
              if (idx != -1) targetFrame = idx;
            }
            firstTargetFrame ??= targetFrame;

            if (gsps.sopInstanceUid != null &&
                gsps.sopInstanceUid!.isNotEmpty) {
              _framePrSopUidMap[targetFrame] = gsps.sopInstanceUid!;
            }

            cache[targetFrame] = List<DicomAnnotation>.from(gsps.annotations);
            final presState = gsps.toDicomPresentationState();
            _framePresentationCache[targetFrame] = presState;
            restoredCount += gsps.annotations.length;

            final slotIdx = _slotActiveFrameIndex.indexOf(targetFrame);
            if (slotIdx != -1) {
              _controllers[slotIdx].applyPresentationState(presState);
            }
          }
        }

        if (firstTargetFrame != null && firstTargetFrame != _currentFrameIndex) {
          _currentFrameIndex = firstTargetFrame;
          await _syncGridLayoutFrames();
        }

        if (mounted) {
          _restoreSlotAnnotations(seriesKey);
          setState(() {
            _lastSaveStatus = 'GSPS Applied: $restoredCount annotation(s)';
          });

          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(
                'Applied $restoredCount GSPS annotation(s) from server.',
              ),
              backgroundColor: const Color(0xFF8957E5),
              duration: const Duration(seconds: 3),
            ),
          );
        }
      },
    );
    if (mounted) {
      setState(() {
        _activeServerUrl = DicomServerUrlStore.getLastUsedUrl();
      });
    }
  }

  void _setAnnotationTool(AnnotationTool tool) {
    setState(() {
      _activeTool = tool;
    });
    for (final c in _annotationControllers) {
      c.setActiveTool(tool);
    }
  }

  void _setCollaboratorFilter(String? creator) {
    setState(() {
      _collaboratorFilter = creator;
    });
    for (final c in _annotationControllers) {
      c.setFilterCreator(creator);
    }
  }

  void _loadSampleAnnotations() {
    _primaryAnnotationController.clearAnnotations();

    // 1. Caliper across thorax
    final caliper = CaliperAnnotation(
      id: 'cal_thorax',
      start: const Offset(140, 256),
      end: const Offset(372, 256),
      label: 'Thoracic width',
      creatorName: 'Dr. Alice',
    );

    // 2. 3-point Angle at subcarina
    final angle = AngleAnnotation(
      id: 'ang_carina',
      p1: const Offset(210, 200),
      vertex: const Offset(256, 235),
      p2: const Offset(302, 200),
      label: 'Carinal angle',
      creatorName: 'Dr. Alice',
    );

    // 3. Circle around hyperdense nodule
    final circle = CircleAnnotation(
      id: 'circ_nodule',
      center: const Offset(332, 195),
      radius: 18.0,
      label: 'Hyperdense nodule',
      creatorName: 'Dr. Bob',
    );

    // 4. Ellipse around vertebra / spine
    final ellipse = EllipseAnnotation(
      id: 'ell_spine',
      center: const Offset(256, 360),
      radiusX: 28.0,
      radiusY: 18.0,
      rotation: 0.0,
      label: 'Vertebral body',
      creatorName: 'Dr. Alice',
    );

    // 5. Text annotation note
    final text = TextAnnotation(
      id: 'txt_finding',
      anchor: const Offset(340, 160),
      text: 'Suspicious nodule (180 HU)',
      creatorName: 'Dr. Bob',
    );

    _primaryAnnotationController.addAnnotation(caliper);
    _primaryAnnotationController.addAnnotation(angle);
    _primaryAnnotationController.addAnnotation(circle);
    _primaryAnnotationController.addAnnotation(ellipse);
    _primaryAnnotationController.addAnnotation(text);

    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(
        content: Text(
          'Loaded clinical sample annotations (Dr. Alice & Dr. Bob).',
        ),
        backgroundColor: Color(0xFF238636),
        duration: Duration(seconds: 2),
      ),
    );
  }

  void _openGspsModal() {
    _saveCurrentSlotAnnotations();
    final gsps = _primaryAnnotationController.toGspsPresentationState(
      contentLabel: 'WORKBENCH_EXPORT',
      contentDescription: 'Clinical review marks',
    );
    final jsonString = gsps.toJsonString();

    showDialog(
      context: context,
      builder: (ctx) => AlertDialog(
        backgroundColor: const Color(0xFF161B22),
        title: const Row(
          children: [
            Icon(Icons.code, color: Color(0xFF58A6FF), size: 20),
            SizedBox(width: 8),
            Text(
              'DICOM GSPS Presentation State JSON',
              style: TextStyle(fontSize: 15, color: Colors.white),
            ),
          ],
        ),
        content: SizedBox(
          width: 600,
          height: 400,
          child: SingleChildScrollView(
            child: SelectableText(
              jsonString,
              style: const TextStyle(
                fontFamily: 'monospace',
                fontSize: 12,
                color: Color(0xFF7EE787),
              ),
            ),
          ),
        ),
        actions: [
          if (_loadedSeries != null)
            ElevatedButton.icon(
              onPressed: () async {
                Navigator.of(ctx).pop();
                await _persistDirtyFrames();
              },
              icon: const Icon(Icons.cloud_upload_outlined, size: 16),
              label: const Text('Store via STOW-RS'),
              style: ElevatedButton.styleFrom(
                backgroundColor: const Color(0xFF1F6FEB),
                foregroundColor: Colors.white,
              ),
            ),
          TextButton(
            onPressed: () => Navigator.of(ctx).pop(),
            child: const Text('Close'),
          ),
        ],
      ),
    );
  }

  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;

    // Keep active frame state and annotations in memory
    _saveCurrentSlotAnnotations();

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

    try {
      await _syncGridLayoutFrames();
      _restoreSlotAnnotations(_computeSeriesKey());
    } 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;
    _isSyncingFrames = true;

    try {
      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) {
              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,
                pixelSpacing: pixelFrame.pixelSpacing,
              );

              // 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);
                  }
                }
              }
            }
          } else {
            controller.clear();
          }
        } else {
          controller.clear();
        }
      }
    } finally {
      _isSyncingFrames = false;
    }
  }

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

  @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: Column(
              children: [
                _buildAnnotationToolbar(),
                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(),
            ),
        ],
      ),
    );
  }

  Widget _buildAnnotationToolbar() {
    return ListenableBuilder(
      listenable: _primaryAnnotationController,
      builder: (context, _) {
        final currentTool = _primaryAnnotationController.activeTool;
        final canUndo = _primaryAnnotationController.canUndo;
        final canRedo = _primaryAnnotationController.canRedo;
        final hasSelected =
            _primaryAnnotationController.selectedAnnotation != null;
        final canDelete = _primaryAnnotationController.hasDeletable;

        return Container(
          height: 44,
          padding: const EdgeInsets.symmetric(horizontal: 10),
          decoration: const BoxDecoration(
            color: Color(0xFF161B22),
            border: Border(
              bottom: BorderSide(color: Color(0xFF30363D), width: 1.5),
            ),
          ),
          child: SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Row(
              children: [
                _buildToolButton(
                  icon: Icons.open_with,
                  label: 'Pan/W-L',
                  tool: AnnotationTool.none,
                  currentTool: currentTool,
                ),
                _buildToolButton(
                  icon: Icons.near_me_outlined,
                  label: 'Select',
                  tool: AnnotationTool.select,
                  currentTool: currentTool,
                ),
                const SizedBox(width: 4),
                const VerticalDivider(
                    width: 16, indent: 8, endIndent: 8, color: Color(0xFF30363D)),
                const SizedBox(width: 4),
                _buildToolButton(
                  icon: Icons.straighten,
                  label: 'Caliper (mm)',
                  tool: AnnotationTool.caliper,
                  currentTool: currentTool,
                ),
                _buildToolButton(
                  icon: Icons.square_foot_rounded,
                  label: 'Angle',
                  tool: AnnotationTool.angle,
                  currentTool: currentTool,
                ),
                _buildToolButton(
                  icon: Icons.timeline,
                  label: 'Polyline',
                  tool: AnnotationTool.polyline,
                  currentTool: currentTool,
                ),
                _buildToolButton(
                  icon: Icons.circle_outlined,
                  label: 'Circle',
                  tool: AnnotationTool.circle,
                  currentTool: currentTool,
                ),
                _buildToolButton(
                  icon: Icons.egg_outlined,
                  label: 'Ellipse',
                  tool: AnnotationTool.ellipse,
                  currentTool: currentTool,
                ),
                _buildToolButton(
                  icon: Icons.text_fields,
                  label: 'Text',
                  tool: AnnotationTool.text,
                  currentTool: currentTool,
                ),
                const SizedBox(width: 4),
                const VerticalDivider(
                    width: 16, indent: 8, endIndent: 8, color: Color(0xFF30363D)),
                const SizedBox(width: 4),
                IconButton(
                  icon: const Icon(Icons.undo, size: 18),
                  tooltip: 'Undo',
                  color: canUndo ? Colors.white : const Color(0xFF484F58),
                  onPressed: canUndo
                      ? () => _primaryAnnotationController.undo()
                      : null,
                ),
                IconButton(
                  icon: const Icon(Icons.redo, size: 18),
                  tooltip: 'Redo',
                  color: canRedo ? Colors.white : const Color(0xFF484F58),
                  onPressed: canRedo
                      ? () => _primaryAnnotationController.redo()
                      : null,
                ),
                IconButton(
                  icon: const Icon(Icons.delete_outline, size: 18),
                  tooltip: hasSelected
                      ? 'Delete Selected (Del)'
                      : 'Delete Last Created (Del)',
                  color: canDelete
                      ? const Color(0xFFF85149)
                      : const Color(0xFF484F58),
                  onPressed: canDelete
                      ? () => _primaryAnnotationController.deleteSelected()
                      : null,
                ),
                IconButton(
                  icon: const Icon(Icons.clear_all, size: 18),
                  tooltip: 'Clear All Annotations',
                  color: _primaryAnnotationController.allAnnotations.isNotEmpty
                      ? const Color(0xFF8B949E)
                      : const Color(0xFF484F58),
                  onPressed:
                      _primaryAnnotationController.allAnnotations.isNotEmpty
                          ? () => _primaryAnnotationController.clearAnnotations()
                          : null,
                ),
                const SizedBox(width: 16),
                _buildCollaboratorFilterDropdown(),
                const SizedBox(width: 8),
                OutlinedButton.icon(
                  onPressed: _loadSampleAnnotations,
                  icon: const Icon(Icons.auto_awesome, size: 14),
                  label: const Text('Samples', style: TextStyle(fontSize: 11)),
                  style: OutlinedButton.styleFrom(
                    foregroundColor: const Color(0xFFFFEB3B),
                    side: const BorderSide(color: Color(0x66FFEB3B)),
                    padding:
                        const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  ),
                ),
                const SizedBox(width: 6),
                ElevatedButton.icon(
                  onPressed: _openGspsModal,
                  icon: const Icon(Icons.download_for_offline_outlined, size: 14),
                  label: const Text('GSPS JSON', style: TextStyle(fontSize: 11)),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: const Color(0xFF21262D),
                    foregroundColor: const Color(0xFF58A6FF),
                    side: const BorderSide(color: Color(0xFF30363D)),
                    padding:
                        const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  ),
                ),
                const SizedBox(width: 6),
                ElevatedButton.icon(
                  onPressed: _isSavingAnnotations || _loadedSeries == null
                      ? null
                      : () => _persistDirtyFrames(),
                  icon: _isSavingAnnotations
                      ? const SizedBox(
                          width: 12,
                          height: 12,
                          child: CircularProgressIndicator(
                            strokeWidth: 2,
                            color: Colors.white,
                          ),
                        )
                      : Icon(
                          Icons.cloud_upload_outlined,
                          size: 14,
                          color: _dirtyFrameIndices.isNotEmpty
                              ? Colors.white
                              : const Color(0xFF8B949E),
                        ),
                  label: Text(
                    _isSavingAnnotations
                        ? 'Saving...'
                        : _dirtyFrameIndices.isNotEmpty
                            ? 'Save STOW (${_dirtyFrameIndices.length})'
                            : 'Save STOW',
                    style: TextStyle(
                      fontSize: 11,
                      color: _dirtyFrameIndices.isNotEmpty
                          ? Colors.white
                          : const Color(0xFF8B949E),
                    ),
                  ),
                  style: ElevatedButton.styleFrom(
                    backgroundColor: _dirtyFrameIndices.isNotEmpty
                        ? const Color(0xFF1F6FEB)
                        : const Color(0xFF21262D),
                    side: BorderSide(
                      color: _dirtyFrameIndices.isNotEmpty
                          ? const Color(0xFF388BFD)
                          : const Color(0xFF30363D),
                    ),
                    padding:
                        const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                  ),
                ),
                if (_lastSaveStatus != null) ...[
                  const SizedBox(width: 8),
                  Container(
                    padding:
                        const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
                    decoration: BoxDecoration(
                      color: const Color(0x33238636),
                      borderRadius: BorderRadius.circular(4),
                      border: Border.all(
                        color: const Color(0x80238636),
                      ),
                    ),
                    child: Text(
                      _lastSaveStatus!,
                      style: const TextStyle(
                        fontSize: 10,
                        color: Color(0xFF7EE787),
                      ),
                    ),
                  ),
                ],
              ],
            ),
          ),
        );
      },
    );
  }

  Widget _buildToolButton({
    required IconData icon,
    required String label,
    required AnnotationTool tool,
    required AnnotationTool currentTool,
  }) {
    final isSelected = tool == currentTool;
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 2.0),
      child: Tooltip(
        message: label,
        child: InkWell(
          borderRadius: BorderRadius.circular(4),
          onTap: () => _setAnnotationTool(tool),
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
            decoration: BoxDecoration(
              color: isSelected ? const Color(0xFF1F6FEB) : Colors.transparent,
              borderRadius: BorderRadius.circular(4),
              border: Border.all(
                color:
                    isSelected ? const Color(0xFF58A6FF) : Colors.transparent,
              ),
            ),
            child: Row(
              mainAxisSize: MainAxisSize.min,
              children: [
                Icon(
                  icon,
                  size: 16,
                  color: isSelected ? Colors.white : const Color(0xFFC9D1D9),
                ),
                const SizedBox(width: 5),
                Text(
                  label,
                  style: TextStyle(
                    fontSize: 11,
                    fontWeight:
                        isSelected ? FontWeight.bold : FontWeight.normal,
                    color: isSelected ? Colors.white : const Color(0xFFC9D1D9),
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildCollaboratorFilterDropdown() {
    final collaborators = _primaryAnnotationController.collaborators.toList();
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8),
      decoration: BoxDecoration(
        color: const Color(0xFF21262D),
        borderRadius: BorderRadius.circular(4),
        border: Border.all(color: const Color(0xFF30363D)),
      ),
      child: DropdownButtonHideUnderline(
        child: DropdownButton<String?>(
          value: _collaboratorFilter,
          dropdownColor: const Color(0xFF161B22),
          isDense: true,
          hint: const Text('All Collaborators',
              style: TextStyle(fontSize: 11, color: Color(0xFF58A6FF))),
          icon: const Icon(Icons.arrow_drop_down,
              size: 16, color: Color(0xFF58A6FF)),
          items: [
            const DropdownMenuItem<String?>(
              value: null,
              child: Text('All Collaborators',
                  style: TextStyle(fontSize: 11, color: Colors.white)),
            ),
            for (final creator in collaborators)
              DropdownMenuItem<String?>(
                value: creator,
                child: Text('User: $creator',
                    style: const TextStyle(fontSize: 11, color: Colors.white)),
              ),
          ],
          onChanged: (val) => _setCollaboratorFilter(val),
        ),
      ),
    );
  }

  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 _buildViewportSlot(int slot) {
    return ClipRect(
      clipBehavior: Clip.hardEdge,
      child: Stack(
        fit: StackFit.expand,
        children: [
          DicomViewport(
            controller: _controllers[slot],
            showOverlay: _showOverlay,
          ),
          DicomAnnotationLayer(
            viewportController: _controllers[slot],
            annotationController: _annotationControllers[slot],
          ),
        ],
      ),
    );
  }

  Widget _buildViewportGrid() {
    switch (_layout) {
      case ViewportLayout.oneOnOne:
        return _buildViewportSlot(0);

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

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

      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: _buildViewportSlot(slot)),
                            ],
                          ),
                        );
                      }),
                    ),
                  ),
                ],
              ),
            );
          }),
        );
    }
  }

  Widget _buildSidebar() {
    return ListenableBuilder(
      listenable:
          Listenable.merge([_primaryController, _primaryAnnotationController]),
      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)),

            // Annotations & GSPS Section
            _buildSectionHeader('ANNOTATIONS & GSPS STATE'),
            _buildAnnotationsSidebarSection(),

            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 _buildAnnotationsSidebarSection() {
    final annotations = _primaryAnnotationController.visibleAnnotations;
    final selected = _primaryAnnotationController.selectedAnnotation;

    if (annotations.isEmpty) {
      return Container(
        padding: const EdgeInsets.all(12),
        decoration: BoxDecoration(
          color: const Color(0xFF21262D),
          borderRadius: BorderRadius.circular(6),
          border: Border.all(color: const Color(0xFF30363D)),
        ),
        child: const Column(
          children: [
            Text(
              'No annotations on current slice.',
              style: TextStyle(fontSize: 12, color: Color(0xFF8B949E)),
            ),
            SizedBox(height: 4),
            Text(
              'Use Caliper, Angle, Circle, Ellipse, or Text on the toolbar to draw.',
              style: TextStyle(fontSize: 10, color: Color(0xFF484F58)),
              textAlign: TextAlign.center,
            ),
          ],
        ),
      );
    }

    return Container(
      decoration: BoxDecoration(
        color: const Color(0xFF21262D),
        borderRadius: BorderRadius.circular(6),
        border: Border.all(color: const Color(0xFF30363D)),
      ),
      child: Column(
        children: [
          for (final ann in annotations) ...[
            Material(
              type: MaterialType.transparency,
              child: ListTile(
                dense: true,
                visualDensity: VisualDensity.compact,
                contentPadding: const EdgeInsets.symmetric(horizontal: 10),
                selected: selected?.id == ann.id,
                selectedTileColor:
                    const Color(0xFF1F6FEB).withValues(alpha: 0.2),
                leading: Icon(
                  _getAnnotationIcon(ann),
                  size: 16,
                  color: const Color(0xFFFFEB3B),
                ),
                title: Text(
                  _getAnnotationTitle(ann),
                  style: const TextStyle(
                    fontSize: 12,
                    fontWeight: FontWeight.w600,
                    color: Colors.white,
                  ),
                ),
                subtitle: Text(
                  'By ${ann.creatorName ?? "Anonymous"} • ${ann.type.name.toUpperCase()}',
                  style:
                      const TextStyle(fontSize: 10, color: Color(0xFF8B949E)),
                ),
                trailing: IconButton(
                  icon: const Icon(Icons.close,
                      size: 14, color: Color(0xFF8B949E)),
                  tooltip: 'Delete',
                  onPressed: () =>
                      _primaryAnnotationController.removeAnnotation(ann.id),
                ),
                onTap: () => _primaryAnnotationController.selectAnnotation(ann),
              ),
            ),
            const Divider(height: 1, thickness: 1, color: Color(0xFF30363D)),
          ],
        ],
      ),
    );
  }

  IconData _getAnnotationIcon(DicomAnnotation ann) {
    switch (ann.type) {
      case AnnotationType.caliper:
        return Icons.straighten;
      case AnnotationType.angle:
        return Icons.square_foot_rounded;
      case AnnotationType.polyline:
        return Icons.timeline;
      case AnnotationType.circle:
        return Icons.circle_outlined;
      case AnnotationType.ellipse:
        return Icons.egg_outlined;
      case AnnotationType.text:
        return Icons.text_fields;
    }
  }

  String _getAnnotationTitle(DicomAnnotation ann) {
    if (ann is CaliperAnnotation) {
      final val =
          ann.formatDistance(pixelSpacing: _primaryController.pixelSpacing);
      return ann.label != null && ann.label!.isNotEmpty
          ? '${ann.label}: $val'
          : 'Distance: $val';
    } else if (ann is AngleAnnotation) {
      final val = ann.formatAngle();
      return ann.label != null && ann.label!.isNotEmpty
          ? '${ann.label}: $val'
          : 'Angle: $val';
    } else if (ann is CircleAnnotation) {
      final area = ann.formatArea(pixelSpacing: _primaryController.pixelSpacing);
      return ann.label != null && ann.label!.isNotEmpty
          ? '${ann.label}: $area'
          : 'Circle ($area)';
    } else if (ann is EllipseAnnotation) {
      final area = ann.formatArea(pixelSpacing: _primaryController.pixelSpacing);
      return ann.label != null && ann.label!.isNotEmpty
          ? '${ann.label}: $area'
          : 'Ellipse ($area)';
    } else if (ann is PolylineAnnotation) {
      if (ann.isClosedOrClosureDetected) {
        final area = ann.formatArea(pixelSpacing: _primaryController.pixelSpacing);
        return ann.label != null && ann.label!.isNotEmpty
            ? '${ann.label}: $area'
            : 'Polygon ($area)';
      }
      return ann.label ?? 'Polyline (${ann.points.length} pts)';
    } else if (ann is TextAnnotation) {
      return '"${ann.text}"';
    }
    return ann.label ?? ann.type.name;
  }

  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
140
points
80
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Drop-in medical imaging kit for Flutter Web 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