Skip to main content

duo_flex 0.4.0 copy "duo_flex: ^0.4.0" to clipboard
duo_flex: ^0.4.0 copied to clipboard

Flutter plugin for iPhone Duo and foldable devices: read hinge angle, posture, display regions and window geometry, and build adaptive layouts.

example/lib/main.dart

import 'dart:async';
import 'dart:math' as math;

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

void main() => runApp(const DuoFlexExampleApp());

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

  @override
  State<DuoFlexExampleApp> createState() => _DuoFlexExampleAppState();
}

typedef MyApp = DuoFlexExampleApp;

class _DuoFlexExampleAppState extends State<DuoFlexExampleApp> {
  DuoDeviceInfo? _device;
  DuoScreenInfo? _screen;
  DuoDisplayInfo? _display;
  DuoCapabilities? _capabilities;
  Object? _error;
  bool _refreshingCapabilities = false;
  bool _capabilityRefreshPending = false;
  StreamSubscription<DuoScreenInfo>? _screenSubscription;
  StreamSubscription<DuoDisplayInfo>? _displaySubscription;
  StreamSubscription<double?>? _hingeSubscription;
  StreamSubscription<DuoFoldPosture>? _postureSubscription;
  double? _hingeAngleRadians;
  DuoFoldPosture _posture = DuoFoldPosture.unknown;
  int _hingeEventRevision = 0;
  int _postureEventRevision = 0;
  String? _foldStreamError;
  final List<String> _eventHistory = <String>[];

  @override
  void initState() {
    super.initState();
    _load();
    _screenSubscription = DuoFlex.screenInfoStream.listen(
      (value) {
        if (!mounted) return;
        setState(() {
          _screen = value;
          _appendEvent('Screen snapshot ${value.width} × ${value.height}');
        });
      },
      onError: (Object error) {
        _recordEvent('Screen stream error: $error');
      },
    );
    _displaySubscription = DuoFlex.displayChangesStream.listen(
      (value) {
        if (!mounted) return;
        setState(() {
          _display = value;
          _appendEvent('Display snapshot ${value.width} × ${value.height}');
        });
        unawaited(_refreshFoldCapabilities());
      },
      onError: (Object error) {
        _recordEvent('Display stream error: $error');
      },
    );
  }

  Future<void> _load() async {
    try {
      final values = await Future.wait<Object?>([
        DuoFlex.getDeviceInfo(),
        DuoFlex.getCapabilities(),
      ]);
      if (!mounted) return;
      setState(() {
        _device = values[0] as DuoDeviceInfo;
        _capabilities = values[1] as DuoCapabilities;
        _appendEvent('Initial device capabilities loaded');
      });
      unawaited(_loadScreenSnapshot());
      unawaited(_loadDisplaySnapshot());
      final capabilities = values[1] as DuoCapabilities;
      if (capabilities.supportsHingeAngle || capabilities.supportsFoldPosture) {
        _listenToFoldUpdates(capabilities);
        await _loadNativeFoldSnapshot(capabilities);
      }
      unawaited(_refreshFoldCapabilities());
    } catch (error) {
      if (mounted) setState(() => _error = error);
    }
  }

  Future<void> _refreshFoldCapabilities() async {
    if (_capabilities == null) return;
    if (_refreshingCapabilities) {
      _capabilityRefreshPending = true;
      return;
    }
    _refreshingCapabilities = true;
    try {
      final capabilities = await DuoFlex.getCapabilities();
      if (!mounted || capabilities == _capabilities) return;
      if (!capabilities.supportsHingeAngle) {
        await _hingeSubscription?.cancel();
        _hingeSubscription = null;
        _hingeEventRevision++;
        _hingeAngleRadians = null;
      }
      if (!capabilities.supportsFoldPosture) {
        await _postureSubscription?.cancel();
        _postureSubscription = null;
        _postureEventRevision++;
        _posture = DuoFoldPosture.unknown;
      }
      if (!mounted) return;
      setState(() {
        _capabilities = capabilities;
        _foldStreamError = null;
      });
      _listenToFoldUpdates(capabilities);
      await _loadNativeFoldSnapshot(capabilities);
      final device = await DuoFlex.getDeviceInfo();
      if (mounted) setState(() => _device = device);
    } catch (error) {
      _recordEvent('Capability refresh unavailable: $error');
    } finally {
      _refreshingCapabilities = false;
      if (mounted && _capabilityRefreshPending) {
        _capabilityRefreshPending = false;
        unawaited(_refreshFoldCapabilities());
      }
    }
  }

