Skip to content

Commit 3330b02

Browse files
committed
Responsive variances: variants mapped to mobile, tablet and desktop
- ScreenBreakpoint (mobile/tablet/desktop) with the screen variable's width rule (600 and 1024) and default design widths 390/834/1440. - Variant.breakpoint, optional and omitted from JSON when unset, so older documents and readers are unaffected. - VarianceNode.isResponsive, variantForBreakpoint (nearest breakpoint, the smaller one on a tie) and variantForWidth. - Tests: breakpoint rule, JSON round trips, legacy JSON, fallback. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VtH72Dg3rGsqwaEe62Sha4
1 parent d870106 commit 3330b02

4 files changed

Lines changed: 228 additions & 2 deletions

File tree

‎lib/src/api/models/breakpoint.dart‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,45 @@ enum ScaleMode {
4040
const ScaleMode(this.label);
4141
}
4242

43+
/// The screen size classes a layout can adapt to: mobile, tablet and desktop.
44+
///
45+
/// A width picks one by the rule the runtime's `screen` variable uses: below
46+
/// [tablet]'s [minWidth] is mobile, below [desktop]'s is tablet, and anything
47+
/// wider is desktop. A responsive [VarianceNode] maps each of its variants to
48+
/// one of these (see [Variant.breakpoint]).
49+
enum ScreenBreakpoint {
50+
/// Phones: narrower than 600.
51+
mobile('Mobile', minWidth: 0, defaultWidth: 390),
52+
53+
/// Tablets: from 600 up to 1024.
54+
tablet('Tablet', minWidth: 600, defaultWidth: 834),
55+
56+
/// Desktops: 1024 and wider.
57+
desktop('Desktop', minWidth: 1024, defaultWidth: 1440);
58+
59+
/// Displayable name.
60+
final String label;
61+
62+
/// The narrowest width this breakpoint covers, inclusive.
63+
final double minWidth;
64+
65+
/// The width a design canvas takes to show this breakpoint.
66+
final double defaultWidth;
67+
68+
const ScreenBreakpoint(
69+
this.label, {
70+
required this.minWidth,
71+
required this.defaultWidth,
72+
});
73+
74+
/// The breakpoint a screen or canvas [width] wide falls in.
75+
static ScreenBreakpoint forWidth(double width) {
76+
if (width >= desktop.minWidth) return desktop;
77+
if (width >= tablet.minWidth) return tablet;
78+
return mobile;
79+
}
80+
}
81+
4382
/// Represents a breakpoint for responsiveness.
4483
@JsonSerializable()
4584
class Breakpoint with Equatable, SerializableMixin {

‎lib/src/api/nodes/variance_node.dart‎

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,37 @@ class VarianceNode extends SinglePlaceholderNode with CustomPropertiesMixin {
3838
.expand((e) => e.children)
3939
.toList();
4040

41+
/// Whether this node is a responsive layout: its variants map to screen
42+
/// breakpoints ([Variant.breakpoint]) and the one shown follows the width of
43+
/// the canvas it is on, instead of [currentVariantId] or a variable.
44+
bool get isResponsive =>
45+
variants.any((variant) => variant.breakpoint != null);
46+
47+
/// The variant mapped to [breakpoint], or the nearest one when none is:
48+
/// the variant whose breakpoint is fewest steps away, the smaller one on a
49+
/// tie (a narrower layout fits a wider screen better than the reverse).
50+
/// Null when no variant has a breakpoint.
51+
Variant? variantForBreakpoint(ScreenBreakpoint breakpoint) {
52+
Variant? best;
53+
int bestDistance = 1 << 30;
54+
for (final Variant variant in variants) {
55+
final ScreenBreakpoint? own = variant.breakpoint;
56+
if (own == null) continue;
57+
final int distance = (own.index - breakpoint.index).abs();
58+
if (distance < bestDistance ||
59+
(distance == bestDistance && own.index < best!.breakpoint!.index)) {
60+
best = variant;
61+
bestDistance = distance;
62+
}
63+
}
64+
return best;
65+
}
66+
67+
/// The variant a responsive node shows on a canvas [width] wide, or the
68+
/// current variant when the node is not responsive.
69+
Variant variantForWidth(double width) =>
70+
variantForBreakpoint(ScreenBreakpoint.forWidth(width)) ?? currentVariant;
71+
4172
/// The variant that is loaded by default.
4273
Variant get defaultVariant => variants.firstWhere((e) => e.id == 'default');
4374

@@ -143,11 +174,24 @@ class Variant with SerializableMixin, Equatable {
143174
/// All the child nodes' IDs that the variant contains.
144175
List<String> children;
145176

177+
/// The screen breakpoint this variant is the layout for, when its
178+
/// [VarianceNode] is responsive. Null for an ordinary state.
179+
ScreenBreakpoint? breakpoint;
180+
146181
/// Creates a [Variant] instance with the given data.
147-
Variant({required this.id, required this.name, required this.children});
182+
Variant({
183+
required this.id,
184+
required this.name,
185+
required this.children,
186+
this.breakpoint,
187+
});
188+
189+
/// The name to show for this variant: its breakpoint's label when it has
190+
/// one, otherwise its [name].
191+
String get label => breakpoint?.label ?? name;
148192

149193
@override
150-
List<Object?> get props => [id, name, children];
194+
List<Object?> get props => [id, name, children, breakpoint];
151195

152196
@override
153197
Map toJson() => _$VariantOmitDefaults(this, _$VariantToJson(this));

‎lib/src/api/nodes/variance_node.g.dart‎

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/responsive_variance_test.dart‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import 'dart:convert';
2+
3+
import 'package:codelessly_api/codelessly_api.dart';
4+
import 'package:test/test.dart';
5+
6+
VarianceNode responsive(List<Variant> variants, {String? current}) =>
7+
VarianceNode(
8+
id: 'layout',
9+
name: 'Responsive layout',
10+
basicBoxLocal: NodeBox(0, 0, 390, 844),
11+
variants: variants,
12+
currentVariantId: current,
13+
);
14+
15+
Variant variant(String id, [ScreenBreakpoint? breakpoint]) =>
16+
Variant(id: id, name: id, children: ['$id-root'], breakpoint: breakpoint);
17+
18+
void main() {
19+
group('ScreenBreakpoint.forWidth', () {
20+
test('follows the screen variable rule (600 and 1024)', () {
21+
expect(ScreenBreakpoint.forWidth(0), ScreenBreakpoint.mobile);
22+
expect(ScreenBreakpoint.forWidth(390), ScreenBreakpoint.mobile);
23+
expect(ScreenBreakpoint.forWidth(599.9), ScreenBreakpoint.mobile);
24+
expect(ScreenBreakpoint.forWidth(600), ScreenBreakpoint.tablet);
25+
expect(ScreenBreakpoint.forWidth(834), ScreenBreakpoint.tablet);
26+
expect(ScreenBreakpoint.forWidth(1023.9), ScreenBreakpoint.tablet);
27+
expect(ScreenBreakpoint.forWidth(1024), ScreenBreakpoint.desktop);
28+
expect(ScreenBreakpoint.forWidth(1440), ScreenBreakpoint.desktop);
29+
});
30+
31+
test('default widths fall in their own breakpoint', () {
32+
for (final ScreenBreakpoint breakpoint in ScreenBreakpoint.values) {
33+
expect(ScreenBreakpoint.forWidth(breakpoint.defaultWidth), breakpoint);
34+
}
35+
});
36+
});
37+
38+
group('Variant.breakpoint JSON', () {
39+
test('round-trips and is omitted when unset', () {
40+
final Variant plain = variant('plain');
41+
expect(plain.toJson().containsKey('breakpoint'), isFalse);
42+
expect(Variant.fromJson(plain.toJson()), plain);
43+
44+
final Variant tablet = variant('tablet', ScreenBreakpoint.tablet);
45+
expect(tablet.toJson()['breakpoint'], 'tablet');
46+
expect(Variant.fromJson(tablet.toJson()), tablet);
47+
expect(
48+
Variant.fromJson(tablet.toJson()).breakpoint,
49+
ScreenBreakpoint.tablet,
50+
);
51+
});
52+
53+
test('a variance from before breakpoints reads as not responsive', () {
54+
// JSON written before breakpoints existed: no `breakpoint` keys.
55+
final Map json = jsonDecode(
56+
jsonEncode(responsive([variant('default')]).toJson()),
57+
) as Map;
58+
expect(jsonEncode(json).contains('breakpoint'), isFalse);
59+
final VarianceNode node = VarianceNode.fromJson(json);
60+
expect(node.isResponsive, isFalse);
61+
expect(node.variants.single.breakpoint, isNull);
62+
expect(node.toJson()['variants'][0].containsKey('breakpoint'), isFalse);
63+
});
64+
65+
test('a responsive variance survives a JSON-encoded round trip', () {
66+
final VarianceNode node = responsive([
67+
variant('m', ScreenBreakpoint.mobile),
68+
variant('t', ScreenBreakpoint.tablet),
69+
variant('d', ScreenBreakpoint.desktop),
70+
], current: 't');
71+
final Map decoded = jsonDecode(jsonEncode(node.toJson())) as Map;
72+
final VarianceNode back = VarianceNode.fromJson(decoded);
73+
expect(back.isResponsive, isTrue);
74+
expect(back.variants.map((Variant v) => v.breakpoint).toList(), [
75+
ScreenBreakpoint.mobile,
76+
ScreenBreakpoint.tablet,
77+
ScreenBreakpoint.desktop,
78+
]);
79+
expect(back.currentVariantId, 't');
80+
expect(back.variants, node.variants);
81+
// Read through the registry, as a page document is loaded.
82+
final BaseNode viaRegistry = NodeJsonConverter().fromJson(decoded)!;
83+
expect((viaRegistry as VarianceNode).variantForWidth(1440).id, 'd');
84+
});
85+
});
86+
87+
group('VarianceNode.variantForWidth', () {
88+
test('picks the exact breakpoint', () {
89+
final VarianceNode node = responsive([
90+
variant('m', ScreenBreakpoint.mobile),
91+
variant('t', ScreenBreakpoint.tablet),
92+
variant('d', ScreenBreakpoint.desktop),
93+
]);
94+
expect(node.variantForWidth(390).id, 'm');
95+
expect(node.variantForWidth(834).id, 't');
96+
expect(node.variantForWidth(1440).id, 'd');
97+
expect(node.children, ['m-root']);
98+
});
99+
100+
test('falls back to the nearest, the smaller one on a tie', () {
101+
final VarianceNode mobileDesktop = responsive([
102+
variant('m', ScreenBreakpoint.mobile),
103+
variant('d', ScreenBreakpoint.desktop),
104+
]);
105+
expect(mobileDesktop.variantForWidth(834).id, 'm');
106+
expect(mobileDesktop.variantForWidth(1440).id, 'd');
107+
108+
final VarianceNode tabletOnly = responsive([
109+
variant('plain'),
110+
variant('t', ScreenBreakpoint.tablet),
111+
]);
112+
expect(tabletOnly.variantForWidth(390).id, 't');
113+
expect(tabletOnly.variantForWidth(1440).id, 't');
114+
115+
final VarianceNode mobileTablet = responsive([
116+
variant('m', ScreenBreakpoint.mobile),
117+
variant('t', ScreenBreakpoint.tablet),
118+
]);
119+
expect(mobileTablet.variantForWidth(1440).id, 't');
120+
});
121+
122+
test('an ordinary variance keeps its current variant', () {
123+
final VarianceNode node = responsive([
124+
variant('a'),
125+
variant('b'),
126+
], current: 'b');
127+
expect(node.isResponsive, isFalse);
128+
expect(node.variantForBreakpoint(ScreenBreakpoint.mobile), isNull);
129+
expect(node.variantForWidth(390).id, 'b');
130+
});
131+
});
132+
}

0 commit comments

Comments
 (0)