From aecdca50ae4db650fbdc193fa3429c244da3c9d8 Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sun, 17 May 2026 17:14:31 +0200 Subject: [PATCH 1/9] [WIP] Allow custom fields --- lib/draggable_grid.dart | 244 ++++++ lib/migrations/migrator.dart | 6 +- lib/migrations/v1_to_v2.dart | 18 +- lib/migrations/v3_to_v4.dart | 192 +++++ lib/models/field.dart | 48 +- lib/models/setting_change.dart | 40 +- lib/models/settings.dart | 193 ++--- lib/models/setup.dart | 94 +-- lib/models/setup_form_controller.dart | 384 +++++---- lib/models/tyres.dart | 31 - lib/setting_tiles.dart | 141 ++-- lib/setup_detail.dart | 54 +- lib/setup_edit.dart | 287 ++++--- lib/setup_snapshot.dart | 6 +- lib/value_edit.dart | 119 +-- test/migrations/migrator_test.dart | 136 +++- test/migrations/v1_to_v2_test.dart | 18 +- test/models/setup_form_controller_test.dart | 165 ++-- test/models/setup_test.dart | 824 +++++++++----------- test/setting_tiles_test.dart | 16 +- test/setup_detail_test.dart | 13 +- test/setup_edit_test.dart | 222 +++--- test/setup_file_utils_test.dart | 63 +- 23 files changed, 1801 insertions(+), 1513 deletions(-) create mode 100644 lib/draggable_grid.dart create mode 100644 lib/migrations/v3_to_v4.dart delete mode 100644 lib/models/tyres.dart diff --git a/lib/draggable_grid.dart b/lib/draggable_grid.dart new file mode 100644 index 0000000..8e59939 --- /dev/null +++ b/lib/draggable_grid.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; + +/// A 2-D drag-and-drop grid where items are arranged in rows of equal width. +class DraggableGrid extends StatefulWidget { + const DraggableGrid({ + super.key, + required this.layout, + required this.itemBuilder, + required this.onLayoutChanged, + this.onItemTap, + }); + + final List> layout; + final Widget Function(String id) itemBuilder; + final void Function(List>) onLayoutChanged; + final void Function(String id)? onItemTap; + + @override + State createState() => _DraggableGridState(); +} + +class _DraggableGridState extends State { + void _drop(String id, _DropTarget target) { + widget.onLayoutChanged(_computeNewLayout(id, target)); + } + + List> _computeNewLayout(String id, _DropTarget target) { + final rows = widget.layout.map((r) => List.from(r)).toList(); + + int srcRow = -1, srcCol = -1; + outer: + for (int r = 0; r < rows.length; r++) { + for (int c = 0; c < rows[r].length; c++) { + if (rows[r][c] == id) { + srcRow = r; + srcCol = c; + break outer; + } + } + } + if (srcRow < 0) return widget.layout; + + switch (target) { + case _InRowTarget(:final rowIndex, :final position): + if (srcRow == rowIndex) { + rows[rowIndex].removeAt(srcCol); + final insertAt = (srcCol < position ? position - 1 : position) + .clamp(0, rows[rowIndex].length); + rows[rowIndex].insert(insertAt, id); + } else { + final willEmpty = rows[srcRow].length == 1; + rows[srcRow].removeAt(srcCol); + int adjRow = rowIndex; + if (willEmpty && srcRow < rowIndex) adjRow--; + rows.removeWhere((r) => r.isEmpty); + if (adjRow < rows.length) { + final insertAt = position.clamp(0, rows[adjRow].length); + rows[adjRow].insert(insertAt, id); + } + } + + case _NewRowTarget(:final position): + final willEmpty = rows[srcRow].length == 1; + rows[srcRow].removeAt(srcCol); + int adjPos = position; + if (willEmpty && srcRow < position) adjPos--; + rows.removeWhere((r) => r.isEmpty); + final insertAt = adjPos.clamp(0, rows.length); + rows.insert(insertAt, [id]); + } + + rows.removeWhere((r) => r.isEmpty); + return rows; + } + + Widget _inRowZone(int rowIndex, int position) { + return DragTarget( + onWillAcceptWithDetails: (_) => true, + onAcceptWithDetails: (d) { + _drop(d.data, _InRowTarget(rowIndex: rowIndex, position: position)); + }, + builder: (context, candidates, _) { + final active = candidates.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + width: active ? 28 : 20, + alignment: Alignment.center, + child: active + ? Container( + width: 2, + color: Theme.of(context).colorScheme.primary, + ) + : null, + ); + }, + ); + } + + Widget _betweenRowsZone(int position) { + return DragTarget( + onWillAcceptWithDetails: (_) => true, + onAcceptWithDetails: (d) { + _drop(d.data, _NewRowTarget(position: position)); + }, + builder: (context, candidates, _) { + final active = candidates.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + height: active ? 28 : 12, + alignment: Alignment.center, + child: active + ? Container( + height: 2, + color: Theme.of(context).colorScheme.primary, + ) + : null, + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _betweenRowsZone(0), + for (int r = 0; r < widget.layout.length; r++) ...[ + IntrinsicHeight( + child: Row( + children: [ + _inRowZone(r, 0), + for (int c = 0; c < widget.layout[r].length; c++) ...[ + Expanded( + child: _DraggableItem( + id: widget.layout[r][c], + itemBuilder: widget.itemBuilder, + onTap: widget.onItemTap, + ), + ), + _inRowZone(r, c + 1), + ], + ], + ), + ), + _betweenRowsZone(r + 1), + ], + ], + ); + } +} + +// ── draggable item ───────────────────────────────────────────────────────── + +class _DraggableItem extends StatefulWidget { + const _DraggableItem({ + required this.id, + required this.itemBuilder, + this.onTap, + }); + + final String id; + final Widget Function(String) itemBuilder; + final void Function(String)? onTap; + + @override + State<_DraggableItem> createState() => _DraggableItemState(); +} + +class _DraggableItemState extends State<_DraggableItem> { + final _childKey = GlobalKey(); + Size _size = const Size(100, 60); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _measureSize()); + } + + @override + void didUpdateWidget(_DraggableItem old) { + super.didUpdateWidget(old); + WidgetsBinding.instance.addPostFrameCallback((_) => _measureSize()); + } + + void _measureSize() { + if (!mounted) return; + final rb = _childKey.currentContext?.findRenderObject() as RenderBox?; + if (rb == null) return; + final newSize = rb.size; + if (newSize != _size) setState(() => _size = newSize); + } + + Offset _dragAnchor(Draggable _, BuildContext __, Offset position) { + final rb = _childKey.currentContext?.findRenderObject() as RenderBox?; + if (rb == null) return Offset.zero; + return rb.globalToLocal(position); + } + + @override + Widget build(BuildContext context) { + final child = KeyedSubtree( + key: _childKey, + child: widget.itemBuilder(widget.id), + ); + return LongPressDraggable( + data: widget.id, + dragAnchorStrategy: _dragAnchor, + feedback: Material( + elevation: 6, + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: _size.width, + height: _size.height, + child: widget.itemBuilder(widget.id), + ), + ), + childWhenDragging: Opacity( + opacity: 0.3, + child: widget.itemBuilder(widget.id), + ), + child: widget.onTap != null + ? GestureDetector( + onTap: () => widget.onTap!(widget.id), + child: child, + ) + : child, + ); + } +} + +// ── drop-target descriptors ──────────────────────────────────────────────── + +sealed class _DropTarget {} + +final class _InRowTarget extends _DropTarget { + _InRowTarget({required this.rowIndex, required this.position}); + final int rowIndex; + final int position; +} + +final class _NewRowTarget extends _DropTarget { + _NewRowTarget({required this.position}); + final int position; +} diff --git a/lib/migrations/migrator.dart b/lib/migrations/migrator.dart index 2e864ec..23e2596 100644 --- a/lib/migrations/migrator.dart +++ b/lib/migrations/migrator.dart @@ -1,7 +1,8 @@ import 'v1_to_v2.dart'; import 'v2_to_v3.dart'; +import 'v3_to_v4.dart'; -const int currentSchemaVersion = 3; +const int currentSchemaVersion = 4; Map migrateIfNeeded(Map json) { final version = json['schemaVersion'] as int? ?? 1; @@ -13,5 +14,8 @@ Map migrateIfNeeded(Map json) { if (version < 3) { data = migrateV2ToV3(data); } + if (version < 4) { + data = migrateV3ToV4(data); + } return data; } diff --git a/lib/migrations/v1_to_v2.dart b/lib/migrations/v1_to_v2.dart index 47be6fd..9582c49 100644 --- a/lib/migrations/v1_to_v2.dart +++ b/lib/migrations/v1_to_v2.dart @@ -1,8 +1,16 @@ -import 'package:suspension_setup/models/setting_change.dart'; -import 'package:suspension_setup/models/settings.dart'; +// v1 structure: { setupId: { id, name, fork: { airPressure: int?, ... }, shock: ..., history } } +// v2 structure: { schemaVersion: 2, setups: { setupId: { ..., fork: { airPressure: {value, unit}?, ... } } } } + +const _defaultUnits = { + 'airPressure': 'PSI', + 'sag': '%', + 'volumeSpacer': 'Spacers', + 'lsc': 'Clicks', + 'hsc': 'Clicks', + 'lsr': 'Clicks', + 'hsr': 'Clicks', +}; -/// v1 structure: { setupId: { id, name, fork: { airPressure: int?, ... }, shock: ..., history } } -/// v2 structure: { schemaVersion: 2, setups: { setupId: { ..., fork: { airPressure: {value, unit}?, ... } } } } Map migrateV1ToV2(Map v1Data) { final migratedSetups = v1Data.map( (id, value) => MapEntry(id, _migrateSetup(value as Map)), @@ -21,7 +29,7 @@ Map _migrateSetup(Map setup) { Map _migrateSettings(Map settings) { return settings.map((key, rawValue) { if (rawValue == null) return MapEntry(key, null); - final unit = Settings.defaultUnits[SettingType.fromJson(key)]; + final unit = _defaultUnits[key] ?? ''; return MapEntry(key, {'value': rawValue, 'unit': unit}); }); } diff --git a/lib/migrations/v3_to_v4.dart b/lib/migrations/v3_to_v4.dart new file mode 100644 index 0000000..d429204 --- /dev/null +++ b/lib/migrations/v3_to_v4.dart @@ -0,0 +1,192 @@ +import 'package:uuid/uuid.dart'; + +const _uuid = Uuid(); + +/// Maps old settingType JSON key → display label. +const _labels = { + 'airPressure': 'Air Pressure', + 'sag': 'Sag', + 'volumeSpacer': 'Volume', + 'lsc': 'Low Speed Compression', + 'hsc': 'High Speed Compression', + 'lsr': 'Low Speed Rebound', + 'hsr': 'High Speed Rebound', + 'frontTyrePressure': 'Front Tyre Pressure', + 'rearTyrePressure': 'Rear Tyre Pressure', +}; + +const _defaultUnits = { + 'airPressure': 'PSI', + 'sag': '%', + 'volumeSpacer': 'Spacers', + 'lsc': 'Clicks', + 'hsc': 'Clicks', + 'lsr': 'Clicks', + 'hsr': 'Clicks', + 'frontTyrePressure': 'PSI', + 'rearTyrePressure': 'PSI', +}; + +/// Row groupings that mirror the old SettingTiles layout. +const _suspensionGroups = [ + ['airPressure', 'sag', 'volumeSpacer'], + ['lsc', 'hsc'], + ['lsr', 'hsr'], +]; + +Map migrateV3ToV4(Map v3Data) { + final setups = Map.from(v3Data['setups'] as Map); + final migratedSetups = setups.map( + (id, value) => MapEntry(id, _migrateSetup(value as Map)), + ); + return {'schemaVersion': 4, 'setups': migratedSetups}; +} + +Map _migrateSetup(Map setup) { + // Assign a UUID per field per section (per-setup, not shared). + final forkIds = _newSuspensionIds(); + final shockIds = _newSuspensionIds(); + final tyreIds = _newTyreIds(); + + // Build (suspensionType, settingType) → fieldId lookup for history. + final fieldIdMap = <(String, String), String>{}; + for (final key in forkIds.keys) { + fieldIdMap[('fork', key)] = forkIds[key]!; + } + for (final key in shockIds.keys) { + fieldIdMap[('shock', key)] = shockIds[key]!; + } + fieldIdMap[('tyre', 'frontTyrePressure')] = tyreIds['frontTyrePressure']!; + fieldIdMap[('tyre', 'rearTyrePressure')] = tyreIds['rearTyrePressure']!; + + final forkJson = setup['fork'] as Map? ?? {}; + final shockJson = setup['shock'] as Map? ?? {}; + final tyresJson = setup['tyres'] as Map?; + + return { + ...setup, + 'fork': _migrateSuspensionSection(forkJson, forkIds), + 'shock': _migrateSuspensionSection(shockJson, shockIds), + 'tyres': _migrateTyresSection(tyresJson, tyreIds), + 'history': _migrateHistory( + setup['history'] as List? ?? [], + fieldIdMap, + ), + }; +} + +Map _newSuspensionIds() => { + for (final key in [ + 'airPressure', + 'sag', + 'volumeSpacer', + 'lsc', + 'hsc', + 'lsr', + 'hsr', + ]) + key: _uuid.v4(), + }; + +Map _newTyreIds() => { + 'frontTyrePressure': _uuid.v4(), + 'rearTyrePressure': _uuid.v4(), + }; + +Map _migrateSuspensionSection( + Map oldSection, + Map ids, +) { + final fields = >[]; + for (final key in ids.keys) { + final raw = oldSection[key] as Map?; + fields.add({ + 'id': ids[key]!, + 'name': _labels[key]!, + 'unit': raw?['unit'] as String? ?? _defaultUnits[key]!, + 'value': raw?['value'], + 'deleted': raw == null, + }); + } + + final layout = >[]; + for (final group in _suspensionGroups) { + final row = group + .where((key) => oldSection[key] != null) + .map((key) => ids[key]!) + .toList(); + if (row.isNotEmpty) layout.add(row); + } + + return { + 'fields': fields, + 'layout': layout, + 'serialNumber': oldSection['serialNumber'], + 'infoUrl': oldSection['infoUrl'], + }; +} + +Map _migrateTyresSection( + Map? tyres, + Map ids, +) { + final frontRaw = tyres?['front'] as Map?; + final rearRaw = tyres?['rear'] as Map?; + final frontId = ids['frontTyrePressure']!; + final rearId = ids['rearTyrePressure']!; + + final fields = [ + { + 'id': frontId, + 'name': _labels['frontTyrePressure']!, + 'unit': frontRaw?['unit'] as String? ?? 'PSI', + 'value': frontRaw?['value'], + 'deleted': frontRaw == null, + }, + { + 'id': rearId, + 'name': _labels['rearTyrePressure']!, + 'unit': rearRaw?['unit'] as String? ?? 'PSI', + 'value': rearRaw?['value'], + 'deleted': rearRaw == null, + }, + ]; + + final row = [ + if (frontRaw != null) frontId, + if (rearRaw != null) rearId, + ]; + + return { + 'fields': fields, + 'layout': row.isEmpty ? [] : [row], + 'serialNumber': null, + 'infoUrl': null, + }; +} + +List _migrateHistory( + List history, + Map<(String, String), String> fieldIdMap, +) { + return history.map((entry) { + final entryMap = Map.from(entry as Map); + final changes = (entryMap['changes'] as List).map((change) { + final c = Map.from(change as Map); + final suspensionType = c['suspensionType'] as String; + final settingType = c['settingType'] as String?; + if (settingType == null) return c; // already migrated + final fieldId = fieldIdMap[(suspensionType, settingType)]; + if (fieldId == null) return c; + return { + 'suspensionType': suspensionType, + 'fieldId': fieldId, + 'oldValue': c['oldValue'], + 'newValue': c['newValue'], + 'oldEnabled': c['oldEnabled'], + 'newEnabled': c['newEnabled'], + }; + }).toList(); + return {...entryMap, 'changes': changes}; + }).toList(); +} diff --git a/lib/models/field.dart b/lib/models/field.dart index 74c41eb..02a5404 100644 --- a/lib/models/field.dart +++ b/lib/models/field.dart @@ -1,12 +1,48 @@ +import 'package:uuid/uuid.dart'; + class Field { - final num value; + final String id; + final String name; final String unit; + final num? value; + final bool deleted; + + Field({ + String? id, + required this.name, + required this.unit, + this.value, + this.deleted = false, + }) : id = id ?? const Uuid().v4(); - const Field({required this.value, required this.unit}); + Field copyWith({ + String? name, + String? unit, + num? value, + bool? deleted, + bool clearValue = false, + }) => + Field( + id: id, + name: name ?? this.name, + unit: unit ?? this.unit, + value: clearValue ? null : (value ?? this.value), + deleted: deleted ?? this.deleted, + ); - factory Field.fromJson(Map json) { - return Field(value: json['value'] as num, unit: json['unit'] as String); - } + factory Field.fromJson(Map json) => Field( + id: json['id'] as String, + name: json['name'] as String, + unit: json['unit'] as String, + value: json['value'] as num?, + deleted: json['deleted'] as bool? ?? false, + ); - Map toJson() => {'value': value, 'unit': unit}; + Map toJson() => { + 'id': id, + 'name': name, + 'unit': unit, + 'value': value, + 'deleted': deleted, + }; } diff --git a/lib/models/setting_change.dart b/lib/models/setting_change.dart index 63a6a6c..569892a 100644 --- a/lib/models/setting_change.dart +++ b/lib/models/setting_change.dart @@ -49,7 +49,7 @@ class SettingChanges { class SettingChange { final SuspensionType suspensionType; - final SettingType settingType; + final String fieldId; final num? oldValue; final num? newValue; final bool? oldEnabled; @@ -57,7 +57,7 @@ class SettingChange { SettingChange({ required this.suspensionType, - required this.settingType, + required this.fieldId, required this.oldValue, required this.newValue, this.oldEnabled, @@ -67,7 +67,7 @@ class SettingChange { factory SettingChange.fromJson(Map json) { return SettingChange( suspensionType: SuspensionType.fromJson(json['suspensionType']), - settingType: SettingType.fromJson(json['settingType']), + fieldId: json['fieldId'] as String, oldValue: json['oldValue'], newValue: json['newValue'], oldEnabled: json['oldEnabled'], @@ -78,7 +78,7 @@ class SettingChange { Map toJson() { return { 'suspensionType': suspensionType.toJson(), - 'settingType': settingType.toJson(), + 'fieldId': fieldId, 'oldValue': oldValue, 'newValue': newValue, 'oldEnabled': oldEnabled, @@ -89,7 +89,7 @@ class SettingChange { SettingChange clone() { return SettingChange( suspensionType: suspensionType, - settingType: settingType, + fieldId: fieldId, oldValue: oldValue, newValue: newValue, oldEnabled: oldEnabled, @@ -100,7 +100,7 @@ class SettingChange { SettingChange inverted() { return SettingChange( suspensionType: suspensionType, - settingType: settingType, + fieldId: fieldId, oldValue: newValue, newValue: oldValue, oldEnabled: newEnabled, @@ -109,34 +109,6 @@ class SettingChange { } } -enum SettingType { - airPressure, - volumeSpacer, - sag, - lsr, - hsr, - lsc, - hsc, - frontTyrePressure, - rearTyrePressure; - - static SettingType fromJson(String json) => values.byName(json); - - String toJson() => name; - - String get label => switch (this) { - SettingType.airPressure => 'Air Pressure', - SettingType.sag => 'Sag', - SettingType.volumeSpacer => 'Volume', - SettingType.lsc => 'Low Speed Compression', - SettingType.hsc => 'High Speed Compression', - SettingType.lsr => 'Low Speed Rebound', - SettingType.hsr => 'High Speed Rebound', - SettingType.frontTyrePressure => 'Front Tyre Pressure', - SettingType.rearTyrePressure => 'Rear Tyre Pressure', - }; -} - enum SuspensionType { fork, shock, diff --git a/lib/models/settings.dart b/lib/models/settings.dart index b73477e..55b6d19 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,153 +1,80 @@ -import 'package:suspension_setup/models/field.dart'; +import 'field.dart'; -import 'setting_change.dart'; - -class Settings { - Field? airPressure; - Field? volumeSpacer; - Field? sag; - Field? lsr; - Field? hsr; - Field? lsc; - Field? hsc; +class SectionSettings { + List fields; + List> layout; String? serialNumber; String? infoUrl; - Settings({ - this.airPressure, - this.volumeSpacer, - this.sag, - this.lsr, - this.hsr, - this.lsc, - this.hsc, + SectionSettings({ + required this.fields, + required this.layout, this.serialNumber, this.infoUrl, }); - factory Settings.fromJson(Map json) { - Field? parse(String key) { - final raw = json[key]; - if (raw == null) return null; - return Field.fromJson(raw as Map); - } - - return Settings( - airPressure: parse('airPressure'), - volumeSpacer: parse('volumeSpacer'), - sag: parse('sag'), - lsr: parse('lsr'), - hsr: parse('hsr'), - lsc: parse('lsc'), - hsc: parse('hsc'), - serialNumber: json['serialNumber'] as String?, - infoUrl: json['infoUrl'] as String?, - ); - } - - Map toJson() { - return { - 'airPressure': airPressure?.toJson(), - 'volumeSpacer': volumeSpacer?.toJson(), - 'sag': sag?.toJson(), - 'lsr': lsr?.toJson(), - 'hsr': hsr?.toJson(), - 'lsc': lsc?.toJson(), - 'hsc': hsc?.toJson(), - 'serialNumber': serialNumber, - 'infoUrl': infoUrl, - }; - } + bool get hasAnyField => + fields.any((f) => !f.deleted) || serialNumber != null || infoUrl != null; - static const Map defaultUnits = { - SettingType.airPressure: 'PSI', - SettingType.sag: '%', - SettingType.volumeSpacer: 'Spacers', - SettingType.lsc: 'Clicks', - SettingType.hsc: 'Clicks', - SettingType.lsr: 'Clicks', - SettingType.hsr: 'Clicks', - SettingType.frontTyrePressure: 'PSI', - SettingType.rearTyrePressure: 'PSI', - }; + Iterable get activeFields => fields.where((f) => !f.deleted); - factory Settings.getDefault() { - return Settings( - airPressure: - Field(value: 0, unit: defaultUnits[SettingType.airPressure]!), - sag: Field(value: 0, unit: defaultUnits[SettingType.sag]!), - volumeSpacer: null, - lsc: Field(value: 0, unit: defaultUnits[SettingType.lsc]!), - hsc: null, - lsr: Field(value: 0, unit: defaultUnits[SettingType.lsr]!), - hsr: null, - ); + Field? fieldById(String id) { + for (final f in fields) { + if (f.id == id) return f; + } + return null; } - bool get hasAnyField => - airPressure != null || - volumeSpacer != null || - sag != null || - lsr != null || - hsr != null || - lsc != null || - hsc != null || - serialNumber != null || - infoUrl != null; - - bool get hasAnyValueField => - airPressure != null || - volumeSpacer != null || - sag != null || - lsr != null || - hsr != null || - lsc != null || - hsc != null; + factory SectionSettings.fromJson(Map json) => + SectionSettings( + fields: (json['fields'] as List) + .map((e) => Field.fromJson(e as Map)) + .toList(), + layout: (json['layout'] as List) + .map((row) => (row as List).cast()) + .toList(), + serialNumber: json['serialNumber'] as String?, + infoUrl: json['infoUrl'] as String?, + ); - Field? fieldFor(SettingType type) => switch (type) { - SettingType.airPressure => airPressure, - SettingType.sag => sag, - SettingType.volumeSpacer => volumeSpacer, - SettingType.lsc => lsc, - SettingType.hsc => hsc, - SettingType.lsr => lsr, - SettingType.hsr => hsr, - SettingType.frontTyrePressure || SettingType.rearTyrePressure => null, + Map toJson() => { + 'fields': fields.map((f) => f.toJson()).toList(), + 'layout': layout, + 'serialNumber': serialNumber, + 'infoUrl': infoUrl, }; - void setField(SettingType type, Field? value) { - switch (type) { - case SettingType.airPressure: - airPressure = value; - case SettingType.sag: - sag = value; - case SettingType.volumeSpacer: - volumeSpacer = value; - case SettingType.lsc: - lsc = value; - case SettingType.hsc: - hsc = value; - case SettingType.lsr: - lsr = value; - case SettingType.hsr: - hsr = value; - case SettingType.frontTyrePressure: - case SettingType.rearTyrePressure: - break; - } + /// Default for fork / shock: Air Pressure, Sag, LSC, LSR (no values yet). + static SectionSettings getDefaultForSuspension() { + final airPressure = Field(name: 'Air Pressure', unit: 'PSI'); + final sag = Field(name: 'Sag', unit: '%'); + final lsc = Field(name: 'Low Speed Compression', unit: 'Clicks'); + final lsr = Field(name: 'Low Speed Rebound', unit: 'Clicks'); + return SectionSettings( + fields: [airPressure, sag, lsc, lsr], + layout: [ + [airPressure.id, sag.id], + [lsc.id, lsr.id], + ], + ); } - Settings clone() { - return Settings( - airPressure: airPressure, - volumeSpacer: volumeSpacer, - sag: sag, - lsr: lsr, - hsr: hsr, - lsc: lsc, - hsc: hsc, - serialNumber: serialNumber, - infoUrl: infoUrl, + /// Default for tyres: Front and Rear Tyre Pressure (no values yet). + static SectionSettings getDefaultForTyres() { + final front = Field(name: 'Front Tyre Pressure', unit: 'PSI'); + final rear = Field(name: 'Rear Tyre Pressure', unit: 'PSI'); + return SectionSettings( + fields: [front, rear], + layout: [ + [front.id, rear.id], + ], ); } + + SectionSettings clone() => SectionSettings( + fields: List.from(fields), + layout: layout.map((row) => List.from(row)).toList(), + serialNumber: serialNumber, + infoUrl: infoUrl, + ); } diff --git a/lib/models/setup.dart b/lib/models/setup.dart index 47490f0..15122b4 100644 --- a/lib/models/setup.dart +++ b/lib/models/setup.dart @@ -1,16 +1,14 @@ -import 'package:suspension_setup/models/settings.dart'; -import 'package:suspension_setup/models/tyres.dart'; import 'package:uuid/uuid.dart'; -import 'field.dart'; import 'setting_change.dart'; +import 'settings.dart'; class Setup { final String id; String name; - final Settings fork; - final Settings shock; - final Tyres tyres; + final SectionSettings fork; + final SectionSettings shock; + final SectionSettings tyres; final List history; Setup({ @@ -26,9 +24,9 @@ class Setup { return Setup( id: json['id'], name: json['name'], - fork: Settings.fromJson(json['fork']), - shock: Settings.fromJson(json['shock']), - tyres: Tyres.fromJson(json['tyres'] as Map?), + fork: SectionSettings.fromJson(json['fork']), + shock: SectionSettings.fromJson(json['shock']), + tyres: SectionSettings.fromJson(json['tyres']), history: List.from( json['history'].map((e) => SettingChanges.fromJson(e))), ); @@ -41,7 +39,7 @@ class Setup { 'fork': fork.toJson(), 'shock': shock.toJson(), 'tyres': tyres.toJson(), - 'history': history.map((e) => e.toJson()).toList() + 'history': history.map((e) => e.toJson()).toList(), }; } @@ -49,32 +47,29 @@ class Setup { return Setup( id: const Uuid().v1(), name: '', - fork: Settings.getDefault(), - shock: Settings.getDefault(), - tyres: Tyres(), + fork: SectionSettings.getDefaultForSuspension(), + shock: SectionSettings.getDefaultForSuspension(), + tyres: SectionSettings.getDefaultForTyres(), history: [], ); } Setup copyMutable() => Setup.fromJson(toJson()); + SectionSettings _sectionFor(SuspensionType type) => switch (type) { + SuspensionType.fork => fork, + SuspensionType.shock => shock, + SuspensionType.tyre => tyres, + }; + List computeUndo(SettingChanges historyEntry) { assert(!historyEntry.isCreationEntry, 'cannot undo a creation entry'); final result = []; for (final change in historyEntry.changes) { - final Field? currentField; - if (change.suspensionType == SuspensionType.tyre) { - currentField = change.settingType == SettingType.frontTyrePressure - ? tyres.front - : tyres.rear; - } else { - final settings = - change.suspensionType == SuspensionType.fork ? fork : shock; - currentField = settings.fieldFor(change.settingType); - } - - final num? currentValue = currentField?.value; - final bool currentEnabled = currentField != null; + final section = _sectionFor(change.suspensionType); + final currentField = section.fieldById(change.fieldId); + final bool currentEnabled = currentField != null && !currentField.deleted; + final num? currentValue = currentEnabled ? currentField.value : null; final bool targetEnabled = change.oldEnabled ?? true; final bool enabledChanges = currentEnabled != targetEnabled; @@ -85,7 +80,7 @@ class Setup { result.add(SettingChange( suspensionType: change.suspensionType, - settingType: change.settingType, + fieldId: change.fieldId, oldValue: currentValue, newValue: targetEnabled ? change.oldValue : null, oldEnabled: enabledChanges ? currentEnabled : null, @@ -97,40 +92,33 @@ class Setup { void applyChanges(List changes) { for (final change in changes) { + final section = _sectionFor(change.suspensionType); final bool targetEnabled = change.newEnabled ?? true; final num? targetValue = change.newValue; assert(!targetEnabled || targetValue != null, 'targetValue must not be null when targetEnabled is true'); if (targetEnabled && targetValue == null) continue; - if (change.suspensionType == SuspensionType.tyre) { - final isFront = change.settingType == SettingType.frontTyrePressure; - final currentField = isFront ? tyres.front : tyres.rear; - final newField = targetEnabled - ? Field( - value: targetValue!, - unit: currentField?.unit ?? - Settings.defaultUnits[change.settingType] ?? - 'PSI') - : null; - if (isFront) { - tyres.front = newField; - } else { - tyres.rear = newField; + final idx = section.fields.indexWhere((f) => f.id == change.fieldId); + if (idx < 0) continue; + + if (targetEnabled) { + section.fields[idx] = section.fields[idx].copyWith( + value: targetValue, + deleted: false, + ); + if (!section.layout.any((row) => row.contains(change.fieldId))) { + section.layout.add([change.fieldId]); } } else { - final settings = - change.suspensionType == SuspensionType.fork ? fork : shock; - if (targetEnabled) { - final currentField = settings.fieldFor(change.settingType); - final unit = currentField?.unit ?? - Settings.defaultUnits[change.settingType] ?? - ''; - settings.setField( - change.settingType, Field(value: targetValue!, unit: unit)); - } else { - settings.setField(change.settingType, null); - } + section.fields[idx] = section.fields[idx].copyWith( + deleted: true, + clearValue: true, + ); + section.layout = section.layout + .map((row) => row.where((id) => id != change.fieldId).toList()) + .where((row) => row.isNotEmpty) + .toList(); } } } diff --git a/lib/models/setup_form_controller.dart b/lib/models/setup_form_controller.dart index 05f39d1..d248bce 100644 --- a/lib/models/setup_form_controller.dart +++ b/lib/models/setup_form_controller.dart @@ -1,64 +1,60 @@ import 'package:flutter/widgets.dart'; -import 'package:suspension_setup/models/setting_change.dart'; -import 'package:suspension_setup/models/settings.dart'; -import 'package:suspension_setup/models/tyres.dart'; +import 'package:uuid/uuid.dart'; import 'field.dart'; +import 'setting_change.dart'; +import 'settings.dart'; import 'setup.dart'; class SetupFormController { SetupFormController(Setup? setup) : name = TextEditingController(text: setup?.name), - fork = SettingsFormController(setup?.fork), - shock = SettingsFormController(setup?.shock), - tyres = TyresFormController(setup?.tyres); + fork = SectionFormController(setup?.fork), + shock = SectionFormController(setup?.shock), + tyres = SectionFormController(setup?.tyres); final TextEditingController name; - final SettingsFormController fork; - final SettingsFormController shock; - final TyresFormController tyres; - - bool hasNewlyEnabledFields(Setup? originalSetup) { - bool isNew(FieldFormController ctrl, Field? original) => - ctrl.enabled.value && original == null; - return isNew(fork.airPressure, originalSetup?.fork.airPressure) || - isNew(fork.sag, originalSetup?.fork.sag) || - isNew(fork.volumeSpacer, originalSetup?.fork.volumeSpacer) || - isNew(fork.lsc, originalSetup?.fork.lsc) || - isNew(fork.hsc, originalSetup?.fork.hsc) || - isNew(fork.lsr, originalSetup?.fork.lsr) || - isNew(fork.hsr, originalSetup?.fork.hsr) || - isNew(shock.airPressure, originalSetup?.shock.airPressure) || - isNew(shock.sag, originalSetup?.shock.sag) || - isNew(shock.volumeSpacer, originalSetup?.shock.volumeSpacer) || - isNew(shock.lsc, originalSetup?.shock.lsc) || - isNew(shock.hsc, originalSetup?.shock.hsc) || - isNew(shock.lsr, originalSetup?.shock.lsr) || - isNew(shock.hsr, originalSetup?.shock.hsr) || - isNew(tyres.front, originalSetup?.tyres.front) || - isNew(tyres.rear, originalSetup?.tyres.rear); + final SectionFormController fork; + final SectionFormController shock; + final SectionFormController tyres; + + /// True if any section has a newly added field (needs value entry). + bool hasNewlyAddedFields() { + return fork.fields.any((f) => f.isNew) || + shock.fields.any((f) => f.isNew) || + tyres.fields.any((f) => f.isNew); } (Setup, SettingChanges) buildResult(Setup? originalSetup) { - final newSetup = originalSetup?.copyMutable() ?? Setup.getDefault(); + final newSetup = originalSetup?.copyMutable() ?? + Setup( + id: const Uuid().v1(), + name: '', + fork: SectionSettings(fields: [], layout: []), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), + history: [], + ); final changes = SettingChanges(changes: [], date: DateTime.now()); + final isEditing = originalSetup != null; - _applySettings( - SuspensionType.fork, fork, originalSetup?.fork, changes, newSetup.fork); - _applySettings(SuspensionType.shock, shock, originalSetup?.shock, changes, - newSetup.shock); - _applyTyres(tyres, originalSetup?.tyres, changes, newSetup.tyres); + _applySection(SuspensionType.fork, fork, originalSetup?.fork, changes, + newSetup.fork, isEditing); + _applySection(SuspensionType.shock, shock, originalSetup?.shock, changes, + newSetup.shock, isEditing); + _applySection(SuspensionType.tyre, tyres, originalSetup?.tyres, changes, + newSetup.tyres, isEditing); String? trimmed(TextEditingController ctrl) { final t = ctrl.text.trim(); return t.isEmpty ? null : t; } + newSetup.name = name.text; newSetup.fork.serialNumber = trimmed(fork.serialNumber); newSetup.fork.infoUrl = trimmed(fork.infoUrl); newSetup.shock.serialNumber = trimmed(shock.serialNumber); newSetup.shock.infoUrl = trimmed(shock.infoUrl); - newSetup.name = name.text; return (newSetup, changes); } @@ -71,197 +67,179 @@ class SetupFormController { } } -void _applyField( - SettingType type, +void _applySection( SuspensionType suspensionType, - Field? oldField, - FieldFormController ctrl, - bool isEditing, + SectionFormController ctrl, + SectionSettings? original, SettingChanges changes, - void Function(Field?) setter, + SectionSettings target, + bool isEditing, ) { - final newField = ctrl.enabled.value - ? Field(value: num.parse(ctrl.value.text), unit: ctrl.unit.text) - : null; - - if (isEditing) { - final wasEnabled = oldField != null; - final isEnabled = ctrl.enabled.value; - final enabledChanged = wasEnabled != isEnabled; - final valueChanged = oldField?.value != newField?.value; - - if (enabledChanged || valueChanged) { - changes.changes.add(SettingChange( - settingType: type, - suspensionType: suspensionType, - oldValue: oldField?.value, - newValue: newField?.value, - oldEnabled: enabledChanged ? wasEnabled : null, - newEnabled: enabledChanged ? isEnabled : null, - )); + // Apply layout from the form controller. + target.layout = ctrl.layout.map((row) => List.from(row)).toList(); + + // Apply each active field in the form. + for (final fieldCtrl in ctrl.fields) { + final origField = original?.fieldById(fieldCtrl.id); + final newValue = num.tryParse(fieldCtrl.value.text); + final newUnit = fieldCtrl.unit.text; + final newName = fieldCtrl.name.text; + + if (fieldCtrl.isNew) { + // Newly created field — add to target registry. + final newField = Field( + id: fieldCtrl.id, + name: newName, + unit: newUnit, + value: newValue, + ); + final existingIdx = target.fields.indexWhere((f) => f.id == fieldCtrl.id); + if (existingIdx >= 0) { + target.fields[existingIdx] = newField; + } else { + target.fields.add(newField); + } + if (isEditing && newValue != null) { + changes.changes.add(SettingChange( + suspensionType: suspensionType, + fieldId: fieldCtrl.id, + oldValue: null, + newValue: newValue, + oldEnabled: false, + newEnabled: true, + )); + } + } else if (origField != null) { + // Existing field — update metadata and value. + final idx = target.fields.indexWhere((f) => f.id == fieldCtrl.id); + if (idx >= 0) { + target.fields[idx] = origField.copyWith( + name: newName, + unit: newUnit, + value: newValue, + deleted: false, + ); + } + if (isEditing && newValue != null && origField.value != newValue) { + changes.changes.add(SettingChange( + suspensionType: suspensionType, + fieldId: fieldCtrl.id, + oldValue: origField.value, + newValue: newValue, + )); + } } } - setter(newField); -} - -void _applySettings( - SuspensionType suspensionType, - SettingsFormController controller, - Settings? oldSettings, - SettingChanges changes, - Settings newSettings, -) { - final isEditing = oldSettings != null; - _applyField( - SettingType.airPressure, - suspensionType, - oldSettings?.airPressure, - controller.airPressure, - isEditing, - changes, - (f) => newSettings.airPressure = f); - _applyField(SettingType.sag, suspensionType, oldSettings?.sag, controller.sag, - isEditing, changes, (f) => newSettings.sag = f); - _applyField( - SettingType.volumeSpacer, - suspensionType, - oldSettings?.volumeSpacer, - controller.volumeSpacer, - isEditing, - changes, - (f) => newSettings.volumeSpacer = f); - _applyField(SettingType.lsc, suspensionType, oldSettings?.lsc, controller.lsc, - isEditing, changes, (f) => newSettings.lsc = f); - _applyField(SettingType.hsc, suspensionType, oldSettings?.hsc, controller.hsc, - isEditing, changes, (f) => newSettings.hsc = f); - _applyField(SettingType.lsr, suspensionType, oldSettings?.lsr, controller.lsr, - isEditing, changes, (f) => newSettings.lsr = f); - _applyField(SettingType.hsr, suspensionType, oldSettings?.hsr, controller.hsr, - isEditing, changes, (f) => newSettings.hsr = f); -} - -void _applyTyres( - TyresFormController controller, - Tyres? oldTyres, - SettingChanges changes, - Tyres newTyres, -) { - final isEditing = oldTyres != null; - _applyField( - SettingType.frontTyrePressure, - SuspensionType.tyre, - oldTyres?.front, - controller.front, - isEditing, - changes, - (f) => newTyres.front = f); - _applyField(SettingType.rearTyrePressure, SuspensionType.tyre, oldTyres?.rear, - controller.rear, isEditing, changes, (f) => newTyres.rear = f); + // Handle fields removed from the form (were active, now gone). + if (isEditing && original != null) { + final activeFormIds = ctrl.fields.map((f) => f.id).toSet(); + for (final origField in original.activeFields) { + if (!activeFormIds.contains(origField.id)) { + final idx = target.fields.indexWhere((f) => f.id == origField.id); + if (idx >= 0) { + target.fields[idx] = + target.fields[idx].copyWith(deleted: true, clearValue: true); + } + target.layout = target.layout + .map((row) => row.where((id) => id != origField.id).toList()) + .where((row) => row.isNotEmpty) + .toList(); + changes.changes.add(SettingChange( + suspensionType: suspensionType, + fieldId: origField.id, + oldValue: origField.value, + newValue: null, + oldEnabled: true, + newEnabled: false, + )); + } + } + } } -class TyresFormController { - TyresFormController(Tyres? tyres) - : front = FieldFormController( - enabled: tyres?.front != null, - value: tyres?.front?.value, - unit: tyres?.front?.unit ?? - Settings.defaultUnits[SettingType.frontTyrePressure]!, - ), - rear = FieldFormController( - enabled: tyres?.rear != null, - value: tyres?.rear?.value, - unit: tyres?.rear?.unit ?? - Settings.defaultUnits[SettingType.rearTyrePressure]!, - ); +class SectionFormController { + SectionFormController(SectionSettings? section) + : fields = + section?.activeFields.map(FieldFormController.fromField).toList() ?? + [], + layout = + section?.layout.map((row) => List.from(row)).toList() ?? [], + serialNumber = TextEditingController(text: section?.serialNumber), + infoUrl = TextEditingController(text: section?.infoUrl); + + final List fields; + List> layout; + final TextEditingController serialNumber; + final TextEditingController infoUrl; - final FieldFormController front; - final FieldFormController rear; + bool get hasActiveFields => fields.isNotEmpty; - void dispose() { - front.dispose(); - rear.dispose(); + /// Returns layout rows resolved to their [FieldFormController]s. + List> get layoutControllers { + final map = {for (final f in fields) f.id: f}; + return layout + .map((row) => + row.map((id) => map[id]).whereType().toList()) + .where((row) => row.isNotEmpty) + .toList(); } -} -class SettingsFormController { - SettingsFormController(Settings? settings) - : airPressure = FieldFormController( - enabled: settings?.airPressure != null, - value: settings?.airPressure?.value, - unit: settings?.airPressure?.unit ?? - Settings.defaultUnits[SettingType.airPressure]!, - ), - sag = FieldFormController( - enabled: settings?.sag != null, - value: settings?.sag?.value, - unit: settings?.sag?.unit ?? Settings.defaultUnits[SettingType.sag]!, - ), - volumeSpacer = FieldFormController( - enabled: settings?.volumeSpacer != null, - value: settings?.volumeSpacer?.value, - unit: settings?.volumeSpacer?.unit ?? - Settings.defaultUnits[SettingType.volumeSpacer]!, - ), - lsc = FieldFormController( - enabled: settings?.lsc != null, - value: settings?.lsc?.value, - unit: settings?.lsc?.unit ?? Settings.defaultUnits[SettingType.lsc]!, - ), - hsc = FieldFormController( - enabled: settings?.hsc != null, - value: settings?.hsc?.value, - unit: settings?.hsc?.unit ?? Settings.defaultUnits[SettingType.hsc]!, - ), - lsr = FieldFormController( - enabled: settings?.lsr != null, - value: settings?.lsr?.value, - unit: settings?.lsr?.unit ?? Settings.defaultUnits[SettingType.lsr]!, - ), - hsr = FieldFormController( - enabled: settings?.hsr != null, - value: settings?.hsr?.value, - unit: settings?.hsr?.unit ?? Settings.defaultUnits[SettingType.hsr]!, - ), - serialNumber = TextEditingController(text: settings?.serialNumber), - infoUrl = TextEditingController(text: settings?.infoUrl); + void addField(String fieldName, String unit) { + final ctrl = FieldFormController( + id: const Uuid().v4(), + fieldName: fieldName, + unit: unit, + isNew: true, + ); + fields.add(ctrl); + layout.add([ctrl.id]); + } - final FieldFormController airPressure; - final FieldFormController volumeSpacer; - final FieldFormController sag; - final FieldFormController lsr; - final FieldFormController hsr; - final FieldFormController lsc; - final FieldFormController hsc; - final TextEditingController serialNumber; - final TextEditingController infoUrl; + void removeField(String fieldId) { + fields.removeWhere((f) => f.id == fieldId); + layout = layout + .map((row) => row.where((id) => id != fieldId).toList()) + .where((row) => row.isNotEmpty) + .toList(); + } void dispose() { - airPressure.dispose(); - volumeSpacer.dispose(); - sag.dispose(); - lsr.dispose(); - hsr.dispose(); - lsc.dispose(); - hsc.dispose(); + for (final f in fields) { + f.dispose(); + } serialNumber.dispose(); infoUrl.dispose(); } } class FieldFormController { - FieldFormController({required bool enabled, num? value, required String unit}) - : enabled = ValueNotifier(enabled), - value = TextEditingController(text: value?.toString() ?? ''), - unit = TextEditingController(text: unit); - - final ValueNotifier enabled; - final TextEditingController value; + FieldFormController({ + required this.id, + required String fieldName, + required String unit, + num? value, + this.isNew = false, + }) : name = TextEditingController(text: fieldName), + unit = TextEditingController(text: unit), + value = TextEditingController(text: value?.toString() ?? ''); + + factory FieldFormController.fromField(Field field) => FieldFormController( + id: field.id, + fieldName: field.name, + unit: field.unit, + value: field.value, + ); + + final String id; + final bool isNew; + final TextEditingController name; final TextEditingController unit; + final TextEditingController value; void dispose() { - enabled.dispose(); - value.dispose(); + name.dispose(); unit.dispose(); + value.dispose(); } } diff --git a/lib/models/tyres.dart b/lib/models/tyres.dart deleted file mode 100644 index 06bf85e..0000000 --- a/lib/models/tyres.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'field.dart'; - -class Tyres { - Field? front; - Field? rear; - - Tyres({this.front, this.rear}); - - bool get hasAnyField => front != null || rear != null; - - factory Tyres.fromJson(Map? json) { - if (json == null) return Tyres(); - Field? parse(String key) { - final raw = json[key]; - if (raw == null) return null; - return Field.fromJson(raw as Map); - } - - return Tyres( - front: parse('front'), - rear: parse('rear'), - ); - } - - Map toJson() => { - 'front': front?.toJson(), - 'rear': rear?.toJson(), - }; - - Tyres clone() => Tyres(front: front, rear: rear); -} diff --git a/lib/setting_tiles.dart b/lib/setting_tiles.dart index 3df272c..db6c6b2 100644 --- a/lib/setting_tiles.dart +++ b/lib/setting_tiles.dart @@ -1,67 +1,37 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'models/setup_form_controller.dart'; import 'models/field.dart'; -import 'models/setting_change.dart'; +import 'models/setup_form_controller.dart'; import 'models/settings.dart'; -import 'models/tyres.dart'; class SettingTiles extends StatelessWidget { - const SettingTiles({super.key, required this.settings}); + const SettingTiles({super.key, required this.section}); - final Settings settings; + final SectionSettings section; @override Widget build(BuildContext context) { - Widget group(List<({String name, Field? field})> specs) { - final enabled = specs.where((e) => e.field != null).toList(); - if (enabled.isEmpty) return const SizedBox.shrink(); - return Row( - children: [ - for (final e in enabled) - SettingTile( - name: e.name, value: e.field!.value, unit: e.field!.unit), - ], - ); - } - - return Column( - children: [ - group([ - (name: SettingType.airPressure.label, field: settings.airPressure), - (name: SettingType.sag.label, field: settings.sag), - (name: SettingType.volumeSpacer.label, field: settings.volumeSpacer), - ]), - group([ - (name: SettingType.lsc.label, field: settings.lsc), - (name: SettingType.hsc.label, field: settings.hsc), - ]), - group([ - (name: SettingType.lsr.label, field: settings.lsr), - (name: SettingType.hsr.label, field: settings.hsr), - ]), - ], - ); - } -} + final rows = section.layout + .map((row) => row + .map((id) => section.fieldById(id)) + .whereType() + .where((f) => !f.deleted && f.value != null) + .toList()) + .where((row) => row.isNotEmpty) + .toList(); -class TyreTiles extends StatelessWidget { - const TyreTiles({super.key, required this.tyres}); + if (rows.isEmpty) return const SizedBox.shrink(); - final Tyres tyres; - - @override - Widget build(BuildContext context) { - final enabled = [ - (name: SettingType.frontTyrePressure.label, field: tyres.front), - (name: SettingType.rearTyrePressure.label, field: tyres.rear), - ].where((e) => e.field != null).toList(); - if (enabled.isEmpty) return const SizedBox.shrink(); - return Row( + return Column( children: [ - for (final e in enabled) - SettingTile(name: e.name, value: e.field!.value, unit: e.field!.unit), + for (final row in rows) + Row( + children: [ + for (final f in row) + SettingTile(name: f.name, value: f.value!, unit: f.unit), + ], + ), ], ); } @@ -106,54 +76,43 @@ class SettingTile extends StatelessWidget { } } -class FieldConfigCard extends StatelessWidget { - const FieldConfigCard({ +class FieldConfigTile extends StatelessWidget { + const FieldConfigTile({ super.key, - required this.name, required this.controller, }); - final String name; final FieldFormController controller; @override Widget build(BuildContext context) { final theme = Theme.of(context); - return ValueListenableBuilder( - valueListenable: controller.enabled, - builder: (context, enabled, _) { - return Card( - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CheckboxListTile( - title: Text(name, style: theme.textTheme.titleMedium), - value: enabled, - onChanged: (v) { - controller.enabled.value = v ?? false; - if (!controller.enabled.value) { - controller.value.clear(); - } - }, - controlAffinity: ListTileControlAffinity.leading, - ), - if (enabled) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - child: TextField( - style: theme.textTheme.bodyLarge, - decoration: InputDecoration( - labelText: 'Unit', - labelStyle: theme.textTheme.bodySmall, - ), - controller: controller.unit, - ), - ), - ], - ), - ); - }, + final value = controller.value.text; + final unit = controller.unit.text; + return Card( + color: theme.colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Column( + children: [ + Text( + controller.name.text, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + Text( + value.isEmpty ? '—' : value, + style: theme.textTheme.headlineSmall + ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + Text( + unit, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + ], + ), + ), ); } } @@ -161,11 +120,9 @@ class FieldConfigCard extends StatelessWidget { class FieldValueCard extends StatelessWidget { const FieldValueCard({ super.key, - required this.name, required this.controller, }); - final String name; final FieldFormController controller; @override @@ -175,7 +132,7 @@ class FieldValueCard extends StatelessWidget { return Column( children: [ Text( - name, + controller.name.text, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center, ), diff --git a/lib/setup_detail.dart b/lib/setup_detail.dart index e0e044c..1954ed4 100644 --- a/lib/setup_detail.dart +++ b/lib/setup_detail.dart @@ -32,9 +32,9 @@ class SetupDetail extends StatelessWidget { if (setup == null) { return const ErrorScreenWidget(message: 'Setup not found'); } else { - final hasValueFields = setup.fork.hasAnyValueField || - setup.shock.hasAnyValueField || - setup.tyres.hasAnyField; + final hasValueFields = setup.fork.activeFields.isNotEmpty || + setup.shock.activeFields.isNotEmpty || + setup.tyres.activeFields.isNotEmpty; return Scaffold( appBar: AppBar( @@ -83,19 +83,19 @@ class SetupDetail extends StatelessWidget { if (setup.fork.hasAnyField) ...[ const TitleWithIcon( title: 'Fork', icon: SuspensionIcons.fork), - _ComponentInfo(settings: setup.fork), - SettingTiles(settings: setup.fork), + _ComponentInfo(section: setup.fork), + SettingTiles(section: setup.fork), ], if (setup.shock.hasAnyField) ...[ const TitleWithIcon( title: 'Shock', icon: SuspensionIcons.shock), - _ComponentInfo(settings: setup.shock), - SettingTiles(settings: setup.shock), + _ComponentInfo(section: setup.shock), + SettingTiles(section: setup.shock), ], if (setup.tyres.hasAnyField) ...[ const TitleWithIcon( title: 'Tyres', icon: SuspensionIcons.tyre), - TyreTiles(tyres: setup.tyres), + SettingTiles(section: setup.tyres), ], if (setup.history.isNotEmpty) History(setup: setup), ], @@ -109,14 +109,14 @@ class SetupDetail extends StatelessWidget { } class _ComponentInfo extends StatelessWidget { - const _ComponentInfo({required this.settings}); + const _ComponentInfo({required this.section}); - final Settings settings; + final SectionSettings section; @override Widget build(BuildContext context) { - final sn = settings.serialNumber; - final url = settings.infoUrl; + final sn = section.serialNumber; + final url = section.infoUrl; if (sn == null && url == null) return const SizedBox.shrink(); final theme = Theme.of(context); @@ -189,7 +189,7 @@ class _EmptySettings extends StatelessWidget { child: Column( children: [ Text( - 'Activate at least one field to see your settings here', + 'No fields configured yet. Add fields in the setup configuration.', style: theme.textTheme.bodyLarge, textAlign: TextAlign.center, ), @@ -197,7 +197,7 @@ class _EmptySettings extends StatelessWidget { FilledButton.icon( onPressed: onEdit, icon: const Icon(Icons.settings), - label: const Text('Edit setup'), + label: const Text('Configure setup'), ), ], ), @@ -214,24 +214,16 @@ class History extends StatelessWidget { final Setup setup; - String _unit(SettingChange change, Setup setup) { - if (change.suspensionType == SuspensionType.tyre) { - return (change.settingType == SettingType.frontTyrePressure - ? setup.tyres.front?.unit - : setup.tyres.rear?.unit) ?? - Settings.defaultUnits[change.settingType] ?? - ''; - } - final settings = - change.suspensionType == SuspensionType.fork ? setup.fork : setup.shock; - return settings.fieldFor(change.settingType)?.unit ?? - Settings.defaultUnits[change.settingType] ?? - ''; - } - String _changeText(SettingChange change, Setup setup) { - final unit = _unit(change, setup); - final label = change.settingType.label; + final section = switch (change.suspensionType) { + SuspensionType.fork => setup.fork, + SuspensionType.shock => setup.shock, + SuspensionType.tyre => setup.tyres, + }; + final field = section.fieldById(change.fieldId); + final label = field?.name ?? 'Unknown field'; + final unit = field?.unit ?? ''; + if (change.newEnabled == true) { return '$label: enabled (${change.newValue} $unit)'.trim(); } diff --git a/lib/setup_edit.dart b/lib/setup_edit.dart index 94d29c2..c577e2a 100644 --- a/lib/setup_edit.dart +++ b/lib/setup_edit.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'models/setting_change.dart'; +import 'draggable_grid.dart'; import 'models/setup_form_controller.dart'; import 'models/setup.dart'; import 'setting_tiles.dart'; @@ -28,47 +28,26 @@ class _SetupEditState extends State { late final SetupFormController _controller; final TextEditingController _commentController = TextEditingController(); - List get _allFieldControllers => [ - _controller.fork.airPressure, - _controller.fork.sag, - _controller.fork.volumeSpacer, - _controller.fork.lsc, - _controller.fork.hsc, - _controller.fork.lsr, - _controller.fork.hsr, - _controller.shock.airPressure, - _controller.shock.sag, - _controller.shock.volumeSpacer, - _controller.shock.lsc, - _controller.shock.hsc, - _controller.shock.lsr, - _controller.shock.hsr, - _controller.tyres.front, - _controller.tyres.rear, - ]; - @override void initState() { super.initState(); _controller = SetupFormController(widget.setup); - for (final field in _allFieldControllers) { - field.enabled.addListener(_onEnabledChanged); - } } - void _onEnabledChanged() => setState(() {}); - @override void dispose() { - for (final field in _allFieldControllers) { - field.enabled.removeListener(_onEnabledChanged); - } _commentController.dispose(); _controller.dispose(); super.dispose(); } - bool get _hasNewlyEnabled => _controller.hasNewlyEnabledFields(widget.setup); + bool get _hasNewlyAdded => _controller.hasNewlyAddedFields(); + + List get _allFieldControllers => [ + ..._controller.fork.fields, + ..._controller.shock.fields, + ..._controller.tyres.fields, + ]; Future _onSave(BuildContext context) async { if (!_formKey.currentState!.validate()) return; @@ -117,10 +96,82 @@ class _SetupEditState extends State { } } + void _showAddFieldDialog( + BuildContext context, SectionFormController section) { + showDialog<({String name, String unit})>( + context: context, + builder: (ctx) => const _AddFieldDialog(), + ).then((result) { + if (result != null) { + section.addField(result.name, result.unit); + setState(() {}); + } + }); + } + + void _showEditFieldSheet(BuildContext context, SectionFormController section, + FieldFormController fieldCtrl) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (ctx) => _EditFieldSheet( + controller: fieldCtrl, + onDelete: () => Navigator.pop(ctx, true), + ), + ).then((deleted) { + if (deleted == true) { + section.removeField(fieldCtrl.id); + } + setState(() {}); + }); + } + + Widget _buildSectionGrid(SectionFormController section) { + if (section.layout.isEmpty) return const SizedBox.shrink(); + return DraggableGrid( + layout: section.layout, + itemBuilder: (id) { + final ctrl = section.fields.firstWhere((f) => f.id == id); + return FieldConfigTile(controller: ctrl); + }, + onLayoutChanged: (newLayout) => + setState(() => section.layout = newLayout), + onItemTap: (id) { + final ctrl = section.fields.firstWhere((f) => f.id == id); + _showEditFieldSheet(context, section, ctrl); + }, + ); + } + + Widget _buildSection( + SectionFormController section, { + bool showComponentInfo = false, + }) { + return Column( + children: [ + if (showComponentInfo) + _ComponentInfoFields( + serialNumberController: section.serialNumber, + infoUrlController: section.infoUrl, + ), + _buildSectionGrid(section), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: OutlinedButton.icon( + onPressed: () => _showAddFieldDialog(context, section), + icon: const Icon(Icons.add), + label: const Text('Add field'), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final hasNewlyEnabled = _hasNewlyEnabled; + final hasNewlyAdded = _hasNewlyAdded; return Scaffold( appBar: AppBar( @@ -147,65 +198,12 @@ class _SetupEditState extends State { }, ), const TitleWithIcon(title: 'Fork', icon: SuspensionIcons.fork), - _ComponentInfoFields( - serialNumberController: _controller.fork.serialNumber, - infoUrlController: _controller.fork.infoUrl, - ), - FieldConfigCard( - name: SettingType.airPressure.label, - controller: _controller.fork.airPressure), - FieldConfigCard( - name: SettingType.sag.label, - controller: _controller.fork.sag), - FieldConfigCard( - name: SettingType.volumeSpacer.label, - controller: _controller.fork.volumeSpacer), - FieldConfigCard( - name: SettingType.lsc.label, - controller: _controller.fork.lsc), - FieldConfigCard ( - name: SettingType.hsc.label, - controller: _controller.fork.hsc), - FieldConfigCard( - name: SettingType.lsr.label, - controller: _controller.fork.lsr), - FieldConfigCard( - name: SettingType.hsr.label, - controller: _controller.fork.hsr), + _buildSection(_controller.fork, showComponentInfo: true), const TitleWithIcon( title: 'Shock', icon: SuspensionIcons.shock), - _ComponentInfoFields( - serialNumberController: _controller.shock.serialNumber, - infoUrlController: _controller.shock.infoUrl, - ), - FieldConfigCard( - name: SettingType.airPressure.label, - controller: _controller.shock.airPressure), - FieldConfigCard( - name: SettingType.sag.label, - controller: _controller.shock.sag), - FieldConfigCard( - name: SettingType.volumeSpacer.label, - controller: _controller.shock.volumeSpacer), - FieldConfigCard( - name: SettingType.lsc.label, - controller: _controller.shock.lsc), - FieldConfigCard( - name: SettingType.hsc.label, - controller: _controller.shock.hsc), - FieldConfigCard( - name: SettingType.lsr.label, - controller: _controller.shock.lsr), - FieldConfigCard( - name: SettingType.hsr.label, - controller: _controller.shock.hsr), + _buildSection(_controller.shock, showComponentInfo: true), const TitleWithIcon(title: 'Tyres', icon: SuspensionIcons.tyre), - FieldConfigCard( - name: SettingType.frontTyrePressure.label, - controller: _controller.tyres.front), - FieldConfigCard( - name: SettingType.rearTyrePressure.label, - controller: _controller.tyres.rear), + _buildSection(_controller.tyres), ], ), ), @@ -214,17 +212,15 @@ class _SetupEditState extends State { floatingActionButton: FloatingActionButton( backgroundColor: theme.colorScheme.primary, foregroundColor: theme.colorScheme.onPrimary, - onPressed: hasNewlyEnabled + onPressed: hasNewlyAdded ? () => _onForwardToValueEdit(context) : () => _onSave(context), - tooltip: hasNewlyEnabled ? 'Edit values' : 'Save setup', + tooltip: hasNewlyAdded ? 'Edit values' : 'Save setup', child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), transitionBuilder: (child, animation) { final isEntering = - (child.key as ValueKey).value == hasNewlyEnabled; - // Exiting: 0.0→0.5 turns (0°→180°), Entering: 0.5→1.0 (180°→360°) - // Both clockwise — exit hands off seamlessly to the entering icon. + (child.key as ValueKey).value == hasNewlyAdded; final rotateTween = isEntering ? Tween(begin: 0.5, end: 1.0) : Tween(begin: 0.5, end: 0.0); @@ -237,8 +233,8 @@ class _SetupEditState extends State { ); }, child: Icon( - hasNewlyEnabled ? Icons.arrow_forward : Icons.save, - key: ValueKey(hasNewlyEnabled), + hasNewlyAdded ? Icons.arrow_forward : Icons.save, + key: ValueKey(hasNewlyAdded), ), ), ), @@ -304,3 +300,108 @@ class _ComponentInfoFields extends StatelessWidget { ); } } + +class _AddFieldDialog extends StatefulWidget { + const _AddFieldDialog(); + + @override + State<_AddFieldDialog> createState() => _AddFieldDialogState(); +} + +class _AddFieldDialogState extends State<_AddFieldDialog> { + final _nameCtrl = TextEditingController(); + final _unitCtrl = TextEditingController(); + + @override + void dispose() { + _nameCtrl.dispose(); + _unitCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add field'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _nameCtrl, + autofocus: true, + decoration: const InputDecoration(labelText: 'Field name'), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 8), + TextField( + controller: _unitCtrl, + decoration: const InputDecoration(labelText: 'Unit (optional)'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final name = _nameCtrl.text.trim(); + if (name.isEmpty) return; + Navigator.pop(context, (name: name, unit: _unitCtrl.text.trim())); + }, + child: const Text('Add'), + ), + ], + ); + } +} + +class _EditFieldSheet extends StatelessWidget { + const _EditFieldSheet({ + required this.controller, + required this.onDelete, + }); + + final FieldFormController controller; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + bottom: 16 + MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Edit field', style: theme.textTheme.titleMedium), + const SizedBox(height: 16), + TextField( + controller: controller.name, + decoration: const InputDecoration(labelText: 'Field name'), + textCapitalization: TextCapitalization.sentences, + ), + const SizedBox(height: 8), + TextField( + controller: controller.unit, + decoration: const InputDecoration(labelText: 'Unit (optional)'), + ), + const SizedBox(height: 16), + TextButton.icon( + onPressed: onDelete, + icon: const Icon(Icons.delete), + label: const Text('Delete field'), + style: TextButton.styleFrom( + foregroundColor: theme.colorScheme.error, + ), + ), + ], + ), + ); + } +} diff --git a/lib/setup_snapshot.dart b/lib/setup_snapshot.dart index ea9dc16..921c60f 100644 --- a/lib/setup_snapshot.dart +++ b/lib/setup_snapshot.dart @@ -65,16 +65,16 @@ class SetupSnapshotPage extends StatelessWidget { ), if (snapshot.fork.hasAnyField) ...[ const TitleWithIcon(title: 'Fork', icon: SuspensionIcons.fork), - SettingTiles(settings: snapshot.fork), + SettingTiles(section: snapshot.fork), ], if (snapshot.shock.hasAnyField) ...[ const TitleWithIcon( title: 'Shock', icon: SuspensionIcons.shock), - SettingTiles(settings: snapshot.shock), + SettingTiles(section: snapshot.shock), ], if (snapshot.tyres.hasAnyField) ...[ const TitleWithIcon(title: 'Tyres', icon: SuspensionIcons.tyre), - TyreTiles(tyres: snapshot.tyres), + SettingTiles(section: snapshot.tyres), ], ], ), diff --git a/lib/value_edit.dart b/lib/value_edit.dart index 718d52d..4f35102 100644 --- a/lib/value_edit.dart +++ b/lib/value_edit.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'models/setting_change.dart'; import 'models/setup_form_controller.dart'; import 'models/setup.dart'; import 'setting_tiles.dart'; @@ -72,49 +71,38 @@ class _ValueEditState extends State { ); } + Widget _sectionRows(SectionFormController section) { + final rows = section.layoutControllers; + if (rows.isEmpty) return const SizedBox.shrink(); + return Column( + children: [ + for (final row in rows) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final ctrl in row) + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, horizontal: 12), + child: FieldValueCard(controller: ctrl), + ), + ), + ), + ], + ), + ], + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final ctrl = _controller; - - Widget group(List<({String name, FieldFormController ctrl})> specs) { - final enabled = specs.where((e) => e.ctrl.enabled.value).toList(); - if (enabled.isEmpty) return const SizedBox.shrink(); - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final e in enabled) - Expanded( - child: Card( - child: Padding( - padding: - const EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: FieldValueCard(name: e.name, controller: e.ctrl), - ), - ), - ), - ], - ); - } - - final hasFork = ctrl.fork.airPressure.enabled.value || - ctrl.fork.sag.enabled.value || - ctrl.fork.volumeSpacer.enabled.value || - ctrl.fork.lsc.enabled.value || - ctrl.fork.hsc.enabled.value || - ctrl.fork.lsr.enabled.value || - ctrl.fork.hsr.enabled.value; - - final hasShock = ctrl.shock.airPressure.enabled.value || - ctrl.shock.sag.enabled.value || - ctrl.shock.volumeSpacer.enabled.value || - ctrl.shock.lsc.enabled.value || - ctrl.shock.hsc.enabled.value || - ctrl.shock.lsr.enabled.value || - ctrl.shock.hsr.enabled.value; - - final hasTyres = - ctrl.tyres.front.enabled.value || ctrl.tyres.rear.enabled.value; + final hasFork = ctrl.fork.layoutControllers.isNotEmpty; + final hasShock = ctrl.shock.layoutControllers.isNotEmpty; + final hasTyres = ctrl.tyres.layoutControllers.isNotEmpty; return Scaffold( appBar: AppBar( @@ -130,62 +118,17 @@ class _ValueEditState extends State { if (hasFork) ...[ const TitleWithIcon( title: 'Fork', icon: SuspensionIcons.fork), - group([ - ( - name: SettingType.airPressure.label, - ctrl: ctrl.fork.airPressure - ), - (name: SettingType.sag.label, ctrl: ctrl.fork.sag), - ( - name: SettingType.volumeSpacer.label, - ctrl: ctrl.fork.volumeSpacer - ), - ]), - group([ - (name: SettingType.lsc.label, ctrl: ctrl.fork.lsc), - (name: SettingType.hsc.label, ctrl: ctrl.fork.hsc), - ]), - group([ - (name: SettingType.lsr.label, ctrl: ctrl.fork.lsr), - (name: SettingType.hsr.label, ctrl: ctrl.fork.hsr), - ]), + _sectionRows(ctrl.fork), ], if (hasShock) ...[ const TitleWithIcon( title: 'Shock', icon: SuspensionIcons.shock), - group([ - ( - name: SettingType.airPressure.label, - ctrl: ctrl.shock.airPressure - ), - (name: SettingType.sag.label, ctrl: ctrl.shock.sag), - ( - name: SettingType.volumeSpacer.label, - ctrl: ctrl.shock.volumeSpacer - ), - ]), - group([ - (name: SettingType.lsc.label, ctrl: ctrl.shock.lsc), - (name: SettingType.hsc.label, ctrl: ctrl.shock.hsc), - ]), - group([ - (name: SettingType.lsr.label, ctrl: ctrl.shock.lsr), - (name: SettingType.hsr.label, ctrl: ctrl.shock.hsr), - ]), + _sectionRows(ctrl.shock), ], if (hasTyres) ...[ const TitleWithIcon( title: 'Tyres', icon: SuspensionIcons.tyre), - group([ - ( - name: SettingType.frontTyrePressure.label, - ctrl: ctrl.tyres.front - ), - ( - name: SettingType.rearTyrePressure.label, - ctrl: ctrl.tyres.rear - ), - ]), + _sectionRows(ctrl.tyres), ], ], ), diff --git a/test/migrations/migrator_test.dart b/test/migrations/migrator_test.dart index 4859c70..77bd3af 100644 --- a/test/migrations/migrator_test.dart +++ b/test/migrations/migrator_test.dart @@ -64,10 +64,17 @@ Map _v3Data() => { 'name': 'Test', 'fork': { 'airPressure': {'value': 100, 'unit': 'PSI'}, + 'sag': null, + 'volumeSpacer': null, + 'lsc': null, + 'hsc': null, + 'lsr': null, + 'hsr': null, }, - 'shock': {}, + 'shock': {}, 'history': [ { + 'id': 'hist-id-1', 'changes': [], 'date': '2024-01-01T00:00:00.000Z', 'comment': 'Setup creation', @@ -78,59 +85,130 @@ Map _v3Data() => { }, }; +Map _v4Data() => { + 'schemaVersion': 4, + 'setups': { + 'id-1': { + 'id': 'id-1', + 'name': 'Test', + 'fork': { + 'fields': [ + { + 'id': 'field-id-1', + 'name': 'Air Pressure', + 'unit': 'PSI', + 'value': 100, + 'deleted': false, + }, + ], + 'layout': [ + ['field-id-1'] + ], + 'serialNumber': null, + 'infoUrl': null, + }, + 'shock': { + 'fields': [], + 'layout': [], + 'serialNumber': null, + 'infoUrl': null, + }, + 'tyres': { + 'fields': [], + 'layout': [], + 'serialNumber': null, + 'infoUrl': null, + }, + 'history': [ + { + 'id': 'hist-id-1', + 'changes': [], + 'date': '2024-01-01T00:00:00.000Z', + 'comment': 'Setup creation', + 'isCreationEntry': true, + }, + ], + }, + }, + }; + void main() { group('currentSchemaVersion', () { - test('is 3', () => expect(currentSchemaVersion, 3)); + test('is 4', () => expect(currentSchemaVersion, 4)); }); group('migrateIfNeeded', () { test('migrates data with no schemaVersion (treated as v1)', () { final result = migrateIfNeeded(_v1Data()); - expect(result['schemaVersion'], 3); + expect(result['schemaVersion'], 4); expect(result['setups'], isA()); }); test('migrates data with explicit schemaVersion 1', () { final data = {'schemaVersion': 1, ..._v1Data()}; final result = migrateIfNeeded(data); - expect(result['schemaVersion'], 3); + expect(result['schemaVersion'], 4); }); - test('migrates v2 data to v3', () { + test('migrates v2 data to v4', () { final result = migrateIfNeeded(_v2Data()); - expect(result['schemaVersion'], 3); - expect(result['setups']['id-1']['fork']['airPressure']['value'], 100); + expect(result['schemaVersion'], 4); + final fork = result['setups']['id-1']['fork'] as Map; + final fields = fork['fields'] as List; + expect( + fields.any((f) => f['name'] == 'Air Pressure' && f['value'] == 100), + isTrue); }); - test('does not re-migrate already-v3 data', () { + test('migrates v3 data to v4', () { final result = migrateIfNeeded(_v3Data()); - expect(result['schemaVersion'], 3); + expect(result['schemaVersion'], 4); + final fork = result['setups']['id-1']['fork'] as Map; + expect(fork['fields'], isA()); + expect(fork['layout'], isA()); + }); + + test('does not re-migrate already-v4 data', () { + final result = migrateIfNeeded(_v4Data()); + expect(result['schemaVersion'], 4); expect(result['setups']['id-1']['history'][0]['isCreationEntry'], true); }); - test('v2 to v3 sets isCreationEntry on first history entry', () { - final v2 = _v2Data(history: [ - { - 'changes': [], - 'date': '2024-01-01T00:00:00.000Z', - 'comment': 'Setup creation' - }, - { - 'changes': [], - 'date': '2024-01-02T00:00:00.000Z', - 'comment': 'Rebound tweak' + test('v1 → v4 preserves history creation entry', () { + final v1 = { + 'id-1': { + 'id': 'id-1', + 'name': 'Test', + 'fork': { + 'airPressure': 100, + 'sag': 25, + 'lsc': 8, + 'lsr': 6, + 'volumeSpacer': null, + 'hsc': null, + 'hsr': null + }, + 'shock': { + 'airPressure': 180, + 'sag': 30, + 'lsc': 5, + 'lsr': 4, + 'volumeSpacer': null, + 'hsc': null, + 'hsr': null + }, + 'history': [ + { + 'changes': [], + 'date': '2024-01-01T00:00:00.000Z', + 'comment': 'Setup creation' + }, + ], }, - ]); - final result = migrateIfNeeded(v2); + }; + final result = migrateIfNeeded(v1); final history = result['setups']['id-1']['history'] as List; expect(history[0]['isCreationEntry'], true); - expect(history[1]['isCreationEntry'], isNull); - }); - - test('v2 to v3 leaves empty history unchanged', () { - final result = migrateIfNeeded(_v2Data()); - final history = result['setups']['id-1']['history'] as List; - expect(history, isEmpty); }); test('returns future schema version data unchanged', () { diff --git a/test/migrations/v1_to_v2_test.dart b/test/migrations/v1_to_v2_test.dart index 6344d14..cfc91c0 100644 --- a/test/migrations/v1_to_v2_test.dart +++ b/test/migrations/v1_to_v2_test.dart @@ -1,7 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:suspension_setup/migrations/v1_to_v2.dart'; -import 'package:suspension_setup/models/setting_change.dart'; -import 'package:suspension_setup/models/settings.dart'; Map _v1Settings({ int? airPressure = 100, @@ -176,33 +174,31 @@ void main() { }); test('airPressure unit is PSI', () { - expect(fork['airPressure']['unit'], - Settings.defaultUnits[SettingType.airPressure]); + expect(fork['airPressure']['unit'], 'PSI'); }); test('sag unit is %', () { - expect(fork['sag']['unit'], Settings.defaultUnits[SettingType.sag]); + expect(fork['sag']['unit'], '%'); }); test('volumeSpacer unit is Spacers', () { - expect(fork['volumeSpacer']['unit'], - Settings.defaultUnits[SettingType.volumeSpacer]); + expect(fork['volumeSpacer']['unit'], 'Spacers'); }); test('lsc unit is Clicks', () { - expect(fork['lsc']['unit'], Settings.defaultUnits[SettingType.lsc]); + expect(fork['lsc']['unit'], 'Clicks'); }); test('hsc unit is Clicks', () { - expect(fork['hsc']['unit'], Settings.defaultUnits[SettingType.hsc]); + expect(fork['hsc']['unit'], 'Clicks'); }); test('lsr unit is Clicks', () { - expect(fork['lsr']['unit'], Settings.defaultUnits[SettingType.lsr]); + expect(fork['lsr']['unit'], 'Clicks'); }); test('hsr unit is Clicks', () { - expect(fork['hsr']['unit'], Settings.defaultUnits[SettingType.hsr]); + expect(fork['hsr']['unit'], 'Clicks'); }); test('null field has no unit', () { diff --git a/test/models/setup_form_controller_test.dart b/test/models/setup_form_controller_test.dart index 45b265d..5f00795 100644 --- a/test/models/setup_form_controller_test.dart +++ b/test/models/setup_form_controller_test.dart @@ -4,15 +4,18 @@ import 'package:suspension_setup/models/setting_change.dart'; import 'package:suspension_setup/models/settings.dart'; import 'package:suspension_setup/models/setup.dart'; import 'package:suspension_setup/models/setup_form_controller.dart'; -import 'package:suspension_setup/models/tyres.dart'; -Setup _makeSetup({Field? airPressure, Field? sag, Field? frontTyre}) { +Setup _makeSetup({List forkFields = const []}) { + final ids = forkFields.map((f) => f.id).toList(); return Setup( id: 'test', name: 'Test Setup', - fork: Settings(airPressure: airPressure, sag: sag), - shock: Settings(), - tyres: Tyres(front: frontTyre), + fork: SectionSettings( + fields: List.from(forkFields), + layout: ids.isEmpty ? [] : [ids], + ), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [], ); } @@ -20,54 +23,79 @@ Setup _makeSetup({Field? airPressure, Field? sag, Field? frontTyre}) { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - group('hasNewlyEnabledFields', () { - test('null setup — no fields enabled → false', () { + group('hasNewlyAddedFields', () { + test('null setup — no fields added → false', () { final ctrl = SetupFormController(null); addTearDown(ctrl.dispose); - expect(ctrl.hasNewlyEnabledFields(null), isFalse); + expect(ctrl.hasNewlyAddedFields(), isFalse); }); - test('null setup — one field enabled → true', () { + test('null setup — field added → true', () { final ctrl = SetupFormController(null); addTearDown(ctrl.dispose); - ctrl.fork.airPressure.enabled.value = true; - expect(ctrl.hasNewlyEnabledFields(null), isTrue); + ctrl.fork.addField('Air Pressure', 'PSI'); + expect(ctrl.hasNewlyAddedFields(), isTrue); }); - test('existing setup — enabled fields unchanged → false', () { - final setup = - _makeSetup(airPressure: const Field(value: 73, unit: 'PSI')); - final ctrl = SetupFormController(setup); + test('existing setup — no new fields → false', () { + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final ctrl = SetupFormController(_makeSetup(forkFields: [f])); addTearDown(ctrl.dispose); - expect(ctrl.hasNewlyEnabledFields(setup), isFalse); + expect(ctrl.hasNewlyAddedFields(), isFalse); }); - test('existing setup — field newly enabled → true', () { - final setup = - _makeSetup(airPressure: const Field(value: 73, unit: 'PSI')); - final ctrl = SetupFormController(setup); + test('existing setup — field added → true', () { + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final ctrl = SetupFormController(_makeSetup(forkFields: [f])); addTearDown(ctrl.dispose); - ctrl.fork.sag.enabled.value = true; - expect(ctrl.hasNewlyEnabledFields(setup), isTrue); + ctrl.fork.addField('Sag', '%'); + expect(ctrl.hasNewlyAddedFields(), isTrue); }); + }); - test('existing setup — field disabled (not newly enabled) → false', () { - final setup = _makeSetup( - airPressure: const Field(value: 73, unit: 'PSI'), - sag: const Field(value: 30, unit: '%'), - ); - final ctrl = SetupFormController(setup); - addTearDown(ctrl.dispose); - ctrl.fork.sag.enabled.value = false; - expect(ctrl.hasNewlyEnabledFields(setup), isFalse); + group('SectionFormController.addField / removeField', () { + test('addField adds to fields list and layout', () { + final ctrl = SectionFormController(null); + ctrl.addField('Sag', '%'); + expect(ctrl.fields, hasLength(1)); + expect(ctrl.fields.first.name.text, 'Sag'); + expect(ctrl.fields.first.unit.text, '%'); + expect(ctrl.fields.first.isNew, isTrue); + expect(ctrl.layout, hasLength(1)); + expect(ctrl.layout.first, contains(ctrl.fields.first.id)); }); - test('existing setup — tyre field newly enabled → true', () { - final setup = _makeSetup(); - final ctrl = SetupFormController(setup); - addTearDown(ctrl.dispose); - ctrl.tyres.front.enabled.value = true; - expect(ctrl.hasNewlyEnabledFields(setup), isTrue); + test('removeField removes from fields list and layout', () { + final f1 = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final f2 = Field(name: 'Sag', unit: '%', value: 25); + final section = SectionSettings(fields: [ + f1, + f2 + ], layout: [ + [f1.id, f2.id] + ]); + final ctrl = SectionFormController(section); + ctrl.removeField(f1.id); + expect(ctrl.fields, hasLength(1)); + expect(ctrl.fields.first.id, f2.id); + expect(ctrl.layout.expand((r) => r).toList(), isNot(contains(f1.id))); + }); + + test('layoutControllers returns field controllers in layout order', () { + final f1 = Field(name: 'Air', unit: 'PSI', value: 73); + final f2 = Field(name: 'Sag', unit: '%', value: 25); + final section = SectionSettings(fields: [ + f1, + f2 + ], layout: [ + [f1.id], + [f2.id] + ]); + final ctrl = SectionFormController(section); + final rows = ctrl.layoutControllers; + expect(rows, hasLength(2)); + expect(rows[0].first.id, f1.id); + expect(rows[1].first.id, f2.id); }); }); @@ -76,21 +104,20 @@ void main() { final ctrl = SetupFormController(null); addTearDown(ctrl.dispose); ctrl.name.text = 'My Setup'; - ctrl.fork.airPressure.enabled.value = true; - ctrl.fork.airPressure.value.text = '73'; - ctrl.fork.airPressure.unit.text = 'PSI'; + ctrl.fork.addField('Air Pressure', 'PSI'); + ctrl.fork.fields.first.value.text = '73'; final (setup, changes) = ctrl.buildResult(null); expect(setup.name, 'My Setup'); - expect(setup.fork.airPressure?.value, 73); - expect(setup.fork.airPressure?.unit, 'PSI'); + expect(setup.fork.activeFields.first.value, 73); + expect(setup.fork.activeFields.first.unit, 'PSI'); expect(changes.changes, isEmpty); }); test('existing setup: unchanged fields produce no changes', () { - final setup = - _makeSetup(airPressure: const Field(value: 73, unit: 'PSI')); + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [f]); final ctrl = SetupFormController(setup); addTearDown(ctrl.dispose); @@ -100,76 +127,58 @@ void main() { }); test('existing setup: value change produces correct SettingChange', () { - final setup = - _makeSetup(airPressure: const Field(value: 73, unit: 'PSI')); + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [f]); final ctrl = SetupFormController(setup); addTearDown(ctrl.dispose); - ctrl.fork.airPressure.value.text = '80'; + ctrl.fork.fields.first.value.text = '80'; final (_, changes) = ctrl.buildResult(setup); expect(changes.changes.length, 1); final change = changes.changes.first; - expect(change.settingType, SettingType.airPressure); + expect(change.fieldId, f.id); expect(change.suspensionType, SuspensionType.fork); expect(change.oldValue, 73); expect(change.newValue, 80); expect(change.newEnabled, isNull); }); - test('existing setup: newly enabled field sets newEnabled=true', () { - final setup = - _makeSetup(airPressure: const Field(value: 73, unit: 'PSI')); + test('existing setup: newly added field emits enable change', () { + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [f]); final ctrl = SetupFormController(setup); addTearDown(ctrl.dispose); - ctrl.fork.sag.enabled.value = true; - ctrl.fork.sag.value.text = '30'; + ctrl.fork.addField('Sag', '%'); + ctrl.fork.fields.last.value.text = '30'; final (_, changes) = ctrl.buildResult(setup); expect(changes.changes.length, 1); final change = changes.changes.first; - expect(change.settingType, SettingType.sag); expect(change.newEnabled, isTrue); expect(change.oldEnabled, isFalse); expect(change.newValue, 30); expect(change.oldValue, isNull); }); - test('existing setup: disabled field sets newEnabled=false', () { - final setup = _makeSetup( - airPressure: const Field(value: 73, unit: 'PSI'), - sag: const Field(value: 30, unit: '%'), - ); + test('existing setup: removed field emits disable change', () { + final f1 = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final f2 = Field(name: 'Sag', unit: '%', value: 25); + final setup = _makeSetup(forkFields: [f1, f2]); final ctrl = SetupFormController(setup); addTearDown(ctrl.dispose); - ctrl.fork.sag.enabled.value = false; + ctrl.fork.removeField(f2.id); final (_, changes) = ctrl.buildResult(setup); expect(changes.changes.length, 1); final change = changes.changes.first; - expect(change.settingType, SettingType.sag); + expect(change.fieldId, f2.id); expect(change.newEnabled, isFalse); expect(change.oldEnabled, isTrue); - expect(change.oldValue, 30); + expect(change.oldValue, 25); expect(change.newValue, isNull); }); - - test('tyre field change is recorded with SuspensionType.tyre', () { - final setup = _makeSetup(frontTyre: const Field(value: 28, unit: 'PSI')); - final ctrl = SetupFormController(setup); - addTearDown(ctrl.dispose); - ctrl.tyres.front.value.text = '30'; - - final (_, changes) = ctrl.buildResult(setup); - - expect(changes.changes.length, 1); - final change = changes.changes.first; - expect(change.settingType, SettingType.frontTyrePressure); - expect(change.suspensionType, SuspensionType.tyre); - expect(change.oldValue, 28); - expect(change.newValue, 30); - }); }); } diff --git a/test/models/setup_test.dart b/test/models/setup_test.dart index 599c336..cedc484 100644 --- a/test/models/setup_test.dart +++ b/test/models/setup_test.dart @@ -3,131 +3,130 @@ import 'package:suspension_setup/models/field.dart'; import 'package:suspension_setup/models/setting_change.dart'; import 'package:suspension_setup/models/settings.dart'; import 'package:suspension_setup/models/setup.dart'; -import 'package:suspension_setup/models/tyres.dart'; - -Settings _makeSettings({bool allEnabled = true}) { - if (allEnabled) { - return Settings( - airPressure: const Field(value: 120, unit: 'PSI'), - sag: const Field(value: 30, unit: '%'), - volumeSpacer: const Field(value: 3, unit: 'Spacers'), - lsc: const Field(value: 10, unit: 'Clicks'), - hsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 8, unit: 'Clicks'), - hsr: const Field(value: 4, unit: 'Clicks'), - ); - } - return Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 6, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ); -} void main() { - group('Settings', () { - test('roundtrips through JSON with all fields enabled', () { - final settings = _makeSettings(allEnabled: true); - final restored = Settings.fromJson(settings.toJson()); - expect(restored.airPressure?.value, 120); - expect(restored.airPressure?.unit, 'PSI'); - expect(restored.sag?.value, 30); - expect(restored.volumeSpacer?.value, 3); - expect(restored.lsc?.value, 10); - expect(restored.hsc?.value, 5); - expect(restored.lsr?.value, 8); - expect(restored.hsr?.value, 4); - }); - - test('roundtrips through JSON with optional fields disabled (null)', () { - final settings = _makeSettings(allEnabled: false); - final restored = Settings.fromJson(settings.toJson()); - expect(restored.airPressure?.value, 100); - expect(restored.volumeSpacer, isNull); - expect(restored.hsc, isNull); - expect(restored.hsr, isNull); - }); - - test('serialNumber and infoUrl roundtrip through JSON', () { - final settings = Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - serialNumber: 'SN-ABC-123', - infoUrl: 'https://example.com/product', - ); - final restored = Settings.fromJson(settings.toJson()); - expect(restored.serialNumber, 'SN-ABC-123'); - expect(restored.infoUrl, 'https://example.com/product'); + group('Field', () { + test('roundtrips a decimal value through JSON', () { + final original = Field(name: 'Air Pressure', unit: 'PSI', value: 28.5); + final restored = Field.fromJson(original.toJson()); + expect(restored.id, original.id); + expect(restored.name, 'Air Pressure'); + expect(restored.value, 28.5); + expect(restored.unit, 'PSI'); + expect(restored.deleted, false); }); - test('hasAnyField true when only serialNumber set', () { - expect(Settings(serialNumber: 'SN-123').hasAnyField, isTrue); + test('roundtrips a deleted field through JSON', () { + final original = Field(name: 'Sag', unit: '%', deleted: true); + final restored = Field.fromJson(original.toJson()); + expect(restored.deleted, true); + expect(restored.value, isNull); }); - test('hasAnyField true when only infoUrl set', () { - expect(Settings(infoUrl: 'https://example.com').hasAnyField, isTrue); + test('legacy integer JSON parses as int (no coercion to double)', () { + final restored = Field.fromJson( + {'id': 'test-id', 'name': 'Air', 'unit': 'PSI', 'value': 100}); + expect(restored.value, 100); + expect(restored.value, isA()); }); - test('hasAnyField false when no fields set', () { - expect(Settings().hasAnyField, isFalse); + test('copyWith updates fields and preserves id', () { + final original = Field(name: 'Air', unit: 'PSI', value: 73); + final updated = original.copyWith(value: 80, unit: 'bar'); + expect(updated.id, original.id); + expect(updated.value, 80); + expect(updated.unit, 'bar'); + expect(updated.name, 'Air'); }); - test('clone copies serialNumber and infoUrl', () { - final original = Settings( - airPressure: const Field(value: 80, unit: 'PSI'), - serialNumber: 'SN-ORIG', - infoUrl: 'https://orig.example.com', - ); - final clone = original.clone(); - expect(clone.serialNumber, 'SN-ORIG'); - expect(clone.infoUrl, 'https://orig.example.com'); + test('copyWith clearValue nulls the value', () { + final original = Field(name: 'Air', unit: 'PSI', value: 73); + final cleared = original.copyWith(clearValue: true); + expect(cleared.value, isNull); }); + }); - test( - 'deserializes JSON without serialNumber/infoUrl keys (backwards compat)', - () { - final json = { - 'airPressure': {'value': 100, 'unit': 'PSI'}, - 'sag': null, - 'volumeSpacer': null, - 'lsc': null, - 'hsc': null, - 'lsr': null, - 'hsr': null, - }; - final settings = Settings.fromJson(json); - expect(settings.serialNumber, isNull); - expect(settings.infoUrl, isNull); - expect(settings.airPressure?.value, 100); - }); - - test('clone produces equal but independent copy', () { - final original = Settings( - airPressure: const Field(value: 80, unit: 'PSI'), - sag: const Field(value: 20, unit: '%'), - lsc: const Field(value: 4, unit: 'Clicks'), - lsr: const Field(value: 3, unit: 'Clicks'), - hsc: const Field(value: 2, unit: 'Clicks'), - hsr: const Field(value: 1, unit: 'Clicks'), + group('SectionSettings', () { + test('roundtrips through JSON with active and deleted fields', () { + final active = Field(name: 'Air Pressure', unit: 'PSI', value: 100); + final deleted = Field(name: 'Sag', unit: '%', deleted: true); + final section = SectionSettings( + fields: [active, deleted], + layout: [ + [active.id] + ], + serialNumber: 'SN-123', + infoUrl: 'https://example.com', ); - final clone = original.clone(); - clone.airPressure = const Field(value: 999, unit: 'PSI'); - expect(original.airPressure?.value, 80); + final restored = SectionSettings.fromJson(section.toJson()); + expect(restored.fields, hasLength(2)); + expect(restored.activeFields.toList(), hasLength(1)); + expect(restored.activeFields.first.name, 'Air Pressure'); + expect(restored.serialNumber, 'SN-123'); + expect(restored.infoUrl, 'https://example.com'); + expect(restored.layout, [ + [active.id] + ]); }); - }); - group('Field', () { - test('roundtrips a decimal value through JSON', () { - const original = Field(value: 28.5, unit: 'PSI'); - final restored = Field.fromJson(original.toJson()); - expect(restored.value, 28.5); - expect(restored.unit, 'PSI'); + test('hasAnyField true when there are active fields', () { + final f = Field(name: 'Air', unit: 'PSI', value: 100); + final section = SectionSettings(fields: [ + f + ], layout: [ + [f.id] + ]); + expect(section.hasAnyField, isTrue); }); - test('legacy integer JSON parses as int (no coercion to double)', () { - final restored = Field.fromJson({'value': 100, 'unit': 'PSI'}); - expect(restored.value, 100); - expect(restored.value, isA()); + test('hasAnyField false when all fields are deleted', () { + final f = Field(name: 'Air', unit: 'PSI', deleted: true); + final section = SectionSettings(fields: [f], layout: []); + expect(section.hasAnyField, isFalse); + }); + + test('hasAnyField true when only serialNumber set', () { + final section = + SectionSettings(fields: [], layout: [], serialNumber: 'SN-123'); + expect(section.hasAnyField, isTrue); + }); + + test('hasAnyField false when empty', () { + expect(SectionSettings(fields: [], layout: []).hasAnyField, isFalse); + }); + + test('fieldById returns field by id', () { + final f = Field(name: 'Air', unit: 'PSI', value: 100); + final section = SectionSettings(fields: [ + f + ], layout: [ + [f.id] + ]); + expect(section.fieldById(f.id), isNotNull); + expect(section.fieldById(f.id)!.name, 'Air'); + }); + + test('fieldById returns null for unknown id', () { + final section = SectionSettings(fields: [], layout: []); + expect(section.fieldById('no-such-id'), isNull); + }); + + test('fieldById returns deleted fields too', () { + final f = Field(name: 'Air', unit: 'PSI', deleted: true); + final section = SectionSettings(fields: [f], layout: []); + expect(section.fieldById(f.id), isNotNull); + }); + + test('clone produces independent copy', () { + final f = Field(name: 'Air', unit: 'PSI', value: 100); + final original = SectionSettings(fields: [ + f + ], layout: [ + [f.id] + ], serialNumber: 'SN-1'); + final clone = original.clone(); + clone.fields.add(Field(name: 'Sag', unit: '%', value: 25)); + expect(original.fields, hasLength(1)); }); }); @@ -135,13 +134,13 @@ void main() { test('roundtrips through JSON', () { final change = SettingChange( suspensionType: SuspensionType.fork, - settingType: SettingType.airPressure, + fieldId: 'field-uuid-123', oldValue: 100, newValue: 120, ); final restored = SettingChange.fromJson(change.toJson()); expect(restored.suspensionType, SuspensionType.fork); - expect(restored.settingType, SettingType.airPressure); + expect(restored.fieldId, 'field-uuid-123'); expect(restored.oldValue, 100); expect(restored.newValue, 120); }); @@ -149,7 +148,7 @@ void main() { test('roundtrips decimal old/new values through JSON', () { final change = SettingChange( suspensionType: SuspensionType.tyre, - settingType: SettingType.frontTyrePressure, + fieldId: 'tyre-field-id', oldValue: 27.5, newValue: 28.25, ); @@ -158,10 +157,10 @@ void main() { expect(restored.newValue, 28.25); }); - test('roundtrips with null values (new setup, no prior value)', () { + test('roundtrips with null values', () { final change = SettingChange( suspensionType: SuspensionType.shock, - settingType: SettingType.hsc, + fieldId: 'field-id', oldValue: null, newValue: null, ); @@ -173,7 +172,7 @@ void main() { test('roundtrips with enabled/disabled toggle', () { final change = SettingChange( suspensionType: SuspensionType.fork, - settingType: SettingType.hsc, + fieldId: 'field-id', oldValue: null, newValue: 5, oldEnabled: false, @@ -184,6 +183,18 @@ void main() { expect(restored.newEnabled, true); expect(restored.newValue, 5); }); + + test('inverted swaps old/new', () { + final change = SettingChange( + suspensionType: SuspensionType.fork, + fieldId: 'field-id', + oldValue: 100, + newValue: 120, + ); + final inv = change.inverted(); + expect(inv.oldValue, 120); + expect(inv.newValue, 100); + }); }); group('SettingChanges', () { @@ -193,7 +204,7 @@ void main() { changes: [ SettingChange( suspensionType: SuspensionType.fork, - settingType: SettingType.sag, + fieldId: 'field-id', oldValue: 25, newValue: 28, ), @@ -222,26 +233,19 @@ void main() { group('Setup', () { test('roundtrips through JSON preserving all fields', () { + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 110); + final sagField = Field(name: 'Sag', unit: '%', value: 25); final original = Setup( id: 'test-id-123', name: 'Enduro race', - fork: Settings( - airPressure: const Field(value: 110, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 8, unit: 'Clicks'), - lsr: const Field(value: 6, unit: 'Clicks'), - hsc: const Field(value: 3, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 200, unit: 'PSI'), - sag: const Field(value: 30, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - tyres: Tyres( - front: const Field(value: 28, unit: 'PSI'), - rear: const Field(value: 26, unit: 'PSI'), + fork: SectionSettings( + fields: [airField, sagField], + layout: [ + [airField.id, sagField.id] + ], ), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [ SettingChanges( changes: [], @@ -254,32 +258,28 @@ void main() { final restored = Setup.fromJson(original.toJson()); expect(restored.id, 'test-id-123'); expect(restored.name, 'Enduro race'); - expect(restored.fork.airPressure?.value, 110); - expect(restored.fork.hsc?.value, 3); - expect(restored.fork.hsr, isNull); - expect(restored.shock.sag?.value, 30); + expect( + restored.fork.activeFields + .firstWhere((f) => f.name == 'Air Pressure') + .value, + 110); expect(restored.history, hasLength(1)); expect(restored.history.first.comment, 'Setup creation'); expect(restored.history.first.isCreationEntry, isTrue); }); test('clone with history produces independent copy with new id', () { + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 100); final original = Setup( id: 'original-id', name: 'Base setup', - fork: Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 20, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), + fork: SectionSettings(fields: [ + f + ], layout: [ + [f.id] + ]), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [ SettingChanges(changes: [], date: DateTime.now(), comment: 'initial'), ], @@ -290,106 +290,18 @@ void main() { expect(clone.history, hasLength(1)); }); - test('copyMutable preserves history entry ids', () { - final entry = SettingChanges(changes: [], date: DateTime.now()); - final original = Setup( - id: 'original-id', - name: 'Test', - fork: Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), - history: [entry], - ); - final copy = original.copyMutable(); - expect(copy.history.first.id, entry.id); - }); - - test('deserializes legacy JSON without tyres key as empty tyres', () { - final json = { - 'id': 'legacy-id', - 'name': 'Legacy setup', - 'fork': { - 'airPressure': {'value': 100, 'unit': 'PSI'}, - 'sag': {'value': 25, 'unit': '%'}, - 'volumeSpacer': null, - 'lsc': {'value': 8, 'unit': 'Clicks'}, - 'hsc': null, - 'lsr': {'value': 6, 'unit': 'Clicks'}, - 'hsr': null, - }, - 'shock': { - 'airPressure': {'value': 180, 'unit': 'PSI'}, - 'sag': {'value': 30, 'unit': '%'}, - 'volumeSpacer': null, - 'lsc': {'value': 5, 'unit': 'Clicks'}, - 'hsc': null, - 'lsr': {'value': 4, 'unit': 'Clicks'}, - 'hsr': null, - }, - // no 'tyres' key — simulates a pre-tyre config - 'history': [], - }; - final setup = Setup.fromJson(json); - expect(setup.tyres.front, isNull); - expect(setup.tyres.rear, isNull); - }); - - test('roundtrips a setup containing decimal field values', () { - final original = Setup( - id: 'decimal-id', - name: 'Decimal setup', - fork: Settings( - airPressure: const Field(value: 73.5, unit: 'PSI'), - sag: const Field(value: 17, unit: '%'), - lsc: const Field(value: 8, unit: 'Clicks'), - lsr: const Field(value: 6, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 165, unit: 'PSI'), - sag: const Field(value: 27, unit: '%'), - lsc: const Field(value: 8, unit: 'Clicks'), - lsr: const Field(value: 8, unit: 'Clicks'), - ), - tyres: Tyres( - front: const Field(value: 22.5, unit: 'PSI'), - rear: const Field(value: 24.75, unit: 'PSI'), - ), - history: [], - ); - final restored = Setup.fromJson(original.toJson()); - expect(restored.fork.airPressure?.value, 73.5); - expect(restored.tyres.front?.value, 22.5); - expect(restored.tyres.rear?.value, 24.75); - expect(restored.fork.sag?.value, isA()); - }); - test('clone without history produces empty history', () { + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 100); final original = Setup( id: 'original-id', name: 'Base setup', - fork: Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 20, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), + fork: SectionSettings(fields: [ + f + ], layout: [ + [f.id] + ]), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [ SettingChanges(changes: [], date: DateTime.now()), ], @@ -397,63 +309,98 @@ void main() { final clone = original.clone(false); expect(clone.history, isEmpty); }); + + test('copyMutable preserves history entry ids', () { + final entry = SettingChanges(changes: [], date: DateTime.now()); + final f = Field(name: 'Air Pressure', unit: 'PSI', value: 100); + final original = Setup( + id: 'original-id', + name: 'Test', + fork: SectionSettings(fields: [ + f + ], layout: [ + [f.id] + ]), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), + history: [entry], + ); + final copy = original.copyMutable(); + expect(copy.history.first.id, entry.id); + }); }); group('computeUndo', () { - Setup makeSetup({ - num forkAir = 110, - num forkLsc = 10, + late String airId; + late String lscId; + late String frontTyreId; + + late Setup Function({ + num forkAir, + num forkLsc, num? frontTyre, - num? shock, - }) => - Setup( + }) makeSetup; + + setUp(() { + airId = 'air-pressure-id'; + lscId = 'lsc-id'; + frontTyreId = 'front-tyre-id'; + + makeSetup = ({num forkAir = 110, num forkLsc = 10, num? frontTyre}) { + final airField = + Field(id: airId, name: 'Air Pressure', unit: 'PSI', value: forkAir); + final lscField = + Field(id: lscId, name: 'LSC', unit: 'Clicks', value: forkLsc); + final tyreField = frontTyre != null + ? Field( + id: frontTyreId, + name: 'Front Tyre', + unit: 'PSI', + value: frontTyre) + : Field( + id: frontTyreId, + name: 'Front Tyre', + unit: 'PSI', + deleted: true); + + return Setup( id: 'test', name: 'Test', - fork: Settings( - airPressure: Field(value: forkAir, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: Field(value: forkLsc, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), + fork: SectionSettings( + fields: [airField, lscField], + layout: [ + [airField.id, lscField.id] + ], ), - shock: Settings( - airPressure: Field(value: shock ?? 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres( - front: - frontTyre != null ? Field(value: frontTyre, unit: 'PSI') : null, + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings( + fields: [tyreField], + layout: frontTyre != null + ? [ + [tyreField.id] + ] + : [], ), history: [], ); - - SettingChange makeChange({ - SuspensionType suspension = SuspensionType.fork, - SettingType setting = SettingType.airPressure, - num? oldValue, - num? newValue, - bool? oldEnabled, - bool? newEnabled, - }) => - SettingChange( - suspensionType: suspension, - settingType: setting, - oldValue: oldValue, - newValue: newValue, - oldEnabled: oldEnabled, - newEnabled: newEnabled, - ); + }; + }); test('returns undo change when value differs from old value', () { final setup = makeSetup(forkAir: 110); final entry = SettingChanges( - changes: [makeChange(oldValue: 100, newValue: 110)], + changes: [ + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: 110) + ], date: DateTime.now(), ); final result = setup.computeUndo(entry); expect(result, hasLength(1)); - expect(result.first.settingType, SettingType.airPressure); + expect(result.first.fieldId, airId); expect(result.first.oldValue, 110); expect(result.first.newValue, 100); }); @@ -462,7 +409,13 @@ void main() { () { final setup = makeSetup(forkAir: 100); final entry = SettingChanges( - changes: [makeChange(oldValue: 100, newValue: 110)], + changes: [ + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: 110) + ], date: DateTime.now(), ); expect(setup.computeUndo(entry), isEmpty); @@ -485,16 +438,16 @@ void main() { }); test('undo of enable: disables field and sets newValue to null', () { - // change was: disabled→enabled (oldEnabled=false, newEnabled=true, newValue=120) - // current state: field enabled at 120; undo should disable it final setup = makeSetup(forkAir: 120); final entry = SettingChanges( changes: [ - makeChange( + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airId, oldValue: null, newValue: 120, oldEnabled: false, - newEnabled: true), + newEnabled: true) ], date: DateTime.now(), ); @@ -505,34 +458,31 @@ void main() { }); test('undo of disable: re-enables field with old value', () { - // change was: enabled→disabled (oldEnabled=true, oldValue=100, newEnabled=false) - // current state: field disabled; undo should enable it at 100 + final disabledField = + Field(id: airId, name: 'Air Pressure', unit: 'PSI', deleted: true); + final sagField = Field(name: 'Sag', unit: '%', value: 25); final setup = Setup( id: 'test', name: 'Test', - fork: Settings( - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 4, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), + fork: SectionSettings(fields: [ + disabledField, + sagField + ], layout: [ + [sagField.id] + ]), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [], ); final entry = SettingChanges( changes: [ - makeChange( - setting: SettingType.airPressure, - oldValue: 100, - newValue: null, - oldEnabled: true, - newEnabled: false, - ), + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: null, + oldEnabled: true, + newEnabled: false) ], date: DateTime.now(), ); @@ -546,12 +496,11 @@ void main() { final setup = makeSetup(frontTyre: 24); final entry = SettingChanges( changes: [ - makeChange( - suspension: SuspensionType.tyre, - setting: SettingType.frontTyrePressure, - oldValue: 22, - newValue: 24, - ), + SettingChange( + suspensionType: SuspensionType.tyre, + fieldId: frontTyreId, + oldValue: 22, + newValue: 24) ], date: DateTime.now(), ); @@ -562,221 +511,152 @@ void main() { }); test('handles multiple fields with partial no-ops', () { - // forkAir already at old value (100), forkLsc changed (8→10) final setup = makeSetup(forkAir: 100, forkLsc: 10); final entry = SettingChanges( changes: [ - makeChange( - setting: SettingType.airPressure, oldValue: 100, newValue: 110), - makeChange(setting: SettingType.lsc, oldValue: 8, newValue: 10), + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: 110), + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: lscId, + oldValue: 8, + newValue: 10), ], date: DateTime.now(), ); final result = setup.computeUndo(entry); expect(result, hasLength(1)); - expect(result.first.settingType, SettingType.lsc); + expect(result.first.fieldId, lscId); expect(result.first.newValue, 8); }); }); group('applyChanges', () { - test('updates fork field value', () { - final setup = Setup( + late String airId; + + late Setup makeSetup; + + setUp(() { + airId = 'air-pressure-id'; + final airField = + Field(id: airId, name: 'Air Pressure', unit: 'PSI', value: 100); + makeSetup = Setup( id: 'test', name: 'Test', - fork: Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), + fork: SectionSettings(fields: [ + airField + ], layout: [ + [airId] + ]), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [], ); - setup.applyChanges([ - SettingChange( - suspensionType: SuspensionType.fork, - settingType: SettingType.airPressure, - oldValue: 100, - newValue: 90, - ), - ]); - expect(setup.fork.airPressure?.value, 90); }); - test('preserves existing field unit when updating value', () { - final setup = Setup( - id: 'test', - name: 'Test', - fork: Settings( - airPressure: const Field(value: 100, unit: 'bar'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), - history: [], - ); - setup.applyChanges([ + test('updates fork field value', () { + makeSetup.applyChanges([ SettingChange( - suspensionType: SuspensionType.fork, - settingType: SettingType.airPressure, - oldValue: 100, - newValue: 90, - ), + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: 90), ]); - expect(setup.fork.airPressure?.unit, 'bar'); + expect(makeSetup.fork.fieldById(airId)?.value, 90); }); - test('disables field when newEnabled is false', () { - final setup = Setup( - id: 'test', - name: 'Test', - fork: Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - volumeSpacer: const Field(value: 2, unit: 'Spacers'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), - history: [], - ); - setup.applyChanges([ + test('preserves existing field unit when updating value', () { + makeSetup.applyChanges([ SettingChange( - suspensionType: SuspensionType.fork, - settingType: SettingType.volumeSpacer, - oldValue: 2, - newValue: null, - oldEnabled: true, - newEnabled: false, - ), + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: 90), ]); - expect(setup.fork.volumeSpacer, isNull); + expect(makeSetup.fork.fieldById(airId)?.unit, 'PSI'); }); - test('enables field when newEnabled is true', () { - final setup = Setup( - id: 'test', - name: 'Test', - fork: Settings( - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), - history: [], - ); - setup.applyChanges([ + test('marks field deleted when newEnabled is false', () { + makeSetup.applyChanges([ SettingChange( - suspensionType: SuspensionType.fork, - settingType: SettingType.airPressure, - oldValue: null, - newValue: 100, - oldEnabled: false, - newEnabled: true, - ), + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: null, + oldEnabled: true, + newEnabled: false), ]); - expect(setup.fork.airPressure?.value, 100); + expect(makeSetup.fork.fieldById(airId)?.deleted, isTrue); + expect(makeSetup.fork.activeFields.toList(), isEmpty); }); - test('updates tyre pressure', () { + test('undeletes field when newEnabled is true', () { + final deletedField = + Field(id: airId, name: 'Air Pressure', unit: 'PSI', deleted: true); final setup = Setup( id: 'test', name: 'Test', - fork: Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(front: const Field(value: 24, unit: 'PSI')), + fork: SectionSettings(fields: [deletedField], layout: []), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [], ); setup.applyChanges([ SettingChange( - suspensionType: SuspensionType.tyre, - settingType: SettingType.frontTyrePressure, - oldValue: 24, - newValue: 22, - ), + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: null, + newValue: 100, + oldEnabled: false, + newEnabled: true), ]); - expect(setup.tyres.front?.value, 22); - expect(setup.tyres.front?.unit, 'PSI'); + expect(setup.fork.fieldById(airId)?.deleted, isFalse); + expect(setup.fork.fieldById(airId)?.value, 100); }); }); group('computeUndo + applyChanges round-trip', () { test('undo restores setup to state before a value change', () { + final airId = 'air-id'; + final lscId = 'lsc-id'; + final airField = + Field(id: airId, name: 'Air Pressure', unit: 'PSI', value: 110); + final lscField = Field(id: lscId, name: 'LSC', unit: 'Clicks', value: 10); final setup = Setup( id: 'test', name: 'Test', - fork: Settings( - airPressure: const Field(value: 110, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 10, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), - ), - shock: Settings( - airPressure: const Field(value: 150, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 3, unit: 'Clicks'), - lsr: const Field(value: 2, unit: 'Clicks'), - ), - tyres: Tyres(), + fork: SectionSettings(fields: [ + airField, + lscField + ], layout: [ + [airId, lscId] + ]), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [], ); final entry = SettingChanges( changes: [ SettingChange( - suspensionType: SuspensionType.fork, - settingType: SettingType.airPressure, - oldValue: 100, - newValue: 110, - ), + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: 110), SettingChange( - suspensionType: SuspensionType.fork, - settingType: SettingType.lsc, - oldValue: 8, - newValue: 10, - ), + suspensionType: SuspensionType.fork, + fieldId: lscId, + oldValue: 8, + newValue: 10), ], date: DateTime.now(), ); final undoChanges = setup.computeUndo(entry); setup.applyChanges(undoChanges); - expect(setup.fork.airPressure?.value, 100); - expect(setup.fork.lsc?.value, 8); + expect(setup.fork.fieldById(airId)?.value, 100); + expect(setup.fork.fieldById(lscId)?.value, 8); }); }); } diff --git a/test/setting_tiles_test.dart b/test/setting_tiles_test.dart index 7181f13..2117e44 100644 --- a/test/setting_tiles_test.dart +++ b/test/setting_tiles_test.dart @@ -8,7 +8,7 @@ Widget _harness(GlobalKey key, FieldFormController controller) { home: Scaffold( body: Form( key: key, - child: FieldValueCard(name: 'Air Pressure', controller: controller), + child: FieldValueCard(controller: controller), ), ), ); @@ -19,8 +19,11 @@ void main() { testWidgets('accepts a decimal value and parses it via num.parse', (tester) async { final formKey = GlobalKey(); - final controller = - FieldFormController(enabled: true, value: null, unit: 'PSI'); + final controller = FieldFormController( + id: 'test-id', + fieldName: 'Air Pressure', + unit: 'PSI', + ); addTearDown(controller.dispose); await tester.pumpWidget(_harness(formKey, controller)); @@ -33,8 +36,11 @@ void main() { testWidgets('validator rejects malformed numbers', (tester) async { final formKey = GlobalKey(); - final controller = - FieldFormController(enabled: true, value: null, unit: 'PSI'); + final controller = FieldFormController( + id: 'test-id', + fieldName: 'Air Pressure', + unit: 'PSI', + ); addTearDown(controller.dispose); await tester.pumpWidget(_harness(formKey, controller)); diff --git a/test/setup_detail_test.dart b/test/setup_detail_test.dart index e53563c..53bd333 100644 --- a/test/setup_detail_test.dart +++ b/test/setup_detail_test.dart @@ -4,7 +4,6 @@ import 'package:provider/provider.dart'; import 'package:suspension_setup/models/field.dart'; import 'package:suspension_setup/models/settings.dart'; import 'package:suspension_setup/models/setup.dart'; -import 'package:suspension_setup/models/tyres.dart'; import 'package:suspension_setup/setup_detail.dart'; import 'package:suspension_setup/setup_storage_model.dart'; @@ -27,16 +26,20 @@ Widget _harness(Setup setup) { } Setup _makeSetup({String? serialNumber, String? infoUrl}) { + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 70); return Setup( id: 'test-id', name: 'Trail Setup', - fork: Settings( - airPressure: const Field(value: 70, unit: 'PSI'), + fork: SectionSettings( + fields: [airField], + layout: [ + [airField.id] + ], serialNumber: serialNumber, infoUrl: infoUrl, ), - shock: Settings(), - tyres: Tyres(), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), history: [], ); } diff --git a/test/setup_edit_test.dart b/test/setup_edit_test.dart index 0288e2a..94c9a6f 100644 --- a/test/setup_edit_test.dart +++ b/test/setup_edit_test.dart @@ -4,7 +4,6 @@ import 'package:provider/provider.dart'; import 'package:suspension_setup/models/field.dart'; import 'package:suspension_setup/models/settings.dart'; import 'package:suspension_setup/models/setup.dart'; -import 'package:suspension_setup/models/tyres.dart'; import 'package:suspension_setup/setting_tiles.dart'; import 'package:suspension_setup/setup_edit.dart'; import 'package:suspension_setup/setup_storage_model.dart'; @@ -26,8 +25,6 @@ class _FakeStorageModel extends SetupStorageModel { } } -// Push SetupEdit onto a parent Scaffold so the snackbar has somewhere to land -// after SetupEdit pops. Widget _setupEditHarness(_FakeStorageModel model, Setup? setup) { return ChangeNotifierProvider.value( value: model, @@ -47,7 +44,6 @@ Widget _setupEditHarness(_FakeStorageModel model, Setup? setup) { ); } -// Push ValueEdit onto a parent Scaffold for the same reason. Widget _valueEditHarness(_FakeStorageModel model, Setup setup) { return ChangeNotifierProvider.value( value: model, @@ -67,6 +63,21 @@ Widget _valueEditHarness(_FakeStorageModel model, Setup setup) { ); } +Setup _makeSetup({List forkFields = const []}) { + final ids = forkFields.map((f) => f.id).toList(); + return Setup( + id: 'test', + name: 'Trail Setup', + fork: SectionSettings( + fields: List.from(forkFields), + layout: ids.isEmpty ? [] : [ids], + ), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), + history: [], + ); +} + void main() { group('validateInfoUrl', () { test('accepts null', () { @@ -98,32 +109,80 @@ void main() { }); }); + group('SetupEdit FAB state', () { + testWidgets('shows Save tooltip when no new fields', (tester) async { + final model = _FakeStorageModel(); + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [airField]); + + await tester.pumpWidget(_setupEditHarness(model, setup)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Save setup'), findsOneWidget); + }); + + testWidgets('FAB switches to Edit values after adding a field', + (tester) async { + final model = _FakeStorageModel(); + + await tester.pumpWidget(_setupEditHarness(model, null)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Tap the first "Add field" button (Fork section) + await tester.tap(find.text('Add field').first); + await tester.pumpAndSettle(); + + // Fill in the dialog + await tester.enterText( + find + .descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ) + .first, + 'Air Pressure', + ); + await tester.pump(); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + expect(find.byTooltip('Edit values'), findsOneWidget); + }); + }); + group('SetupEdit → ValueEdit cancel', () { testWidgets('cancelling ValueEdit restores shared controller field values', (tester) async { final model = _FakeStorageModel(); - final setup = Setup( - id: 'test', - name: 'Trail Setup', - fork: Settings(airPressure: const Field(value: 73, unit: 'PSI')), - shock: Settings(), - tyres: Tyres(), - history: [], - ); + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [airField]); await tester.pumpWidget(_setupEditHarness(model, setup)); await tester.tap(find.text('open')); await tester.pumpAndSettle(); - // Enable Sag — _hasNewlyEnabled becomes true, FAB switches to arrow. - await tester.tap(find.text('Sag').first); - await tester.pump(); + // Add a field to make FAB show "Edit values" + await tester.tap(find.text('Add field').first); + await tester.pumpAndSettle(); + await tester.enterText( + find + .descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ) + .first, + 'Sag', + ); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); - // Navigate to ValueEdit. + // Navigate to ValueEdit await tester.tap(find.byTooltip('Edit values')); await tester.pumpAndSettle(); - // In ValueEdit: change the existing air-pressure value from 73 → 80. + // Change the existing air-pressure value from 73 → 80 await tester.enterText( find.descendant( of: find.byType(FieldValueCard).first, @@ -133,11 +192,11 @@ void main() { ); await tester.pump(); - // Cancel ValueEdit without saving. + // Cancel ValueEdit without saving await tester.pageBack(); await tester.pumpAndSettle(); - // Navigate to ValueEdit again — the value must be restored to 73. + // Navigate to ValueEdit again — the air pressure value must be restored await tester.tap(find.byTooltip('Edit values')); await tester.pumpAndSettle(); @@ -151,54 +210,25 @@ void main() { }); }); - group('SetupEdit → ValueEdit save (happy path)', () { - testWidgets( - 'saving in ValueEdit persists the setup with the newly enabled field', + group('SetupEdit direct save', () { + testWidgets('saving with no new fields calls upsert directly', (tester) async { final model = _FakeStorageModel(); - final setup = Setup( - id: 'test', - name: 'Trail Setup', - fork: Settings(airPressure: const Field(value: 73, unit: 'PSI')), - shock: Settings(), - tyres: Tyres(), - history: [], - ); + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [airField]); await tester.pumpWidget(_setupEditHarness(model, setup)); await tester.tap(find.text('open')); await tester.pumpAndSettle(); - // Enable Sag → FAB becomes arrow. - await tester.tap(find.text('Sag').first); - await tester.pump(); - - // Navigate to ValueEdit. - await tester.tap(find.byTooltip('Edit values')); - await tester.pumpAndSettle(); - - // Enter a value for the newly-enabled Sag field (second FieldValueCard). - await tester.enterText( - find.descendant( - of: find.byType(FieldValueCard).at(1), - matching: find.byType(TextFormField), - ), - '25', - ); - await tester.pump(); - - // Tap Save FAB → comment dialog appears. - await tester.tap(find.byTooltip('Save values')); - await tester.pumpAndSettle(); + expect(find.byTooltip('Save setup'), findsOneWidget); - // Dismiss the comment dialog without adding a comment. - await tester.tap(find.text('Save')); + await tester.tap(find.byTooltip('Save setup')); await tester.pumpAndSettle(); + // No changes → no comment dialog → saved directly expect(model.lastUpserted, isNotNull); - expect(model.lastUpserted!.fork.airPressure?.value, 73); - expect(model.lastUpserted!.fork.sag?.value, 25); - expect(find.text('Setup saved successfully'), findsOneWidget); + expect(model.lastUpserted!.name, 'Trail Setup'); }); }); @@ -206,20 +236,14 @@ void main() { testWidgets('editing a value and saving persists the change', (tester) async { final model = _FakeStorageModel(); - final setup = Setup( - id: 'test', - name: 'Trail Setup', - fork: Settings(airPressure: const Field(value: 73, unit: 'PSI')), - shock: Settings(), - tyres: Tyres(), - history: [], - ); + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [airField]); await tester.pumpWidget(_valueEditHarness(model, setup)); await tester.tap(find.text('open')); await tester.pumpAndSettle(); - // Change air pressure from 73 to 80. + // Change air pressure from 73 to 80 await tester.enterText( find.descendant( of: find.byType(FieldValueCard).first, @@ -229,81 +253,37 @@ void main() { ); await tester.pump(); - // Tap Save FAB → comment dialog appears. + // Tap Save FAB → comment dialog appears await tester.tap(find.byTooltip('Save values')); await tester.pumpAndSettle(); - // Dismiss the comment dialog. + // Dismiss the comment dialog await tester.tap(find.text('Save')); await tester.pumpAndSettle(); expect(model.lastUpserted, isNotNull); - expect(model.lastUpserted!.fork.airPressure?.value, 80); + expect( + model.lastUpserted!.fork.activeFields + .firstWhere((f) => f.name == 'Air Pressure') + .value, + 80, + ); expect(find.text('Setup saved successfully'), findsOneWidget); }); testWidgets('owns and disposes its own controller', (tester) async { final model = _FakeStorageModel(); - final setup = Setup( - id: 'test', - name: 'Trail Setup', - fork: Settings(airPressure: const Field(value: 73, unit: 'PSI')), - shock: Settings(), - tyres: Tyres(), - history: [], - ); + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [airField]); await tester.pumpWidget(_valueEditHarness(model, setup)); await tester.tap(find.text('open')); await tester.pumpAndSettle(); - // Replacing the widget tree disposes ValueEdit — no assertion errors from - // double-dispose if _ownsController is handled correctly. + // Replacing the widget tree disposes ValueEdit — no assertion errors await tester.pumpWidget(const MaterialApp(home: SizedBox.shrink())); await tester.pump(); - // No exception thrown = controller was disposed exactly once. - }); - }); - - group('SetupEdit direct save (no new fields)', () { - testWidgets('disabling a field saves without navigating to ValueEdit', - (tester) async { - final model = _FakeStorageModel(); - final setup = Setup( - id: 'test', - name: 'Trail Setup', - fork: Settings( - airPressure: const Field(value: 73, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - ), - shock: Settings(), - tyres: Tyres(), - history: [], - ); - - await tester.pumpWidget(_setupEditHarness(model, setup)); - await tester.tap(find.text('open')); - await tester.pumpAndSettle(); - - // Disable Sag — no new fields enabled, FAB stays on 'Save setup'. - await tester.tap(find.text('Sag').first); - await tester.pump(); - - // No ValueEdit navigation expected: FAB has 'Save setup' tooltip. - expect(find.byTooltip('Save setup'), findsOneWidget); - - // Tap Save FAB → comment dialog (disabling is a change). - await tester.tap(find.byTooltip('Save setup')); - await tester.pumpAndSettle(); - - // Dismiss the comment dialog. - await tester.tap(find.text('Save')); - await tester.pumpAndSettle(); - - expect(model.lastUpserted, isNotNull); - expect(model.lastUpserted!.fork.sag, isNull); - expect(model.lastUpserted!.fork.airPressure?.value, 73); - expect(find.text('Setup saved successfully'), findsOneWidget); + // No exception thrown = controller was disposed exactly once }); }); } diff --git a/test/setup_file_utils_test.dart b/test/setup_file_utils_test.dart index d164344..de52499 100644 --- a/test/setup_file_utils_test.dart +++ b/test/setup_file_utils_test.dart @@ -6,26 +6,33 @@ import 'package:suspension_setup/migrations/migrator.dart'; import 'package:suspension_setup/models/field.dart'; import 'package:suspension_setup/models/settings.dart'; import 'package:suspension_setup/models/setup.dart'; -import 'package:suspension_setup/models/tyres.dart'; import 'package:suspension_setup/setup_file_utils.dart'; Setup _makeSetup(String id, String name) { + final forkFields = [ + Field(name: 'Air Pressure', unit: 'PSI', value: 100), + Field(name: 'Sag', unit: '%', value: 25), + Field(name: 'Low Speed Compression', unit: 'Clicks', value: 8), + Field(name: 'Low Speed Rebound', unit: 'Clicks', value: 6), + ]; + final shockFields = [ + Field(name: 'Air Pressure', unit: 'PSI', value: 180), + Field(name: 'Sag', unit: '%', value: 30), + Field(name: 'Low Speed Compression', unit: 'Clicks', value: 5), + Field(name: 'Low Speed Rebound', unit: 'Clicks', value: 4), + ]; return Setup( id: id, name: name, - fork: Settings( - airPressure: const Field(value: 100, unit: 'PSI'), - sag: const Field(value: 25, unit: '%'), - lsc: const Field(value: 8, unit: 'Clicks'), - lsr: const Field(value: 6, unit: 'Clicks'), + fork: SectionSettings( + fields: forkFields, + layout: [forkFields.map((f) => f.id).toList()], ), - shock: Settings( - airPressure: const Field(value: 180, unit: 'PSI'), - sag: const Field(value: 30, unit: '%'), - lsc: const Field(value: 5, unit: 'Clicks'), - lsr: const Field(value: 4, unit: 'Clicks'), + shock: SectionSettings( + fields: shockFields, + layout: [shockFields.map((f) => f.id).toList()], ), - tyres: Tyres(), + tyres: SectionSettings(fields: [], layout: []), history: [], ); } @@ -48,7 +55,7 @@ void main() { expect(result, isNull); }); - test('reads and parses valid v2 setup file', () async { + test('reads and parses valid setup file', () async { final setup = _makeSetup('id-1', 'Trail setup'); final filePath = '${tempDir.path}/setups.json'; await SetupFileUtil.writeSetups({'id-1': setup}, filePath); @@ -58,10 +65,17 @@ void main() { expect(result, isNotNull); expect(result!.length, 1); expect(result['id-1']?.name, 'Trail setup'); - expect(result['id-1']?.fork.airPressure?.value, 100); + expect( + result['id-1'] + ?.fork + .activeFields + .firstWhere((f) => f.name == 'Air Pressure') + .value, + 100, + ); }); - test('migrates v1 file to v2 on read', () async { + test('migrates v1 file to v4 on read', () async { final filePath = '${tempDir.path}/v1setups.json'; final v1Json = jsonEncode({ 'id-1': { @@ -93,11 +107,22 @@ void main() { final result = await SetupFileUtil.readSetups(filePath); expect(result, isNotNull); - expect(result!['id-1']?.fork.airPressure?.value, 100); - expect(result['id-1']?.fork.airPressure?.unit, 'PSI'); - expect(result['id-1']?.fork.hsc, isNull); + final fork = result!['id-1']!.fork; + expect( + fork.activeFields.firstWhere((f) => f.name == 'Air Pressure').value, + 100, + ); + expect( + fork.activeFields.firstWhere((f) => f.name == 'Air Pressure').unit, + 'PSI', + ); + // hsc was null in v1 so it should be deleted (not active) + expect( + fork.activeFields.any((f) => f.name == 'High Speed Compression'), + isFalse, + ); - // File should have been rewritten in v2 format + // File should have been rewritten in v4 format final rewritten = jsonDecode(await File(filePath).readAsString()); expect(rewritten['schemaVersion'], currentSchemaVersion); }); From c5582d6a3ca743121759b8d0bfeb0213caccb119 Mon Sep 17 00:00:00 2001 From: pec0ra Date: Mon, 18 May 2026 06:49:38 +0200 Subject: [PATCH 2/9] Drag and drop improvements --- lib/draggable_grid.dart | 203 +++++++++++++++++++++++++++++++--------- lib/setting_tiles.dart | 46 ++++++--- 2 files changed, 193 insertions(+), 56 deletions(-) diff --git a/lib/draggable_grid.dart b/lib/draggable_grid.dart index 8e59939..2e79e07 100644 --- a/lib/draggable_grid.dart +++ b/lib/draggable_grid.dart @@ -20,7 +20,22 @@ class DraggableGrid extends StatefulWidget { } class _DraggableGridState extends State { + int _draggingCount = 0; + bool get _isDragging => _draggingCount > 0; + _DropTarget? _hoveredTarget; + + bool _isHoveredInRow(int r, int c) { + final t = _hoveredTarget; + return t is _InRowTarget && t.rowIndex == r && t.position == c; + } + + bool _isHoveredBetweenRows(int p) { + final t = _hoveredTarget; + return t is _NewRowTarget && t.position == p; + } + void _drop(String id, _DropTarget target) { + setState(() => _hoveredTarget = null); widget.onLayoutChanged(_computeNewLayout(id, target)); } @@ -75,22 +90,42 @@ class _DraggableGridState extends State { Widget _inRowZone(int rowIndex, int position) { return DragTarget( - onWillAcceptWithDetails: (_) => true, + onWillAcceptWithDetails: (_) { + if (!_isHoveredInRow(rowIndex, position)) { + setState(() => _hoveredTarget = + _InRowTarget(rowIndex: rowIndex, position: position)); + } + return true; + }, + onMove: (_) { + if (!_isHoveredInRow(rowIndex, position)) { + setState(() => _hoveredTarget = + _InRowTarget(rowIndex: rowIndex, position: position)); + } + }, + onLeave: (_) { + if (_isHoveredInRow(rowIndex, position)) { + setState(() => _hoveredTarget = null); + } + }, onAcceptWithDetails: (d) { _drop(d.data, _InRowTarget(rowIndex: rowIndex, position: position)); }, - builder: (context, candidates, _) { - final active = candidates.isNotEmpty; + builder: (context, _, __) { + if (_isHoveredInRow(rowIndex, position)) { + return Padding( + padding: const EdgeInsets.all(4), + child: CustomPaint( + painter: _DashedBorderPainter( + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } return AnimatedContainer( duration: const Duration(milliseconds: 150), - width: active ? 28 : 20, - alignment: Alignment.center, - child: active - ? Container( - width: 2, - color: Theme.of(context).colorScheme.primary, - ) - : null, + curve: Curves.easeOut, + width: _isDragging ? 12 : 0, ); }, ); @@ -98,22 +133,43 @@ class _DraggableGridState extends State { Widget _betweenRowsZone(int position) { return DragTarget( - onWillAcceptWithDetails: (_) => true, + onWillAcceptWithDetails: (_) { + if (!_isHoveredBetweenRows(position)) { + setState(() => _hoveredTarget = _NewRowTarget(position: position)); + } + return true; + }, + onMove: (_) { + if (!_isHoveredBetweenRows(position)) { + setState(() => _hoveredTarget = _NewRowTarget(position: position)); + } + }, + onLeave: (_) { + if (_isHoveredBetweenRows(position)) { + setState(() => _hoveredTarget = null); + } + }, onAcceptWithDetails: (d) { _drop(d.data, _NewRowTarget(position: position)); }, - builder: (context, candidates, _) { - final active = candidates.isNotEmpty; + builder: (context, _, __) { + if (_isHoveredBetweenRows(position)) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: SizedBox( + height: 72, + child: CustomPaint( + painter: _DashedBorderPainter( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ); + } return AnimatedContainer( duration: const Duration(milliseconds: 150), - height: active ? 28 : 12, - alignment: Alignment.center, - child: active - ? Container( - height: 2, - color: Theme.of(context).colorScheme.primary, - ) - : null, + curve: Curves.easeOut, + height: _isDragging ? 12 : 0, ); }, ); @@ -122,22 +178,29 @@ class _DraggableGridState extends State { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _betweenRowsZone(0), for (int r = 0; r < widget.layout.length; r++) ...[ IntrinsicHeight( child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _inRowZone(r, 0), - for (int c = 0; c < widget.layout[r].length; c++) ...[ - Expanded( - child: _DraggableItem( - id: widget.layout[r][c], - itemBuilder: widget.itemBuilder, - onTap: widget.onItemTap, + for (int c = 0; c <= widget.layout[r].length; c++) ...[ + if (_isHoveredInRow(r, c)) + Expanded(child: _inRowZone(r, c)) + else + _inRowZone(r, c), + if (c < widget.layout[r].length) + Expanded( + child: _DraggableItem( + id: widget.layout[r][c], + itemBuilder: widget.itemBuilder, + onTap: widget.onItemTap, + onDragStarted: () => setState(() => _draggingCount++), + onDragEnded: () => setState(() => _draggingCount--), + ), ), - ), - _inRowZone(r, c + 1), ], ], ), @@ -156,11 +219,15 @@ class _DraggableItem extends StatefulWidget { required this.id, required this.itemBuilder, this.onTap, + this.onDragStarted, + this.onDragEnded, }); final String id; final Widget Function(String) itemBuilder; final void Function(String)? onTap; + final VoidCallback? onDragStarted; + final VoidCallback? onDragEnded; @override State<_DraggableItem> createState() => _DraggableItemState(); @@ -168,7 +235,7 @@ class _DraggableItem extends StatefulWidget { class _DraggableItemState extends State<_DraggableItem> { final _childKey = GlobalKey(); - Size _size = const Size(100, 60); + Size _size = const Size(200, 88); @override void initState() { @@ -205,18 +272,32 @@ class _DraggableItemState extends State<_DraggableItem> { return LongPressDraggable( data: widget.id, dragAnchorStrategy: _dragAnchor, - feedback: Material( - elevation: 6, - borderRadius: BorderRadius.circular(12), - child: SizedBox( - width: _size.width, - height: _size.height, - child: widget.itemBuilder(widget.id), + onDragStarted: widget.onDragStarted, + onDragEnd: (_) => widget.onDragEnded?.call(), + feedback: Transform.scale( + scale: 1.05, + child: Material( + color: Colors.transparent, + elevation: 8, + borderRadius: BorderRadius.circular(12), + clipBehavior: Clip.antiAlias, + child: SizedBox( + width: _size.width, + height: _size.height, + child: widget.itemBuilder(widget.id), + ), ), ), - childWhenDragging: Opacity( - opacity: 0.3, - child: widget.itemBuilder(widget.id), + childWhenDragging: Padding( + padding: const EdgeInsets.all(4), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: _size.height - 8), + child: CustomPaint( + painter: _DashedBorderPainter( + color: Theme.of(context).colorScheme.outline), + child: const SizedBox.expand(), + ), + ), ), child: widget.onTap != null ? GestureDetector( @@ -242,3 +323,41 @@ final class _NewRowTarget extends _DropTarget { _NewRowTarget({required this.position}); final int position; } + +// ── dashed placeholder painter ───────────────────────────────────────────── + +class _DashedBorderPainter extends CustomPainter { + _DashedBorderPainter({required this.color}); + final Color color; + + static const _dashWidth = 6.0; + static const _dashGap = 4.0; + static const _radius = 12.0; + static const _strokeWidth = 1.5; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = _strokeWidth + ..style = PaintingStyle.stroke; + final path = Path() + ..addRRect(RRect.fromRectAndRadius( + Rect.fromLTWH(0, 0, size.width, size.height), + const Radius.circular(_radius), + )); + for (final metric in path.computeMetrics()) { + double distance = 0; + while (distance < metric.length) { + canvas.drawPath( + metric.extractPath(distance, distance + _dashWidth), + paint, + ); + distance += _dashWidth + _dashGap; + } + } + } + + @override + bool shouldRepaint(_DashedBorderPainter old) => old.color != color; +} diff --git a/lib/setting_tiles.dart b/lib/setting_tiles.dart index db6c6b2..8e064fb 100644 --- a/lib/setting_tiles.dart +++ b/lib/setting_tiles.dart @@ -93,22 +93,40 @@ class FieldConfigTile extends StatelessWidget { color: theme.colorScheme.primaryContainer, child: Padding( padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), - child: Column( + child: Stack( children: [ - Text( - controller.name.text, - style: theme.textTheme.bodyMedium - ?.copyWith(color: theme.colorScheme.onPrimaryContainer), - ), - Text( - value.isEmpty ? '—' : value, - style: theme.textTheme.headlineSmall - ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + controller.name.text, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium + ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + Text( + value.isEmpty ? '—' : value, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall + ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + Text( + unit, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + ), + ], ), - Text( - unit, - style: theme.textTheme.bodySmall - ?.copyWith(color: theme.colorScheme.onPrimaryContainer), + Positioned( + top: 0, + right: 0, + child: Icon( + Icons.drag_indicator, + size: 14, + color: theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.4), + ), ), ], ), From 8f3f097ab2356b08d08cd325896af08582482b24 Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sat, 30 May 2026 18:17:59 +0200 Subject: [PATCH 3/9] Improve field ordering --- lib/draggable_grid.dart | 263 ++++++++++++++++++++++------------------ 1 file changed, 143 insertions(+), 120 deletions(-) diff --git a/lib/draggable_grid.dart b/lib/draggable_grid.dart index 2e79e07..a078f5a 100644 --- a/lib/draggable_grid.dart +++ b/lib/draggable_grid.dart @@ -20,23 +20,34 @@ class DraggableGrid extends StatefulWidget { } class _DraggableGridState extends State { - int _draggingCount = 0; - bool get _isDragging => _draggingCount > 0; - _DropTarget? _hoveredTarget; + _DropTarget? _lastTarget; + bool _seenInRowSinceLastBetweenRows = true; + bool _isDragging = false; + Offset? _dragTouchOffset; + Size? _dragSize; + final _cardKeys = {}; - bool _isHoveredInRow(int r, int c) { - final t = _hoveredTarget; - return t is _InRowTarget && t.rowIndex == r && t.position == c; - } - - bool _isHoveredBetweenRows(int p) { - final t = _hoveredTarget; - return t is _NewRowTarget && t.position == p; - } + GlobalKey _cardKey(String id) => _cardKeys.putIfAbsent(id, () => GlobalKey()); - void _drop(String id, _DropTarget target) { - setState(() => _hoveredTarget = null); - widget.onLayoutChanged(_computeNewLayout(id, target)); + bool _isNoOp(String id, _DropTarget target) { + int srcRow = -1, srcCol = -1; + outer: + for (int r = 0; r < widget.layout.length; r++) { + for (int c = 0; c < widget.layout[r].length; c++) { + if (widget.layout[r][c] == id) { + srcRow = r; + srcCol = c; + break outer; + } + } + } + if (srcRow < 0) return false; + return switch (target) { + _InRowTarget(:final rowIndex, :final position) => + srcRow == rowIndex && (position == srcCol || position == srcCol + 1), + _NewRowTarget(:final position) => widget.layout[srcRow].length == 1 && + (position == srcRow || position == srcRow + 1), + }; } List> _computeNewLayout(String id, _DropTarget target) { @@ -88,90 +99,93 @@ class _DraggableGridState extends State { return rows; } - Widget _inRowZone(int rowIndex, int position) { - return DragTarget( - onWillAcceptWithDetails: (_) { - if (!_isHoveredInRow(rowIndex, position)) { - setState(() => _hoveredTarget = - _InRowTarget(rowIndex: rowIndex, position: position)); - } - return true; - }, - onMove: (_) { - if (!_isHoveredInRow(rowIndex, position)) { - setState(() => _hoveredTarget = - _InRowTarget(rowIndex: rowIndex, position: position)); - } - }, - onLeave: (_) { - if (_isHoveredInRow(rowIndex, position)) { - setState(() => _hoveredTarget = null); - } - }, - onAcceptWithDetails: (d) { - _drop(d.data, _InRowTarget(rowIndex: rowIndex, position: position)); - }, - builder: (context, _, __) { - if (_isHoveredInRow(rowIndex, position)) { - return Padding( - padding: const EdgeInsets.all(4), - child: CustomPaint( - painter: _DashedBorderPainter( - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - width: _isDragging ? 12 : 0, - ); - }, - ); + void _onBottomHover(DragTargetDetails details) { + final target = _NewRowTarget(position: widget.layout.length); + if (target == _lastTarget) return; + _lastTarget = target; + _seenInRowSinceLastBetweenRows = false; + if (_isNoOp(details.data, target)) return; + widget.onLayoutChanged(_computeNewLayout(details.data, target)); } - Widget _betweenRowsZone(int position) { - return DragTarget( - onWillAcceptWithDetails: (_) { - if (!_isHoveredBetweenRows(position)) { - setState(() => _hoveredTarget = _NewRowTarget(position: position)); - } - return true; - }, - onMove: (_) { - if (!_isHoveredBetweenRows(position)) { - setState(() => _hoveredTarget = _NewRowTarget(position: position)); - } - }, - onLeave: (_) { - if (_isHoveredBetweenRows(position)) { - setState(() => _hoveredTarget = null); - } - }, - onAcceptWithDetails: (d) { - _drop(d.data, _NewRowTarget(position: position)); - }, - builder: (context, _, __) { - if (_isHoveredBetweenRows(position)) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), - child: SizedBox( - height: 72, - child: CustomPaint( - painter: _DashedBorderPainter( - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - ); - } - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - height: _isDragging ? 12 : 0, - ); - }, + void _onCardHover( + DragTargetDetails details, int r, int c, String hoveredId) { + final rb = + _cardKey(hoveredId).currentContext?.findRenderObject() as RenderBox?; + if (rb == null) return; + + final cardTopLeft = rb.localToGlobal(Offset.zero); + final touchOffset = + _dragTouchOffset ?? Offset(rb.size.width / 2, rb.size.height / 2); + final dragSize = _dragSize ?? rb.size; + + final feedbackCenterY = + details.offset.dy - touchOffset.dy + dragSize.height / 2; + + const betweenRowsBuffer = 28.0; + final _DropTarget target; + if (feedbackCenterY < cardTopLeft.dy - betweenRowsBuffer) { + target = _NewRowTarget(position: r); + } else if (feedbackCenterY > cardTopLeft.dy + rb.size.height + betweenRowsBuffer) { + target = _NewRowTarget(position: r + 1); + } else { + final pointerX = details.offset.dx + touchOffset.dx; + final rowLen = widget.layout[r].length; + final rowLeft = cardTopLeft.dx - c * rb.size.width; + final rowWidth = rb.size.width * rowLen; + final position = + ((pointerX - rowLeft) * (rowLen + 1) / rowWidth) + .floor() + .clamp(0, rowLen); + target = _InRowTarget(rowIndex: r, position: position); + } + + if (target == _lastTarget) return; + _lastTarget = target; + + if (target is _NewRowTarget && !_seenInRowSinceLastBetweenRows) return; + if (_isNoOp(details.data, target)) return; + + if (target is _NewRowTarget) { + _seenInRowSinceLastBetweenRows = false; + } else { + _seenInRowSinceLastBetweenRows = true; + } + + widget.onLayoutChanged(_computeNewLayout(details.data, target)); + } + + Widget _buildCardSlot(int r, int c) { + final id = widget.layout[r][c]; + return Expanded( + child: DragTarget( + key: _cardKey(id), + onWillAcceptWithDetails: (details) { + _onCardHover(details, r, c, id); + return true; + }, + onMove: (details) => _onCardHover(details, r, c, id), + builder: (_, __, ___) => _DraggableItem( + key: ValueKey(id), + id: id, + itemBuilder: widget.itemBuilder, + onTap: widget.onItemTap, + onDragStarted: (touchOffset, size) { + setState(() { + _isDragging = true; + _dragTouchOffset = touchOffset; + _dragSize = size; + }); + }, + onDragEnded: () { + _seenInRowSinceLastBetweenRows = true; + setState(() { + _isDragging = false; + _lastTarget = null; + }); + }, + ), + ), ); } @@ -180,33 +194,24 @@ class _DraggableGridState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _betweenRowsZone(0), - for (int r = 0; r < widget.layout.length; r++) ...[ + for (int r = 0; r < widget.layout.length; r++) IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - for (int c = 0; c <= widget.layout[r].length; c++) ...[ - if (_isHoveredInRow(r, c)) - Expanded(child: _inRowZone(r, c)) - else - _inRowZone(r, c), - if (c < widget.layout[r].length) - Expanded( - child: _DraggableItem( - id: widget.layout[r][c], - itemBuilder: widget.itemBuilder, - onTap: widget.onItemTap, - onDragStarted: () => setState(() => _draggingCount++), - onDragEnded: () => setState(() => _draggingCount--), - ), - ), - ], + for (int c = 0; c < widget.layout[r].length; c++) + _buildCardSlot(r, c), ], ), ), - _betweenRowsZone(r + 1), - ], + DragTarget( + onWillAcceptWithDetails: (details) { + _onBottomHover(details); + return true; + }, + onMove: _onBottomHover, + builder: (_, __, ___) => SizedBox(height: _isDragging ? 64 : 0), + ), ], ); } @@ -216,6 +221,7 @@ class _DraggableGridState extends State { class _DraggableItem extends StatefulWidget { const _DraggableItem({ + super.key, required this.id, required this.itemBuilder, this.onTap, @@ -226,7 +232,7 @@ class _DraggableItem extends StatefulWidget { final String id; final Widget Function(String) itemBuilder; final void Function(String)? onTap; - final VoidCallback? onDragStarted; + final void Function(Offset touchOffset, Size size)? onDragStarted; final VoidCallback? onDragEnded; @override @@ -260,7 +266,9 @@ class _DraggableItemState extends State<_DraggableItem> { Offset _dragAnchor(Draggable _, BuildContext __, Offset position) { final rb = _childKey.currentContext?.findRenderObject() as RenderBox?; if (rb == null) return Offset.zero; - return rb.globalToLocal(position); + final anchor = rb.globalToLocal(position); + widget.onDragStarted?.call(anchor, rb.size); + return anchor; } @override @@ -272,7 +280,6 @@ class _DraggableItemState extends State<_DraggableItem> { return LongPressDraggable( data: widget.id, dragAnchorStrategy: _dragAnchor, - onDragStarted: widget.onDragStarted, onDragEnd: (_) => widget.onDragEnded?.call(), feedback: Transform.scale( scale: 1.05, @@ -317,11 +324,27 @@ final class _InRowTarget extends _DropTarget { _InRowTarget({required this.rowIndex, required this.position}); final int rowIndex; final int position; + + @override + bool operator ==(Object other) => + other is _InRowTarget && + other.rowIndex == rowIndex && + other.position == position; + + @override + int get hashCode => Object.hash(rowIndex, position); } final class _NewRowTarget extends _DropTarget { _NewRowTarget({required this.position}); final int position; + + @override + bool operator ==(Object other) => + other is _NewRowTarget && other.position == position; + + @override + int get hashCode => position.hashCode; } // ── dashed placeholder painter ───────────────────────────────────────────── From 438b74d8eb66da8c62f5d7a22ca29d1670d18010 Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sun, 31 May 2026 08:46:48 +0200 Subject: [PATCH 4/9] Add animations --- lib/draggable_grid.dart | 159 +++++++++++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 19 deletions(-) diff --git a/lib/draggable_grid.dart b/lib/draggable_grid.dart index a078f5a..395547f 100644 --- a/lib/draggable_grid.dart +++ b/lib/draggable_grid.dart @@ -26,9 +26,93 @@ class _DraggableGridState extends State { Offset? _dragTouchOffset; Size? _dragSize; final _cardKeys = {}; - + final _enteringCards = {}; + String? _draggingId; + final _cardCols = {}; + final _cardSlideOffsets = {}; + double _rowWidth = 0; GlobalKey _cardKey(String id) => _cardKeys.putIfAbsent(id, () => GlobalKey()); + @override + void didUpdateWidget(DraggableGrid old) { + super.didUpdateWidget(old); + final dragId = _draggingId; + if (dragId == null) return; + int? oldR; + for (int r = 0; r < old.layout.length; r++) { + if (old.layout[r].contains(dragId)) { + oldR = r; + break; + } + } + if (oldR == null) return; + final oldRowIds = old.layout[oldR].toSet(); + final entering = {}; + + // Rule 1: dragged card crossed into a different logical row. + int? newR; + for (int r = 0; r < widget.layout.length; r++) { + if (!widget.layout[r].contains(dragId)) continue; + newR = r; + final movedToNewRowIndex = r != oldR; + final joinedNewCompanions = + widget.layout[r].any((c) => c != dragId && !oldRowIds.contains(c)); + if (movedToNewRowIndex || joinedNewCompanions) entering.add(dragId); + break; + } + + // Rule 2: when the dragged card's Row column index stayed the same but gained + // new cards, those new cards' target widths would overflow alongside the dragged + // card's existing (large) AC width — so they must also start at 0. + if (newR != null && + entering.contains(dragId) && + newR < old.layout.length && + old.layout[newR].contains(dragId)) { + final oldAtNewR = old.layout[newR].toSet(); + for (final id in widget.layout[newR]) { + if (id != dragId && !oldAtNewR.contains(id)) entering.add(id); + } + } + + if (entering.isNotEmpty) { + _enteringCards.addAll(entering); + // Clear any active slide offsets: the entering animation takes over + // visually, and residual slide transforms corrupt zone calculations. + _cardSlideOffsets.clear(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _enteringCards.removeAll(entering)); + }); + } + + // Slide animation: only when no card is entering. Still update _cardCols + // every frame so future slide detections start from the correct column. + final slideCards = {}; + for (int r = 0; r < widget.layout.length; r++) { + for (int c = 0; c < widget.layout[r].length; c++) { + final id = widget.layout[r][c]; + final prevC = _cardCols[id]; + final wasInSameRow = + r < old.layout.length && old.layout[r].contains(id); + if (entering.isEmpty && wasInSameRow && prevC != null && prevC != c) { + _cardSlideOffsets[id] = Offset((prevC - c).toDouble(), 0); + slideCards.add(id); + } + _cardCols[id] = c; + } + } + if (slideCards.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + for (final id in slideCards) { + _cardSlideOffsets.remove(id); + } + }); + } + }); + } + } + bool _isNoOp(String id, _DropTarget target) { int srcRow = -1, srcCol = -1; outer: @@ -131,8 +215,18 @@ class _DraggableGridState extends State { } else { final pointerX = details.offset.dx + touchOffset.dx; final rowLen = widget.layout[r].length; - final rowLeft = cardTopLeft.dx - c * rb.size.width; - final rowWidth = rb.size.width * rowLen; + final nominalCardWidth = + _rowWidth > 0 ? _rowWidth / rowLen : rb.size.width; + // Card 0 always sits at the row's left edge regardless of its own width. + // Using it gives a stable rowLeft even while entering cards animate. + final firstRb = c == 0 + ? rb + : (_cardKey(widget.layout[r][0]) + .currentContext + ?.findRenderObject() as RenderBox?) ?? + rb; + final rowLeft = firstRb.localToGlobal(Offset.zero).dx; + final rowWidth = nominalCardWidth * rowLen; final position = ((pointerX - rowLeft) * (rowLen + 1) / rowWidth) .floor() @@ -144,20 +238,25 @@ class _DraggableGridState extends State { _lastTarget = target; if (target is _NewRowTarget && !_seenInRowSinceLastBetweenRows) return; + if (target is _InRowTarget) _seenInRowSinceLastBetweenRows = true; if (_isNoOp(details.data, target)) return; - - if (target is _NewRowTarget) { - _seenInRowSinceLastBetweenRows = false; - } else { - _seenInRowSinceLastBetweenRows = true; - } + if (target is _NewRowTarget) _seenInRowSinceLastBetweenRows = false; widget.onLayoutChanged(_computeNewLayout(details.data, target)); } - Widget _buildCardSlot(int r, int c) { + Widget _buildCardContent(int r, int c) { final id = widget.layout[r][c]; - return Expanded( + // Key includes column so a new AnimatedSlide is created when the card + // shifts columns. When entering, a special key forces a fresh instance at + // Offset.zero so no residual slide transform corrupts zone calculations. + return AnimatedSlide( + key: _enteringCards.contains(id) + ? ValueKey('${id}_slide_entering') + : ValueKey('${id}_slide_$c'), + offset: _cardSlideOffsets[id] ?? Offset.zero, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, child: DragTarget( key: _cardKey(id), onWillAcceptWithDetails: (details) { @@ -175,6 +274,7 @@ class _DraggableGridState extends State { _isDragging = true; _dragTouchOffset = touchOffset; _dragSize = size; + _draggingId = id; }); }, onDragEnded: () { @@ -182,6 +282,7 @@ class _DraggableGridState extends State { setState(() { _isDragging = false; _lastTarget = null; + _draggingId = null; }); }, ), @@ -195,14 +296,34 @@ class _DraggableGridState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (int r = 0; r < widget.layout.length; r++) - IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (int c = 0; c < widget.layout[r].length; c++) - _buildCardSlot(r, c), - ], - ), + LayoutBuilder( + builder: (context, constraints) { + _rowWidth = constraints.maxWidth; + final rowLen = widget.layout[r].length; + final cardWidth = _rowWidth / rowLen; + // ClipRect clips the visual overflow from AnimatedSlide during + // in-row slide animations (paint-only, no layout impact). + return ClipRect( + child: IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (int c = 0; c < rowLen; c++) + AnimatedContainer( + key: ValueKey(widget.layout[r][c]), + width: _enteringCards + .contains(widget.layout[r][c]) + ? 0.0 + : cardWidth, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: _buildCardContent(r, c), + ), + ], + ), + ), + ); + }, ), DragTarget( onWillAcceptWithDetails: (details) { From b80af33e1d2aab28c9ba3f43e211ad44727a9062 Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sun, 31 May 2026 08:47:06 +0200 Subject: [PATCH 5/9] Update CLAUDE.md --- CLAUDE.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 72662ac..ec9ff64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,3 +50,16 @@ The app uses **Provider** for state management with a simple single-file-on-disk **Navigation flow**: `HomePage` → `SetupDetail` (view + history) or `SetupEdit` (create/edit). Edit computes a diff on save, appends a `SettingChanges` to the setup history, then calls `SetupStorageModel.upsert()`. **Custom assets**: `fonts/SuspensionIcons.ttf` with constants in `lib/suspension_icons.dart`; `assets/icon/icon-white.svg` used in the AppBar. + +## DraggableGrid (`lib/draggable_grid.dart`) + +This file has subtle interactions. After any change, test on a real device or emulator — `flutter analyze` cannot catch drag-interaction regressions. + +### Non-obvious invariants — do not break these + +- **Zone calculation uses nominal width, not animated width.** `rb.size.width` on an `AnimatedContainer` child may be mid-animation. Always use `_rowWidth / rowLen` for zone boundaries. +- **Zone calculation uses card-0's position for `rowLeft`.** Card 0 is always at the row's left edge regardless of its own width. Using the hovered card's position drifts as entering cards animate. +- **`rb.localToGlobal` includes `AnimatedSlide` transform.** When `AnimatedSlide` has a non-zero offset, `localToGlobal` reports the visual position, not the layout position. This corrupts zone calculations. +- **`_cardSlideOffsets` must be cleared when `_enteringCards` is non-empty.** Active slide transforms + entering width animation cause zone flip-flopping. `didUpdateWidget` clears both simultaneously. +- **`AnimatedContainer` children in a Row must not overflow.** The `_enteringCards` mechanism ensures entering cards start at width 0 so `sum(widths) ≤ rowWidth` at all times. The entering detection rules (Rule 1 + Rule 2 in `didUpdateWidget`) cover all cross-row move cases. +- **Do not use `Stack + AnimatedPositioned` inside the grid rows.** This causes a Flutter semantics `parentDataDirty` assertion in debug mode. The current layout uses `ClipRect + IntrinsicHeight + Row + AnimatedContainer + AnimatedSlide`. From 6440a5d154f474fdf13b9a17f0a2a755dfb47ce6 Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sun, 31 May 2026 09:44:06 +0200 Subject: [PATCH 6/9] Fixes and improvements --- lib/models/settings.dart | 7 +- lib/models/setup.dart | 4 +- lib/models/setup_form_controller.dart | 23 ++++- lib/setup_edit.dart | 7 +- test/migrations/migrator_test.dart | 83 ++++++++++++++++++ test/models/setup_form_controller_test.dart | 12 +-- test/setup_edit_test.dart | 93 ++++++++++++++++----- 7 files changed, 199 insertions(+), 30 deletions(-) diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 55b6d19..a34d405 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -49,12 +49,15 @@ class SectionSettings { final airPressure = Field(name: 'Air Pressure', unit: 'PSI'); final sag = Field(name: 'Sag', unit: '%'); final lsc = Field(name: 'Low Speed Compression', unit: 'Clicks'); + final hsc = Field(name: 'High Speed Compression', unit: 'Clicks'); final lsr = Field(name: 'Low Speed Rebound', unit: 'Clicks'); + final hsr = Field(name: 'High Speed Rebound', unit: 'Clicks'); return SectionSettings( - fields: [airPressure, sag, lsc, lsr], + fields: [airPressure, sag, lsc, hsc, lsr, hsr], layout: [ [airPressure.id, sag.id], - [lsc.id, lsr.id], + [lsc.id, hsc.id], + [lsr.id, hsr.id], ], ); } diff --git a/lib/models/setup.dart b/lib/models/setup.dart index 15122b4..6e3eb61 100644 --- a/lib/models/setup.dart +++ b/lib/models/setup.dart @@ -26,7 +26,9 @@ class Setup { name: json['name'], fork: SectionSettings.fromJson(json['fork']), shock: SectionSettings.fromJson(json['shock']), - tyres: SectionSettings.fromJson(json['tyres']), + tyres: json['tyres'] != null + ? SectionSettings.fromJson(json['tyres'] as Map) + : SectionSettings(fields: [], layout: []), history: List.from( json['history'].map((e) => SettingChanges.fromJson(e))), ); diff --git a/lib/models/setup_form_controller.dart b/lib/models/setup_form_controller.dart index d248bce..8dbb671 100644 --- a/lib/models/setup_form_controller.dart +++ b/lib/models/setup_form_controller.dart @@ -11,7 +11,14 @@ class SetupFormController { : name = TextEditingController(text: setup?.name), fork = SectionFormController(setup?.fork), shock = SectionFormController(setup?.shock), - tyres = SectionFormController(setup?.tyres); + tyres = SectionFormController(setup?.tyres) { + if (setup == null) { + final defaults = Setup.getDefault(); + fork.addFields(defaults.fork); + shock.addFields(defaults.shock); + tyres.addFields(defaults.tyres); + } + } final TextEditingController name; final SectionFormController fork; @@ -55,6 +62,8 @@ class SetupFormController { newSetup.fork.infoUrl = trimmed(fork.infoUrl); newSetup.shock.serialNumber = trimmed(shock.serialNumber); newSetup.shock.infoUrl = trimmed(shock.infoUrl); + newSetup.tyres.serialNumber = trimmed(tyres.serialNumber); + newSetup.tyres.infoUrl = trimmed(tyres.infoUrl); return (newSetup, changes); } @@ -185,6 +194,18 @@ class SectionFormController { .toList(); } + void addFields(SectionSettings section) { + for (final f in section.activeFields) { + fields.add(FieldFormController( + id: f.id, + fieldName: f.name, + unit: f.unit, + isNew: true, + )); + } + layout = section.layout.map((row) => List.from(row)).toList(); + } + void addField(String fieldName, String unit) { final ctrl = FieldFormController( id: const Uuid().v4(), diff --git a/lib/setup_edit.dart b/lib/setup_edit.dart index c577e2a..67d41c7 100644 --- a/lib/setup_edit.dart +++ b/lib/setup_edit.dart @@ -129,16 +129,19 @@ class _SetupEditState extends State { Widget _buildSectionGrid(SectionFormController section) { if (section.layout.isEmpty) return const SizedBox.shrink(); + final ctrlMap = {for (final f in section.fields) f.id: f}; return DraggableGrid( layout: section.layout, itemBuilder: (id) { - final ctrl = section.fields.firstWhere((f) => f.id == id); + final ctrl = ctrlMap[id]; + if (ctrl == null) return const SizedBox.shrink(); return FieldConfigTile(controller: ctrl); }, onLayoutChanged: (newLayout) => setState(() => section.layout = newLayout), onItemTap: (id) { - final ctrl = section.fields.firstWhere((f) => f.id == id); + final ctrl = ctrlMap[id]; + if (ctrl == null) return; _showEditFieldSheet(context, section, ctrl); }, ); diff --git a/test/migrations/migrator_test.dart b/test/migrations/migrator_test.dart index 77bd3af..59fa1ed 100644 --- a/test/migrations/migrator_test.dart +++ b/test/migrations/migrator_test.dart @@ -211,6 +211,89 @@ void main() { expect(history[0]['isCreationEntry'], true); }); + test('v3 → v4 translates settingType to fieldId in history', () { + final v3 = { + 'schemaVersion': 3, + 'setups': { + 'id-1': { + 'id': 'id-1', + 'name': 'Test', + 'fork': { + 'airPressure': {'value': 100, 'unit': 'PSI'}, + 'sag': null, + 'volumeSpacer': null, + 'lsc': null, + 'hsc': null, + 'lsr': null, + 'hsr': null, + }, + 'shock': {}, + 'tyres': { + 'front': {'value': 28, 'unit': 'PSI'}, + 'rear': null, + }, + 'history': [ + { + 'id': 'hist-1', + 'changes': [ + { + 'suspensionType': 'fork', + 'settingType': 'airPressure', + 'oldValue': 90, + 'newValue': 100, + 'oldEnabled': null, + 'newEnabled': null, + }, + { + 'suspensionType': 'tyre', + 'settingType': 'frontTyrePressure', + 'oldValue': 25, + 'newValue': 28, + 'oldEnabled': null, + 'newEnabled': null, + }, + ], + 'date': '2024-01-02T00:00:00.000Z', + 'comment': 'Pressure tweak', + }, + ], + }, + }, + }; + + final result = migrateIfNeeded(v3); + + // Extract the ids assigned to the fields during migration. + final forkFields = result['setups']['id-1']['fork']['fields'] as List; + final airPressureId = (forkFields.firstWhere( + (f) => (f as Map)['name'] == 'Air Pressure') as Map)['id'] + as String; + + final tyreFields = result['setups']['id-1']['tyres']['fields'] as List; + final frontTyreId = (tyreFields.firstWhere( + (f) => (f as Map)['name'] == 'Front Tyre Pressure') as Map)['id'] + as String; + + final changes = + result['setups']['id-1']['history'][0]['changes'] as List; + + final forkChange = + changes.firstWhere((c) => (c as Map)['suspensionType'] == 'fork') + as Map; + expect(forkChange.containsKey('settingType'), isFalse); + expect(forkChange['fieldId'], airPressureId); + expect(forkChange['oldValue'], 90); + expect(forkChange['newValue'], 100); + + final tyreChange = + changes.firstWhere((c) => (c as Map)['suspensionType'] == 'tyre') + as Map; + expect(tyreChange.containsKey('settingType'), isFalse); + expect(tyreChange['fieldId'], frontTyreId); + expect(tyreChange['oldValue'], 25); + expect(tyreChange['newValue'], 28); + }); + test('returns future schema version data unchanged', () { final futureData = { 'schemaVersion': 99, diff --git a/test/models/setup_form_controller_test.dart b/test/models/setup_form_controller_test.dart index 5f00795..5584615 100644 --- a/test/models/setup_form_controller_test.dart +++ b/test/models/setup_form_controller_test.dart @@ -24,17 +24,19 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('hasNewlyAddedFields', () { - test('null setup — no fields added → false', () { + test('null setup — default fields pre-populated as new → true', () { final ctrl = SetupFormController(null); addTearDown(ctrl.dispose); - expect(ctrl.hasNewlyAddedFields(), isFalse); + expect(ctrl.hasNewlyAddedFields(), isTrue); }); - test('null setup — field added → true', () { + test('null setup — default fields present in fork, shock, tyres', () { final ctrl = SetupFormController(null); addTearDown(ctrl.dispose); - ctrl.fork.addField('Air Pressure', 'PSI'); - expect(ctrl.hasNewlyAddedFields(), isTrue); + expect(ctrl.fork.fields, isNotEmpty); + expect(ctrl.shock.fields, isNotEmpty); + expect(ctrl.tyres.fields, isNotEmpty); + expect(ctrl.fork.fields.every((f) => f.isNew), isTrue); }); test('existing setup — no new fields → false', () { diff --git a/test/setup_edit_test.dart b/test/setup_edit_test.dart index 94c9a6f..22888d8 100644 --- a/test/setup_edit_test.dart +++ b/test/setup_edit_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:suspension_setup/models/field.dart'; +import 'package:suspension_setup/models/setting_change.dart'; import 'package:suspension_setup/models/settings.dart'; import 'package:suspension_setup/models/setup.dart'; import 'package:suspension_setup/setting_tiles.dart'; @@ -122,7 +123,7 @@ void main() { expect(find.byTooltip('Save setup'), findsOneWidget); }); - testWidgets('FAB switches to Edit values after adding a field', + testWidgets('new setup shows Edit values tooltip immediately', (tester) async { final model = _FakeStorageModel(); @@ -130,24 +131,7 @@ void main() { await tester.tap(find.text('open')); await tester.pumpAndSettle(); - // Tap the first "Add field" button (Fork section) - await tester.tap(find.text('Add field').first); - await tester.pumpAndSettle(); - - // Fill in the dialog - await tester.enterText( - find - .descendant( - of: find.byType(AlertDialog), - matching: find.byType(TextField), - ) - .first, - 'Air Pressure', - ); - await tester.pump(); - await tester.tap(find.text('Add')); - await tester.pumpAndSettle(); - + // Default fields are pre-populated as new, so FAB starts as "Edit values". expect(find.byTooltip('Edit values'), findsOneWidget); }); }); @@ -210,6 +194,77 @@ void main() { }); }); + group('SetupEdit → ValueEdit save (new field)', () { + testWidgets('adding a field and saving persists the new field with value', + (tester) async { + final model = _FakeStorageModel(); + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 73); + final setup = _makeSetup(forkFields: [airField]); + + await tester.pumpWidget(_setupEditHarness(model, setup)); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + // Add a new field to the fork section. + await tester.tap(find.text('Add field').first); + await tester.pumpAndSettle(); + await tester.enterText( + find + .descendant( + of: find.byType(AlertDialog), + matching: find.byType(TextField), + ) + .first, + 'Sag', + ); + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + // Navigate to ValueEdit. + await tester.tap(find.byTooltip('Edit values')); + await tester.pumpAndSettle(); + + // Enter a value for the new Sag field (second FieldValueCard). + await tester.enterText( + find.descendant( + of: find.byType(FieldValueCard).at(1), + matching: find.byType(TextFormField), + ), + '25', + ); + await tester.pump(); + + // Tap Save FAB → comment dialog appears. + await tester.tap(find.byTooltip('Save values')); + await tester.pumpAndSettle(); + + // Dismiss the comment dialog. + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(model.lastUpserted, isNotNull); + final fork = model.lastUpserted!.fork; + expect( + fork.activeFields.firstWhere((f) => f.name == 'Air Pressure').value, + 73, + ); + expect( + fork.activeFields.firstWhere((f) => f.name == 'Sag').value, + 25, + ); + // The new field must appear in the history as an enabled change. + final lastChanges = model.lastUpserted!.history.last.changes; + expect( + lastChanges.any((c) => + c.suspensionType == SuspensionType.fork && + c.newEnabled == true && + c.newValue == 25), + isTrue, + ); + expect(find.text('Setup saved successfully'), findsOneWidget); + }); + }); + group('SetupEdit direct save', () { testWidgets('saving with no new fields calls upsert directly', (tester) async { From be288563ce4f2ef4dbdcf8b031c76c8c7ae58bda Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sun, 31 May 2026 09:50:34 +0200 Subject: [PATCH 7/9] Add tests and format --- lib/draggable_grid.dart | 18 +++--- lib/setting_tiles.dart | 4 +- test/migrations/migrator_test.dart | 25 ++++---- test/models/setup_form_controller_test.dart | 14 +++++ test/models/setup_test.dart | 62 +++++++++++++++++++ test/setup_detail_test.dart | 68 +++++++++++++++++++++ 6 files changed, 165 insertions(+), 26 deletions(-) diff --git a/lib/draggable_grid.dart b/lib/draggable_grid.dart index 395547f..d110448 100644 --- a/lib/draggable_grid.dart +++ b/lib/draggable_grid.dart @@ -210,7 +210,8 @@ class _DraggableGridState extends State { final _DropTarget target; if (feedbackCenterY < cardTopLeft.dy - betweenRowsBuffer) { target = _NewRowTarget(position: r); - } else if (feedbackCenterY > cardTopLeft.dy + rb.size.height + betweenRowsBuffer) { + } else if (feedbackCenterY > + cardTopLeft.dy + rb.size.height + betweenRowsBuffer) { target = _NewRowTarget(position: r + 1); } else { final pointerX = details.offset.dx + touchOffset.dx; @@ -221,16 +222,14 @@ class _DraggableGridState extends State { // Using it gives a stable rowLeft even while entering cards animate. final firstRb = c == 0 ? rb - : (_cardKey(widget.layout[r][0]) - .currentContext - ?.findRenderObject() as RenderBox?) ?? + : (_cardKey(widget.layout[r][0]).currentContext?.findRenderObject() + as RenderBox?) ?? rb; final rowLeft = firstRb.localToGlobal(Offset.zero).dx; final rowWidth = nominalCardWidth * rowLen; - final position = - ((pointerX - rowLeft) * (rowLen + 1) / rowWidth) - .floor() - .clamp(0, rowLen); + final position = ((pointerX - rowLeft) * (rowLen + 1) / rowWidth) + .floor() + .clamp(0, rowLen); target = _InRowTarget(rowIndex: r, position: position); } @@ -311,8 +310,7 @@ class _DraggableGridState extends State { for (int c = 0; c < rowLen; c++) AnimatedContainer( key: ValueKey(widget.layout[r][c]), - width: _enteringCards - .contains(widget.layout[r][c]) + width: _enteringCards.contains(widget.layout[r][c]) ? 0.0 : cardWidth, duration: const Duration(milliseconds: 200), diff --git a/lib/setting_tiles.dart b/lib/setting_tiles.dart index 8e064fb..397e918 100644 --- a/lib/setting_tiles.dart +++ b/lib/setting_tiles.dart @@ -124,8 +124,8 @@ class FieldConfigTile extends StatelessWidget { child: Icon( Icons.drag_indicator, size: 14, - color: theme.colorScheme.onPrimaryContainer - .withValues(alpha: 0.4), + color: + theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.4), ), ), ], diff --git a/test/migrations/migrator_test.dart b/test/migrations/migrator_test.dart index 59fa1ed..392f689 100644 --- a/test/migrations/migrator_test.dart +++ b/test/migrations/migrator_test.dart @@ -265,29 +265,26 @@ void main() { // Extract the ids assigned to the fields during migration. final forkFields = result['setups']['id-1']['fork']['fields'] as List; - final airPressureId = (forkFields.firstWhere( - (f) => (f as Map)['name'] == 'Air Pressure') as Map)['id'] - as String; + final airPressureId = + (forkFields.firstWhere((f) => (f as Map)['name'] == 'Air Pressure') + as Map)['id'] as String; final tyreFields = result['setups']['id-1']['tyres']['fields'] as List; - final frontTyreId = (tyreFields.firstWhere( - (f) => (f as Map)['name'] == 'Front Tyre Pressure') as Map)['id'] - as String; + final frontTyreId = (tyreFields + .firstWhere((f) => (f as Map)['name'] == 'Front Tyre Pressure') + as Map)['id'] as String; - final changes = - result['setups']['id-1']['history'][0]['changes'] as List; + final changes = result['setups']['id-1']['history'][0]['changes'] as List; - final forkChange = - changes.firstWhere((c) => (c as Map)['suspensionType'] == 'fork') - as Map; + final forkChange = changes + .firstWhere((c) => (c as Map)['suspensionType'] == 'fork') as Map; expect(forkChange.containsKey('settingType'), isFalse); expect(forkChange['fieldId'], airPressureId); expect(forkChange['oldValue'], 90); expect(forkChange['newValue'], 100); - final tyreChange = - changes.firstWhere((c) => (c as Map)['suspensionType'] == 'tyre') - as Map; + final tyreChange = changes + .firstWhere((c) => (c as Map)['suspensionType'] == 'tyre') as Map; expect(tyreChange.containsKey('settingType'), isFalse); expect(tyreChange['fieldId'], frontTyreId); expect(tyreChange['oldValue'], 25); diff --git a/test/models/setup_form_controller_test.dart b/test/models/setup_form_controller_test.dart index 5584615..bd6b4e4 100644 --- a/test/models/setup_form_controller_test.dart +++ b/test/models/setup_form_controller_test.dart @@ -55,6 +55,20 @@ void main() { }); }); + group('SectionFormController.addFields', () { + test('copies fields as new and preserves layout grouping', () { + final ctrl = SectionFormController(null); + final section = SectionSettings.getDefaultForSuspension(); + ctrl.addFields(section); + + expect(ctrl.fields.length, section.activeFields.length); + expect(ctrl.fields.every((f) => f.isNew), isTrue); + expect(ctrl.layout, section.layout); + // Layout uses multi-field rows, not one row per field. + expect(ctrl.layout.length, lessThan(ctrl.fields.length)); + }); + }); + group('SectionFormController.addField / removeField', () { test('addField adds to fields list and layout', () { final ctrl = SectionFormController(null); diff --git a/test/models/setup_test.dart b/test/models/setup_test.dart index cedc484..0c557f1 100644 --- a/test/models/setup_test.dart +++ b/test/models/setup_test.dart @@ -232,6 +232,30 @@ void main() { }); group('Setup', () { + test('fromJson with null tyres falls back to empty section', () { + final json = { + 'id': 'test-id', + 'name': 'Trail Setup', + 'fork': { + 'fields': [], + 'layout': [], + 'serialNumber': null, + 'infoUrl': null + }, + 'shock': { + 'fields': [], + 'layout': [], + 'serialNumber': null, + 'infoUrl': null + }, + 'tyres': null, + 'history': [], + }; + final setup = Setup.fromJson(json); + expect(setup.tyres.fields, isEmpty); + expect(setup.tyres.layout, isEmpty); + }); + test('roundtrips through JSON preserving all fields', () { final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 110); final sagField = Field(name: 'Sag', unit: '%', value: 25); @@ -593,6 +617,19 @@ void main() { expect(makeSetup.fork.activeFields.toList(), isEmpty); }); + test('removes deleted field from layout', () { + makeSetup.applyChanges([ + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: 100, + newValue: null, + oldEnabled: true, + newEnabled: false), + ]); + expect(makeSetup.fork.layout, isEmpty); + }); + test('undeletes field when newEnabled is true', () { final deletedField = Field(id: airId, name: 'Air Pressure', unit: 'PSI', deleted: true); @@ -616,6 +653,31 @@ void main() { expect(setup.fork.fieldById(airId)?.deleted, isFalse); expect(setup.fork.fieldById(airId)?.value, 100); }); + + test('adds re-enabled field back to layout', () { + final deletedField = + Field(id: airId, name: 'Air Pressure', unit: 'PSI', deleted: true); + final setup = Setup( + id: 'test', + name: 'Test', + fork: SectionSettings(fields: [deletedField], layout: []), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), + history: [], + ); + setup.applyChanges([ + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airId, + oldValue: null, + newValue: 100, + oldEnabled: false, + newEnabled: true), + ]); + expect(setup.fork.layout, [ + [airId] + ]); + }); }); group('computeUndo + applyChanges round-trip', () { diff --git a/test/setup_detail_test.dart b/test/setup_detail_test.dart index 53bd333..d61914f 100644 --- a/test/setup_detail_test.dart +++ b/test/setup_detail_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; import 'package:suspension_setup/models/field.dart'; +import 'package:suspension_setup/models/setting_change.dart'; import 'package:suspension_setup/models/settings.dart'; import 'package:suspension_setup/models/setup.dart'; import 'package:suspension_setup/setup_detail.dart'; @@ -45,6 +46,73 @@ Setup _makeSetup({String? serialNumber, String? infoUrl}) { } void main() { + group('History', () { + testWidgets('shows value change with field name and unit', (tester) async { + final airField = Field(name: 'Air Pressure', unit: 'PSI', value: 110); + final setup = Setup( + id: 'test-id', + name: 'Trail Setup', + fork: SectionSettings(fields: [ + airField + ], layout: [ + [airField.id] + ]), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), + history: [ + SettingChanges( + changes: [ + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: airField.id, + oldValue: 100, + newValue: 110, + ), + ], + date: DateTime.now(), + ), + ], + ); + + await tester.pumpWidget(_harness(setup)); + await tester.pump(); + + expect(find.text('Air Pressure: 100 → 110 PSI'), findsOneWidget); + }); + + testWidgets('shows correct name and unit for deleted field in history', + (tester) async { + final sagField = Field(name: 'Sag', unit: '%', deleted: true); + final setup = Setup( + id: 'test-id', + name: 'Trail Setup', + fork: SectionSettings(fields: [sagField], layout: []), + shock: SectionSettings(fields: [], layout: []), + tyres: SectionSettings(fields: [], layout: []), + history: [ + SettingChanges( + changes: [ + SettingChange( + suspensionType: SuspensionType.fork, + fieldId: sagField.id, + oldValue: 25, + newValue: null, + oldEnabled: true, + newEnabled: false, + ), + ], + date: DateTime.now(), + ), + ], + ); + + await tester.pumpWidget(_harness(setup)); + await tester.pump(); + + expect(find.text('Sag: disabled (was 25 %)'), findsOneWidget); + }); + }); + group('_ComponentInfo', () { testWidgets('renders nothing when serialNumber and infoUrl are both null', (tester) async { From 1eddd72b9feca9f4adcb451753332cee526e0d0a Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sun, 31 May 2026 10:09:02 +0200 Subject: [PATCH 8/9] Add help hint --- lib/setup_edit.dart | 59 ++++++++++++++++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 56 ++++++++++++++++++ pubspec.yaml | 1 + 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/lib/setup_edit.dart b/lib/setup_edit.dart index 67d41c7..579eab0 100644 --- a/lib/setup_edit.dart +++ b/lib/setup_edit.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'draggable_grid.dart'; import 'models/setup_form_controller.dart'; @@ -23,15 +24,30 @@ class SetupEdit extends StatefulWidget { State createState() => _SetupEditState(); } +const _kEditGridHintSeen = 'edit_grid_hint_seen'; + class _SetupEditState extends State { final _formKey = GlobalKey(); late final SetupFormController _controller; final TextEditingController _commentController = TextEditingController(); + bool _showGridHint = false; @override void initState() { super.initState(); _controller = SetupFormController(widget.setup); + _initGridHint(); + } + + Future _initGridHint() async { + final prefs = await SharedPreferences.getInstance(); + if (prefs.getBool(_kEditGridHintSeen) ?? false) return; + final hasAnyField = _controller.fork.layout.isNotEmpty || + _controller.shock.layout.isNotEmpty || + _controller.tyres.layout.isNotEmpty; + if (!hasAnyField) return; + await prefs.setBool(_kEditGridHintSeen, true); + if (mounted) setState(() => _showGridHint = true); } @override @@ -150,7 +166,9 @@ class _SetupEditState extends State { Widget _buildSection( SectionFormController section, { bool showComponentInfo = false, + bool showGridHint = false, }) { + final theme = Theme.of(context); return Column( children: [ if (showComponentInfo) @@ -158,6 +176,30 @@ class _SetupEditState extends State { serialNumberController: section.serialNumber, infoUrlController: section.infoUrl, ), + if (showGridHint && section.layout.isNotEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.touch_app, + size: 14, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text('Tap to edit', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + const SizedBox(width: 16), + Icon(Icons.drag_indicator, + size: 14, + color: theme.colorScheme.onSurfaceVariant), + const SizedBox(width: 4), + Text('Hold to reorder', + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + ], + ), + ), _buildSectionGrid(section), Padding( padding: const EdgeInsets.symmetric(vertical: 4), @@ -201,12 +243,23 @@ class _SetupEditState extends State { }, ), const TitleWithIcon(title: 'Fork', icon: SuspensionIcons.fork), - _buildSection(_controller.fork, showComponentInfo: true), + _buildSection(_controller.fork, + showComponentInfo: true, + showGridHint: _showGridHint && + _controller.fork.layout.isNotEmpty), const TitleWithIcon( title: 'Shock', icon: SuspensionIcons.shock), - _buildSection(_controller.shock, showComponentInfo: true), + _buildSection(_controller.shock, + showComponentInfo: true, + showGridHint: _showGridHint && + _controller.fork.layout.isEmpty && + _controller.shock.layout.isNotEmpty), const TitleWithIcon(title: 'Tyres', icon: SuspensionIcons.tyre), - _buildSection(_controller.tyres), + _buildSection(_controller.tyres, + showGridHint: _showGridHint && + _controller.fork.layout.isEmpty && + _controller.shock.layout.isEmpty && + _controller.tyres.layout.isNotEmpty), ], ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 2a76e0b..6411980 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,10 +7,12 @@ import Foundation import file_picker import share_plus +import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f399f9c..4266d3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -615,6 +615,62 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 94c9824..cd25afe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: share_plus: ^10.0.0 permission_handler: ^12.0.1 url_launcher: ^6.3.0 + shared_preferences: ^2.3.0 dev_dependencies: flutter_native_splash: ^2.4.0 From 7fc04d2f94bbbe01d36d12c29948c11d82e2991b Mon Sep 17 00:00:00 2001 From: pec0ra Date: Sun, 31 May 2026 10:19:21 +0200 Subject: [PATCH 9/9] Fix overflow on window resizing --- CLAUDE.md | 1 + lib/draggable_grid.dart | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index ec9ff64..aad2aff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,4 +62,5 @@ This file has subtle interactions. After any change, test on a real device or em - **`rb.localToGlobal` includes `AnimatedSlide` transform.** When `AnimatedSlide` has a non-zero offset, `localToGlobal` reports the visual position, not the layout position. This corrupts zone calculations. - **`_cardSlideOffsets` must be cleared when `_enteringCards` is non-empty.** Active slide transforms + entering width animation cause zone flip-flopping. `didUpdateWidget` clears both simultaneously. - **`AnimatedContainer` children in a Row must not overflow.** The `_enteringCards` mechanism ensures entering cards start at width 0 so `sum(widths) ≤ rowWidth` at all times. The entering detection rules (Rule 1 + Rule 2 in `didUpdateWidget`) cover all cross-row move cases. +- **Window resize must not animate card widths.** When `_rowWidth` changes, the Row immediately adopts the new width but `AnimatedContainer` would animate from the old `cardWidth`, causing the combined child width to exceed the Row's new width. `_rowWidthSnapping` detects a `_rowWidth` change and forces `duration: Duration.zero` for that one frame, then resets. - **Do not use `Stack + AnimatedPositioned` inside the grid rows.** This causes a Flutter semantics `parentDataDirty` assertion in debug mode. The current layout uses `ClipRect + IntrinsicHeight + Row + AnimatedContainer + AnimatedSlide`. diff --git a/lib/draggable_grid.dart b/lib/draggable_grid.dart index d110448..e9e3f0d 100644 --- a/lib/draggable_grid.dart +++ b/lib/draggable_grid.dart @@ -31,6 +31,10 @@ class _DraggableGridState extends State { final _cardCols = {}; final _cardSlideOffsets = {}; double _rowWidth = 0; + // True for exactly one frame after _rowWidth changes; suppresses the + // AnimatedContainer width animation so cards snap to the new size and + // never exceed the row's immediately-updated width (which would overflow). + bool _rowWidthSnapping = false; GlobalKey _cardKey(String id) => _cardKeys.putIfAbsent(id, () => GlobalKey()); @override @@ -297,7 +301,14 @@ class _DraggableGridState extends State { for (int r = 0; r < widget.layout.length; r++) LayoutBuilder( builder: (context, constraints) { - _rowWidth = constraints.maxWidth; + final newRowWidth = constraints.maxWidth; + if (newRowWidth != _rowWidth && !_rowWidthSnapping) { + _rowWidthSnapping = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _rowWidthSnapping = false); + }); + } + _rowWidth = newRowWidth; final rowLen = widget.layout[r].length; final cardWidth = _rowWidth / rowLen; // ClipRect clips the visual overflow from AnimatedSlide during @@ -313,7 +324,9 @@ class _DraggableGridState extends State { width: _enteringCards.contains(widget.layout[r][c]) ? 0.0 : cardWidth, - duration: const Duration(milliseconds: 200), + duration: _rowWidthSnapping + ? Duration.zero + : const Duration(milliseconds: 200), curve: Curves.easeInOut, child: _buildCardContent(r, c), ),