  Future<void> _loadScreenSnapshot() async {
    try {
      final screen = await DuoFlex.getScreenInfo();
      if (!mounted || screen == null) return;
      setState(() => _screen ??= screen);
    } catch (error) {
      _recordEvent('Initial screen snapshot unavailable: $error');
    }
  }

  Future<void> _loadDisplaySnapshot() async {
    try {
      final display = await DuoFlex.getDisplayInfo();
      if (!mounted || display == null) return;
      setState(() => _display ??= display);
    } catch (error) {
      _recordEvent('Initial display snapshot unavailable: $error');
    }
  }

  Future<void> _loadNativeFoldSnapshot(DuoCapabilities capabilities) async {
    final hingeRevision = _hingeEventRevision;
    final postureRevision = _postureEventRevision;
    try {
      final readings = await Future.wait<Object?>([
        capabilities.supportsHingeAngle
            ? DuoFlex.getHingeAngle()
            : Future<double?>.value(null),
        capabilities.supportsFoldPosture
            ? DuoFlex.getFoldPosture()
            : Future<DuoFoldPosture>.value(DuoFoldPosture.unknown),
      ]);
      if (!mounted) return;
      setState(() {
        // Preserve a newer live event if it arrived while the snapshot calls
        // were in flight.
        if (_hingeEventRevision == hingeRevision &&
            capabilities.supportsHingeAngle) {
          _hingeAngleRadians = readings[0] as double?;
        }
        if (_postureEventRevision == postureRevision &&
            capabilities.supportsFoldPosture) {
          _posture = readings[1] as DuoFoldPosture;
        }
      });
    } catch (error) {
      if (!mounted) return;
      setState(() => _foldStreamError = error.toString());
    }
  }

