Nikolas Rimikis
1 year ago
committed by
GitHub
30 changed files with 655 additions and 394 deletions
@ -1,51 +1,67 @@ |
|||||||
import 'dart:async'; |
import 'dart:async'; |
||||||
|
|
||||||
|
import 'package:collection/collection.dart'; |
||||||
import 'package:neon/src/settings/models/option.dart'; |
import 'package:neon/src/settings/models/option.dart'; |
||||||
import 'package:neon/src/settings/widgets/label_builder.dart'; |
import 'package:neon/src/settings/widgets/label_builder.dart'; |
||||||
import 'package:rxdart/rxdart.dart'; |
|
||||||
|
|
||||||
class SelectOption<T> extends Option<T> { |
class SelectOption<T> extends Option<T> { |
||||||
|
/// Creates a SelectOption |
||||||
SelectOption({ |
SelectOption({ |
||||||
required super.storage, |
required super.storage, |
||||||
required super.key, |
required super.key, |
||||||
required super.label, |
required super.label, |
||||||
required super.defaultValue, |
required super.defaultValue, |
||||||
required this.values, |
required final Map<T, LabelBuilder> values, |
||||||
super.category, |
super.category, |
||||||
super.enabled, |
super.enabled, |
||||||
}) : super(stream: BehaviorSubject()) { |
}) : _values = values, |
||||||
unawaited( |
super(initialValue: _fromString(values, storage.getString(key))); |
||||||
values.first.then((final vs) async { |
|
||||||
final valueStr = storage.getString(key); |
|
||||||
T? initialValue; |
|
||||||
|
|
||||||
if (valueStr != null) { |
|
||||||
initialValue = _fromString(vs, valueStr); |
|
||||||
} |
|
||||||
stream.add(initialValue ?? defaultValue); |
|
||||||
}), |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
T? _fromString(final Map<T, LabelBuilder> vs, final String? valueStr) { |
/// Creates a SelectOption depending on the State of another [Option]. |
||||||
final v = vs.keys.where((final e) => e.toString() == valueStr); |
SelectOption.depend({ |
||||||
if (v.length == 1) { |
required super.storage, |
||||||
return v.first; |
required super.key, |
||||||
} |
required super.label, |
||||||
|
required super.defaultValue, |
||||||
|
required final Map<T, LabelBuilder> values, |
||||||
|
required super.enabled, |
||||||
|
super.category, |
||||||
|
}) : _values = values, |
||||||
|
super.depend(initialValue: _fromString(values, storage.getString(key))); |
||||||
|
|
||||||
|
static T? _fromString<T>(final Map<T, LabelBuilder> vs, final String? valueStr) { |
||||||
|
if (valueStr == null) { |
||||||
return null; |
return null; |
||||||
} |
} |
||||||
|
|
||||||
final BehaviorSubject<Map<T, LabelBuilder>> values; |
return vs.keys.firstWhereOrNull((final e) => e.toString() == valueStr); |
||||||
|
} |
||||||
|
|
||||||
|
Map<T, LabelBuilder> _values; |
||||||
|
|
||||||
@override |
@override |
||||||
Future set(final T value) { |
set value(final T value) { |
||||||
stream.add(value); |
super.value = value; |
||||||
return storage.setString(key, value.toString()); |
unawaited(storage.setString(key, serialize())); |
||||||
|
} |
||||||
|
|
||||||
|
/// A collection of different values this can have. |
||||||
|
/// |
||||||
|
/// See: |
||||||
|
/// * [value] for the currently selected one |
||||||
|
Map<T, LabelBuilder> get values => _values; |
||||||
|
|
||||||
|
set values(final Map<T, LabelBuilder> newValues) { |
||||||
|
if (_values == newValues) { |
||||||
|
return; |
||||||
|
} |
||||||
|
_values = newValues; |
||||||
|
notifyListeners(); |
||||||
} |
} |
||||||
|
|
||||||
@override |
@override |
||||||
String? serialize() => value?.toString(); |
String serialize() => value.toString(); |
||||||
|
|
||||||
@override |
@override |
||||||
Future<T?> deserialize(final dynamic data) async => _fromString(await values.first, data as String?); |
T deserialize(final Object data) => _fromString(_values, data as String)!; |
||||||
} |
} |
||||||
|
@ -1,25 +1,39 @@ |
|||||||
|
import 'dart:async'; |
||||||
|
|
||||||
import 'package:neon/src/settings/models/option.dart'; |
import 'package:neon/src/settings/models/option.dart'; |
||||||
import 'package:rxdart/rxdart.dart'; |
|
||||||
|
|
||||||
class ToggleOption extends Option<bool> { |
class ToggleOption extends Option<bool> { |
||||||
|
/// Creates a ToggleOption |
||||||
ToggleOption({ |
ToggleOption({ |
||||||
required super.storage, |
required super.storage, |
||||||
required super.key, |
required super.key, |
||||||
required super.label, |
required super.label, |
||||||
required super.defaultValue, |
required final bool defaultValue, |
||||||
super.category, |
super.category, |
||||||
super.enabled, |
super.enabled, |
||||||
}) : super(stream: BehaviorSubject.seeded(storage.getBool(key) ?? defaultValue)); |
}) : super(defaultValue: storage.getBool(key) ?? defaultValue); |
||||||
|
|
||||||
|
/// Creates a ToggleOption depending on the State of another [Option]. |
||||||
|
ToggleOption.depend({ |
||||||
|
required super.storage, |
||||||
|
required super.key, |
||||||
|
required super.label, |
||||||
|
required final bool defaultValue, |
||||||
|
required super.enabled, |
||||||
|
super.category, |
||||||
|
}) : super.depend( |
||||||
|
defaultValue: storage.getBool(key) ?? defaultValue, |
||||||
|
); |
||||||
|
|
||||||
@override |
@override |
||||||
Future set(final bool value) { |
set value(final bool value) { |
||||||
stream.add(value); |
super.value = value; |
||||||
return storage.setBool(key, value); |
unawaited(storage.setBool(key, serialize())); |
||||||
} |
} |
||||||
|
|
||||||
@override |
@override |
||||||
bool serialize() => value; |
bool serialize() => value; |
||||||
|
|
||||||
@override |
@override |
||||||
Future<bool?> deserialize(final dynamic data) async => data as bool; |
bool deserialize(final Object data) => data as bool; |
||||||
} |
} |
||||||
|
@ -1,24 +0,0 @@ |
|||||||
import 'package:flutter/widgets.dart'; |
|
||||||
import 'package:neon/src/settings/models/option.dart'; |
|
||||||
|
|
||||||
typedef OptionBuilderFunction<T> = Widget Function(BuildContext context, T snapshot); |
|
||||||
|
|
||||||
class OptionBuilder<T> extends StreamBuilderBase<T, T> { |
|
||||||
OptionBuilder({ |
|
||||||
required this.option, |
|
||||||
required this.builder, |
|
||||||
super.key, |
|
||||||
}) : super(stream: option.stream); |
|
||||||
|
|
||||||
final Option<T> option; |
|
||||||
final OptionBuilderFunction<T> builder; |
|
||||||
|
|
||||||
@override |
|
||||||
T afterData(final T current, final T data) => data; |
|
||||||
|
|
||||||
@override |
|
||||||
T initial() => option.defaultValue; |
|
||||||
|
|
||||||
@override |
|
||||||
Widget build(final BuildContext context, final T currentSummary) => builder(context, currentSummary); |
|
||||||
} |
|
@ -0,0 +1,251 @@ |
|||||||
|
// ignore_for_file: discarded_futures |
||||||
|
|
||||||
|
import 'dart:async'; |
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart'; |
||||||
|
import 'package:mocktail/mocktail.dart'; |
||||||
|
import 'package:neon/src/settings/models/select_option.dart'; |
||||||
|
import 'package:neon/src/settings/models/storage.dart'; |
||||||
|
import 'package:neon/src/settings/models/toggle_option.dart'; |
||||||
|
import 'package:test/test.dart'; |
||||||
|
|
||||||
|
class MockStorage extends Mock implements SettingsStorage {} |
||||||
|
|
||||||
|
class MockCallbackFunction extends Mock { |
||||||
|
FutureOr<void> call(); |
||||||
|
} |
||||||
|
|
||||||
|
enum SelectValues { |
||||||
|
first, |
||||||
|
second, |
||||||
|
third, |
||||||
|
} |
||||||
|
|
||||||
|
void main() { |
||||||
|
final storage = MockStorage(); |
||||||
|
const key = 'storage-key'; |
||||||
|
String labelBuilder(final _) => 'label'; |
||||||
|
|
||||||
|
group('SelectOption', () { |
||||||
|
final valuesLabel = { |
||||||
|
SelectValues.first: (final _) => 'first', |
||||||
|
SelectValues.second: (final _) => 'second', |
||||||
|
SelectValues.third: (final _) => 'third', |
||||||
|
}; |
||||||
|
|
||||||
|
late SelectOption<SelectValues> option; |
||||||
|
|
||||||
|
setUp(() { |
||||||
|
when(() => storage.setString(key, any())).thenAnswer((final _) async {}); |
||||||
|
option = SelectOption<SelectValues>( |
||||||
|
storage: storage, |
||||||
|
key: key, |
||||||
|
label: labelBuilder, |
||||||
|
defaultValue: SelectValues.first, |
||||||
|
values: valuesLabel, |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
tearDown(() { |
||||||
|
reset(storage); |
||||||
|
option.dispose(); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Create', () { |
||||||
|
expect(option.value, option.defaultValue, reason: 'Should default to defaultValue.'); |
||||||
|
|
||||||
|
when(() => storage.getString(key)).thenReturn('SelectValues.second'); |
||||||
|
|
||||||
|
option = SelectOption<SelectValues>( |
||||||
|
storage: storage, |
||||||
|
key: key, |
||||||
|
label: labelBuilder, |
||||||
|
defaultValue: SelectValues.first, |
||||||
|
values: valuesLabel, |
||||||
|
); |
||||||
|
|
||||||
|
expect(option.value, SelectValues.second, reason: 'Should load value from storage when available.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Depend', () { |
||||||
|
final enabled = ValueNotifier(false); |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
|
||||||
|
option = SelectOption<SelectValues>.depend( |
||||||
|
storage: storage, |
||||||
|
key: key, |
||||||
|
label: labelBuilder, |
||||||
|
defaultValue: SelectValues.first, |
||||||
|
values: valuesLabel, |
||||||
|
enabled: enabled, |
||||||
|
)..addListener(callback.call); |
||||||
|
|
||||||
|
expect(option.enabled, enabled.value, reason: 'Should initialize with enabled value.'); |
||||||
|
|
||||||
|
enabled.value = true; |
||||||
|
verify(callback.call).called(1); |
||||||
|
expect(option.enabled, enabled.value, reason: 'Should update the enabled state.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Update', () { |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
option |
||||||
|
..addListener(callback.call) |
||||||
|
..value = SelectValues.third; |
||||||
|
|
||||||
|
verify(callback.call).called(1); |
||||||
|
verify(() => storage.setString(key, 'SelectValues.third')).called(1); |
||||||
|
expect(option.value, SelectValues.third, reason: 'Should update the value.'); |
||||||
|
|
||||||
|
option.value = SelectValues.third; |
||||||
|
verifyNever(callback.call); // Don't notify with the same value |
||||||
|
expect(option.value, SelectValues.third, reason: 'Should keep the value.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Disable', () { |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
option.addListener(callback.call); |
||||||
|
|
||||||
|
expect(option.enabled, true, reason: 'Should default to enabled'); |
||||||
|
|
||||||
|
option.enabled = false; |
||||||
|
verify(callback.call).called(1); |
||||||
|
expect(option.enabled, false, reason: 'Should disable option.'); |
||||||
|
|
||||||
|
option.enabled = false; |
||||||
|
verifyNever(callback.call); // Don't notify with the same value |
||||||
|
expect(option.enabled, false, reason: 'Should keep the value.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Change values', () { |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
option.addListener(callback.call); |
||||||
|
|
||||||
|
expect(option.values, equals(valuesLabel)); |
||||||
|
|
||||||
|
final newValues = { |
||||||
|
SelectValues.second: (final _) => 'second', |
||||||
|
SelectValues.third: (final _) => 'third', |
||||||
|
}; |
||||||
|
|
||||||
|
option.values = newValues; |
||||||
|
verify(callback.call).called(1); |
||||||
|
expect(option.values, equals(newValues), reason: 'Should change values.'); |
||||||
|
|
||||||
|
option.values = newValues; |
||||||
|
verifyNever(callback.call); // Don't notify with the same value |
||||||
|
expect(option.values, newValues, reason: 'Should keep the values.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Reset', () { |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
option |
||||||
|
..value = SelectValues.third |
||||||
|
..addListener(callback.call); |
||||||
|
|
||||||
|
expect(option.value, SelectValues.third); |
||||||
|
|
||||||
|
option.reset(); |
||||||
|
|
||||||
|
verify(callback.call).called(1); |
||||||
|
expect(option.value, option.defaultValue, reason: 'Should reset the value.'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
group('ToggleOption', () { |
||||||
|
late ToggleOption option; |
||||||
|
|
||||||
|
setUp(() { |
||||||
|
when(() => storage.setBool(key, any())).thenAnswer((final _) async {}); |
||||||
|
option = ToggleOption( |
||||||
|
storage: storage, |
||||||
|
key: key, |
||||||
|
label: labelBuilder, |
||||||
|
defaultValue: true, |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
tearDown(() { |
||||||
|
reset(storage); |
||||||
|
option.dispose(); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Create', () { |
||||||
|
expect(option.value, option.defaultValue, reason: 'Should default to defaultValue.'); |
||||||
|
|
||||||
|
when(() => storage.getBool(key)).thenReturn(true); |
||||||
|
|
||||||
|
option = ToggleOption( |
||||||
|
storage: storage, |
||||||
|
key: key, |
||||||
|
label: labelBuilder, |
||||||
|
defaultValue: false, |
||||||
|
); |
||||||
|
|
||||||
|
expect(option.value, true, reason: 'Should load value from storage when available.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Depend', () { |
||||||
|
final enabled = ValueNotifier(false); |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
|
||||||
|
option = ToggleOption.depend( |
||||||
|
storage: storage, |
||||||
|
key: key, |
||||||
|
label: labelBuilder, |
||||||
|
defaultValue: true, |
||||||
|
enabled: enabled, |
||||||
|
)..addListener(callback.call); |
||||||
|
|
||||||
|
expect(option.enabled, enabled.value, reason: 'Should initialize with enabled value.'); |
||||||
|
|
||||||
|
enabled.value = true; |
||||||
|
verify(callback.call).called(1); |
||||||
|
expect(option.enabled, enabled.value, reason: 'Should update the enabled state.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Update', () { |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
option |
||||||
|
..addListener(callback.call) |
||||||
|
..value = false; |
||||||
|
|
||||||
|
verify(callback.call).called(1); |
||||||
|
verify(() => storage.setBool(key, false)).called(1); |
||||||
|
expect(option.value, false, reason: 'Should update the value.'); |
||||||
|
|
||||||
|
option.value = false; |
||||||
|
verifyNever(callback.call); // Don't notify with the same value |
||||||
|
expect(option.value, false, reason: 'Should keep the value.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Disable', () { |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
option.addListener(callback.call); |
||||||
|
|
||||||
|
expect(option.enabled, true, reason: 'Should default to enabled'); |
||||||
|
|
||||||
|
option.enabled = false; |
||||||
|
verify(callback.call).called(1); |
||||||
|
expect(option.enabled, false, reason: 'Should disable option.'); |
||||||
|
|
||||||
|
option.enabled = false; |
||||||
|
verifyNever(callback.call); // Don't notify with the same value |
||||||
|
expect(option.enabled, false, reason: 'Should keep the value.'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('Reset', () { |
||||||
|
final callback = MockCallbackFunction(); |
||||||
|
option |
||||||
|
..value = false |
||||||
|
..addListener(callback.call); |
||||||
|
|
||||||
|
expect(option.value, false); |
||||||
|
|
||||||
|
option.reset(); |
||||||
|
|
||||||
|
verify(callback.call).called(1); |
||||||
|
expect(option.value, option.defaultValue, reason: 'Should reset the value.'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
Loading…
Reference in new issue