Skip to main content

duo_dynamic_sizing 2.0.5 copy "duo_dynamic_sizing: ^2.0.5" to clipboard
duo_dynamic_sizing: ^2.0.5 copied to clipboard

Adaptive layout for the foldable iPhone Duo and any screen. Live hinge angle, posture, fold and camera regions, split panes, side navigation and a pose simulator.

example/lib/main.dart

// Playground for duo_dynamic_sizing: flip through every pose and watch the
// app keep its state. flutter run on any device, emulator or desktop.
import 'dart:async';
import 'dart:math' as math;

import 'package:duo_dynamic_sizing/duo_dynamic_sizing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

import 'showcase.dart';

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

const brand = Color(0xFF6D5DFC);

typedef Track = ({String title, String artist, Color a, Color b, int secs});

const tracks = <Track>[
  (
    title: 'Hinge Theory',
    artist: 'The Creases',
    a: Color(0xFFFF6B6B),
    b: Color(0xFF6D5DFC),
    secs: 214,
  ),
  (
    title: 'Two Screens, One Heart',
    artist: 'Split View',
    a: Color(0xFF00C9A7),
    b: Color(0xFF0081CF),
    secs: 187,
  ),
  (
    title: 'Tabletop Nights',
    artist: 'Posture',
    a: Color(0xFFFFC75F),
    b: Color(0xFFFF6F91),
    secs: 243,
  ),
  (
    title: 'Island on the Right',
    artist: 'Safe Area',
    a: Color(0xFF845EC2),
    b: Color(0xFFD65DB1),
    secs: 198,
  ),
  (
    title: 'Fold at 475.5',
    artist: 'Passport',
    a: Color(0xFF4D8076),
    b: Color(0xFFC4FCEF),
    secs: 226,
  ),
  (
    title: 'Book Mode',
    artist: 'The Creases',
    a: Color(0xFFF9F871),
    b: Color(0xFF2C73D2),
    secs: 171,
  ),
  (
    title: 'GlobalKey Lullaby',
    artist: 'Reparent',
    a: Color(0xFFFF9671),
    b: Color(0xFF008F7A),
    secs: 255,
  ),
  (
    title: 'Never Scale',
    artist: '153 ppi',
    a: Color(0xFFB39CD0),
    b: Color(0xFF00C2A8),
    secs: 202,
  ),
  (
    title: 'Crease Lightning',
    artist: 'Hinge Theory',
    a: Color(0xFFFF5E62),
    b: Color(0xFFFF9966),
    secs: 189,
  ),
  (
    title: 'Safe Area Blues',
    artist: 'The Insets',
    a: Color(0xFF3A7BD5),
    b: Color(0xFF00D2FF),
    secs: 231,
  ),
  (
    title: 'Rail by the Island',
    artist: 'Split View',
    a: Color(0xFF11998E),
    b: Color(0xFF38EF7D),
    secs: 176,
  ),
  (
    title: 'Pure Dart Heart',
    artist: 'No Channels',
    a: Color(0xFFDA22FF),
    b: Color(0xFF9733EE),
    secs: 244,
  ),
];

typedef Photo = ({
  String title,
  double ratio,
  Color sky,
  Color sun,
  Color land,
});

const photos = <Photo>[
  (
    title: 'Wide dusk',
    ratio: 16 / 9,
    sky: Color(0xFF2B2D6E),
    sun: Color(0xFFFF8A5B),
    land: Color(0xFF3A2E5C),
  ),
  (
    title: 'Square noon',
    ratio: 1,
    sky: Color(0xFF4FC3F7),
    sun: Color(0xFFFFF176),
    land: Color(0xFF2E7D32),
  ),
  (
    title: 'Tall story',
    ratio: 9 / 16,
    sky: Color(0xFF6A1B9A),
    sun: Color(0xFFFFAB91),
    land: Color(0xFF1A237E),
  ),
  (
    title: 'Classic 4:3',
    ratio: 4 / 3,
    sky: Color(0xFFFFCC80),
    sun: Color(0xFFFFFFFF),
    land: Color(0xFF8D6E63),
  ),
  (
    title: 'Cinema',
    ratio: 21 / 9,
    sky: Color(0xFF0D1B2A),
    sun: Color(0xFFE0E1DD),
    land: Color(0xFF1B263B),
  ),
  (
    title: 'Portrait 3:4',
    ratio: 3 / 4,
    sky: Color(0xFFF48FB1),
    sun: Color(0xFFFFF59D),
    land: Color(0xFF6D4C41),
  ),
  (
    title: 'Aurora',
    ratio: 3 / 2,
    sky: Color(0xFF004D40),
    sun: Color(0xFF69F0AE),
    land: Color(0xFF102027),
  ),
  (
    title: 'Desert',
    ratio: 2 / 3,
    sky: Color(0xFFFFE0B2),
    sun: Color(0xFFFF7043),
    land: Color(0xFFD84315),
  ),
];

// the poses the tour walks through, a full day with the Duo
const tour = [
  DuoPose.closedPortrait,
  DuoPose.openLandscape,
  DuoPose.splitLeft,
  DuoPose.splitRight,
  DuoPose.openPortrait,
  DuoPose.closedLandscape,
  DuoPose.foldableBook,
  DuoPose.foldableTabletop,
];

String n(double v) => v.toStringAsFixed(v % 1 == 0 ? 0 : 1);

// english names read right inside arabic text
String iso(String s) => '\u2068$s\u2069';

typedef Copy = ({
  List<String> tabs,
  String shuffle,
  String pick,
  String close,
  String photo,
  List<String> fits,
  String book,
  String tabletop,
  String closed,
  String open,
  String openPortrait,
  String split,
  String other,
  String counter,
  String note,
  String popCard,
  String dialog,
  String dialogBody,
  String ok,
  String cardBody,
});

const Copy en = (
  tabs: ['Music', 'Gallery', 'Lab'],
  shuffle: 'Shuffle',
  pick: 'Pick a track',
  close: 'Close',
  photo: 'photo',
  fits: [
    'Fills the pane when it hides 15% or less, else letterboxes.',
    'Whole photo, bars where it doesn\'t fit.',
    'Fills the pane, crops whatever sticks out.',
  ],
  book:
      'Book posture: a real hinge. Panes split exactly at it, the gallery '
      'keeps an even column count and "Pop a card" lands on one half.',
  tabletop:
      'Tabletop: open a photo, it goes on top and the info sits on the '
      'bottom half, like a laptop.',
  closed:
      'Closed. Play a track, bump the counter, type a note, then unfold. '
      'Everything should still be there, the record still spinning.',
  open:
      'Open. List and detail sit on each side of the fold at 475.5, the '
      'rail moved next to the island and the FAB went into it.',
  openPortrait:
      'Open portrait. Bottom bar is back, panes stack above and below the '
      'fold.',
  split:
      'Split View: half the inner screen. One pane, and the rail follows '
      'the side the island is on (try splitLeft and splitRight).',
  other: 'Not a Duo. Same app, it just gets two panes because there is room.',
  counter: 'DuoKeep counter\nshould survive every fold',
  note: 'Type something, then fold',
  popCard: 'Pop a card',
  dialog: 'Dialog',
  dialogBody: 'Material dialogs dodge a separating fold on their own.',
  ok: 'OK',
  cardBody: 'Never on the crease. Tap to close.',
);