  @override
  void dispose() {
    _screenSubscription?.cancel();
    _displaySubscription?.cancel();
    _hingeSubscription?.cancel();
    _postureSubscription?.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    theme: ThemeData(
      useMaterial3: true,
      colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF5546A5)),
      scaffoldBackgroundColor: const Color(0xFFFCF8FF),
    ),
    home: DefaultTabController(
      length: 2,
      child: Scaffold(
        appBar: AppBar(
          title: const Row(
            children: [
              Icon(Icons.phonelink_rounded),
              SizedBox(width: 8),
              Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text('DuoFlex'),
                  Text(
                    'Native display monitor',
                    style: TextStyle(fontSize: 12),
                  ),
                ],
              ),
            ],
          ),
          centerTitle: false,
          toolbarHeight: 60,
          bottom: const TabBar(
            dividerColor: Colors.transparent,
            indicatorSize: TabBarIndicatorSize.label,
            labelPadding: EdgeInsets.symmetric(horizontal: 28),
            tabs: [
              Tab(
                icon: Icon(Icons.phone_iphone_rounded, size: 20),
                text: 'Device',
              ),
              Tab(
                icon: Icon(Icons.dashboard_customize_rounded, size: 20),
                text: 'Adaptive UI',
              ),
            ],
          ),
        ),
        body: TabBarView(children: [_deviceDashboard(), _adaptiveDashboard()]),
      ),
    ),
  );

  Widget _deviceDashboard() => ListView(
    padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
    children: [
      if (_error != null) _errorCard(_error.toString()),
      _deviceSummaryCard(),
      _hingeAngleCard(),
      _foldShapeCard(),
      _sectionHeading('Live viewport'),
      _metricGrid([
        _MetricData(
          label: 'Window size',
          value: _screen == null
              ? 'Unavailable'
              : '${_screen!.width.toStringAsFixed(0)} × ${_screen!.height.toStringAsFixed(0)}',
          detail: 'logical pixels',
          icon: Icons.aspect_ratio_rounded,
        ),
        _MetricData(
          label: 'Pixel ratio',
          value: _screen?.devicePixelRatio.toStringAsFixed(2) ?? '—',
          detail: 'device scale',
          icon: Icons.zoom_in_rounded,
        ),
        _MetricData(
          label: 'Orientation',
          value: _screen?.orientation ?? 'Unavailable',
          detail: 'active Flutter view',
          icon: Icons.screen_rotation_rounded,
        ),
        _MetricData(
          label: 'Safe area',
          value: _screen == null
              ? 'Unavailable'
              : 'T ${_screen!.safeArea.top.toStringAsFixed(0)}  '
                    'B ${_screen!.safeArea.bottom.toStringAsFixed(0)}',
          detail: 'top and bottom insets',
          icon: Icons.space_bar_rounded,
        ),
      ]),
      _safeAreaCard(),
      const SizedBox(height: 8),
      _displayCard(),
      _capabilitiesCard(),
      _eventHistoryPanel(),
    ],
  );

  Widget _adaptiveDashboard() => LayoutBuilder(
    builder: (context, constraints) {
      final viewInsets = MediaQuery.viewInsetsOf(context);
      final horizontalPadding = constraints.maxWidth >= 840 ? 48.0 : 16.0;
      return ListView(
        padding: EdgeInsets.fromLTRB(
          horizontalPadding,
          14,
          horizontalPadding,
          24,
        ),
        children: [
          Text(
            'See your layout adapt',
            style: Theme.of(context).textTheme.headlineSmall,
          ),
          const SizedBox(height: 6),
          const Text(
            'Resize the window to watch the layout change, or explore the previews below.',
          ),
          const SizedBox(height: 16),
          _ViewportSummary(
            constraints: constraints,
            safeArea: MediaQuery.paddingOf(context),
            viewInsets: viewInsets,
          ),
          const SizedBox(height: 14),
          SizedBox(
            width: constraints.maxWidth - horizontalPadding * 2,
            child: DuoAdaptiveLayout.builder(
              builder: (context, layout) =>
                  _ResponsiveLayoutCard(layout: layout),
            ),
          ),
          const SizedBox(height: 12),
          _sectionHeading('What is the device reporting?'),
          const Padding(
            padding: EdgeInsets.symmetric(horizontal: 4),
            child: Text(
              'Window width chooses the layout. Physical fold readings are a separate signal.',
            ),
          ),
          const SizedBox(height: 8),
          DuoFoldBuilder(
            builder: (context, state, capabilities) => _NativeFoldStatusCard(
              posture: capabilities.supportsFoldPosture
                  ? state.posture.name
                  : 'Unsupported',
              angle: capabilities.supportsHingeAngle && state.hingeAngle != null
                  ? '${(state.hingeAngle! * 180 / math.pi).toStringAsFixed(1)}°'
                  : 'Unavailable',
            ),
            errorBuilder: (context, error, state, capabilities) =>
                _NativeFoldStatusCard(
                  posture: capabilities.supportsFoldPosture
                      ? state.posture.name
                      : 'Unsupported',
                  angle: 'Unavailable',
                  error: error.toString(),
                ),
          ),
          const SizedBox(height: 8),
          DuoFoldAwareLayout(
            closedLayout: const _FoldAwareSample(
              icon: Icons.smartphone_rounded,
              title: 'Closed posture reported',
              detail: 'The native API reports closed. Window size is still measured independently.',
            ),
            bookLayout: const _FoldAwareSample(
              icon: Icons.menu_book_rounded,
              title: 'Book posture reported',
              detail: 'A vertical half-open fold is reported. An app could show reading content on each side.',
            ),
            defaultLayout: const _FoldAwareSample(
              icon: Icons.dashboard_outlined,
              title: 'Standard layout',
              detail: 'No special posture layout selected. Content still adapts to its available width.',
            ),
            tabletopLayout: const _FoldAwareSample(
              icon: Icons.table_restaurant_outlined,
              title: 'Tabletop layout selected',
              detail: 'The device reports a horizontal half-open fold. An app could place controls on the lower half.',
            ),
            fullyOpenLayout: const _FoldAwareSample(
              icon: Icons.open_in_full_rounded,
              title: 'Fully open layout selected',
              detail: 'The device reports fully open. Available width still decides how many columns fit.',
            ),
          ),
        ],
      );
    },
  );

  Widget _sectionHeading(String title) => Padding(
    padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
    child: Text(title, style: Theme.of(context).textTheme.titleMedium),
  );

  Widget _deviceSummaryCard() {
    final foldability = _device?.isFoldable == true
        ? 'Foldable detected'
        : _device?.isFoldable == false
        ? 'No fold feature reported'
        : 'Fold state unavailable';
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            CircleAvatar(
              radius: 25,
              child: Icon(
                _device?.isFoldable == true
                    ? Icons.phone_android_rounded
                    : Icons.phone_iphone_rounded,
              ),
            ),
            const SizedBox(width: 14),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(
                    _device?.model ?? 'Reading device…',
                    style: Theme.of(context).textTheme.titleMedium,
                  ),
                  const SizedBox(height: 3),
                  Text(
                    '${_device?.systemName ?? 'Unknown OS'} ${_device?.systemVersion ?? ''} · $foldability',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
                ],
              ),
            ),
            _StatusPill(
              label: _capabilities == null ? 'Loading' : 'Connected',
              active: _capabilities != null,
            ),
          ],
        ),
      ),
    );
  }

  Widget _metricGrid(List<_MetricData> metrics) => LayoutBuilder(
    builder: (context, constraints) {
      final columns = constraints.maxWidth >= 620 ? 4 : 2;
      return Column(
        children: [
          for (var start = 0; start < metrics.length; start += columns) ...[
            if (start > 0) const SizedBox(height: 8),
            IntrinsicHeight(
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  for (var offset = 0; offset < columns; offset++) ...[
                    if (offset > 0) const SizedBox(width: 8),
                    Expanded(
                      child: start + offset < metrics.length
                          ? _metricCard(metrics[start + offset])
                          : const SizedBox.shrink(),
                    ),
                  ],
                ],
              ),
            ),
          ],
        ],
      );
    },
  );

  Widget _metricCard(_MetricData metric) => Card(
    margin: EdgeInsets.zero,
    child: Padding(
      padding: const EdgeInsets.all(12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        mainAxisSize: MainAxisSize.min,
        children: [
          Icon(
            metric.icon,
            size: 19,
            color: Theme.of(context).colorScheme.primary,
          ),
          const SizedBox(height: 8),
          Text(metric.label, style: Theme.of(context).textTheme.labelSmall),
          const SizedBox(height: 4),
          Text(metric.value, style: Theme.of(context).textTheme.titleSmall),
          const SizedBox(height: 4),
          Text(metric.detail, style: Theme.of(context).textTheme.labelSmall),
        ],
      ),
    ),
  );

  Widget _safeAreaCard() {
    final safeArea = _screen?.safeArea;
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Safe area insets',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 10),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _InsetValue('Top', safeArea?.top),
                _InsetValue('Bottom', safeArea?.bottom),
                _InsetValue('Left', safeArea?.left),
                _InsetValue('Right', safeArea?.right),
              ],
            ),
            const SizedBox(height: 8),
            Text(
              'Native window insets in logical pixels. Use MediaQuery for Flutter layout padding.',
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }

  Widget _errorCard(String error) => Card(
    color: Theme.of(context).colorScheme.errorContainer,
    margin: const EdgeInsets.only(bottom: 12),
    child: Padding(
      padding: const EdgeInsets.all(14),
      child: Row(
        children: [
          const Icon(Icons.warning_amber_rounded),
          const SizedBox(width: 10),
          Expanded(child: Text('Native read failed: $error')),
        ],
      ),
    ),
  );

  Widget _displayCard() => Card(
    margin: const EdgeInsets.only(bottom: 12),
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Row(
            children: [
              const Icon(Icons.display_settings_rounded),
              const SizedBox(width: 8),
              Text(
                'Display and regions',
                style: Theme.of(context).textTheme.titleMedium,
              ),
              const Spacer(),
              _StatusPill(
                label: _display == null
                    ? 'Waiting'
                    : _display!.isActive
                    ? 'Active'
                    : 'Inactive',
                active: _display?.isActive ?? false,
              ),
            ],
          ),
          const SizedBox(height: 8),
          Text(
            _display == null
                ? 'Display snapshot unavailable'
                : '${_display!.width.toStringAsFixed(0)} × ${_display!.height.toStringAsFixed(0)} pt  ·  ${_display!.scale.toStringAsFixed(1)}×',
          ),
          const SizedBox(height: 10),
          if (_display == null || _display!.regions.isEmpty)
            Text(
              'No native regions reported',
              style: Theme.of(context).textTheme.bodySmall,
            )
          else
            Wrap(
              spacing: 6,
              runSpacing: 6,
              children: [
                for (final region in _display!.regions)
                  Chip(
                    avatar: Icon(
                      region.type == DuoDisplayRegionType.reservedDivision
                          ? Icons.compare_arrows_rounded
                          : Icons.crop_free_rounded,
                      size: 16,
                    ),
                    label: Text(
                      '${region.type.name} · ${region.width.toStringAsFixed(0)} × ${region.height.toStringAsFixed(0)}',
                    ),
                    visualDensity: VisualDensity.compact,
                  ),
              ],
            ),
        ],
      ),
    ),
  );

  Widget _capabilitiesCard() {
    final capabilities = _capabilities;
    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Plugin capabilities',
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 10),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                _CapabilityChip(
                  'Hinge angle',
                  capabilities?.supportsHingeAngle,
                ),
                _CapabilityChip(
                  'Fold posture',
                  capabilities?.supportsFoldPosture,
                ),
                _CapabilityChip(
                  'Reserved regions',
                  capabilities?.supportsDisplayRegions,
                ),
                _CapabilityChip(
                  'Live screen',
                  capabilities?.supportsDisplayChanges,
                ),
              ],
            ),
            if (_foldStreamError != null) ...[
              const SizedBox(height: 10),
              Text(
                _foldStreamError!,
                style: TextStyle(color: Theme.of(context).colorScheme.error),
              ),
            ],
          ],
        ),
      ),
    );
  }

  Widget _hingeAngleCard() {
    final radians = _capabilities?.supportsHingeAngle == true
        ? _hingeAngleRadians
        : null;
    final degrees = radians == null ? null : radians * 180 / math.pi;
    final progress = radians == null
        ? null
        : (radians / math.pi).clamp(0.0, 1.0).toDouble();
    final colors = Theme.of(context).colorScheme;

    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text('Hinge angle', style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 8),
            Row(
              children: [
                Text(
                  degrees == null
                      ? 'Unavailable'
                      : '${degrees.toStringAsFixed(1)}°',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                const SizedBox(width: 16),
                Expanded(
                  child: progress == null
                      ? Container(
                          height: 4,
                          decoration: BoxDecoration(
                            color: colors.outline,
                            borderRadius: BorderRadius.circular(2),
                          ),
                        )
                      : LinearProgressIndicator(
                          value: progress,
                          minHeight: 4,
                          backgroundColor: colors.outline,
                          color: colors.primary,
                        ),
                ),
              ],
            ),
            const SizedBox(height: 6),
            Text(
              '0° closed, 180° flat',
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }

  Widget _foldShapeCard() {
    final radians = _capabilities?.supportsHingeAngle == true
        ? _hingeAngleRadians
        : null;
    final degrees = radians == null ? null : radians * 180 / math.pi;
    final posture = _capabilities?.supportsFoldPosture == true
        ? _posture.name
        : 'unavailable';
    final foldRegion = _display?.regions
        .where(
          (region) =>
              region.isActive &&
              (region.type == DuoDisplayRegionType.hingeExclusion ||
                  region.type == DuoDisplayRegionType.reservedDivision),
        )
        .firstOrNull;
    // final windowRegion = _display?.regions
    //     .where(
    //       (region) => region.type == DuoDisplayRegionType.applicationWindow,
    //     )
    //     .firstOrNull;

    return Card(
      margin: const EdgeInsets.only(bottom: 12),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              'Native fold · $posture',
              style: Theme.of(context).textTheme.bodySmall,
            ),
            const SizedBox(height: 4),
            SizedBox(
              height: 46,
              width: double.infinity,
              child: degrees == null
                  ? Center(
                      child: Text(
                        foldRegion == null
                            ? 'Fold shape unavailable'
                            : 'Fold bounds available · angle unavailable',
                        style: Theme.of(context).textTheme.bodySmall,
                      ),
                    )
                  : CustomPaint(
                      painter: _NativeFoldShapePainter(
                        angleDegrees: degrees,
                        color: Theme.of(context).colorScheme.primary,
                      ),
                    ),
            ),
          ],
        ),
      ),
    );
  }

  void _listenToFoldUpdates(DuoCapabilities capabilities) {
    if (capabilities.supportsHingeAngle && _hingeSubscription == null) {
      _hingeSubscription = DuoFlex.hingeAngleStream.listen(
        (value) {
          if (!mounted) return;
          setState(() {
            _hingeEventRevision++;
            _hingeAngleRadians = value;
            _foldStreamError = null;
            _appendEvent('Native hinge update: ${value ?? 'unavailable'} rad');
          });
        },
        onError: (Object error) {
          if (mounted) {
            setState(() {
              _foldStreamError = error.toString();
              _appendEvent('Hinge stream error: $error');
            });
          }
        },
      );
    }
    if (capabilities.supportsFoldPosture && _postureSubscription == null) {
      _postureSubscription = DuoFlex.foldPostureStream.listen(
        (value) {
          if (!mounted) return;
          setState(() {
            _postureEventRevision++;
            _posture = value;
            _foldStreamError = null;
            _appendEvent('Native posture update: ${value.name}');
          });
        },
        onError: (Object error) {
          if (mounted) {
            setState(() {
              _foldStreamError = error.toString();
              _appendEvent('Posture stream error: $error');
            });
          }
        },
      );
    }
  }

  void _appendEvent(String message) {
    _eventHistory.insert(
      0,
      '${DateTime.now().toLocal().toIso8601String()}  $message',
    );
    if (_eventHistory.length > 20) {
      _eventHistory.removeRange(20, _eventHistory.length);
    }
  }

  void _recordEvent(String message) {
    if (!mounted) return;
    setState(() => _appendEvent(message));
  }

  Widget _eventHistoryPanel() => Card(
    margin: const EdgeInsets.only(bottom: 12),
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text('Latest events', style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 8),
          if (_eventHistory.isEmpty) const Text('Waiting for native events.'),
          for (final event in _eventHistory)
            Padding(
              padding: const EdgeInsets.only(bottom: 4),
              child: Text(event),
            ),
        ],
      ),
    ),
  );
}

