Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions integration_test/screenshot_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,8 @@ Future<void> _screenshot(
await binding.takeScreenshot(name);
}

// demo.json data migrated to schema v2
const _fixture = {
'schemaVersion': 2,
'schemaVersion': 3,
'setups': {
'a5f0c760-7346-11ef-89aa-5137c552c0bc': {
'id': 'a5f0c760-7346-11ef-89aa-5137c552c0bc',
Expand All @@ -109,9 +108,11 @@ const _fixture = {
'tyres': {'front': null, 'rear': null},
'history': [
{
'id': 'a5f0c760-7346-11ef-89aa-000000000001',
'changes': [],
'date': '2024-09-15T11:41:14.071433',
'comment': 'Setup creation',
'isCreationEntry': true,
},
],
},
Expand Down Expand Up @@ -141,11 +142,14 @@ const _fixture = {
'tyres': {'front': null, 'rear': null},
'history': [
{
'id': 'f65fdaf0-735b-11ef-8bca-000000000001',
'changes': [],
'date': '2024-09-15T14:13:48.447488',
'comment': 'Setup creation',
'isCreationEntry': true,
},
{
'id': 'f65fdaf0-735b-11ef-8bca-000000000002',
'changes': [
{
'suspensionType': 'shock',
Expand All @@ -168,6 +172,7 @@ const _fixture = {
'comment': 'Less pressure, more compression',
},
{
'id': 'f65fdaf0-735b-11ef-8bca-000000000003',
'changes': [
{
'suspensionType': 'fork',
Expand Down
69 changes: 42 additions & 27 deletions lib/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:provider/provider.dart';

import 'error_screen.dart';
import 'models/setup.dart';
import 'setup_actions.dart';
import 'setup_detail.dart';
import 'setup_edit.dart';
import 'setup_storage_model.dart';
Expand Down Expand Up @@ -137,8 +138,7 @@ class _HomePageState extends State<HomePage> {
)),
);
},
// TODO: add context menu in addition to dialog
onLongPress: () => deleteSetup(context, setup, setupModel),
onLongPress: () => _showSetupSheet(context, setup, setupModel),
),
),
],
Expand All @@ -155,38 +155,53 @@ class _HomePageState extends State<HomePage> {
);
}

void _showSetupSheet(BuildContext context, Setup setup, SetupStorageModel setupModel) {
final theme = Theme.of(context);
showModalBottomSheet(
context: context,
useSafeArea: true,
builder: (sheetContext) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(setup.name, style: theme.textTheme.titleMedium),
subtitle: setup.history.isNotEmpty
? Text(DateFormat.yMMMd().format(setup.history.last.date))
: null,
),
const Divider(height: 0),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('Clone'),
onTap: () {
Navigator.pop(sheetContext);
showCloneSetupDialog(context, setup,
onConfirm: (copyHistory) =>
navigateToClone(context, setup, copyHistory));
},
),
ListTile(
leading: Icon(Icons.delete, color: theme.colorScheme.error),
title: Text('Delete', style: TextStyle(color: theme.colorScheme.error)),
onTap: () {
Navigator.pop(sheetContext);
showDeleteSetupDialog(context, setup, setupModel);
},
),
],
);
},
);
}

void createSetup() {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SetupEdit()),
);
}

Future<String?> deleteSetup(
BuildContext context, Setup setup, SetupStorageModel setupModel) {
return showDialog<String>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text('Delete Setup ${setup.name} ?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, 'Cancel'),
child: const Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
onPressed: () async {
await setupModel.deleteSetup(setup);
if (!context.mounted) return;
Navigator.pop(context, 'OK');
},
child: const Text('Delete'),
),
],
),
);
}

Future<void> _backup(BuildContext context) async {
final success = await Provider.of<SetupStorageModel>(context, listen: false)
.backup();
Expand Down
9 changes: 6 additions & 3 deletions lib/migrations/migrator.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'v1_to_v2.dart';
import 'v2_to_v3.dart';

const int currentSchemaVersion = 2;
const int currentSchemaVersion = 3;

Map<String, dynamic> migrateIfNeeded(Map<String, dynamic> json) {
final version = json['schemaVersion'] as int? ?? 1;
Expand All @@ -9,6 +10,8 @@ Map<String, dynamic> migrateIfNeeded(Map<String, dynamic> json) {
final setupsOnly = Map<String, dynamic>.from(data)..remove('schemaVersion');
data = migrateV1ToV2(setupsOnly);
}
// Future: if (version < 3) data = migrateV2ToV3(data['setups']);
if (version < 3) {
data = migrateV2ToV3(data);
}
return data;
}
}
27 changes: 27 additions & 0 deletions lib/migrations/v2_to_v3.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import 'package:uuid/uuid.dart';