const Copy ar = (
  tabs: ['الأغاني', 'الصور', 'المختبر'],
  shuffle: 'عشوائي',
  pick: 'اختار أغنية',
  close: 'سد',
  photo: 'صورة',
  fits: [
    'يملّي المساحة إذا ما يگص أكثر من 15%، وإلا يخلي حواشي.',
    'الصورة كلها، وحواشي بالمكان اللي ما تركب بي.',
    'يملّي المساحة ويگص اللي يطلع برّه.',
  ],
  book:
      'وضعية الكتاب: مفصل حقيقي. الألواح تنقسم عليه بالضبط، الصور تبقى '
      'أعمدتها زوجية، و"طلّع كارت" يطلع بنص واحد.',
  tabletop:
      'وضعية الطاولة: افتح صورة، تصير فوگ والمعلومات تنزل للنص الجوّاني، '
      'مثل اللابتوب.',
  closed:
      'مسدود. شغّل أغنية، زيد العداد، اكتب ملاحظة، وبعدين افتحه. كلشي لازم '
      'يبقى، والأسطوانة بعدها تدور.',
  open:
      'مفتوح. القائمة والتفاصيل كل وحدة بصف من الطبگة عند 475.5، الشريط صار '
      'يم الجزيرة والزر دخل بي.',
  openPortrait: 'مفتوح بالطول. الشريط الجوّاني رجع، والألواح فوگ وجوّه الطبگة.',
  split:
      'Split View: نص الشاشة الداخلية. لوح واحد، والشريط يمشي ويه الصف اللي '
      'بي الجزيرة (جرّب splitLeft و splitRight).',
  other: 'مو Duo. نفس التطبيق، بس ياخذ لوحين لأن اكو مكان.',
  counter: 'عداد DuoKeep\nلازم يبقى بكل طبگة',
  note: 'اكتب شي، وبعدين طبّگه',
  popCard: 'طلّع كارت',
  dialog: 'نافذة',
  dialogBody: 'نوافذ Material تتجنب الطبگة الفاصلة من نفسها.',
  ok: 'تمام',
  cardBody: 'أبد ما يگعد عالطبگة. دوس حتى تسده.',
);

// the language follows the direction the lab picks
extension on BuildContext {
  Copy get copy => Directionality.of(this) == TextDirection.rtl ? ar : en;
}

// ---------------------------------------------------------------- lab shell

/// The test lab: a pose picker and toggles around the demo app.
class Playground extends StatefulWidget {
  const Playground({super.key, this.helpOnStart = true});

  final bool helpOnStart;

  @override
  State<Playground> createState() => _PlaygroundState();
}