class _ViewportSummary extends StatelessWidget {
  const _ViewportSummary({
    required this.constraints,
    required this.safeArea,
    required this.viewInsets,
  });

  final BoxConstraints constraints;
  final EdgeInsets safeArea;
  final EdgeInsets viewInsets;

  String _extent(double value) =>
      value.isFinite ? value.toStringAsFixed(0) : 'Unbounded';

  @override
  Widget build(BuildContext context) => Card(
    margin: EdgeInsets.zero,
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Row(
            children: [
              Icon(Icons.aspect_ratio_rounded),
              SizedBox(width: 8),
              Text('Available window space'),
            ],
          ),
          const SizedBox(height: 10),
          Text(
            '${_extent(constraints.maxWidth)} × ${_extent(constraints.maxHeight)}',
            style: Theme.of(context).textTheme.headlineMedium,
          ),
          const Text('Logical pixels · updates as the window changes'),
          const SizedBox(height: 8),
          ExpansionTile(
            tilePadding: EdgeInsets.zero,
            title: const Text('View measurements'),
            children: [
              ListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Safe area · top / bottom'),
                subtitle: Text(
                  '${safeArea.top.toStringAsFixed(0)} / ${safeArea.bottom.toStringAsFixed(0)} logical pixels',
                ),
              ),
              ListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Keyboard obstruction'),
                subtitle: Text(
                  viewInsets.bottom > 0
                      ? '${viewInsets.bottom.toStringAsFixed(0)} logical pixels at the bottom'
                      : 'No bottom obstruction reported',
                ),
              ),
              ListTile(
                contentPadding: EdgeInsets.zero,
                title: const Text('Pixel density'),
                subtitle: Text(
                  '${MediaQuery.devicePixelRatioOf(context).toStringAsFixed(1)} physical pixels per logical pixel',
                ),
              ),
            ],
          ),
        ],
      ),
    ),
  );
}

