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

PlatformiOS

The Liquid Glass mode of iphone_duo_ui_pro: official UIKit bars, glass capsules in the iPhone Duo vertical bar, through adaptive_platform_ui, over fold-aware Flutter content.

example/lib/main.dart

// A gallery of iphone_duo_ui_pro_glass.
//
// The bars are UIKit's own Liquid Glass (through adaptive_platform_ui); the
// content below them is laid out by iphone_duo_ui_pro. Run it on an iPhone Duo
// simulator: the toolbar and the tab bar move into the vertical bar as native
// glass capsules, the panes split on the fold, the grid mirrors around it.

import 'dart:async';
import 'dart:io' show Directory, File;

import 'package:adaptive_platform_ui/adaptive_platform_ui.dart';
import 'package:flutter/material.dart';
import 'package:iphone_duo_ui_pro_glass/iphone_duo_ui_pro_glass.dart';

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

const Color _seed = Color(0xFF0F6079);

/// The gallery runs in either of the two modes `iphone_duo_ui_pro` offers for
/// the bars: official Liquid Glass (`DuoGlassScaffold`, UIKit) or custom
/// navigation (`DuoAdaptiveScaffold`, drawn by Flutter). The same actions and
/// tabs feed both; switch on the Controls tab, with `mode=custom` in the launch
/// options, or with the `mode=` remote command.
final ValueNotifier<bool> liquidGlass = ValueNotifier<bool>(
  launchOptions['mode'] != 'custom',
);

/// Launch options for screenshots and recordings, read from
/// `<app data container>/tmp/duo_launch.txt`, e.g. `tab=1;demo=1`.
/// Write it from the host with `xcrun simctl get_app_container <udid> <bundle id> data`.
final Map<String, String> launchOptions = () {
  try {
    final file = File('${Directory.systemTemp.path}/duo_launch.txt');
    if (!file.existsSync()) return const <String, String>{};
    return <String, String>{
      for (final part in file.readAsStringSync().trim().split(';'))
        if (part.contains('='))
          part.split('=')[0].trim(): part.split('=')[1].trim(),
    };
  } on Object {
    return const <String, String>{};
  }
}();

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Duo Glass gallery',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: _seed),
      darkTheme: ThemeData(colorSchemeSeed: _seed, brightness: Brightness.dark),
      // One line wires both packages: the bridge, the fold as a DisplayFeature,
      // and the fixed Liquid Glass toolbar host.
      builder: (context, child) => DuoGlassHost(
        child: _EnvironmentLog(child: child ?? const SizedBox.shrink()),
      ),
      home: const GalleryHome(),
    );
  }
}

/// Prints the environment on every change, so the state can be read from the
/// simulator log without touching the screen.
class _EnvironmentLog extends StatelessWidget {
  const _EnvironmentLog({required this.child});

  final Widget child;

  @override
  Widget build(BuildContext context) {
    final media = MediaQuery.of(context);
    final duo = DuoScope.of(context);
    debugPrint(
      'DUO-ENV size=${media.size.width.round()}x${media.size.height.round()} '
      'viewPadding=${_insets(media.viewPadding)} '
      'pose=${DuoGlassPose.forViewPadding(media.viewPadding).name} '
      'core=${DuoAdaptiveScaffold.resolveLayout(context).name} '
      'classes=${duo.horizontalSizeClass.name}x${duo.verticalSizeClass.name} '
      'sdk271=${duo.sdk271} hinge=${duo.hinge?.status.name}:${duo.hinge?.angleDegrees?.round()} '
      'divisions=[${duo.divisions.map((r) => '${_rect(r.rect)}${r.active ? '!' : ''}').join('; ')}] '
      'occlusions=[${duo.occlusions.map((r) => _rect(r.rect)).join('; ')}]',
    );
    return child;
  }
}

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

  @override
  State<GalleryHome> createState() => _GalleryHomeState();
}

class _GalleryHomeState extends State<GalleryHome> {
  int _tab = (int.tryParse(launchOptions['tab'] ?? '') ?? 0).clamp(0, 3);
  int _selected = 0;
  int _unread = 3;
  Timer? _remote;
  String _lastCommand = '';

  static const List<String> _titles = <String>[
    'Library',
    'Grid',
    'Controls',
    'Bridge',
  ];

  @override
  void initState() {
    super.initState();
    // A remote control for recordings: taps sent to a simulator in the
    // background never arrive, so the host writes commands into
    // `<app data container>/tmp/duo_cmd.txt` instead, as `<counter>:<command>`.
    // Commands: tab=2, open=3, back, alert, scroll=640, mode=custom, mode=glass.
    if (launchOptions['remote'] == '1') {
      // Whatever is in the file now was meant for an earlier run.
      _pollCommand(execute: false);
      _remote = Timer.periodic(
        const Duration(milliseconds: 150),
        (_) => _pollCommand(),
      );
    }
  }