class _PlaygroundState extends State<Playground>
    with SingleTickerProviderStateMixin {
  // null = the real device, no simulator
  DuoPose? pose = DuoPose.closedPortrait;
  bool guides = true, debug = false, rtl = false, dark = true, bigText = false;
  // real Liquid Glass on iOS, the blur look elsewhere
  bool glass = !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
  // null = the pose's own hinge, else the slider's angle
  double? hinge;
  Timer? touring;
  final _app = GlobalKey();
  final _home = GlobalKey();
  late final flip = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 650),
  );

  @override
  void initState() {
    super.initState();
    if (widget.helpOnStart) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        final context = _home.currentContext;
        if (context != null) showHelp(context);
      });
    }
  }

  void setPose(DuoPose? p) {
    if (p == pose) return;
    setState(() {
      pose = p;
      hinge = null;
    });
    flip.forward(from: 0);
  }

  void toggleTour() {
    if (touring != null) {
      touring!.cancel();
      return setState(() => touring = null);
    }
    var i = math.max(0, tour.indexOf(pose ?? tour.last) + 1);
    setPose(tour[i++ % tour.length]);
    setState(
      () => touring = Timer.periodic(
        const Duration(milliseconds: 2800),
        (_) => setPose(tour[i++ % tour.length]),
      ),
    );
  }

  @override
  void dispose() {
    touring?.cancel();
    flip.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    // the real hinge, used when the pose is 'real device'
    return DuoHardwareScope(
      child: MaterialApp(
        debugShowCheckedModeBanner: false,
        theme: theme(Brightness.dark),
        home: Builder(key: _home, builder: stage),
      ),
    );
  }

  Widget stage(BuildContext context) {
    final base = MediaQuery.of(context);
    // same element across every pose, debug and real-device switch
    final app = KeyedSubtree(
      key: _app,
      child: DuoApp(dark: dark, arabic: rtl, glass: glass),
    );
    final p = bent(pose, hinge);
    Widget screen = MediaQuery(
      data: base.copyWith(
        textScaler: bigText ? const TextScaler.linear(2) : base.textScaler,
      ),
      child: p == null
          ? DuoDebugOverlay(enabled: debug, child: app)
          : DuoSimulator(
              pose: p,
              showGuides: guides,
              child: DuoDebugOverlay(enabled: debug, child: app),
            ),
    );
    if (p != null) {
      screen = Padding(
        padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
        child: Center(
          child: AnimatedBuilder(
            animation: flip,
            child: DecoratedBox(
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(18),
                border: Border.all(color: const Color(0xFF2A2A33), width: 3),
                boxShadow: [
                  BoxShadow(
                    color: brand.withValues(alpha: .35),
                    blurRadius: 40,
                  ),
                ],
              ),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(15),
                child: screen,
              ),
            ),
            builder: (context, child) {
              final t = flip.value;
              final bump = math.sin(t * math.pi);
              return Transform(
                alignment: Alignment.center,
                transform: Matrix4.identity()
                  ..setEntry(3, 2, .0012)
                  ..rotateY(bump * .45 * (1 - t)),
                child: Transform.scale(
                  scale: 1 - .07 * bump,
                  child: Stack(
                    children: [
                      child!,
                      Positioned.fill(
                        child: IgnorePointer(
                          child: CustomPaint(painter: _Sweep(t)),
                        ),
                      ),
                    ],
                  ),
                ),
              );
            },
          ),
        ),
      );
    }
    return Scaffold(
      backgroundColor: const Color(0xFF0B0B10),
      body: Stack(
        children: [
          Column(
            children: [
              Expanded(
                child: SafeArea(
                  top: p != null,
                  bottom: false,
                  left: p != null,
                  right: p != null,
                  child: screen,
                ),
              ),
              if (p != null) controls(context),
            ],
          ),
          if (p == null)
            Positioned(
              top: base.padding.top + 6,
              right: 12,
              child: FilledButton.tonalIcon(
                onPressed: () => setPose(tour.first),
                icon: const Icon(Icons.devices_fold),
                label: const Text('Back to poses'),
              ),
            ),
        ],
      ),
    );
  }

  Widget controls(BuildContext context) {
    Widget toggle(IconData icon, String tip, bool on, VoidCallback tap) =>
        IconButton(
          tooltip: tip,
          isSelected: on,
          onPressed: tap,
          icon: Icon(icon),
          style: IconButton.styleFrom(
            foregroundColor: on ? Colors.white : Colors.white54,
            backgroundColor: on ? brand : Colors.transparent,
          ),
        );
    return SafeArea(
      top: false,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          _Caption(pose: pose, guides: guides),
          if (hingeShape(pose) != null) hingeSlider(),
          SizedBox(
            height: 48,
            // 11 chips, all built so each one can be scrolled to
            child: SingleChildScrollView(
              scrollDirection: Axis.horizontal,
              padding: const EdgeInsets.symmetric(horizontal: 12),
              child: Row(
                children: [
                  for (final p in [...DuoPose.values, null])
                    Padding(
                      padding: const EdgeInsets.only(right: 6),
                      child: ChoiceChip(
                        selected: pose == p,
                        onSelected: (_) {
                          touring?.cancel();
                          touring = null;
                          setPose(p);
                        },
                        avatar: Icon(poseIcon(p), size: 18),
                        label: Text(p?.name ?? 'real device'),
                      ),
                    ),
                ],
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 8),
            child: Row(
              children: [
                FilledButton.icon(
                  onPressed: toggleTour,
                  icon: Icon(
                    touring == null ? Icons.play_arrow : Icons.stop_rounded,
                  ),
                  label: Text(touring == null ? 'Tour' : 'Stop'),
                ),
                // scrolls on narrow phones, right-aligned on wide screens
                Expanded(
                  child: Align(
                    alignment: Alignment.centerRight,
                    child: SingleChildScrollView(
                      scrollDirection: Axis.horizontal,
                      child: Row(
                        mainAxisSize: MainAxisSize.min,
                        children: [
                          toggle(
                            Icons.help_outline,
                            'How to test',
                            false,
                            () => showHelp(context),
                          ),
                          toggle(
                            Icons.blur_on,
                            'Liquid Glass',
                            glass,
                            () => setState(() => glass = !glass),
                          ),
                          toggle(
                            Icons.grid_on,
                            'Fold & safe-area guides',
                            guides,
                            () => setState(() => guides = !guides),
                          ),
                          toggle(
                            Icons.bug_report_outlined,
                            'DuoDebugOverlay',
                            debug,
                            () => setState(() => debug = !debug),
                          ),
                          toggle(
                            Icons.translate,
                            'العربية, right to left',
                            rtl,
                            () => setState(() => rtl = !rtl),
                          ),
                          toggle(
                            Icons.format_size,
                            'Text 2x',
                            bigText,
                            () => setState(() => bigText = !bigText),
                          ),
                          toggle(
                            Icons.dark_mode_outlined,
                            'Dark app',
                            dark,
                            () => setState(() => dark = !dark),
                          ),
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }

  // bends the simulated Duo, the app sees it like the real hinge
  Widget hingeSlider() {
    final angle = hinge ?? pose?.hardware?.angle ?? 180;
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Row(
        children: [
          const Icon(Icons.devices_fold, color: Colors.white70, size: 20),
          const SizedBox(width: 8),
          const Text('Hinge', style: TextStyle(color: Colors.white70)),
          Expanded(
            child: Slider(
              min: 20,
              max: 180,
              value: angle,
              label: '${angle.round()}°',
              onChanged: (v) {
                touring?.cancel();
                setState(() {
                  touring = null;
                  hinge = v;
                });
              },
            ),
          ),
          SizedBox(
            width: 44,
            child: Text(
              '${angle.round()}°',
              textAlign: TextAlign.end,
              style: const TextStyle(
                color: Colors.white,
                fontWeight: FontWeight.w700,
                fontFeatures: [FontFeature.tabularFigures()],
              ),
            ),
          ),
        ],
      ),
    );
  }
}

// the inner screen poses the hinge slider can bend
DuoPose? hingeShape(DuoPose? p) => switch (p?.name) {
  'openLandscape' || 'halfOpenBook' => DuoPose.halfOpenBook,
  'openPortrait' || 'halfOpenTabletop' => DuoPose.halfOpenTabletop,
  _ => null,
};

// p with its hinge at angle, iOS reports it flat from about 176°
DuoPose? bent(DuoPose? p, double? angle) {
  final shape = hingeShape(p);
  if (p == null || shape == null || angle == null) return p;
  final h = shape.hardware!;
  return DuoPose(
    p.name,
    p.size,
    padding: p.padding,
    hardware: DuoHardware(
      supported: true,
      hasHinge: true,
      status: angle >= 176
          ? DuoHingeStatus.fullyOpen
          : DuoHingeStatus.partiallyOpen,
      angle: angle,
      folds: h.folds,
      horizontalSizeClass: h.horizontalSizeClass,
      verticalSizeClass: h.verticalSizeClass,
      barEdge: h.barEdge,
    ),
  );
}

// what each pose is, in plain words
(String, String) poseInfo(DuoPose? p) => switch (p?.name) {
  null => (
    'This device',
    'No simulation. The app sees your real screen, so on a foldable '
        '(like a Pixel Fold emulator) the fold comes from Android itself.',
  ),
  'closedPortrait' => (
    'iPhone Duo, closed',
    'Outer screen, 466 × 678. Works like a small phone: one pane.',
  ),
  'closedLandscape' => (
    'iPhone Duo, closed, sideways',
    'Outer screen turned, 678 × 466. Still one pane.',
  ),
  'openLandscape' => (
    'iPhone Duo, open',
    'Inner screen, 951 × 669, fold down the middle. Two panes.',
  ),
  'openPortrait' => (
    'iPhone Duo, open, upright',
    'Inner screen turned, 669 × 951, fold across. Two panes, bottom bar.',
  ),
  'splitLeft' => (
    'iPhone Duo, Split View, left app',
    'Two apps side by side, this is the left one. One pane.',
  ),
  'splitRight' => (
    'iPhone Duo, Split View, right app',
    'This is the right one, next to the island. One pane.',
  ),
  'halfOpenBook' => (
    'iPhone Duo, half open',
    'Inner screen bent like a book. iOS reports a 40 pt fold, panes '
        'split around it.',
  ),
  'halfOpenTabletop' => (
    'iPhone Duo, tabletop',
    'Inner screen upright, bent on a table. Top and bottom halves.',
  ),
  'foldableBook' => (
    'Android foldable, half open',
    'Held like a book with a real hinge. Panes split at it.',
  ),
  'foldableTabletop' => (
    'Android foldable, tabletop',
    'Half open on a table, like a laptop. Top and bottom halves.',
  ),
  'iPhone' => ('Regular iPhone', '402 × 874. Nothing special, one pane.'),
  'iPad' => ('iPad', '1032 × 1376. Room for two panes.'),
  _ => (p!.name, ''),
};

const statHelp = {
  'mode': 'What kind of window this is: closed, open, split view, tablet…',
  'posture':
      'How a foldable is bent: closed, flat, book or tabletop. Comes from '
      'the hinge on the Duo and from Android on other foldables.',
  'hinge': 'Hinge status and live angle, 0° shut and 180° flat.',
  'cameras': 'Cameras in use that content should keep clear of.',
  'bar': 'The side iOS puts its vertical bar on, where the rail goes.',
  'size': 'Window size in points.',
  'iPhone Duo':
      'True for the exact Duo sizes on iOS, or when the hinge '
      'says so.',
  'expanded': 'Room for two panes (at least 600 × 480). Layouts use this.',
  'columns': '1 or 2, from expanded.',
  'rail': 'Side rail instead of a bottom bar.',
  'fold': 'Where the fold is, in points.',
  'separating':
      'A real hinge or a half-open fold that content should '
      'avoid.',
  'safe': 'Space the system covers: left, top, right, bottom.',
  'grid(150)': 'Columns for 150 pt tiles, even on a vertical fold.',
  'platform': 'Which platform the app thinks it runs on.',
};

class _Caption extends StatelessWidget {
  const _Caption({required this.pose, required this.guides});

  final DuoPose? pose;
  final bool guides;

  @override
  Widget build(BuildContext context) {
    final (title, about) = poseInfo(pose);
    Widget dot(Color c, String label) => Padding(
      padding: const EdgeInsets.only(left: 10),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 10,
            height: 10,
            decoration: BoxDecoration(
              color: c,
              borderRadius: BorderRadius.circular(2),
            ),
          ),
          const SizedBox(width: 4),
          Text(label, style: const TextStyle(fontSize: 11)),
        ],
      ),
    );
    return Padding(
      padding: const EdgeInsets.fromLTRB(16, 4, 16, 4),
      child: AnimatedSwitcher(
        duration: const Duration(milliseconds: 250),
        child: Row(
          key: ValueKey('$title$guides'),
          children: [
            Expanded(
              child: Text.rich(
                TextSpan(
                  children: [
                    TextSpan(
                      text: '$title  ',
                      style: const TextStyle(fontWeight: FontWeight.w700),
                    ),
                    TextSpan(
                      text: about,
                      style: const TextStyle(color: Colors.white60),
                    ),
                  ],
                ),
                maxLines: 2,
                overflow: TextOverflow.ellipsis,
                style: const TextStyle(fontSize: 12),
              ),
            ),
            if (guides && pose != null) ...[
              dot(const Color(0xFFFF6B60), 'system'),
              dot(const Color(0xFF3B8BFF), 'fold'),
            ],
          ],
        ),
      ),
    );
  }
}

void showHelp(BuildContext context) {
  Widget h(String t) => Padding(
    padding: const EdgeInsets.only(top: 20, bottom: 6),
    child: Text(
      t,
      style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
    ),
  );
  Widget row(Widget lead, String title, String body) => Padding(
    padding: const EdgeInsets.symmetric(vertical: 6),
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SizedBox(width: 36, child: lead),
        const SizedBox(width: 8),
        Expanded(
          child: Text.rich(
            TextSpan(
              children: [
                TextSpan(
                  text: '$title\n',
                  style: const TextStyle(fontWeight: FontWeight.w700),
                ),
                TextSpan(
                  text: body,
                  style: const TextStyle(color: Colors.white70),
                ),
              ],
            ),
          ),
        ),
      ],
    ),
  );
  Widget icon(IconData i) => Icon(i, color: brand);
  Widget swatch(Color c) => Align(
    alignment: Alignment.topLeft,
    child: Container(
      width: 22,
      height: 22,
      margin: const EdgeInsets.only(top: 2),
      decoration: BoxDecoration(
        color: c,
        borderRadius: BorderRadius.circular(4),
      ),
    ),
  );
  showModalBottomSheet<void>(
    context: context,
    isScrollControlled: true,
    showDragHandle: true,
    builder: (context) => DraggableScrollableSheet(
      expand: false,
      initialChildSize: .85,
      maxChildSize: .95,
      builder: (context, scroll) => ListView(
        controller: scroll,
        padding: const EdgeInsets.fromLTRB(20, 0, 20, 32),
        children: [
          const Text(
            'How to test duo_dynamic_sizing',
            style: TextStyle(fontSize: 24, fontWeight: FontWeight.w800),
          ),
          const SizedBox(height: 8),
          const Text(
            'The phone in the middle is a real Flutter app running inside a '
            'pretend screen. Pick a pose at the bottom and the same app '
            'rearranges itself for that screen, without losing what you '
            'were doing. That is the whole package in one sentence.',
            style: TextStyle(color: Colors.white70, height: 1.4),
          ),
          h('Try this'),
          row(
            const Text('1', style: TextStyle(fontSize: 20)),
            'Fold with music playing',
            'On closedPortrait, open Music and play a track. Tap '
                'openLandscape: the list appears next to it and the song '
                'keeps playing from the same second.',
          ),
          row(
            const Text('2', style: TextStyle(fontSize: 20)),
            'Nothing gets lost',
            'In Lab, bump the counter and type a note. Switch poses as '
                'much as you like, they stay.',
          ),
          row(
            const Text('3', style: TextStyle(fontSize: 20)),
            'Laptop mode',
            'Pick foldableTabletop, open a photo in Gallery: photo on top, '
                'details on the bottom half.',
          ),
          row(
            const Text('4', style: TextStyle(fontSize: 20)),
            'Split View',
            'Compare splitLeft and splitRight: the side rail moves to the '
                'side of the Dynamic Island.',
          ),
          row(
            const Text('5', style: TextStyle(fontSize: 20)),
            'Arabic and big text',
            'Turn on العربية: everything mirrors but panes stay on the fold. '
                'Turn on 2x text: nothing should overflow.',
          ),
          h('The colors on the screen'),
          row(
            swatch(const Color(0xFFFF6B60)),
            'Red: the system uses this',
            'On the Duo, the wide strip is the vertical Dynamic Island and '
                'the thin one at the bottom is the home bar. Content stays '
                'out of it.',
          ),
          row(
            swatch(const Color(0xFF3B8BFF)),
            'Blue: the fold',
            'Where the screen bends. Two-pane layouts split exactly here.',
          ),
          h('The buttons'),
          row(
            icon(Icons.play_arrow),
            'Tour',
            'Walks through the Duo poses by itself every few seconds.',
          ),
          row(
            icon(Icons.grid_on),
            'Guides',
            'Shows or hides the red and blue areas above.',
          ),
          row(
            icon(Icons.bug_report_outlined),
            'Debug overlay',
            'Prints what the package sees (context.duo) on top of the app.',
          ),
          row(
            icon(Icons.translate),
            'العربية',
            'Switches the app to Arabic, right to left.',
          ),
          row(icon(Icons.format_size), 'Big text', 'Text at 200%.'),
          row(icon(Icons.dark_mode_outlined), 'Dark', 'Light or dark app.'),
          row(
            icon(Icons.blur_on),
            'Liquid Glass',
            'Puts the side rail, or the bottom bar as a floating capsule, on '
                'Liquid Glass: the real one on iOS, the same look drawn in '
                'Flutter elsewhere.',
          ),
          row(
            icon(Icons.devices_fold),
            'Hinge slider',
            'On the open Duo poses, bend the hinge from flat to almost '
                'shut. Posture, fold and panes follow, and the Lab shows '
                'the angle, like on a real Duo.',
          ),
          h('The poses'),
          for (final p in [...DuoPose.values, null])
            row(
              icon(poseIcon(p)),
              '${p?.name ?? 'real device'} · ${poseInfo(p).$1}',
              poseInfo(p).$2,
            ),
          h('The tabs'),
          row(
            icon(Icons.library_music),
            'Music',
            'A list and a player. One pane on small screens, side by side '
                'when there is room (DuoListDetail).',
          ),
          row(
            icon(Icons.photo_library),
            'Gallery',
            'Tap a photo. The three buttons at the top change how it fits '
                'odd-shaped screens (DuoMedia).',
          ),
          row(
            icon(Icons.science),
            'Lab',
            'Live values from the package. Tap any value to see what it '
                'means.',
          ),
          h('Lab values'),
          for (final e in statHelp.entries)
            row(const SizedBox(), e.key, e.value),
          const SizedBox(height: 16),
          FilledButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('Start testing'),
          ),
        ],
      ),
    ),
  );
}

IconData poseIcon(DuoPose? p) => switch (p?.name) {
  null => Icons.phone_android,
  'closedPortrait' => Icons.stay_current_portrait,
  'closedLandscape' => Icons.stay_current_landscape,
  'openLandscape' || 'openPortrait' => Icons.menu_book,
  'splitLeft' || 'splitRight' => Icons.vertical_split,
  'foldableBook' || 'halfOpenBook' => Icons.auto_stories,
  'halfOpenTabletop' => Icons.laptop_mac,
  'foldableTabletop' => Icons.laptop,
  'iPad' => Icons.tablet_mac,
  _ => Icons.phone_iphone,
};

// light sweep across the screen on every fold
class _Sweep extends CustomPainter {
  _Sweep(this.t);

  final double t;

  @override
  void paint(Canvas canvas, Size size) {
    if (t == 0 || t == 1) return;
    final x = size.width * (t * 1.6 - .3);
    final band = Rect.fromLTWH(x - 80, 0, 160, size.height);
    canvas.drawRect(
      band,
      Paint()
        ..shader = LinearGradient(
          colors: [
            Colors.white.withValues(alpha: 0),
            Colors.white.withValues(alpha: .22 * math.sin(t * math.pi)),
            Colors.white.withValues(alpha: 0),
          ],
        ).createShader(band),
    );
  }

  @override
  bool shouldRepaint(_Sweep old) => old.t != t;
}

ThemeData theme(Brightness b) {
  final scheme = ColorScheme.fromSeed(seedColor: brand, brightness: b);
  return ThemeData(
    colorScheme: scheme,
    scaffoldBackgroundColor: scheme.surface,
    appBarTheme: AppBarTheme(
      backgroundColor: scheme.surface,
      scrolledUnderElevation: 0,
      centerTitle: false,
    ),
  );
}

// ---------------------------------------------------------------- the app

/// The demo app. The lab around it picks the pose, language and theme.
class DuoApp extends StatelessWidget {
  const DuoApp({
    super.key,
    this.dark = true,
    this.arabic = false,
    this.initialTab = 0,
    this.initialTrack,
    this.initialPhoto,
    this.initialFit = DuoMediaFit.smart,
    this.fontFallback,
    this.glass = false,
  });

  final bool dark, arabic, glass;
  final int initialTab;
  final int? initialTrack, initialPhoto;
  final DuoMediaFit initialFit;
  final List<String>? fontFallback;

  @override
  Widget build(BuildContext context) {
    ThemeData themed(Brightness b) => theme(b).copyWith(
      textTheme: theme(b).textTheme.apply(fontFamilyFallback: fontFallback),
    );
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: themed(Brightness.light),
      darkTheme: themed(Brightness.dark),
      themeMode: dark ? ThemeMode.dark : ThemeMode.light,
      themeAnimationDuration: const Duration(milliseconds: 400),
      builder: (context, child) => _GlassRail(
        on: glass,
        child: Directionality(
          textDirection: arabic ? TextDirection.rtl : TextDirection.ltr,
          child: child!,
        ),
      ),
      onGenerateRoute: (_) => MaterialPageRoute(
        builder: (_) =>
            Home(initialTab: initialTab, initialTrack: initialTrack),
      ),
      // home, with a photo already open on top for the README renders
      onGenerateInitialRoutes: (_) => [
        MaterialPageRoute(
          builder: (_) =>
              Home(initialTab: initialTab, initialTrack: initialTrack),
        ),
        if (initialPhoto case final photo?)
          MaterialPageRoute(
            builder: (_) => Viewer(index: photo, initialFit: initialFit),
          ),
      ],
    );
  }
}

// carries the playground's glass button to the scaffold inside the routes
class _GlassRail extends InheritedWidget {
  const _GlassRail({required this.on, required super.child});

  final bool on;

  static bool of(BuildContext context) =>
      context.dependOnInheritedWidgetOfExactType<_GlassRail>()?.on ?? false;

  @override
  bool updateShouldNotify(_GlassRail old) => on != old.on;
}

// soft brand color under the glass rail, readable in light and dark
class _Aurora extends StatelessWidget {
  const _Aurora();

  @override
  Widget build(BuildContext context) {
    final surface = Theme.of(context).colorScheme.surface;
    final dark = Theme.of(context).brightness == Brightness.dark;
    Color tint(Color c, double a) =>
        Color.alphaBlend(c.withValues(alpha: dark ? a * 1.4 : a), surface);
    return DecoratedBox(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [
            tint(brand, .28),
            tint(const Color(0xFF22D3EE), .16),
            tint(const Color(0xFFEC4899), .22),
          ],
        ),
      ),
    );
  }
}