class _ResponsiveLayoutCard extends StatefulWidget {
  const _ResponsiveLayoutCard({required this.layout});
  final DuoLayoutInfo layout;

  @override
  State<_ResponsiveLayoutCard> createState() => _ResponsiveLayoutCardState();
}

class _ResponsiveLayoutCardState extends State<_ResponsiveLayoutCard> {
  DuoLayoutSize? _preview;

  @override
  Widget build(BuildContext context) {
    final selected = _preview ?? widget.layout.size;
    final description = switch (selected) {
      DuoLayoutSize.compact =>
        'One column keeps content easy to read in a narrow space.',
      DuoLayoutSize.medium =>
        'A navigation rail makes room for a list and its details.',
      DuoLayoutSize.expanded =>
        'Persistent navigation, a list, and details can sit side by side.',
    };
    final range = switch (selected) {
      DuoLayoutSize.compact => 'Below 600 logical pixels',
      DuoLayoutSize.medium => '600–839 logical pixels',
      DuoLayoutSize.expanded => '840 logical pixels and above',
    };
    return Card(
      margin: EdgeInsets.zero,
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text(
              'Layout playground',
              style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
            ),
            const SizedBox(height: 6),
            Text(
              'Live content width: ${widget.layout.width.toStringAsFixed(0)} px · ${widget.layout.size.name}',
            ),
            const SizedBox(height: 12),
            Wrap(
              spacing: 8,
              runSpacing: 4,
              children: [
                ChoiceChip(
                  label: const Text('Live'),
                  selected: _preview == null,
                  onSelected: (_) => setState(() => _preview = null),
                ),
                for (final size in DuoLayoutSize.values)
                  ChoiceChip(
                    label: Text(switch (size) {
                      DuoLayoutSize.compact => 'Compact',
                      DuoLayoutSize.medium => 'Medium',
                      DuoLayoutSize.expanded => 'Expanded',
                    }),
                    selected: _preview == size,
                    onSelected: (_) => setState(() => _preview = size),
                  ),
              ],
            ),
            const SizedBox(height: 12),
            Text(
              _preview == null
                  ? 'Following the available width'
                  : 'Illustration only · your window has not changed',
              style: Theme.of(context).textTheme.labelMedium,
            ),
            const SizedBox(height: 12),
            _AdaptiveLayoutContent(size: selected),
            const SizedBox(height: 12),
            Text(description),
            const SizedBox(height: 6),
            Text(
              'Default breakpoint: $range',
              style: Theme.of(context).textTheme.bodySmall,
            ),
          ],
        ),
      ),
    );
  }
}

