duo_flex 0.4.0
duo_flex: ^0.4.0 copied to clipboard
Flutter plugin for iPhone Duo and foldable devices: read hinge angle, posture, display regions and window geometry, and build adaptive layouts.
example/lib/main.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:duo_flex/duo_flex.dart';
import 'package:flutter/material.dart';
void main() => runApp(const DuoFlexExampleApp());
class DuoFlexExampleApp extends StatefulWidget {
const DuoFlexExampleApp({super.key});
@override
State<DuoFlexExampleApp> createState() => _DuoFlexExampleAppState();
}
typedef MyApp = DuoFlexExampleApp;
class _DuoFlexExampleAppState extends State<DuoFlexExampleApp> {
DuoDeviceInfo? _device;
DuoScreenInfo? _screen;
DuoDisplayInfo? _display;
DuoCapabilities? _capabilities;
Object? _error;
bool _refreshingCapabilities = false;
bool _capabilityRefreshPending = false;
StreamSubscription<DuoScreenInfo>? _screenSubscription;
StreamSubscription<DuoDisplayInfo>? _displaySubscription;
StreamSubscription<double?>? _hingeSubscription;
StreamSubscription<DuoFoldPosture>? _postureSubscription;
double? _hingeAngleRadians;
DuoFoldPosture _posture = DuoFoldPosture.unknown;
int _hingeEventRevision = 0;
int _postureEventRevision = 0;
String? _foldStreamError;
final List<String> _eventHistory = <String>[];
@override
void initState() {
super.initState();
_load();
_screenSubscription = DuoFlex.screenInfoStream.listen(
(value) {
if (!mounted) return;
setState(() {
_screen = value;
_appendEvent('Screen snapshot ${value.width} × ${value.height}');
});
},
onError: (Object error) {
_recordEvent('Screen stream error: $error');
},
);
_displaySubscription = DuoFlex.displayChangesStream.listen(
(value) {
if (!mounted) return;
setState(() {
_display = value;
_appendEvent('Display snapshot ${value.width} × ${value.height}');
});
unawaited(_refreshFoldCapabilities());
},
onError: (Object error) {
_recordEvent('Display stream error: $error');
},
);
}
Future<void> _load() async {
try {
final values = await Future.wait<Object?>([
DuoFlex.getDeviceInfo(),
DuoFlex.getCapabilities(),
]);
if (!mounted) return;
setState(() {
_device = values[0] as DuoDeviceInfo;
_capabilities = values[1] as DuoCapabilities;
_appendEvent('Initial device capabilities loaded');
});
unawaited(_loadScreenSnapshot());
unawaited(_loadDisplaySnapshot());
final capabilities = values[1] as DuoCapabilities;
if (capabilities.supportsHingeAngle || capabilities.supportsFoldPosture) {
_listenToFoldUpdates(capabilities);
await _loadNativeFoldSnapshot(capabilities);
}
unawaited(_refreshFoldCapabilities());
} catch (error) {
if (mounted) setState(() => _error = error);
}
}
Future<void> _refreshFoldCapabilities() async {
if (_capabilities == null) return;
if (_refreshingCapabilities) {
_capabilityRefreshPending = true;
return;
}
_refreshingCapabilities = true;
try {
final capabilities = await DuoFlex.getCapabilities();
if (!mounted || capabilities == _capabilities) return;
if (!capabilities.supportsHingeAngle) {
await _hingeSubscription?.cancel();
_hingeSubscription = null;
_hingeEventRevision++;
_hingeAngleRadians = null;
}
if (!capabilities.supportsFoldPosture) {
await _postureSubscription?.cancel();
_postureSubscription = null;
_postureEventRevision++;
_posture = DuoFoldPosture.unknown;
}
if (!mounted) return;
setState(() {
_capabilities = capabilities;
_foldStreamError = null;
});
_listenToFoldUpdates(capabilities);
await _loadNativeFoldSnapshot(capabilities);
final device = await DuoFlex.getDeviceInfo();
if (mounted) setState(() => _device = device);
} catch (error) {
_recordEvent('Capability refresh unavailable: $error');
} finally {
_refreshingCapabilities = false;
if (mounted && _capabilityRefreshPending) {
_capabilityRefreshPending = false;
unawaited(_refreshFoldCapabilities());
}
}
}
Future<void> _loadScreenSnapshot() async {
try {
final screen = await DuoFlex.getScreenInfo();
if (!mounted || screen == null) return;
setState(() => _screen ??= screen);
} catch (error) {
_recordEvent('Initial screen snapshot unavailable: $error');
}
}
Future<void> _loadDisplaySnapshot() async {
try {
final display = await DuoFlex.getDisplayInfo();
if (!mounted || display == null) return;
setState(() => _display ??= display);
} catch (error) {
_recordEvent('Initial display snapshot unavailable: $error');
}
}
Future<void> _loadNativeFoldSnapshot(DuoCapabilities capabilities) async {
final hingeRevision = _hingeEventRevision;
final postureRevision = _postureEventRevision;
try {
final readings = await Future.wait<Object?>([
capabilities.supportsHingeAngle
? DuoFlex.getHingeAngle()
: Future<double?>.value(null),
capabilities.supportsFoldPosture
? DuoFlex.getFoldPosture()
: Future<DuoFoldPosture>.value(DuoFoldPosture.unknown),
]);
if (!mounted) return;
setState(() {
// Preserve a newer live event if it arrived while the snapshot calls
// were in flight.
if (_hingeEventRevision == hingeRevision &&
capabilities.supportsHingeAngle) {
_hingeAngleRadians = readings[0] as double?;
}
if (_postureEventRevision == postureRevision &&
capabilities.supportsFoldPosture) {
_posture = readings[1] as DuoFoldPosture;
}
});
} catch (error) {
if (!mounted) return;
setState(() => _foldStreamError = error.toString());
}
}
@override
void dispose() {
_screenSubscription?.cancel();
_displaySubscription?.cancel();
_hingeSubscription?.cancel();
_postureSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF5546A5)),
scaffoldBackgroundColor: const Color(0xFFFCF8FF),
),
home: DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Row(
children: [
Icon(Icons.phonelink_rounded),
SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('DuoFlex'),
Text(
'Native display monitor',
style: TextStyle(fontSize: 12),
),
],
),
],
),
centerTitle: false,
toolbarHeight: 60,
bottom: const TabBar(
dividerColor: Colors.transparent,
indicatorSize: TabBarIndicatorSize.label,
labelPadding: EdgeInsets.symmetric(horizontal: 28),
tabs: [
Tab(
icon: Icon(Icons.phone_iphone_rounded, size: 20),
text: 'Device',
),
Tab(
icon: Icon(Icons.dashboard_customize_rounded, size: 20),
text: 'Adaptive UI',
),
],
),
),
body: TabBarView(children: [_deviceDashboard(), _adaptiveDashboard()]),
),
),
);
Widget _deviceDashboard() => ListView(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
children: [
if (_error != null) _errorCard(_error.toString()),
_deviceSummaryCard(),
_hingeAngleCard(),
_foldShapeCard(),
_sectionHeading('Live viewport'),
_metricGrid([
_MetricData(
label: 'Window size',
value: _screen == null
? 'Unavailable'
: '${_screen!.width.toStringAsFixed(0)} × ${_screen!.height.toStringAsFixed(0)}',
detail: 'logical pixels',
icon: Icons.aspect_ratio_rounded,
),
_MetricData(
label: 'Pixel ratio',
value: _screen?.devicePixelRatio.toStringAsFixed(2) ?? '—',
detail: 'device scale',
icon: Icons.zoom_in_rounded,
),
_MetricData(
label: 'Orientation',
value: _screen?.orientation ?? 'Unavailable',
detail: 'active Flutter view',
icon: Icons.screen_rotation_rounded,
),
_MetricData(
label: 'Safe area',
value: _screen == null
? 'Unavailable'
: 'T ${_screen!.safeArea.top.toStringAsFixed(0)} '
'B ${_screen!.safeArea.bottom.toStringAsFixed(0)}',
detail: 'top and bottom insets',
icon: Icons.space_bar_rounded,
),
]),
_safeAreaCard(),
const SizedBox(height: 8),
_displayCard(),
_capabilitiesCard(),
_eventHistoryPanel(),
],
);
Widget _adaptiveDashboard() => LayoutBuilder(
builder: (context, constraints) {
final viewInsets = MediaQuery.viewInsetsOf(context);
final horizontalPadding = constraints.maxWidth >= 840 ? 48.0 : 16.0;
return ListView(
padding: EdgeInsets.fromLTRB(
horizontalPadding,
14,
horizontalPadding,
24,
),
children: [
Text(
'See your layout adapt',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 6),
const Text(
'Resize the window to watch the layout change, or explore the previews below.',
),
const SizedBox(height: 16),
_ViewportSummary(
constraints: constraints,
safeArea: MediaQuery.paddingOf(context),
viewInsets: viewInsets,
),
const SizedBox(height: 14),
SizedBox(
width: constraints.maxWidth - horizontalPadding * 2,
child: DuoAdaptiveLayout.builder(
builder: (context, layout) =>
_ResponsiveLayoutCard(layout: layout),
),
),
const SizedBox(height: 12),
_sectionHeading('What is the device reporting?'),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Window width chooses the layout. Physical fold readings are a separate signal.',
),
),
const SizedBox(height: 8),
DuoFoldBuilder(
builder: (context, state, capabilities) => _NativeFoldStatusCard(
posture: capabilities.supportsFoldPosture
? state.posture.name
: 'Unsupported',
angle: capabilities.supportsHingeAngle && state.hingeAngle != null
? '${(state.hingeAngle! * 180 / math.pi).toStringAsFixed(1)}°'
: 'Unavailable',
),
errorBuilder: (context, error, state, capabilities) =>
_NativeFoldStatusCard(
posture: capabilities.supportsFoldPosture
? state.posture.name
: 'Unsupported',
angle: 'Unavailable',
error: error.toString(),
),
),
const SizedBox(height: 8),
DuoFoldAwareLayout(
closedLayout: const _FoldAwareSample(
icon: Icons.smartphone_rounded,
title: 'Closed posture reported',
detail: 'The native API reports closed. Window size is still measured independently.',
),
bookLayout: const _FoldAwareSample(
icon: Icons.menu_book_rounded,
title: 'Book posture reported',
detail: 'A vertical half-open fold is reported. An app could show reading content on each side.',
),
defaultLayout: const _FoldAwareSample(
icon: Icons.dashboard_outlined,
title: 'Standard layout',
detail: 'No special posture layout selected. Content still adapts to its available width.',
),
tabletopLayout: const _FoldAwareSample(
icon: Icons.table_restaurant_outlined,
title: 'Tabletop layout selected',
detail: 'The device reports a horizontal half-open fold. An app could place controls on the lower half.',
),
fullyOpenLayout: const _FoldAwareSample(
icon: Icons.open_in_full_rounded,
title: 'Fully open layout selected',
detail: 'The device reports fully open. Available width still decides how many columns fit.',
),
),
],
);
},
);
Widget _sectionHeading(String title) => Padding(
padding: const EdgeInsets.fromLTRB(4, 8, 4, 10),
child: Text(title, style: Theme.of(context).textTheme.titleMedium),
);
Widget _deviceSummaryCard() {
final foldability = _device?.isFoldable == true
? 'Foldable detected'
: _device?.isFoldable == false
? 'No fold feature reported'
: 'Fold state unavailable';
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
CircleAvatar(
radius: 25,
child: Icon(
_device?.isFoldable == true
? Icons.phone_android_rounded
: Icons.phone_iphone_rounded,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_device?.model ?? 'Reading device…',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 3),
Text(
'${_device?.systemName ?? 'Unknown OS'} ${_device?.systemVersion ?? ''} · $foldability',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
_StatusPill(
label: _capabilities == null ? 'Loading' : 'Connected',
active: _capabilities != null,
),
],
),
),
);
}
Widget _metricGrid(List<_MetricData> metrics) => LayoutBuilder(
builder: (context, constraints) {
final columns = constraints.maxWidth >= 620 ? 4 : 2;
return Column(
children: [
for (var start = 0; start < metrics.length; start += columns) ...[
if (start > 0) const SizedBox(height: 8),
IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var offset = 0; offset < columns; offset++) ...[
if (offset > 0) const SizedBox(width: 8),
Expanded(
child: start + offset < metrics.length
? _metricCard(metrics[start + offset])
: const SizedBox.shrink(),
),
],
],
),
),
],
],
);
},
);
Widget _metricCard(_MetricData metric) => Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
metric.icon,
size: 19,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 8),
Text(metric.label, style: Theme.of(context).textTheme.labelSmall),
const SizedBox(height: 4),
Text(metric.value, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
Text(metric.detail, style: Theme.of(context).textTheme.labelSmall),
],
),
),
);
Widget _safeAreaCard() {
final safeArea = _screen?.safeArea;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Safe area insets',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_InsetValue('Top', safeArea?.top),
_InsetValue('Bottom', safeArea?.bottom),
_InsetValue('Left', safeArea?.left),
_InsetValue('Right', safeArea?.right),
],
),
const SizedBox(height: 8),
Text(
'Native window insets in logical pixels. Use MediaQuery for Flutter layout padding.',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
Widget _errorCard(String error) => Card(
color: Theme.of(context).colorScheme.errorContainer,
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
const Icon(Icons.warning_amber_rounded),
const SizedBox(width: 10),
Expanded(child: Text('Native read failed: $error')),
],
),
),
);
Widget _displayCard() => Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.display_settings_rounded),
const SizedBox(width: 8),
Text(
'Display and regions',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
_StatusPill(
label: _display == null
? 'Waiting'
: _display!.isActive
? 'Active'
: 'Inactive',
active: _display?.isActive ?? false,
),
],
),
const SizedBox(height: 8),
Text(
_display == null
? 'Display snapshot unavailable'
: '${_display!.width.toStringAsFixed(0)} × ${_display!.height.toStringAsFixed(0)} pt · ${_display!.scale.toStringAsFixed(1)}×',
),
const SizedBox(height: 10),
if (_display == null || _display!.regions.isEmpty)
Text(
'No native regions reported',
style: Theme.of(context).textTheme.bodySmall,
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final region in _display!.regions)
Chip(
avatar: Icon(
region.type == DuoDisplayRegionType.reservedDivision
? Icons.compare_arrows_rounded
: Icons.crop_free_rounded,
size: 16,
),
label: Text(
'${region.type.name} · ${region.width.toStringAsFixed(0)} × ${region.height.toStringAsFixed(0)}',
),
visualDensity: VisualDensity.compact,
),
],
),
],
),
),
);
Widget _capabilitiesCard() {
final capabilities = _capabilities;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Plugin capabilities',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_CapabilityChip(
'Hinge angle',
capabilities?.supportsHingeAngle,
),
_CapabilityChip(
'Fold posture',
capabilities?.supportsFoldPosture,
),
_CapabilityChip(
'Reserved regions',
capabilities?.supportsDisplayRegions,
),
_CapabilityChip(
'Live screen',
capabilities?.supportsDisplayChanges,
),
],
),
if (_foldStreamError != null) ...[
const SizedBox(height: 10),
Text(
_foldStreamError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
),
);
}
Widget _hingeAngleCard() {
final radians = _capabilities?.supportsHingeAngle == true
? _hingeAngleRadians
: null;
final degrees = radians == null ? null : radians * 180 / math.pi;
final progress = radians == null
? null
: (radians / math.pi).clamp(0.0, 1.0).toDouble();
final colors = Theme.of(context).colorScheme;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Hinge angle', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
Row(
children: [
Text(
degrees == null
? 'Unavailable'
: '${degrees.toStringAsFixed(1)}°',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 16),
Expanded(
child: progress == null
? Container(
height: 4,
decoration: BoxDecoration(
color: colors.outline,
borderRadius: BorderRadius.circular(2),
),
)
: LinearProgressIndicator(
value: progress,
minHeight: 4,
backgroundColor: colors.outline,
color: colors.primary,
),
),
],
),
const SizedBox(height: 6),
Text(
'0° closed, 180° flat',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
Widget _foldShapeCard() {
final radians = _capabilities?.supportsHingeAngle == true
? _hingeAngleRadians
: null;
final degrees = radians == null ? null : radians * 180 / math.pi;
final posture = _capabilities?.supportsFoldPosture == true
? _posture.name
: 'unavailable';
final foldRegion = _display?.regions
.where(
(region) =>
region.isActive &&
(region.type == DuoDisplayRegionType.hingeExclusion ||
region.type == DuoDisplayRegionType.reservedDivision),
)
.firstOrNull;
// final windowRegion = _display?.regions
// .where(
// (region) => region.type == DuoDisplayRegionType.applicationWindow,
// )
// .firstOrNull;
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Native fold · $posture',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 4),
SizedBox(
height: 46,
width: double.infinity,
child: degrees == null
? Center(
child: Text(
foldRegion == null
? 'Fold shape unavailable'
: 'Fold bounds available · angle unavailable',
style: Theme.of(context).textTheme.bodySmall,
),
)
: CustomPaint(
painter: _NativeFoldShapePainter(
angleDegrees: degrees,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
),
);
}
void _listenToFoldUpdates(DuoCapabilities capabilities) {
if (capabilities.supportsHingeAngle && _hingeSubscription == null) {
_hingeSubscription = DuoFlex.hingeAngleStream.listen(
(value) {
if (!mounted) return;
setState(() {
_hingeEventRevision++;
_hingeAngleRadians = value;
_foldStreamError = null;
_appendEvent('Native hinge update: ${value ?? 'unavailable'} rad');
});
},
onError: (Object error) {
if (mounted) {
setState(() {
_foldStreamError = error.toString();
_appendEvent('Hinge stream error: $error');
});
}
},
);
}
if (capabilities.supportsFoldPosture && _postureSubscription == null) {
_postureSubscription = DuoFlex.foldPostureStream.listen(
(value) {
if (!mounted) return;
setState(() {
_postureEventRevision++;
_posture = value;
_foldStreamError = null;
_appendEvent('Native posture update: ${value.name}');
});
},
onError: (Object error) {
if (mounted) {
setState(() {
_foldStreamError = error.toString();
_appendEvent('Posture stream error: $error');
});
}
},
);
}
}
void _appendEvent(String message) {
_eventHistory.insert(
0,
'${DateTime.now().toLocal().toIso8601String()} $message',
);
if (_eventHistory.length > 20) {
_eventHistory.removeRange(20, _eventHistory.length);
}
}
void _recordEvent(String message) {
if (!mounted) return;
setState(() => _appendEvent(message));
}
Widget _eventHistoryPanel() => Card(
margin: const EdgeInsets.only(bottom: 12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Latest events', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_eventHistory.isEmpty) const Text('Waiting for native events.'),
for (final event in _eventHistory)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(event),
),
],
),
),
);
}
class _ViewportSummary extends StatelessWidget {
const _ViewportSummary({
required this.constraints,
required this.safeArea,
required this.viewInsets,
});
final BoxConstraints constraints;
final EdgeInsets safeArea;
final EdgeInsets viewInsets;
String _extent(double value) =>
value.isFinite ? value.toStringAsFixed(0) : 'Unbounded';
@override
Widget build(BuildContext context) => Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.aspect_ratio_rounded),
SizedBox(width: 8),
Text('Available window space'),
],
),
const SizedBox(height: 10),
Text(
'${_extent(constraints.maxWidth)} × ${_extent(constraints.maxHeight)}',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('Logical pixels · updates as the window changes'),
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
title: const Text('View measurements'),
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Safe area · top / bottom'),
subtitle: Text(
'${safeArea.top.toStringAsFixed(0)} / ${safeArea.bottom.toStringAsFixed(0)} logical pixels',
),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Keyboard obstruction'),
subtitle: Text(
viewInsets.bottom > 0
? '${viewInsets.bottom.toStringAsFixed(0)} logical pixels at the bottom'
: 'No bottom obstruction reported',
),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Pixel density'),
subtitle: Text(
'${MediaQuery.devicePixelRatioOf(context).toStringAsFixed(1)} physical pixels per logical pixel',
),
),
],
),
],
),
),
);
}
class _ResponsiveLayoutCard extends StatefulWidget {
const _ResponsiveLayoutCard({required this.layout});
final DuoLayoutInfo layout;
@override
State<_ResponsiveLayoutCard> createState() => _ResponsiveLayoutCardState();
}
class _ResponsiveLayoutCardState extends State<_ResponsiveLayoutCard> {
DuoLayoutSize? _preview;
@override
Widget build(BuildContext context) {
final selected = _preview ?? widget.layout.size;
final description = switch (selected) {
DuoLayoutSize.compact =>
'One column keeps content easy to read in a narrow space.',
DuoLayoutSize.medium =>
'A navigation rail makes room for a list and its details.',
DuoLayoutSize.expanded =>
'Persistent navigation, a list, and details can sit side by side.',
};
final range = switch (selected) {
DuoLayoutSize.compact => 'Below 600 logical pixels',
DuoLayoutSize.medium => '600–839 logical pixels',
DuoLayoutSize.expanded => '840 logical pixels and above',
};
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Layout playground',
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 18),
),
const SizedBox(height: 6),
Text(
'Live content width: ${widget.layout.width.toStringAsFixed(0)} px · ${widget.layout.size.name}',
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: [
ChoiceChip(
label: const Text('Live'),
selected: _preview == null,
onSelected: (_) => setState(() => _preview = null),
),
for (final size in DuoLayoutSize.values)
ChoiceChip(
label: Text(switch (size) {
DuoLayoutSize.compact => 'Compact',
DuoLayoutSize.medium => 'Medium',
DuoLayoutSize.expanded => 'Expanded',
}),
selected: _preview == size,
onSelected: (_) => setState(() => _preview = size),
),
],
),
const SizedBox(height: 12),
Text(
_preview == null
? 'Following the available width'
: 'Illustration only · your window has not changed',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 12),
_AdaptiveLayoutContent(size: selected),
const SizedBox(height: 12),
Text(description),
const SizedBox(height: 6),
Text(
'Default breakpoint: $range',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
}
}
class _NativeFoldStatusCard extends StatelessWidget {
const _NativeFoldStatusCard({
required this.posture,
required this.angle,
this.error,
});
final String posture;
final String angle;
final String? error;
@override
Widget build(BuildContext context) => Card(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Live hardware readings',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 10),
Text('Posture: $posture'),
const SizedBox(height: 4),
Text('Hinge angle: $angle'),
const SizedBox(height: 8),
const Text(
'Unavailable means the platform has not supplied a reading. These values are never guessed from window width.',
),
if (error != null) ...[
const SizedBox(height: 4),
Text(
'Fold readings could not be refreshed. The responsive layout still works.',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
),
);
}
class _FoldAwareSample extends StatelessWidget {
const _FoldAwareSample({
required this.icon,
required this.title,
required this.detail,
});
final IconData icon;
final String title;
final String detail;
@override
Widget build(BuildContext context) => Card(
child: ListTile(
leading: Icon(icon),
title: Text(title),
subtitle: Text(detail),
),
);
}
class _MetricData {
const _MetricData({
required this.label,
required this.value,
required this.detail,
required this.icon,
});
final String label;
final String value;
final String detail;
final IconData icon;
}
class _StatusPill extends StatelessWidget {
const _StatusPill({required this.label, required this.active});
final String label;
final bool active;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: active
? colors.tertiaryContainer
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: active
? colors.onTertiaryContainer
: colors.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
class _CapabilityChip extends StatelessWidget {
const _CapabilityChip(this.label, this.supported);
final String label;
final bool? supported;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final icon = switch (supported) {
true => Icons.check_circle_rounded,
false => Icons.remove_circle_outline_rounded,
null => Icons.hourglass_empty_rounded,
};
final state = switch (supported) {
true => 'Available',
false => 'Unsupported',
null => 'Checking',
};
return Chip(
avatar: Icon(icon, size: 17),
label: Text('$label · $state'),
visualDensity: VisualDensity.compact,
side: BorderSide(color: colors.outline),
);
}
}
class _InsetValue extends StatelessWidget {
const _InsetValue(this.side, this.value);
final String side;
final Object? value;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Text('$side ${value?.toString() ?? '—'}'),
);
}
class _AdaptiveLayoutContent extends StatelessWidget {
const _AdaptiveLayoutContent({required this.size});
final DuoLayoutSize size;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final navigation = _AdaptivePanelCard(
title: size == DuoLayoutSize.medium ? 'Rail' : 'Menu',
icon: Icons.menu_rounded,
color: colors.primaryContainer,
);
final content = _AdaptivePanelCard(
title: 'List',
icon: Icons.view_list_rounded,
color: colors.secondaryContainer,
);
final details = _AdaptivePanelCard(
title: 'Details',
icon: Icons.article_outlined,
color: colors.tertiaryContainer,
);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
border: Border.all(color: colors.outlineVariant),
borderRadius: BorderRadius.circular(16),
),
child: size == DuoLayoutSize.compact
? Column(
children: [
navigation,
const SizedBox(height: 8),
content,
const SizedBox(height: 8),
details,
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: navigation),
const SizedBox(width: 6),
Expanded(flex: 2, child: content),
const SizedBox(width: 6),
Expanded(flex: 2, child: details),
],
),
);
}
}
class _AdaptivePanelCard extends StatelessWidget {
const _AdaptivePanelCard({
required this.title,
required this.icon,
required this.color,
});
final String title;
final IconData icon;
final Color color;
@override
Widget build(BuildContext context) => Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 16),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(10),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 22),
const SizedBox(height: 6),
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium,
),
],
),
);
}
class _NativeFoldShapePainter extends CustomPainter {
const _NativeFoldShapePainter({
required this.angleDegrees,
required this.color,
});
final double angleDegrees;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final angle = angleDegrees.clamp(0.0, 180.0);
final halfRadians = (180 - angle) * math.pi / 360;
final center = Offset(size.width / 2, size.height * 0.78);
final armLength = size.width * 0.16;
final rise = armLength * math.sin(halfRadians);
final run = armLength * math.cos(halfRadians);
final stroke = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 4
..strokeCap = StrokeCap.round;
canvas.drawLine(center, Offset(center.dx - run, center.dy - rise), stroke);
canvas.drawLine(center, Offset(center.dx + run, center.dy - rise), stroke);
canvas.drawCircle(center, 3.5, Paint()..color = color);
}
@override
bool shouldRepaint(_NativeFoldShapePainter oldDelegate) =>
oldDelegate.angleDegrees != angleDegrees || oldDelegate.color != color;
}
// class _NativeFoldRegionPainter extends CustomPainter {
// const _NativeFoldRegionPainter({
// required this.x,
// required this.y,
// required this.width,
// required this.height,
// required this.displayX,
// required this.displayY,
// required this.displayWidth,
// required this.displayHeight,
// required this.color,
// });
// final double x;
// final double y;
// final double width;
// final double height;
// final double displayX;
// final double displayY;
// final double displayWidth;
// final double displayHeight;
// final Color color;
// @override
// void paint(Canvas canvas, Size size) {
// if (displayWidth <= 0 || displayHeight <= 0) return;
// final displayRect = Rect.fromLTWH(0, 0, size.width, size.height);
// final scale = math.min(
// size.width / displayWidth,
// size.height / displayHeight,
// );
// final fittedWidth = displayWidth * scale;
// final fittedHeight = displayHeight * scale;
// final left = (size.width - fittedWidth) / 2;
// final top = (size.height - fittedHeight) / 2;
// final bounds = Rect.fromLTWH(
// left + (x - displayX) * scale,
// top + (y - displayY) * scale,
// math.max(width * scale, 4),
// math.max(height * scale, 4),
// );
// final devicePaint = Paint()
// ..color = color.withValues(alpha: 0.12)
// ..style = PaintingStyle.fill;
// final featurePaint = Paint()
// ..color = color
// ..style = PaintingStyle.fill;
// final borderPaint = Paint()
// ..color = color.withValues(alpha: 0.65)
// ..style = PaintingStyle.stroke
// ..strokeWidth = 1.5;
// canvas.drawRRect(
// RRect.fromRectAndRadius(
// displayRect.deflate(4),
// const Radius.circular(12),
// ),
// devicePaint,
// );
// canvas.drawRect(bounds, featurePaint);
// canvas.drawRRect(
// RRect.fromRectAndRadius(
// displayRect.deflate(4),
// const Radius.circular(12),
// ),
// borderPaint,
// );
// }
// @override
// bool shouldRepaint(_NativeFoldRegionPainter oldDelegate) =>
// oldDelegate.x != x ||
// oldDelegate.y != y ||
// oldDelegate.width != width ||
// oldDelegate.height != height ||
// oldDelegate.displayX != displayX ||
// oldDelegate.displayY != displayY ||
// oldDelegate.displayWidth != displayWidth ||
// oldDelegate.displayHeight != displayHeight ||
// oldDelegate.color != color;
// }