class Home extends StatefulWidget {
  const Home({super.key, this.initialTab = 0, this.initialTrack});

  final int initialTab;
  final int? initialTrack;

  @override
  State<Home> createState() => _HomeState();
}

class _HomeState extends State<Home> {
  late int tab = widget.initialTab;
  late final playing = ValueNotifier<int?>(widget.initialTrack);
  final _rng = math.Random();

  @override
  void dispose() {
    playing.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final c = context.copy;
    return DuoNavigationScaffold(
      // the playground's glass button, off in the README renders. glass over
      // a flat color looks like a gray pill, so color runs under it too
      glass: _GlassRail.of(context),
      background: _GlassRail.of(context) ? const _Aurora() : null,
      selectedIndex: tab,
      onDestinationSelected: (i) => setState(() => tab = i),
      appBar: AppBar(
        // the color runs to the top of the screen, no band behind the title
        backgroundColor: _GlassRail.of(context) ? Colors.transparent : null,
        title: AnimatedSwitcher(
          duration: const Duration(milliseconds: 300),
          transitionBuilder: (child, a) => FadeTransition(
            opacity: a,
            child: SlideTransition(
              position: Tween(
                begin: const Offset(0, .4),
                end: Offset.zero,
              ).animate(a),
              child: child,
            ),
          ),
          child: Text(
            c.tabs[tab],
            key: ValueKey(tab),
            style: const TextStyle(fontWeight: FontWeight.w800),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        tooltip: c.shuffle,
        onPressed: () {
          playing.value = _rng.nextInt(tracks.length);
          setState(() => tab = 0);
        },
        child: const Icon(Icons.shuffle),
      ),
      destinations: [
        DuoDestination(
          icon: const Icon(Icons.library_music_outlined),
          selectedIcon: const Icon(Icons.library_music),
          label: c.tabs[0],
        ),
        DuoDestination(
          icon: const Icon(Icons.photo_library_outlined),
          selectedIcon: const Icon(Icons.photo_library),
          label: c.tabs[1],
        ),
        DuoDestination(
          icon: const Icon(Icons.science_outlined),
          selectedIcon: const Icon(Icons.science),
          label: c.tabs[2],
        ),
      ],
      // every tab stays alive, so a fold never loses anything
      body: IndexedStack(
        index: tab,
        children: [
          MusicPage(playing: playing),
          const GalleryPage(),
          const LabPage(),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------- music

class MusicPage extends StatelessWidget {
  const MusicPage({super.key, required this.playing});

  final ValueNotifier<int?> playing;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: playing,
      builder: (context, selected, _) => DuoListDetail<int>(
        selected: selected,
        onClose: () => playing.value = null,
        list: (context) => ListView.builder(
          padding: const EdgeInsets.symmetric(vertical: 8),
          itemCount: tracks.length,
          itemBuilder: (context, i) => _Stagger(
            index: i,
            child: _TrackTile(
              track: tracks[i],
              selected: i == selected,
              onTap: () => playing.value = i,
            ),
          ),
        ),
        empty: (context) =>
            _Empty(icon: Icons.album_outlined, text: context.copy.pick),
        detail: (context, i) => NowPlaying(
          key: ValueKey(i),
          track: tracks[i],
          onClose: () => playing.value = null,
        ),
      ),
    );
  }
}

// slides in once, a replay after a fold would mean the list lost its state
class _Stagger extends StatelessWidget {
  const _Stagger({required this.index, required this.child});

  final int index;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    if (MediaQuery.disableAnimationsOf(context)) return child;
    return TweenAnimationBuilder<double>(
      tween: Tween(begin: 0, end: 1),
      duration: Duration(milliseconds: 380 + index * 70),
      curve: Curves.easeOutCubic,
      child: child,
      builder: (context, v, child) => Opacity(
        opacity: v,
        child: Transform.translate(
          offset: Offset(0, 24 * (1 - v)),
          child: child,
        ),
      ),
    );
  }
}

class _TrackTile extends StatelessWidget {
  const _TrackTile({
    required this.track,
    required this.selected,
    required this.onTap,
  });

  final Track track;
  final bool selected;
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
      child: ListTile(
        selected: selected,
        selectedTileColor: scheme.primaryContainer,
        selectedColor: scheme.onPrimaryContainer,
        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
        onTap: onTap,
        leading: Container(
          width: 48,
          height: 48,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(12),
            gradient: LinearGradient(colors: [track.a, track.b]),
          ),
          child: selected
              ? const Icon(Icons.graphic_eq, color: Colors.white)
              : null,
        ),
        title: Text(
          iso(track.title),
          maxLines: 1,
          overflow: TextOverflow.ellipsis,
          style: const TextStyle(fontWeight: FontWeight.w600),
        ),
        subtitle: Text(iso(track.artist)),
        trailing: Text(clock(track.secs.toDouble())),
      ),
    );
  }
}

String clock(double secs) =>
    '${secs ~/ 60}:${(secs % 60).floor().toString().padLeft(2, '0')}';

class NowPlaying extends StatefulWidget {
  const NowPlaying({super.key, required this.track, required this.onClose});