class _NativeFoldStatusCard extends StatelessWidget {
  const _NativeFoldStatusCard({
    required this.posture,
    required this.angle,
    this.error,
  });

  final String posture;
  final String angle;
  final String? error;

  @override
  Widget build(BuildContext context) => Card(
    child: Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            'Live hardware readings',
            style: TextStyle(fontWeight: FontWeight.w600),
          ),
          const SizedBox(height: 10),
          Text('Posture: $posture'),
          const SizedBox(height: 4),
          Text('Hinge angle: $angle'),
          const SizedBox(height: 8),
          const Text(
            'Unavailable means the platform has not supplied a reading. These values are never guessed from window width.',
          ),
          if (error != null) ...[
            const SizedBox(height: 4),
            Text(
              'Fold readings could not be refreshed. The responsive layout still works.',
              style: TextStyle(color: Theme.of(context).colorScheme.error),
            ),
          ],
        ],
      ),
    ),
  );
}

class _FoldAwareSample extends StatelessWidget {
  const _FoldAwareSample({
    required this.icon,
    required this.title,
    required this.detail,
  });

  final IconData icon;
  final String title;
  final String detail;

  @override
  Widget build(BuildContext context) => Card(
    child: ListTile(
      leading: Icon(icon),
      title: Text(title),
      subtitle: Text(detail),
    ),
  );
}