  void _pollCommand({bool execute = true}) {
    try {
      final file = File('${Directory.systemTemp.path}/duo_cmd.txt');
      if (!file.existsSync()) return;
      final raw = file.readAsStringSync().trim();
      if (raw == _lastCommand || !raw.contains(':')) return;
      _lastCommand = raw;
      if (execute) _run(raw.substring(raw.indexOf(':') + 1));
    } on Object {
      // A half-written file: the next poll reads it again.
    }
  }

  void _run(String command) {
    if (!mounted) return;
    final value = command.contains('=') ? command.split('=')[1] : '';
    final navigator = Navigator.of(context);
    debugPrint('DUO-CMD $command');
    if (command.startsWith('tab=')) {
      setState(() => _tab = (int.tryParse(value) ?? 0).clamp(0, 3));
    } else if (command.startsWith('open=')) {
      final index = int.tryParse(value) ?? 0;
      setState(() => _selected = index);
      navigator.push(
        MaterialPageRoute<void>(
          builder: (context) => _SessionPage(index: index),
        ),
      );
    } else if (command.startsWith('mode=')) {
      liquidGlass.value = value != 'custom';
    } else if (command == 'back') {
      navigator.maybePop();
    } else if (command == 'alert') {
      _openDialog();
    } else if (command.startsWith('select=')) {
      setState(() => _selected = int.tryParse(value) ?? 0);
    } else if (command.startsWith('scroll=')) {
      final offset = double.tryParse(value) ?? 0;
      for (final position in gridScroll.positions) {
        position.animateTo(
          offset,
          duration: const Duration(milliseconds: 900),
          curve: Curves.easeInOutCubic,
        );
      }
    }
  }

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

  void _openDialog() {
    AdaptiveAlertDialog.show(
      context: context,
      title: 'Official Liquid Glass',
      message:
          'This alert, the toolbar and the tab bar are UIKit views. '
          'The layout underneath is Flutter.',
      actions: <AlertAction>[AlertAction(title: 'Close', onPressed: () {})],
    );
  }

  @override
  Widget build(BuildContext context) {
    final prominentAction = DuoBarAction(
      icon: Icons.add,
      sfSymbol: 'plus',
      label: 'Add',
      onPressed: () => setState(() => _unread++),
    );
    final topActions = <DuoBarAction>[
      DuoBarAction(
        icon: Icons.edit_outlined,
        sfSymbol: 'square.and.pencil',
        label: 'Compose',
        priority: DuoVisibilityPriority.high,
        onPressed: _openDialog,
      ),
      DuoBarAction(
        icon: Icons.inbox_outlined,
        sfSymbol: 'tray',
        label: 'Inbox',
        onPressed: () => setState(() => _unread = 0),
      ),
      DuoBarAction(
        icon: Icons.delete_outline,
        sfSymbol: 'trash',
        label: 'Delete',
        priority: DuoVisibilityPriority.low,
        onPressed: () {},
      ),
    ];
    final bottomActions = <DuoBarAction>[
      DuoBarAction(
        icon: Icons.tune,
        sfSymbol: 'slider.horizontal.3',
        label: 'Filters',
        onPressed: () {},
      ),
    ];
    final textActions = <DuoTextAction>[
      DuoTextAction(label: 'Select', onPressed: () {}),
    ];
    final tabs = <DuoTab>[
      const DuoTab(
        icon: Icons.photo_library_outlined,
        sfSymbol: 'photo.on.rectangle',
        selectedSfSymbol: 'photo.fill.on.rectangle.fill',
        label: 'Library',
      ),
      const DuoTab(
        icon: Icons.grid_view_outlined,
        sfSymbol: 'square.grid.2x2',
        selectedSfSymbol: 'square.grid.2x2.fill',
        label: 'Grid',
      ),
      const DuoTab(
        icon: Icons.toggle_on_outlined,
        sfSymbol: 'switch.2',
        label: 'Controls',
      ),
      DuoTab(
        icon: Icons.memory_outlined,
        sfSymbol: 'cpu',
        selectedSfSymbol: 'cpu.fill',
        label: 'Bridge',
        badgeCount: _unread == 0 ? null : _unread,
      ),
    ];
    final body = switch (_tab) {
      0 => _LibraryPane(
        selected: _selected,
        onSelected: (index) => setState(() => _selected = index),
      ),
      1 => const _GridPane(),
      2 => const _ControlsPane(),
      _ => const _BridgePane(),
    };
    void selectTab(int index) => setState(() => _tab = index);

    // Two modes, one set of values: the same actions and tabs go to whichever
    // scaffold draws the bars.
    return ValueListenableBuilder<bool>(
      valueListenable: liquidGlass,
      builder: (context, glass, _) {
        if (glass) {
          return DuoGlassScaffold(
            title: _titles[_tab],
            tintColor: _tint(context),
            backdrop: const _Backdrop(),
            textActions: textActions,
            prominentAction: prominentAction,
            topActions: topActions,
            bottomActions: bottomActions,
            tabs: tabs,
            selectedTabIndex: _tab,
            onTabSelected: selectTab,
            body: body,
          );
        }
        return Stack(
          fit: StackFit.expand,
          children: <Widget>[
            const _Backdrop(),
            DuoAdaptiveScaffold(
              title: _titles[_tab],
              backgroundColor: Colors.transparent,
              textActions: textActions,
              prominentAction: prominentAction,
              topActions: topActions,
              bottomActions: bottomActions,
              tabs: tabs,
              selectedTabIndex: _tab,
              onTabSelected: selectTab,
              body: body,
            ),
          ],
        );
      },
    );
  }
}