  final Track track;
  final VoidCallback onClose;

  @override
  State<NowPlaying> createState() => _NowPlayingState();
}

class _NowPlayingState extends State<NowPlaying> with TickerProviderStateMixin {
  // spin angle and position survive a fold, that's the test
  late final spin = AnimationController(
    vsync: this,
    duration: const Duration(seconds: 6),
  );
  late final progress = AnimationController(
    vsync: this,
    duration: Duration(seconds: widget.track.secs),
  );
  bool playing = true;
  bool _started = false;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (_started) return;
    _started = true;
    // reduce motion: a still record
    if (MediaQuery.disableAnimationsOf(context)) return;
    spin.repeat();
    progress.repeat();
  }

  void toggle() {
    setState(() => playing = !playing);
    if (playing) {
      spin.repeat();
      progress.repeat();
    } else {
      spin.stop();
      progress.stop();
    }
  }

  @override
  void dispose() {
    spin.dispose();
    progress.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final t = widget.track;
    final scheme = Theme.of(context).colorScheme;
    final text = Theme.of(context).textTheme;
    return DecoratedBox(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [t.a.withValues(alpha: .45), scheme.surface],
        ),
      ),
      child: LayoutBuilder(
        builder: (context, box) {
          final wide = box.maxWidth > box.maxHeight * 1.15;
          final disc = wide
              ? math.min(box.maxHeight * .7, box.maxWidth * .4)
              : math.min(box.maxWidth * .62, box.maxHeight * .42);
          final record = SizedBox.square(
            dimension: disc,
            child: Stack(
              fit: StackFit.expand,
              children: [
                RotationTransition(
                  turns: spin,
                  child: CustomPaint(painter: _Vinyl(t.a, t.b)),
                ),
                const IgnorePointer(child: CustomPaint(painter: _Shine())),
              ],
            ),
          );
          final info = Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              Text(
                iso(t.title),
                textAlign: TextAlign.center,
                style: text.headlineSmall?.copyWith(
                  fontWeight: FontWeight.w800,
                ),
              ),
              Text(
                iso(t.artist),
                textAlign: TextAlign.center,
                style: text.titleMedium?.copyWith(color: scheme.outline),
              ),
              const SizedBox(height: 16),
              Center(
                child: _Equalizer(spin: spin, playing: playing, color: t.a),
              ),
              AnimatedBuilder(
                animation: progress,
                builder: (context, _) => Column(
                  children: [
                    Slider(
                      value: progress.value,
                      activeColor: t.a,
                      onChanged: (v) => progress.value = v,
                    ),
                    Padding(
                      padding: const EdgeInsets.symmetric(horizontal: 24),
                      child: Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          Text(clock(progress.value * t.secs)),
                          Text(clock(t.secs.toDouble())),
                        ],
                      ),
                    ),
                  ],
                ),
              ),
              Row(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  IconButton(
                    onPressed: () => progress.value = 0,
                    icon: const Icon(Icons.skip_previous_rounded, size: 32),
                  ),
                  const SizedBox(width: 12),
                  FloatingActionButton.large(
                    heroTag: null,
                    backgroundColor: t.a,
                    foregroundColor: Colors.white,
                    onPressed: toggle,
                    child: AnimatedSwitcher(
                      duration: const Duration(milliseconds: 250),
                      transitionBuilder: (c, a) =>
                          ScaleTransition(scale: a, child: c),
                      child: Icon(
                        playing
                            ? Icons.pause_rounded
                            : Icons.play_arrow_rounded,
                        key: ValueKey(playing),
                        size: 40,
                      ),
                    ),
                  ),
                  const SizedBox(width: 12),
                  IconButton(
                    onPressed: () =>
                        progress.value = math.min(1, progress.value + .1),
                    icon: const Icon(Icons.skip_next_rounded, size: 32),
                  ),
                ],
              ),
            ],
          );
          return Stack(
            children: [
              Center(
                child: SingleChildScrollView(
                  padding: const EdgeInsets.all(20),
                  child: wide
                      ? Row(
                          children: [
                            Expanded(child: Center(child: record)),
                            const SizedBox(width: 20),
                            Expanded(child: info),
                          ],
                        )
                      : Column(
                          mainAxisSize: MainAxisSize.min,
                          children: [record, const SizedBox(height: 20), info],
                        ),
                ),
              ),
              PositionedDirectional(
                top: 4,
                start: 4,
                child: IconButton(
                  tooltip: context.copy.close,
                  onPressed: widget.onClose,
                  icon: const Icon(Icons.close),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

class _Equalizer extends StatelessWidget {
  const _Equalizer({
    required this.spin,
    required this.playing,
    required this.color,
  });

  final Animation<double> spin;
  final bool playing;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 36,
      child: AnimatedBuilder(
        animation: spin,
        builder: (context, _) => Row(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            for (var i = 0; i < 9; i++)
              Container(
                width: 5,
                height: playing
                    ? 6 +
                          28 *
                              (.5 +
                                  .5 *
                                      math.sin(
                                        spin.value * math.pi * 2 * (i % 4 + 3) +
                                            i * 1.3,
                                      ))
                    : 6,
                margin: const EdgeInsets.symmetric(horizontal: 2),
                decoration: BoxDecoration(
                  color: color,
                  borderRadius: BorderRadius.circular(3),
                ),
              ),
          ],
        ),
      ),
    );
  }
}

class _Vinyl extends CustomPainter {
  _Vinyl(this.a, this.b);

  final Color a, b;

  @override
  void paint(Canvas canvas, Size size) {
    final c = size.center(Offset.zero);
    final r = size.shortestSide / 2;
    canvas.drawCircle(c, r, Paint()..color = const Color(0xFF111116));
    final groove = Paint()
      ..style = PaintingStyle.stroke
      ..strokeWidth = 1
      ..color = Colors.white.withValues(alpha: .07);
    for (var g = r * .42; g < r * .97; g += r * .035) {
      canvas.drawCircle(c, g, groove);
    }
    final label = Rect.fromCircle(center: c, radius: r * .36);
    canvas
      ..drawCircle(
        c,
        r * .36,
        Paint()..shader = LinearGradient(colors: [a, b]).createShader(label),
      )
      ..drawCircle(
        c + Offset(0, -r * .24),
        r * .04,
        Paint()..color = Colors.white70,
      )
      ..drawCircle(c, r * .035, Paint()..color = const Color(0xFF111116));
  }

  @override
  bool shouldRepaint(_Vinyl old) => old.a != a || old.b != b;
}

// static glare, stays put while the record turns under it
class _Shine extends CustomPainter {
  const _Shine();

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    canvas.drawCircle(
      rect.center,
      size.shortestSide / 2,
      Paint()
        ..shader = SweepGradient(
          colors: [
            Colors.white.withValues(alpha: 0),
            Colors.white.withValues(alpha: .10),
            Colors.white.withValues(alpha: 0),
            Colors.white.withValues(alpha: .10),
            Colors.white.withValues(alpha: 0),
          ],
        ).createShader(rect),
    );
  }

  @override
  bool shouldRepaint(_Shine old) => false;
}