class _MetricData {
  const _MetricData({
    required this.label,
    required this.value,
    required this.detail,
    required this.icon,
  });

  final String label;
  final String value;
  final String detail;
  final IconData icon;
}

class _StatusPill extends StatelessWidget {
  const _StatusPill({required this.label, required this.active});

  final String label;
  final bool active;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    return DecoratedBox(
      decoration: BoxDecoration(
        color: active
            ? colors.tertiaryContainer
            : colors.surfaceContainerHighest,
        borderRadius: BorderRadius.circular(20),
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
        child: Text(
          label,
          style: Theme.of(context).textTheme.labelSmall?.copyWith(
            color: active
                ? colors.onTertiaryContainer
                : colors.onSurfaceVariant,
            fontWeight: FontWeight.w600,
          ),
        ),
      ),
    );
  }
}

class _CapabilityChip extends StatelessWidget {
  const _CapabilityChip(this.label, this.supported);

  final String label;
  final bool? supported;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final icon = switch (supported) {
      true => Icons.check_circle_rounded,
      false => Icons.remove_circle_outline_rounded,
      null => Icons.hourglass_empty_rounded,
    };
    final state = switch (supported) {
      true => 'Available',
      false => 'Unsupported',
      null => 'Checking',
    };
    return Chip(
      avatar: Icon(icon, size: 17),
      label: Text('$label · $state'),
      visualDensity: VisualDensity.compact,
      side: BorderSide(color: colors.outline),
    );
  }
}

class _InsetValue extends StatelessWidget {
  const _InsetValue(this.side, this.value);

  final String side;
  final Object? value;

  @override
  Widget build(BuildContext context) => Container(
    padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
    decoration: BoxDecoration(
      color: Theme.of(context).colorScheme.surfaceContainerHighest,
      borderRadius: BorderRadius.circular(12),
    ),
    child: Text('$side  ${value?.toString() ?? '—'}'),
  );
}

class _AdaptiveLayoutContent extends StatelessWidget {
  const _AdaptiveLayoutContent({required this.size});
  final DuoLayoutSize size;

  @override
  Widget build(BuildContext context) {
    final colors = Theme.of(context).colorScheme;
    final navigation = _AdaptivePanelCard(
      title: size == DuoLayoutSize.medium ? 'Rail' : 'Menu',
      icon: Icons.menu_rounded,
      color: colors.primaryContainer,
    );
    final content = _AdaptivePanelCard(
      title: 'List',
      icon: Icons.view_list_rounded,
      color: colors.secondaryContainer,
    );
    final details = _AdaptivePanelCard(
      title: 'Details',
      icon: Icons.article_outlined,
      color: colors.tertiaryContainer,
    );
    return Container(
      padding: const EdgeInsets.all(10),
      decoration: BoxDecoration(
        border: Border.all(color: colors.outlineVariant),
        borderRadius: BorderRadius.circular(16),
      ),
      child: size == DuoLayoutSize.compact
          ? Column(
              children: [
                navigation,
                const SizedBox(height: 8),
                content,
                const SizedBox(height: 8),
                details,
              ],
            )
          : Row(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Expanded(child: navigation),
                const SizedBox(width: 6),
                Expanded(flex: 2, child: content),
                const SizedBox(width: 6),
                Expanded(flex: 2, child: details),
              ],
            ),
    );
  }
}

class _AdaptivePanelCard extends StatelessWidget {
  const _AdaptivePanelCard({
    required this.title,
    required this.icon,
    required this.color,
  });
  final String title;
  final IconData icon;
  final Color color;