/// v2 structure: { schemaVersion: 2, setups: { setupId: { ..., history: [...] } } }
/// v3 structure: { schemaVersion: 3, setups: { setupId: { ..., history: [{ id: '...', isCreationEntry: true, ... }, ...] } } }
Map<String, dynamic> migrateV2ToV3(Map<String, dynamic> v2Data) {
final setups = Map<String, dynamic>.from(v2Data['setups'] as Map);
final migratedSetups = setups.map(
(id, value) => MapEntry(id, _migrateSetup(value as Map<String, dynamic>)),
);
return {'schemaVersion': 3, 'setups': migratedSetups};
}

Map<String, dynamic> _migrateSetup(Map<String, dynamic> setup) {
final history = setup['history'] as List<dynamic>;
if (history.isEmpty) return Map<String, dynamic>.from(setup);
final migratedHistory = [
{
...history[0] as Map<String, dynamic>,
'id': const Uuid().v1(),
'isCreationEntry': true,
},
...history.skip(1).map(
(e) => {...e as Map<String, dynamic>, 'id': const Uuid().v1()},
),
];
return {...setup, 'history': migratedHistory};
}
16 changes: 13 additions & 3 deletions lib/models/setting_change.dart
Original file line number Diff line number Diff line change
@@ -1,38 +1,48 @@
import 'package:uuid/uuid.dart';

class SettingChanges {
final String id;
final List<SettingChange> changes;
final DateTime date;
String? comment;

static const String defaultComment = 'Setup creation';
final bool isCreationEntry;

SettingChanges({
String? id,
required this.changes,
required this.date,
this.comment,
});
this.isCreationEntry = false,
}) : id = id ?? const Uuid().v1();

factory SettingChanges.fromJson(Map<String, dynamic> json) {
return SettingChanges(
id: json['id'] as String,
changes: List<SettingChange>.from(
json['changes'].map((c) => SettingChange.fromJson(c))),
date: DateTime.parse((json['date'])),
comment: (json['comment']),
isCreationEntry: json['isCreationEntry'] as bool? ?? false,
);
}

Map<String, dynamic> toJson() {
return {
'id': id,
'changes': changes.map((c) => c.toJson()).toList(),
'date': date.toIso8601String(),
'comment': comment,
'isCreationEntry': isCreationEntry,
};
}

SettingChanges clone() {
return SettingChanges(
id: id,
changes: changes.map((e) => e.clone()).toList(),
date: date,
comment: comment,
isCreationEntry: isCreationEntry,
);
}
}
Expand Down
22 changes: 22 additions & 0 deletions lib/models/settings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,28 @@ class Settings {
SettingType.frontTyrePressure || SettingType.rearTyrePressure => null,
};

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;
}
}

Settings clone() {
return Settings(
airPressure: airPressure,
Expand Down
80 changes: 80 additions & 0 deletions lib/models/setup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ 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';

class Setup {
Expand Down Expand Up @@ -55,6 +56,85 @@ class Setup {
);
}

Setup copyMutable() => Setup.fromJson(toJson());

List<SettingChange> computeUndo(SettingChanges historyEntry) {
assert(!historyEntry.isCreationEntry, 'cannot undo a creation entry');
final result = <SettingChange>[];
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 bool targetEnabled = change.oldEnabled ?? true;

final bool enabledChanges = currentEnabled != targetEnabled;
final bool valueChanges =
!enabledChanges && currentEnabled && currentValue != change.oldValue;

if (!enabledChanges && !valueChanges) continue;

result.add(SettingChange(
suspensionType: change.suspensionType,
settingType: change.settingType,
oldValue: currentValue,
newValue: targetEnabled ? change.oldValue : null,
oldEnabled: enabledChanges ? currentEnabled : null,
newEnabled: enabledChanges ? targetEnabled : null,
));
}
return result;
}

void applyChanges(List<SettingChange> changes) {
for (final change in changes) {
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;
}
} 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);
}
}
}
}

Setup clone(bool includeHistory) {
return Setup(
id: const Uuid().v1(),
Expand Down
Loading
Loading