class _Empty extends StatefulWidget {
  const _Empty({required this.icon, required this.text});

  final IconData icon;
  final String text;

  @override
  State<_Empty> createState() => _EmptyState();
}

class _EmptyState extends State<_Empty> with SingleTickerProviderStateMixin {
  late final pulse = AnimationController(
    vsync: this,
    value: .5,
    duration: const Duration(milliseconds: 1400),
  );

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    if (!MediaQuery.disableAnimationsOf(context) && !pulse.isAnimating) {
      pulse.repeat(reverse: true);
    }
  }

  @override
  void dispose() {
    pulse.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Center(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          ScaleTransition(
            scale: Tween(
              begin: .9,
              end: 1.1,
            ).animate(CurvedAnimation(parent: pulse, curve: Curves.easeInOut)),
            child: Icon(widget.icon, size: 72, color: scheme.primary),
          ),
          const SizedBox(height: 12),
          Text(widget.text, style: TextStyle(color: scheme.outline)),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------- gallery

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

  @override
  Widget build(BuildContext context) {
    final duo = context.duo;
    return GridView.builder(
      padding: EdgeInsets.all(duo.margin),
      // even count on a vertical fold, no tile sits on the crease
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: duo.gridColumns(150),
        mainAxisSpacing: 10,
        crossAxisSpacing: 10,
      ),
      itemCount: photos.length,
      itemBuilder: (context, i) => _Stagger(
        index: i,
        child: GestureDetector(
          onTap: () => Navigator.of(context).push(
            PageRouteBuilder(
              transitionDuration: const Duration(milliseconds: 450),
              reverseTransitionDuration: const Duration(milliseconds: 350),
              pageBuilder: (context, a, _) => FadeTransition(
                opacity: a,
                child: Viewer(index: i),
              ),
            ),
          ),
          child: Hero(
            tag: 'photo$i',
            child: ClipRRect(
              borderRadius: BorderRadius.circular(14),
              child: CustomPaint(painter: _Landscape(photos[i])),
            ),
          ),
        ),
      ),
    );
  }
}

class _Landscape extends CustomPainter {
  _Landscape(this.p);

  final Photo p;

  @override
  void paint(Canvas canvas, Size size) {
    final rect = Offset.zero & size;
    canvas.drawRect(
      rect,
      Paint()
        ..shader = LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [p.sky, Color.lerp(p.sky, p.sun, .55)!],
        ).createShader(rect),
    );
    final sun = Offset(size.width * .66, size.height * .42);
    final s = size.shortestSide;
    canvas
      ..drawCircle(sun, s * .26, Paint()..color = p.sun.withValues(alpha: .18))
      ..drawCircle(sun, s * .14, Paint()..color = p.sun);
    for (var k = 0; k < 3; k++) {
      final base = size.height * (.6 + k * .13);
      final path = Path()..moveTo(0, size.height);
      for (var x = 0.0; x <= size.width; x += size.width / 32) {
        final wave = math.sin(x / size.width * math.pi * (2 + k) + k * 1.7);
        path.lineTo(x, base - (wave + 1) * size.height * .06);
      }
      path
        ..lineTo(size.width, size.height)
        ..close();
      canvas.drawPath(
        path,
        Paint()..color = Color.lerp(p.land, Colors.black, k * .28)!,
      );
    }
  }

  @override
  bool shouldRepaint(_Landscape old) => old.p != p;
}

class Viewer extends StatefulWidget {
  const Viewer({
    super.key,
    required this.index,
    this.initialFit = DuoMediaFit.smart,
  });

  final int index;
  final DuoMediaFit initialFit;

  @override
  State<Viewer> createState() => _ViewerState();
}

class _ViewerState extends State<Viewer> {
  late DuoMediaFit fit = widget.initialFit;