  @override
  Widget build(BuildContext context) => Container(
    width: double.infinity,
    padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 16),
    decoration: BoxDecoration(
      color: color,
      borderRadius: BorderRadius.circular(10),
    ),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(icon, size: 22),
        const SizedBox(height: 6),
        Text(
          title,
          textAlign: TextAlign.center,
          style: Theme.of(context).textTheme.labelMedium,
        ),
      ],
    ),
  );
}

class _NativeFoldShapePainter extends CustomPainter {
  const _NativeFoldShapePainter({
    required this.angleDegrees,
    required this.color,
  });

  final double angleDegrees;
  final Color color;

  @override
  void paint(Canvas canvas, Size size) {
    final angle = angleDegrees.clamp(0.0, 180.0);
    final halfRadians = (180 - angle) * math.pi / 360;
    final center = Offset(size.width / 2, size.height * 0.78);
    final armLength = size.width * 0.16;
    final rise = armLength * math.sin(halfRadians);
    final run = armLength * math.cos(halfRadians);
    final stroke = Paint()
      ..color = color
      ..style = PaintingStyle.stroke
      ..strokeWidth = 4
      ..strokeCap = StrokeCap.round;
    canvas.drawLine(center, Offset(center.dx - run, center.dy - rise), stroke);
    canvas.drawLine(center, Offset(center.dx + run, center.dy - rise), stroke);
    canvas.drawCircle(center, 3.5, Paint()..color = color);
  }

  @override
  bool shouldRepaint(_NativeFoldShapePainter oldDelegate) =>
      oldDelegate.angleDegrees != angleDegrees || oldDelegate.color != color;
}

// class _NativeFoldRegionPainter extends CustomPainter {
//   const _NativeFoldRegionPainter({
//     required this.x,
//     required this.y,
//     required this.width,
//     required this.height,
//     required this.displayX,
//     required this.displayY,
//     required this.displayWidth,
//     required this.displayHeight,
//     required this.color,
//   });

//   final double x;
//   final double y;
//   final double width;
//   final double height;
//   final double displayX;
//   final double displayY;
//   final double displayWidth;
//   final double displayHeight;
//   final Color color;

//   @override
//   void paint(Canvas canvas, Size size) {
//     if (displayWidth <= 0 || displayHeight <= 0) return;
//     final displayRect = Rect.fromLTWH(0, 0, size.width, size.height);
//     final scale = math.min(
//       size.width / displayWidth,
//       size.height / displayHeight,
//     );
//     final fittedWidth = displayWidth * scale;
//     final fittedHeight = displayHeight * scale;
//     final left = (size.width - fittedWidth) / 2;
//     final top = (size.height - fittedHeight) / 2;
//     final bounds = Rect.fromLTWH(
//       left + (x - displayX) * scale,
//       top + (y - displayY) * scale,
//       math.max(width * scale, 4),
//       math.max(height * scale, 4),
//     );
//     final devicePaint = Paint()
//       ..color = color.withValues(alpha: 0.12)
//       ..style = PaintingStyle.fill;
//     final featurePaint = Paint()
//       ..color = color
//       ..style = PaintingStyle.fill;
//     final borderPaint = Paint()
//       ..color = color.withValues(alpha: 0.65)
//       ..style = PaintingStyle.stroke
//       ..strokeWidth = 1.5;
//     canvas.drawRRect(
//       RRect.fromRectAndRadius(
//         displayRect.deflate(4),
//         const Radius.circular(12),
//       ),
//       devicePaint,
//     );
//     canvas.drawRect(bounds, featurePaint);
//     canvas.drawRRect(
//       RRect.fromRectAndRadius(
//         displayRect.deflate(4),
//         const Radius.circular(12),
//       ),
//       borderPaint,
//     );
//   }

//   @override
//   bool shouldRepaint(_NativeFoldRegionPainter oldDelegate) =>
//       oldDelegate.x != x ||
//       oldDelegate.y != y ||
//       oldDelegate.width != width ||
//       oldDelegate.height != height ||
//       oldDelegate.displayX != displayX ||
//       oldDelegate.displayY != displayY ||
//       oldDelegate.displayWidth != displayWidth ||
//       oldDelegate.displayHeight != displayHeight ||
//       oldDelegate.color != color;
// }
3
likes
160
points
1.08k
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter plugin for iPhone Duo and foldable devices: read hinge angle, posture, display regions and window geometry, and build adaptive layouts.

Homepage

Topics

#foldable #iphone-duo #dual-screen #responsive #adaptive-layout

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, plugin_platform_interface, web

More

Packages that depend on duo_flex

Packages that implement duo_flex