/// List and detail, split exactly on the fold when the device is half open.
/// At compact width (the cover display) the list stands alone and a session
/// opens as a page of its own, with the system back button in the glass bar.
class _LibraryPane extends StatelessWidget {
  const _LibraryPane({required this.selected, required this.onSelected});

  final int selected;
  final ValueChanged<int> onSelected;

  @override
  Widget build(BuildContext context) {
    return Material(
      type: MaterialType.transparency,
      child: FoldAwareTwoPane(
        divider: const VerticalDivider(width: 1),
        startPane: _SessionList(selected: selected, onSelected: onSelected),
        endPane: _DetailPane(index: selected),
        compactPane: _SessionList(
          selected: selected,
          onSelected: (index) {
            onSelected(index);
            Navigator.of(context).push(
              MaterialPageRoute<void>(
                builder: (context) => _SessionPage(index: index),
              ),
            );
          },
        ),
      ),
    );
  }
}

class _SessionList extends StatelessWidget {
  const _SessionList({required this.selected, required this.onSelected});

  final int selected;
  final ValueChanged<int> onSelected;

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 18,
      itemBuilder: (context, index) => ListTile(
        selected: index == selected,
        leading: CircleAvatar(
          backgroundColor: _tileColor(index),
          foregroundColor: Colors.white,
          child: Text('${index + 1}'),
        ),
        title: Text('Session ${index + 1}'),
        subtitle: const Text('12 min · 340 MB'),
        onTap: () => onSelected(index),
      ),
    );
  }
}