  @override
  Widget build(BuildContext context) {
    final p = photos[widget.index];
    return Scaffold(
      backgroundColor: Colors.black,
      appBar: AppBar(
        backgroundColor: Colors.black,
        foregroundColor: Colors.white,
        title: Text(iso(p.title)),
        actions: [
          SegmentedButton<DuoMediaFit>(
            showSelectedIcon: false,
            style: SegmentedButton.styleFrom(
              foregroundColor: Colors.white70,
              selectedForegroundColor: Colors.white,
              selectedBackgroundColor: brand,
              visualDensity: VisualDensity.compact,
            ),
            segments: const [
              ButtonSegment(
                value: DuoMediaFit.smart,
                icon: Icon(Icons.auto_awesome),
                tooltip: 'smart',
              ),
              ButtonSegment(
                value: DuoMediaFit.contain,
                icon: Icon(Icons.fit_screen),
                tooltip: 'contain',
              ),
              ButtonSegment(
                value: DuoMediaFit.cover,
                icon: Icon(Icons.crop),
                tooltip: 'cover',
              ),
            ],
            selected: {fit},
            onSelectionChanged: (s) => setState(() => fit = s.first),
          ),
          const SizedBox(width: 8),
        ],
      ),
      // book: photo | info, tabletop: photo over info, closed: photo only
      body: DuoSplit(
        primary: LayoutBuilder(
          builder: (context, box) {
            final shown = DuoMedia.fitSize(p.ratio, box.biggest, fit: fit);
            final visible =
                math.min(box.maxWidth, shown.width) *
                math.min(box.maxHeight, shown.height) /
                (shown.width * shown.height);
            final crop = ((1 - visible) * 100).round();
            return Stack(
              fit: StackFit.expand,
              children: [
                Hero(
                  tag: 'photo${widget.index}',
                  child: DuoMedia(
                    aspectRatio: p.ratio,
                    fit: fit,
                    child: CustomPaint(painter: _Landscape(p)),
                  ),
                ),
                PositionedDirectional(
                  bottom: 12,
                  start: 12,
                  child: _Pill(
                    '${fit.name} · ${ratio(p.ratio)} · '
                    '${crop == 0 ? 'no crop' : 'crops $crop%'}',
                  ),
                ),
              ],
            );
          },
        ),
        secondary: _PhotoInfo(photo: p, fit: fit),
      ),
    );
  }
}

String ratio(double r) => switch (r) {
  1 => '1:1',
  _ when (r - 16 / 9).abs() < .01 => '16:9',
  _ when (r - 9 / 16).abs() < .01 => '9:16',
  _ when (r - 21 / 9).abs() < .01 => '21:9',
  _ when (r - 4 / 3).abs() < .01 => '4:3',
  _ when (r - 3 / 4).abs() < .01 => '3:4',
  _ when (r - 3 / 2).abs() < .01 => '3:2',
  _ => '2:3',
};

class _Pill extends StatelessWidget {
  const _Pill(this.text);

  final String text;

  @override
  Widget build(BuildContext context) {
    return DecoratedBox(
      decoration: BoxDecoration(
        color: Colors.black.withValues(alpha: .6),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
        child: AnimatedSwitcher(
          duration: const Duration(milliseconds: 250),
          child: Text(
            text,
            key: ValueKey(text),
            style: const TextStyle(color: Colors.white, fontSize: 12),
          ),
        ),
      ),
    );
  }
}

class _PhotoInfo extends StatelessWidget {
  const _PhotoInfo({required this.photo, required this.fit});

  final Photo photo;
  final DuoMediaFit fit;

  @override
  Widget build(BuildContext context) {
    final text = Theme.of(context).textTheme;
    return ColoredBox(
      color: const Color(0xFF121218),
      child: ListView(
        padding: const EdgeInsets.all(24),
        children: [
          Text(
            iso(photo.title),
            style: text.headlineMedium?.copyWith(
              color: Colors.white,
              fontWeight: FontWeight.w800,
            ),
          ),
          const SizedBox(height: 8),
          Text(
            '${ratio(photo.ratio)} · ${context.copy.photo}',
            style: const TextStyle(color: Colors.white54),
          ),
          const SizedBox(height: 20),
          for (final (f, about) in [
            (DuoMediaFit.smart, context.copy.fits[0]),
            (DuoMediaFit.contain, context.copy.fits[1]),
            (DuoMediaFit.cover, context.copy.fits[2]),
          ])
            AnimatedContainer(
              duration: const Duration(milliseconds: 250),
              margin: const EdgeInsets.only(bottom: 8),
              padding: const EdgeInsets.all(14),
              decoration: BoxDecoration(
                color: f == fit
                    ? brand.withValues(alpha: .35)
                    : Colors.white.withValues(alpha: .05),
                borderRadius: BorderRadius.circular(14),
              ),
              child: Text(
                '${f.name}\n$about',
                style: const TextStyle(color: Colors.white, height: 1.4),
              ),
            ),
        ],
      ),
    );
  }
}

// ---------------------------------------------------------------- lab

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

  @override
  Widget build(BuildContext context) {
    final readout = [
      const _Hint(),
      const SizedBox(height: 12),
      const _ScreenShape(),
      const SizedBox(height: 12),
      const _Readout(),
    ];
    final tools = [
      const DuoKeep(id: 'counter', child: _Counter()),
      const SizedBox(height: 12),
      const DuoKeep(id: 'note', child: _Note()),
      const SizedBox(height: 12),
      const _FoldButtons(),
    ];
    // the counter and note are DuoKeep'd, they carry over between builders
    return DuoLayout(
      // not a lazy ListView: an unbuilt DuoKeep child would lose its state
      closed: (context) => SingleChildScrollView(
        padding: EdgeInsets.all(context.duo.margin),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [...readout, const SizedBox(height: 12), ...tools],
        ),
      ),
      open: (context) => DuoSplit(
        primary: ListView(
          padding: EdgeInsets.all(context.duo.margin),
          children: readout,
        ),
        secondary: ListView(
          padding: EdgeInsets.all(context.duo.margin),
          children: tools,
        ),
      ),
    );
  }
}

class _Hint extends StatelessWidget {
  const _Hint();