/// A pushed page: the back button appears in the bar on its own.
class _SessionPage extends StatelessWidget {
  const _SessionPage({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    final topActions = <DuoBarAction>[
      DuoBarAction(
        icon: Icons.ios_share,
        sfSymbol: 'square.and.arrow.up',
        label: 'Share',
        onPressed: () {},
      ),
      DuoBarAction(
        icon: Icons.star_border,
        sfSymbol: 'star',
        label: 'Favourite',
        onPressed: () {},
      ),
    ];
    final body = Material(
      type: MaterialType.transparency,
      child: _DetailPane(index: index),
    );
    return ValueListenableBuilder<bool>(
      valueListenable: liquidGlass,
      builder: (context, glass, _) {
        if (glass) {
          return DuoGlassScaffold(
            title: 'Session ${index + 1}',
            tintColor: _tint(context),
            topActions: topActions,
            backdrop: const _Backdrop(),
            body: body,
          );
        }
        return Stack(
          fit: StackFit.expand,
          children: <Widget>[
            const _Backdrop(),
            DuoAdaptiveScaffold(
              title: 'Session ${index + 1}',
              backgroundColor: Colors.transparent,
              // The system back button is UIKit's; here it is an action.
              leading: DuoBarAction(
                icon: Icons.arrow_back_ios_new,
                label: 'Back',
                onPressed: () => Navigator.of(context).maybePop(),
              ),
              topActions: topActions,
              body: body,
            ),
          ],
        );
      },
    );
  }
}

class _DetailPane extends StatelessWidget {
  const _DetailPane({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return SafeArea(
      bottom: false,
      child: Padding(
        padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            Text('Session ${index + 1}', style: theme.textTheme.headlineSmall),
            const SizedBox(height: 8),
            Text(
              'The bars are UIKit. The panes are Flutter, and neither of them '
              'crosses the hinge.',
              style: theme.textTheme.bodyMedium,
            ),
            const SizedBox(height: 12),
            Expanded(
              child: MediaQuery.removePadding(
                context: context,
                removeTop: true,
                child: FoldAlignedGrid(
                  itemCount: 12,
                  tileHeight: 92,
                  itemBuilder: (context, i) => _Tile(index: i + index),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// Scroll positions of the grid, for the `scroll=` remote command.
final ScrollController gridScroll = ScrollController();

/// A grid whose columns stay symmetric around the fold.
class _GridPane extends StatelessWidget {
  const _GridPane();

  @override
  Widget build(BuildContext context) {
    return Material(
      type: MaterialType.transparency,
      child: FoldAlignedGrid(
        controller: gridScroll,
        itemCount: 48,
        tileHeight: 104,
        minTileWidth: 120,
        itemBuilder: (context, index) => _Tile(index: index),
      ),
    );
  }
}

/// A background that runs under the vertical bar, the way Apple asks a hero or
/// a background image to: it is what the glass capsules refract.
class _Backdrop extends StatelessWidget {
  const _Backdrop();

  @override
  Widget build(BuildContext context) {
    final dark = Theme.of(context).brightness == Brightness.dark;
    final base = dark ? const Color(0xFF0B141A) : const Color(0xFFF4F8FA);
    final strength = dark ? 0.55 : 0.42;

    Widget light(Alignment center, Color color, double radius) {
      return DecoratedBox(
        decoration: BoxDecoration(
          gradient: RadialGradient(
            center: center,
            radius: radius,
            colors: <Color>[
              color.withValues(alpha: strength),
              color.withValues(alpha: 0),
            ],
          ),
        ),
      );
    }

    return Stack(
      fit: StackFit.expand,
      children: <Widget>[
        ColoredBox(color: base),
        light(const Alignment(1.1, -0.75), const Color(0xFF18B7C9), 0.9),
        light(const Alignment(1.15, 0.2), const Color(0xFF7A5CFF), 0.8),
        light(const Alignment(0.9, 1.1), const Color(0xFFFF9F45), 0.75),
        light(const Alignment(-1.1, 1.0), const Color(0xFF18B7C9), 0.7),
      ],
    );
  }
}

/// The brand colour in light mode, its lighter tone in dark mode, where the
/// seed itself would not read on the glass.
Color _tint(BuildContext context) => Theme.of(context).colorScheme.primary;

Color _tileColor(int index) {
  const hues = <double>[192, 168, 212, 28, 340, 262, 142, 48];
  return HSLColor.fromAHSL(1, hues[index % hues.length], 0.62, 0.52).toColor();
}

class _Tile extends StatelessWidget {
  const _Tile({required this.index});

  final int index;

  @override
  Widget build(BuildContext context) {
    final color = _tileColor(index);
    return DecoratedBox(
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: <Color>[color, Color.lerp(color, Colors.black, 0.28)!],
        ),
        borderRadius: BorderRadius.circular(16),
      ),
      child: Center(
        child: Text(
          '${index + 1}',
          style: const TextStyle(
            color: Colors.white,
            fontSize: 20,
            fontWeight: FontWeight.w600,
          ),
        ),
      ),
    );
  }
}

/// Native Liquid Glass controls from adaptive_platform_ui, one group per pane.
class _ControlsPane extends StatefulWidget {
  const _ControlsPane();

  @override
  State<_ControlsPane> createState() => _ControlsPaneState();
}

class _ControlsPaneState extends State<_ControlsPane> {
  bool _glass = true;
  double _level = 0.6;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Material(
      type: MaterialType.transparency,
      child: FoldAwareTwoPane(
        startPane: SafeArea(
          bottom: false,
          child: ListView(
            padding: const EdgeInsets.all(20),
            children: <Widget>[
              Text('Native controls', style: theme.textTheme.titleLarge),
              const SizedBox(height: 16),
              Text('Bars drawn by', style: theme.textTheme.labelLarge),
              const SizedBox(height: 8),
              ValueListenableBuilder<bool>(
                valueListenable: liquidGlass,
                builder: (context, glass, _) => AdaptiveSegmentedControl(
                  labels: const <String>['Flutter', 'UIKit glass'],
                  selectedIndex: glass ? 1 : 0,
                  onValueChanged: (index) => liquidGlass.value = index == 1,
                ),
              ),
              const SizedBox(height: 24),
              Row(
                children: <Widget>[
                  const Expanded(child: Text('Liquid Glass')),
                  AdaptiveSwitch(
                    value: _glass,
                    activeColor: _tint(context),
                    onChanged: (value) => setState(() => _glass = value),
                  ),
                ],
              ),
              const SizedBox(height: 16),
              AdaptiveSlider(
                value: _level,
                activeColor: _tint(context),
                onChanged: (value) => setState(() => _level = value),
              ),
            ],
          ),
        ),
        endPane: SafeArea(
          bottom: false,
          child: ListView(
            padding: const EdgeInsets.all(20),
            children: <Widget>[
              Text('Buttons', style: theme.textTheme.titleLarge),
              const SizedBox(height: 16),
              AdaptiveButton(
                onPressed: () {},
                style: AdaptiveButtonStyle.prominentGlass,
                color: _tint(context),
                textColor: Theme.of(context).colorScheme.onPrimary,
                label: 'Prominent glass',
              ),
              const SizedBox(height: 12),
              AdaptiveButton(
                onPressed: () {},
                style: AdaptiveButtonStyle.glass,
                textColor: _tint(context),
                label: 'Glass',
              ),
              const SizedBox(height: 12),
              AdaptiveButton(
                onPressed: () {},
                style: AdaptiveButtonStyle.tinted,
                color: _tint(context),
                label: 'Tinted',
              ),
            ],
          ),
        ),
      ),
    );
  }
}

/// Live values from the native bridge, handy while folding the simulator.
class _BridgePane extends StatelessWidget {
  const _BridgePane();

  @override
  Widget build(BuildContext context) {
    final duo = DuoScope.of(context);
    final classes = DuoSizeClasses.of(context);
    final fold = duo.activeFold;
    final media = MediaQuery.of(context);
    final angle = duo.hinge?.angleDegrees;

    return Material(
      type: MaterialType.transparency,
      child: ListView(
        padding: EdgeInsets.fromLTRB(20, media.padding.top + 12, 20, 40),
        children: <Widget>[
          Text(
            angle == null ? '—' : '${angle.round()}°',
            style: Theme.of(context).textTheme.displayLarge?.copyWith(
              fontWeight: FontWeight.w700,
              color: _tint(context),
            ),
          ),
          Text(
            duo.hinge == null ? 'no hinge' : duo.hinge!.status.name,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 16),
          _Row(
            label: 'Chrome',
            value: PlatformInfo.isIOS26OrHigher()
                ? 'UIKit Liquid Glass (iOS ${PlatformInfo.iOSVersion})'
                : 'fallback bars',
          ),
          _Row(label: 'Bars', value: DuoGlassHost.poseOf(context).name),
          _Row(
            label: 'Bridge',
            value: duo.isAvailable ? 'connected' : 'unavailable',
          ),
          _Row(label: 'iOS 27.1 APIs', value: duo.sdk271 ? 'on' : 'off'),
          _Row(
            label: 'Window',
            value:
                '${media.size.width.round()} × ${media.size.height.round()} pt',
          ),
          _Row(
            label: 'Size classes',
            value: '${classes.horizontal.name} × ${classes.vertical.name}',
          ),
          _Row(
            label: 'Pose',
            value: duo.isBookPose
                ? 'book'
                : duo.isTabletopPose
                ? 'tabletop'
                : 'flat',
          ),
          _Row(
            label: 'Active fold',
            value: fold == null ? 'none' : _rect(fold.rect),
          ),
          _Row(
            label: 'Occlusions',
            value: duo.occlusions.map((r) => _rect(r.rect)).join('; '),
          ),
        ],
      ),
    );
  }
}

String _rect(Rect r) =>
    '${r.left.round()},${r.top.round()} ${r.width.round()}×${r.height.round()}';

String _insets(EdgeInsets i) =>
    'L${i.left.round()} T${i.top.round()} R${i.right.round()} B${i.bottom.round()}';

class _Row extends StatelessWidget {
  const _Row({required this.label, required this.value});

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 6),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          SizedBox(
            width: 130,
            child: Text(label, style: theme.textTheme.labelLarge),
          ),
          Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
        ],
      ),
    );
  }
}
0
likes
160
points
55
downloads
screenshot

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

The Liquid Glass mode of iphone_duo_ui_pro: official UIKit bars, glass capsules in the iPhone Duo vertical bar, through adaptive_platform_ui, over fold-aware Flutter content.

Repository (GitHub)
View/report issues

Topics

#iphone-duo #liquid-glass #foldable #ios #cupertino

License

MIT (license)

Dependencies

adaptive_platform_ui, flutter, iphone_duo_ui_pro

More

Packages that depend on iphone_duo_ui_pro_glass