  @override
  Widget build(BuildContext context) {
    final duo = context.duo;
    final c = context.copy;
    final hint = switch ((duo.mode, duo.posture)) {
      (_, DuoPosture.book) => c.book,
      (_, DuoPosture.tabletop) => c.tabletop,
      (DuoMode.closedPortrait || DuoMode.closedLandscape, _) => c.closed,
      (DuoMode.openLandscape, _) => c.open,
      (DuoMode.openPortrait, _) => c.openPortrait,
      (DuoMode.splitView, _) => c.split,
      (DuoMode.tablet || DuoMode.desktop, _) => c.other,
    };
    final scheme = Theme.of(context).colorScheme;
    return AnimatedSwitcher(
      duration: const Duration(milliseconds: 350),
      child: Container(
        key: ValueKey(hint),
        padding: const EdgeInsets.all(16),
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [scheme.primaryContainer, scheme.tertiaryContainer],
          ),
          borderRadius: BorderRadius.circular(18),
        ),
        child: Row(
          children: [
            Icon(Icons.lightbulb_outline, color: scheme.onPrimaryContainer),
            const SizedBox(width: 12),
            Expanded(
              child: Text(
                hint,
                style: TextStyle(color: scheme.onPrimaryContainer),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

// the window shape morphs between poses, fold in blue, insets in red
class _ScreenShape extends StatelessWidget {
  const _ScreenShape();

  @override
  Widget build(BuildContext context) {
    final duo = context.duo;
    return SizedBox(
      height: 150,
      child: Center(
        child: TweenAnimationBuilder<Size?>(
          tween: SizeTween(end: duo.size),
          duration: const Duration(milliseconds: 600),
          curve: Curves.easeInOutCubic,
          builder: (context, s, _) => AspectRatio(
            aspectRatio: s!.isEmpty ? 1 : s.width / s.height,
            child: CustomPaint(
              painter: _ShapePainter(duo, s, Theme.of(context).colorScheme),
            ),
          ),
        ),
      ),
    );
  }
}

class _ShapePainter extends CustomPainter {
  _ShapePainter(this.duo, this.window, this.scheme);

  final DuoData duo;
  final Size window;
  final ColorScheme scheme;

  @override
  void paint(Canvas canvas, Size size) {
    if (window.isEmpty) return;
    final k = size.width / window.width;
    final screen = RRect.fromRectAndRadius(
      Offset.zero & size,
      const Radius.circular(10),
    );
    canvas
      ..drawRRect(screen, Paint()..color = scheme.surfaceContainerHighest)
      ..save()
      ..clipRRect(screen);
    final p = duo.padding * k;
    final inset = Paint()..color = Colors.red.withValues(alpha: .35);
    canvas
      ..drawRect(Rect.fromLTWH(0, 0, p.left, size.height), inset)
      ..drawRect(
        Rect.fromLTWH(size.width - p.right, 0, p.right, size.height),
        inset,
      )
      ..drawRect(Rect.fromLTWH(0, 0, size.width, p.top), inset)
      ..drawRect(
        Rect.fromLTWH(0, size.height - p.bottom, size.width, p.bottom),
        inset,
      );
    final fold = duo.fold;
    if (fold != null) {
      final f = Rect.fromLTRB(
        fold.left * k,
        fold.top * k,
        fold.right * k,
        fold.bottom * k,
      );
      final vertical = duo.foldDirection == Axis.vertical;
      canvas.drawLine(
        vertical ? f.topCenter : f.centerLeft,
        vertical ? f.bottomCenter : f.centerRight,
        Paint()
          ..color = Colors.blueAccent
          ..strokeWidth = 3,
      );
    }
    canvas.restore();
    canvas.drawRRect(
      screen,
      Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2
        ..color = scheme.outline,
    );
  }

  @override
  bool shouldRepaint(_ShapePainter old) =>
      old.window != window || old.duo.toString() != duo.toString();
}

class _Readout extends StatelessWidget {
  const _Readout();

  @override
  Widget build(BuildContext context) {
    final duo = context.duo;
    final p = duo.padding;
    final f = duo.fold;
    final stats = {
      'mode': duo.mode.name,
      'posture': duo.posture.name,
      'hinge': [
        duo.hardware.status.name,
        if (DuoHardware.angleOf(context) case final a?) '${a.round()}°',
      ].join(' '),
      'cameras': '${duo.cameras.length}',
      'bar': duo.hardware.barEdge.name,
      'size': '${n(duo.size.width)}×${n(duo.size.height)}',
      'iPhone Duo': '${duo.isIphoneDuo}',
      'expanded': '${duo.isExpanded}',
      'columns': '${duo.columns}',
      'rail': '${duo.prefersRail}',
      'fold': f == null
          ? 'none'
          : '${duo.foldDirection!.name} @${n(duo.foldDirection == Axis.vertical ? f.center.dx : f.center.dy)}',
      'separating': '${duo.isSeparating}',
      'safe': '${n(p.left)} ${n(p.top)} ${n(p.right)} ${n(p.bottom)}',
      'grid(150)': '${duo.gridColumns(150)}',
      'platform': duo.platform.name,
    };
    final scheme = Theme.of(context).colorScheme;
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: [
        for (final e in stats.entries)
          Tooltip(
            message: statHelp[e.key],
            triggerMode: TooltipTriggerMode.tap,
            showDuration: const Duration(seconds: 4),
            child: Container(
              constraints: const BoxConstraints(minWidth: 96),
              padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
              decoration: BoxDecoration(
                color: scheme.surfaceContainerHigh,
                borderRadius: BorderRadius.circular(12),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(
                    e.key,
                    style: TextStyle(fontSize: 11, color: scheme.outline),
                  ),
                  AnimatedSwitcher(
                    duration: const Duration(milliseconds: 350),
                    transitionBuilder: (child, a) => FadeTransition(
                      opacity: a,
                      child: ScaleTransition(scale: a, child: child),
                    ),
                    child: Text(
                      e.value,
                      key: ValueKey(e.value),
                      style: TextStyle(
                        fontWeight: FontWeight.w700,
                        color: e.value == 'true'
                            ? Colors.green
                            : e.value == 'false'
                            ? scheme.outline
                            : scheme.onSurface,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
      ],
    );
  }
}

class _Counter extends StatefulWidget {
  const _Counter();

  @override
  State<_Counter> createState() => _CounterState();
}

class _CounterState extends State<_Counter> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Card.filled(
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Row(
          children: [
            Expanded(
              child: Text(
                context.copy.counter,
                style: const TextStyle(fontWeight: FontWeight.w600),
              ),
            ),
            IconButton.filledTonal(
              onPressed: () => setState(() => count--),
              icon: const Icon(Icons.remove),
            ),
            SizedBox(
              width: 56,
              child: AnimatedSwitcher(
                duration: const Duration(milliseconds: 250),
                transitionBuilder: (child, a) =>
                    ScaleTransition(scale: a, child: child),
                child: Text(
                  '$count',
                  key: ValueKey(count),
                  textAlign: TextAlign.center,
                  style: TextStyle(
                    fontSize: 28,
                    fontWeight: FontWeight.w800,
                    color: scheme.primary,
                  ),
                ),
              ),
            ),
            IconButton.filled(
              onPressed: () => setState(() => count++),
              icon: const Icon(Icons.add),
            ),
          ],
        ),
      ),
    );
  }
}

class _Note extends StatefulWidget {
  const _Note();

  @override
  State<_Note> createState() => _NoteState();
}

class _NoteState extends State<_Note> {
  final controller = TextEditingController();

  @override
  void dispose() {
    controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      decoration: InputDecoration(
        labelText: context.copy.note,
        prefixIcon: const Icon(Icons.edit_note),
        border: const OutlineInputBorder(),
      ),
    );
  }
}

class _FoldButtons extends StatelessWidget {
  const _FoldButtons();

  @override
  Widget build(BuildContext context) {
    final c = context.copy;
    return Wrap(
      spacing: 8,
      runSpacing: 8,
      children: [
        FilledButton.icon(
          onPressed: () => popCard(context),
          icon: const Icon(Icons.style),
          label: Text(c.popCard),
        ),
        OutlinedButton.icon(
          onPressed: () => showDialog<void>(
            context: context,
            builder: (context) => AlertDialog(
              title: Text(c.dialog),
              content: Text(c.dialogBody),
              actions: [
                TextButton(
                  onPressed: () => Navigator.pop(context),
                  child: Text(c.ok),
                ),
              ],
            ),
          ),
          icon: const Icon(Icons.chat_bubble_outline),
          label: Text(c.dialog),
        ),
        // what the running OS exposes, for bug reports
        TextButton.icon(
          onPressed: () async {
            final text = await DuoHardware.describeNative();
            if (!context.mounted) return;
            showDialog<void>(
              context: context,
              builder: (context) => AlertDialog(
                title: const Text('Native API'),
                content: SingleChildScrollView(
                  child: SelectableText(
                    text,
                    style: const TextStyle(fontSize: 12),
                  ),
                ),
              ),
            );
          },
          icon: const Icon(Icons.memory),
          label: const Text('Native API'),
        ),
      ],
    );
  }

  // full screen overlay so DuoAvoidFold can pick a half
  void popCard(BuildContext context) {
    late final OverlayEntry entry;
    entry = OverlayEntry(
      builder: (context) => DuoAvoidFold(
        child: GestureDetector(
          onTap: entry.remove,
          child: ColoredBox(
            color: Colors.black26,
            child: Center(
              child: TweenAnimationBuilder<double>(
                tween: Tween(begin: 0, end: 1),
                duration: const Duration(milliseconds: 600),
                curve: Curves.elasticOut,
                builder: (context, v, child) =>
                    Transform.scale(scale: v, child: child),
                child: Card(
                  child: Padding(
                    padding: const EdgeInsets.all(24),
                    child: Column(
                      mainAxisSize: MainAxisSize.min,
                      children: [
                        Icon(
                          Icons.check_circle,
                          size: 48,
                          color: Theme.of(context).colorScheme.primary,
                        ),
                        const SizedBox(height: 8),
                        const Text('DuoAvoidFold'),
                        Text(
                          context.copy.cardBody,
                          style: const TextStyle(fontSize: 12),
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
    Overlay.of(context).insert(entry);
  }
}
4
likes
160
points
1.01k
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Adaptive layout for the foldable iPhone Duo and any screen. Live hinge angle, posture, fold and camera regions, split panes, side navigation and a pose simulator.

Homepage
Repository (GitHub)
View/report issues

Topics

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

License

MIT (license)

Dependencies

flutter, flutter_web_plugins

More

Packages that depend on duo_dynamic_sizing

Packages that implement duo_dynamic_sizing