diff --git a/lib/api/api_service.dart b/lib/api/api_service.dart index 5c1f306b..695e965d 100644 --- a/lib/api/api_service.dart +++ b/lib/api/api_service.dart @@ -1,6 +1,8 @@ import 'package:stelaris/api/api_client.dart'; import 'package:stelaris/api/base_api.dart'; +import 'package:stelaris/api/service/client/sound_client_api.dart'; import 'package:stelaris/api/client_api.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; import 'package:stelaris/api/model/attribute_model.dart'; import 'package:stelaris/api/model/notification_model.dart'; import 'package:stelaris/api/service/font_api.dart'; @@ -40,6 +42,10 @@ class ApiService { toJson: (model) => model.toJson(), ); + late final ClientAPI soundApi = SoundClientApi( + apiClient: _apiClient, + ); + /// Creates an instance of [ApiClient] with the backend URL. ApiClient _createApiClient() => ApiClient(Environment.backendURl); diff --git a/lib/api/model/sound/sound_event_model.dart b/lib/api/model/sound/sound_event_model.dart new file mode 100644 index 00000000..1f95a009 --- /dev/null +++ b/lib/api/model/sound/sound_event_model.dart @@ -0,0 +1,42 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stelaris/api/model/data_model.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/api/paginated_result.dart'; + +part 'sound_event_model.freezed.dart'; + +part 'sound_event_model.g.dart'; + +SoundEventModel soundEventFromJson(dynamic json) => + SoundEventModel.fromJson(json); + +Map soundEventToJson(SoundEventModel model) => model.toJson(); + +@freezed +abstract class SoundEventModel with _$SoundEventModel, DataModel { + const SoundEventModel._(); + + // Define the specific const default directly within the class that uses it. + // This is well-encapsulated. + static const PaginatedResult _defaultFiles = PaginatedResult( + items: [], + totalItems: 0, + totalPages: 0, + currentPage: 1, + pageSize: 0, + ); + + factory SoundEventModel({ + required String uiName, + String? id, + String? variableName, + String? keyName, + String? subTitle, + @Default(SoundEventModel._defaultFiles) + PaginatedResult files, + @Default(false) @JsonKey(includeToJson: false) bool isLoading, + }) = _SoundEventModel; + + factory SoundEventModel.fromJson(Map json) => + _$SoundEventModelFromJson(json); +} diff --git a/lib/api/model/sound/sound_event_model.freezed.dart b/lib/api/model/sound/sound_event_model.freezed.dart new file mode 100644 index 00000000..93deb99f --- /dev/null +++ b/lib/api/model/sound/sound_event_model.freezed.dart @@ -0,0 +1,295 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'sound_event_model.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SoundEventModel { + + String get uiName; String? get id; String? get variableName; String? get keyName; String? get subTitle; PaginatedResult get files;@JsonKey(includeToJson: false) bool get isLoading; +/// Create a copy of SoundEventModel +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SoundEventModelCopyWith get copyWith => _$SoundEventModelCopyWithImpl(this as SoundEventModel, _$identity); + + /// Serializes this SoundEventModel to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SoundEventModel&&(identical(other.uiName, uiName) || other.uiName == uiName)&&(identical(other.id, id) || other.id == id)&&(identical(other.variableName, variableName) || other.variableName == variableName)&&(identical(other.keyName, keyName) || other.keyName == keyName)&&(identical(other.subTitle, subTitle) || other.subTitle == subTitle)&&(identical(other.files, files) || other.files == files)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uiName,id,variableName,keyName,subTitle,files,isLoading); + +@override +String toString() { + return 'SoundEventModel(uiName: $uiName, id: $id, variableName: $variableName, keyName: $keyName, subTitle: $subTitle, files: $files, isLoading: $isLoading)'; +} + + +} + +/// @nodoc +abstract mixin class $SoundEventModelCopyWith<$Res> { + factory $SoundEventModelCopyWith(SoundEventModel value, $Res Function(SoundEventModel) _then) = _$SoundEventModelCopyWithImpl; +@useResult +$Res call({ + String uiName, String? id, String? variableName, String? keyName, String? subTitle, PaginatedResult files,@JsonKey(includeToJson: false) bool isLoading +}); + + + + +} +/// @nodoc +class _$SoundEventModelCopyWithImpl<$Res> + implements $SoundEventModelCopyWith<$Res> { + _$SoundEventModelCopyWithImpl(this._self, this._then); + + final SoundEventModel _self; + final $Res Function(SoundEventModel) _then; + +/// Create a copy of SoundEventModel +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? uiName = null,Object? id = freezed,Object? variableName = freezed,Object? keyName = freezed,Object? subTitle = freezed,Object? files = null,Object? isLoading = null,}) { + return _then(_self.copyWith( +uiName: null == uiName ? _self.uiName : uiName // ignore: cast_nullable_to_non_nullable +as String,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,variableName: freezed == variableName ? _self.variableName : variableName // ignore: cast_nullable_to_non_nullable +as String?,keyName: freezed == keyName ? _self.keyName : keyName // ignore: cast_nullable_to_non_nullable +as String?,subTitle: freezed == subTitle ? _self.subTitle : subTitle // ignore: cast_nullable_to_non_nullable +as String?,files: null == files ? _self.files : files // ignore: cast_nullable_to_non_nullable +as PaginatedResult,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SoundEventModel]. +extension SoundEventModelPatterns on SoundEventModel { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SoundEventModel value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SoundEventModel() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SoundEventModel value) $default,){ +final _that = this; +switch (_that) { +case _SoundEventModel(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SoundEventModel value)? $default,){ +final _that = this; +switch (_that) { +case _SoundEventModel() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String uiName, String? id, String? variableName, String? keyName, String? subTitle, PaginatedResult files, @JsonKey(includeToJson: false) bool isLoading)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SoundEventModel() when $default != null: +return $default(_that.uiName,_that.id,_that.variableName,_that.keyName,_that.subTitle,_that.files,_that.isLoading);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String uiName, String? id, String? variableName, String? keyName, String? subTitle, PaginatedResult files, @JsonKey(includeToJson: false) bool isLoading) $default,) {final _that = this; +switch (_that) { +case _SoundEventModel(): +return $default(_that.uiName,_that.id,_that.variableName,_that.keyName,_that.subTitle,_that.files,_that.isLoading);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String uiName, String? id, String? variableName, String? keyName, String? subTitle, PaginatedResult files, @JsonKey(includeToJson: false) bool isLoading)? $default,) {final _that = this; +switch (_that) { +case _SoundEventModel() when $default != null: +return $default(_that.uiName,_that.id,_that.variableName,_that.keyName,_that.subTitle,_that.files,_that.isLoading);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SoundEventModel extends SoundEventModel { + _SoundEventModel({required this.uiName, this.id, this.variableName, this.keyName, this.subTitle, this.files = SoundEventModel._defaultFiles, @JsonKey(includeToJson: false) this.isLoading = false}): super._(); + factory _SoundEventModel.fromJson(Map json) => _$SoundEventModelFromJson(json); + +@override final String uiName; +@override final String? id; +@override final String? variableName; +@override final String? keyName; +@override final String? subTitle; +@override@JsonKey() final PaginatedResult files; +@override@JsonKey(includeToJson: false) final bool isLoading; + +/// Create a copy of SoundEventModel +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SoundEventModelCopyWith<_SoundEventModel> get copyWith => __$SoundEventModelCopyWithImpl<_SoundEventModel>(this, _$identity); + +@override +Map toJson() { + return _$SoundEventModelToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SoundEventModel&&(identical(other.uiName, uiName) || other.uiName == uiName)&&(identical(other.id, id) || other.id == id)&&(identical(other.variableName, variableName) || other.variableName == variableName)&&(identical(other.keyName, keyName) || other.keyName == keyName)&&(identical(other.subTitle, subTitle) || other.subTitle == subTitle)&&(identical(other.files, files) || other.files == files)&&(identical(other.isLoading, isLoading) || other.isLoading == isLoading)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uiName,id,variableName,keyName,subTitle,files,isLoading); + +@override +String toString() { + return 'SoundEventModel(uiName: $uiName, id: $id, variableName: $variableName, keyName: $keyName, subTitle: $subTitle, files: $files, isLoading: $isLoading)'; +} + + +} + +/// @nodoc +abstract mixin class _$SoundEventModelCopyWith<$Res> implements $SoundEventModelCopyWith<$Res> { + factory _$SoundEventModelCopyWith(_SoundEventModel value, $Res Function(_SoundEventModel) _then) = __$SoundEventModelCopyWithImpl; +@override @useResult +$Res call({ + String uiName, String? id, String? variableName, String? keyName, String? subTitle, PaginatedResult files,@JsonKey(includeToJson: false) bool isLoading +}); + + + + +} +/// @nodoc +class __$SoundEventModelCopyWithImpl<$Res> + implements _$SoundEventModelCopyWith<$Res> { + __$SoundEventModelCopyWithImpl(this._self, this._then); + + final _SoundEventModel _self; + final $Res Function(_SoundEventModel) _then; + +/// Create a copy of SoundEventModel +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? uiName = null,Object? id = freezed,Object? variableName = freezed,Object? keyName = freezed,Object? subTitle = freezed,Object? files = null,Object? isLoading = null,}) { + return _then(_SoundEventModel( +uiName: null == uiName ? _self.uiName : uiName // ignore: cast_nullable_to_non_nullable +as String,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?,variableName: freezed == variableName ? _self.variableName : variableName // ignore: cast_nullable_to_non_nullable +as String?,keyName: freezed == keyName ? _self.keyName : keyName // ignore: cast_nullable_to_non_nullable +as String?,subTitle: freezed == subTitle ? _self.subTitle : subTitle // ignore: cast_nullable_to_non_nullable +as String?,files: null == files ? _self.files : files // ignore: cast_nullable_to_non_nullable +as PaginatedResult,isLoading: null == isLoading ? _self.isLoading : isLoading // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/api/model/sound/sound_event_model.g.dart b/lib/api/model/sound/sound_event_model.g.dart new file mode 100644 index 00000000..b91ba06e --- /dev/null +++ b/lib/api/model/sound/sound_event_model.g.dart @@ -0,0 +1,34 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sound_event_model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SoundEventModel _$SoundEventModelFromJson(Map json) => + _SoundEventModel( + uiName: json['uiName'] as String, + id: json['id'] as String?, + variableName: json['variableName'] as String?, + keyName: json['keyName'] as String?, + subTitle: json['subTitle'] as String?, + files: json['files'] == null + ? SoundEventModel._defaultFiles + : PaginatedResult.fromJson( + json['files'] as Map, + (value) => + SoundFileSource.fromJson(value as Map), + ), + isLoading: json['isLoading'] as bool? ?? false, + ); + +Map _$SoundEventModelToJson(_SoundEventModel instance) => + { + 'uiName': instance.uiName, + 'id': instance.id, + 'variableName': instance.variableName, + 'keyName': instance.keyName, + 'subTitle': instance.subTitle, + 'files': instance.files.toJson((value) => value), + }; diff --git a/lib/api/model/sound/sound_file_source.dart b/lib/api/model/sound/sound_file_source.dart new file mode 100644 index 00000000..ad3ed46c --- /dev/null +++ b/lib/api/model/sound/sound_file_source.dart @@ -0,0 +1,24 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stelaris/api/model/data_model.dart'; + +part 'sound_file_source.freezed.dart'; +part 'sound_file_source.g.dart'; + +@freezed +abstract class SoundFileSource with _$SoundFileSource, DataModel { + const SoundFileSource._(); + + const factory SoundFileSource({ + required String name, + required double volume, + required double pitch, + required int attenuationDistance, + required bool preload, + required String type, + required int weight, + String? id, + }) = _SoundFileSource; + + factory SoundFileSource.fromJson(Map json) => + _$SoundFileSourceFromJson(json); +} diff --git a/lib/api/model/sound/sound_file_source.freezed.dart b/lib/api/model/sound/sound_file_source.freezed.dart new file mode 100644 index 00000000..d76b1881 --- /dev/null +++ b/lib/api/model/sound/sound_file_source.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'sound_file_source.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$SoundFileSource { + + String get name; double get volume; double get pitch; int get attenuationDistance; bool get preload; String get type; int get weight; String? get id; +/// Create a copy of SoundFileSource +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$SoundFileSourceCopyWith get copyWith => _$SoundFileSourceCopyWithImpl(this as SoundFileSource, _$identity); + + /// Serializes this SoundFileSource to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is SoundFileSource&&(identical(other.name, name) || other.name == name)&&(identical(other.volume, volume) || other.volume == volume)&&(identical(other.pitch, pitch) || other.pitch == pitch)&&(identical(other.attenuationDistance, attenuationDistance) || other.attenuationDistance == attenuationDistance)&&(identical(other.preload, preload) || other.preload == preload)&&(identical(other.type, type) || other.type == type)&&(identical(other.weight, weight) || other.weight == weight)&&(identical(other.id, id) || other.id == id)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,volume,pitch,attenuationDistance,preload,type,weight,id); + +@override +String toString() { + return 'SoundFileSource(name: $name, volume: $volume, pitch: $pitch, attenuationDistance: $attenuationDistance, preload: $preload, type: $type, weight: $weight, id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class $SoundFileSourceCopyWith<$Res> { + factory $SoundFileSourceCopyWith(SoundFileSource value, $Res Function(SoundFileSource) _then) = _$SoundFileSourceCopyWithImpl; +@useResult +$Res call({ + String name, double volume, double pitch, int attenuationDistance, bool preload, String type, int weight, String? id +}); + + + + +} +/// @nodoc +class _$SoundFileSourceCopyWithImpl<$Res> + implements $SoundFileSourceCopyWith<$Res> { + _$SoundFileSourceCopyWithImpl(this._self, this._then); + + final SoundFileSource _self; + final $Res Function(SoundFileSource) _then; + +/// Create a copy of SoundFileSource +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? volume = null,Object? pitch = null,Object? attenuationDistance = null,Object? preload = null,Object? type = null,Object? weight = null,Object? id = freezed,}) { + return _then(_self.copyWith( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,volume: null == volume ? _self.volume : volume // ignore: cast_nullable_to_non_nullable +as double,pitch: null == pitch ? _self.pitch : pitch // ignore: cast_nullable_to_non_nullable +as double,attenuationDistance: null == attenuationDistance ? _self.attenuationDistance : attenuationDistance // ignore: cast_nullable_to_non_nullable +as int,preload: null == preload ? _self.preload : preload // ignore: cast_nullable_to_non_nullable +as bool,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,weight: null == weight ? _self.weight : weight // ignore: cast_nullable_to_non_nullable +as int,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [SoundFileSource]. +extension SoundFileSourcePatterns on SoundFileSource { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _SoundFileSource value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _SoundFileSource() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _SoundFileSource value) $default,){ +final _that = this; +switch (_that) { +case _SoundFileSource(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _SoundFileSource value)? $default,){ +final _that = this; +switch (_that) { +case _SoundFileSource() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String name, double volume, double pitch, int attenuationDistance, bool preload, String type, int weight, String? id)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _SoundFileSource() when $default != null: +return $default(_that.name,_that.volume,_that.pitch,_that.attenuationDistance,_that.preload,_that.type,_that.weight,_that.id);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String name, double volume, double pitch, int attenuationDistance, bool preload, String type, int weight, String? id) $default,) {final _that = this; +switch (_that) { +case _SoundFileSource(): +return $default(_that.name,_that.volume,_that.pitch,_that.attenuationDistance,_that.preload,_that.type,_that.weight,_that.id);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, double volume, double pitch, int attenuationDistance, bool preload, String type, int weight, String? id)? $default,) {final _that = this; +switch (_that) { +case _SoundFileSource() when $default != null: +return $default(_that.name,_that.volume,_that.pitch,_that.attenuationDistance,_that.preload,_that.type,_that.weight,_that.id);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _SoundFileSource extends SoundFileSource { + const _SoundFileSource({required this.name, required this.volume, required this.pitch, required this.attenuationDistance, required this.preload, required this.type, required this.weight, this.id}): super._(); + factory _SoundFileSource.fromJson(Map json) => _$SoundFileSourceFromJson(json); + +@override final String name; +@override final double volume; +@override final double pitch; +@override final int attenuationDistance; +@override final bool preload; +@override final String type; +@override final int weight; +@override final String? id; + +/// Create a copy of SoundFileSource +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$SoundFileSourceCopyWith<_SoundFileSource> get copyWith => __$SoundFileSourceCopyWithImpl<_SoundFileSource>(this, _$identity); + +@override +Map toJson() { + return _$SoundFileSourceToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SoundFileSource&&(identical(other.name, name) || other.name == name)&&(identical(other.volume, volume) || other.volume == volume)&&(identical(other.pitch, pitch) || other.pitch == pitch)&&(identical(other.attenuationDistance, attenuationDistance) || other.attenuationDistance == attenuationDistance)&&(identical(other.preload, preload) || other.preload == preload)&&(identical(other.type, type) || other.type == type)&&(identical(other.weight, weight) || other.weight == weight)&&(identical(other.id, id) || other.id == id)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,name,volume,pitch,attenuationDistance,preload,type,weight,id); + +@override +String toString() { + return 'SoundFileSource(name: $name, volume: $volume, pitch: $pitch, attenuationDistance: $attenuationDistance, preload: $preload, type: $type, weight: $weight, id: $id)'; +} + + +} + +/// @nodoc +abstract mixin class _$SoundFileSourceCopyWith<$Res> implements $SoundFileSourceCopyWith<$Res> { + factory _$SoundFileSourceCopyWith(_SoundFileSource value, $Res Function(_SoundFileSource) _then) = __$SoundFileSourceCopyWithImpl; +@override @useResult +$Res call({ + String name, double volume, double pitch, int attenuationDistance, bool preload, String type, int weight, String? id +}); + + + + +} +/// @nodoc +class __$SoundFileSourceCopyWithImpl<$Res> + implements _$SoundFileSourceCopyWith<$Res> { + __$SoundFileSourceCopyWithImpl(this._self, this._then); + + final _SoundFileSource _self; + final $Res Function(_SoundFileSource) _then; + +/// Create a copy of SoundFileSource +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? volume = null,Object? pitch = null,Object? attenuationDistance = null,Object? preload = null,Object? type = null,Object? weight = null,Object? id = freezed,}) { + return _then(_SoundFileSource( +name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable +as String,volume: null == volume ? _self.volume : volume // ignore: cast_nullable_to_non_nullable +as double,pitch: null == pitch ? _self.pitch : pitch // ignore: cast_nullable_to_non_nullable +as double,attenuationDistance: null == attenuationDistance ? _self.attenuationDistance : attenuationDistance // ignore: cast_nullable_to_non_nullable +as int,preload: null == preload ? _self.preload : preload // ignore: cast_nullable_to_non_nullable +as bool,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable +as String,weight: null == weight ? _self.weight : weight // ignore: cast_nullable_to_non_nullable +as int,id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/lib/api/model/sound/sound_file_source.g.dart b/lib/api/model/sound/sound_file_source.g.dart new file mode 100644 index 00000000..ca358b5b --- /dev/null +++ b/lib/api/model/sound/sound_file_source.g.dart @@ -0,0 +1,31 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sound_file_source.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_SoundFileSource _$SoundFileSourceFromJson(Map json) => + _SoundFileSource( + name: json['name'] as String, + volume: (json['volume'] as num).toDouble(), + pitch: (json['pitch'] as num).toDouble(), + attenuationDistance: (json['attenuationDistance'] as num).toInt(), + preload: json['preload'] as bool, + type: json['type'] as String, + weight: (json['weight'] as num).toInt(), + id: json['id'] as String?, + ); + +Map _$SoundFileSourceToJson(_SoundFileSource instance) => + { + 'name': instance.name, + 'volume': instance.volume, + 'pitch': instance.pitch, + 'attenuationDistance': instance.attenuationDistance, + 'preload': instance.preload, + 'type': instance.type, + 'weight': instance.weight, + 'id': instance.id, + }; diff --git a/lib/api/paginated_result.dart b/lib/api/paginated_result.dart index 2ec00595..cec4f29b 100644 --- a/lib/api/paginated_result.dart +++ b/lib/api/paginated_result.dart @@ -71,19 +71,6 @@ class PaginatedResult { ); } - /// Creates an empty paginated result. - /// - /// Useful for initializing state before data is loaded. - factory PaginatedResult.empty() { - return PaginatedResult( - items: [], - totalItems: 0, - totalPages: 0, - currentPage: 1, - pageSize: 0, - ); - } - /// Creates a paginated result from a JSON map. /// /// The [fromJson] parameter is a function that converts a JSON object diff --git a/lib/api/service/client/sound_client_api.dart b/lib/api/service/client/sound_client_api.dart new file mode 100644 index 00000000..994887cd --- /dev/null +++ b/lib/api/service/client/sound_client_api.dart @@ -0,0 +1,105 @@ +import 'package:stelaris/api/base_api.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/api/paginated_result.dart'; + +/// Client API for interacting with sound event-related endpoints. +/// +/// Extends [BaseApi] to provide CRUD operations for [SoundEventModel] instances +/// and includes specific methods for sound event functionalities like fetching +/// associated sound files. +class SoundClientApi extends BaseApi { + /// Creates an instance of [SoundClientApi]. + /// + /// Requires an [apiClient] for making HTTP requests. + /// The base endpoint for sound events is set to 'sound'. + /// Serialization and deserialization for [SoundEventModel] are handled + /// by [SoundEventModel.fromJson] and [SoundEventModel.toJson] respectively. + SoundClientApi({required super.apiClient}) + : super( + endpoint: 'sound', + fromJson: SoundEventModel.fromJson, + toJson: (p0) => p0.toJson(), + ); + + /// Fetches a paginated list of [SoundFileSource] objects associated with a specific sound event. + /// + /// The request is made to the `/sound/{id}/sources` endpoint. + /// + /// - [id]: The unique identifier of the sound event. + /// - [page]: Optional. The page number to retrieve (defaults to 1). + /// - [items]: Optional. The number of items per page (defaults to 20). + /// + /// Returns a [Future] that completes with a [PaginatedResult] containing + /// a list of [SoundFileSource] and pagination details. + Future> getFiles( + String id, [ + int page = 1, + int items = 20, + ]) async { + final baseUri = Uri.parse(apiClient.baseUrl); + final uri = baseUri.replace( + path: '${baseUri.path}/$endpoint/$id/sources', + // Many backends expect zero-based page index; convert if needed. + queryParameters: { + 'page': (page - 1).toString(), + 'size': items.toString(), + }, + ); + final result = await apiClient.dio.getUri(uri); + return PaginatedResult.fromJson( + result.data, + (jsonSource) => + SoundFileSource.fromJson(jsonSource as Map), + ); + } + + /// Links a [SoundFileSource] to a specific sound event. + /// + /// The request is the updated entry from the backend + /// - [modelId]: The unique id of the sound event + /// - [fileToLink]: The [SoundFileSource] which should be linked + Future linkFile( + String modelId, + SoundFileSource fileToLink, + ) async { + final baseUri = Uri.parse(apiClient.baseUrl); + final uri = baseUri.replace( + path: '${baseUri.path}/$endpoint/$modelId/sources', + ); + final result = await apiClient.dio.postUri(uri, data: fileToLink.toJson()); + return SoundFileSource.fromJson(result.data); + } + + Future updateFile( + String modelId, + SoundFileSource file, + ) async { + final baseUri = Uri.parse(apiClient.baseUrl); + final uri = baseUri.replace( + path: '${baseUri.path}/$endpoint/$modelId/sources/update', + ); + final result = await apiClient.dio.postUri(uri, data: file.toJson()); + return SoundFileSource.fromJson(result.data); + } + + /// Deletes a [SoundFileSource] associated with a specific sound event. + /// + /// The request is made to the `/sound/{id}/sources/delete` endpoint. + /// + /// - [modelId]: The unique identifier of the sound event. + /// - [file]: The [SoundFileSource] to be deleted. + /// + /// Returns a [Future] that completes with the deleted [SoundFileSource]. + Future deleteFile( + String modelId, + SoundFileSource file, + ) async { + final baseUri = Uri.parse(apiClient.baseUrl); + final uri = baseUri.replace( + path: '${baseUri.path}/$endpoint/$modelId/sources/delete/${file.id}', + ); + final result = await apiClient.dio.deleteUri(uri); + return SoundFileSource.fromJson(result.data); + } +} diff --git a/lib/api/state/actions/sound/sound_actions.dart b/lib/api/state/actions/sound/sound_actions.dart new file mode 100644 index 00000000..b654de64 --- /dev/null +++ b/lib/api/state/actions/sound/sound_actions.dart @@ -0,0 +1,185 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:stelaris/api/api_service.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; +import 'package:stelaris/api/paginated_result.dart'; +import 'package:stelaris/api/state/app_state.dart'; + +class SelectSoundAction extends ReduxAction { + final SoundEventModel model; + + SelectSoundAction(this.model); + + @override + AppState reduce() => state.copyWith(selectedSoundEvent: model); +} + +class RemoveSelectedSoundEvent extends ReduxAction { + @override + AppState? reduce() { + if (state.selectedFont == null) return null; + return state.copyWith(selectedSoundEvent: null); + } +} + +class InitSoundAction extends ReduxAction { + InitSoundAction(); + + @override + Future reduce() async { + // If we already have items and more pages, treat this as load-more. + final hasExisting = state.soundEvents.items.isNotEmpty; + final canLoadMore = state.soundEvents.hasNextPage; + + if (hasExisting && canLoadMore) { + if (state.isLoadingMoreSoundEvents) return null; + dispatchSync(_SetLoadMoreSoundEventModels(true)); + try { + final current = state.soundEvents; + final nextPage = current.currentPage + 1; + final size = 10; + final next = await ApiService().soundApi.getPage( + page: nextPage, + size: size, + ); + + final merged = List.of(current.items) + ..addAll(next.items); + final updated = current.copyWith( + items: merged, + totalItems: next.totalItems != 0 + ? next.totalItems + : current.totalItems, + totalPages: next.totalPages != 0 + ? next.totalPages + : current.totalPages, + currentPage: next.currentPage != 0 ? next.currentPage : nextPage, + pageSize: next.pageSize != 0 ? next.pageSize : size, + ); + return state.copyWith(soundEvents: updated); + } finally { + dispatchSync(_SetLoadMoreSoundEventModels(false)); + } + } else { + // Initial load (or refresh) + final PaginatedResult result = await ApiService() + .soundApi + .getPage( + page: 1, + size: state.soundEvents.pageSize == 0 + ? 10 + : state.soundEvents.pageSize, + ); + return state.copyWith(soundEvents: result); + } + } +} + +class SoundRemoveAction extends ReduxAction { + final SoundEventModel model; + + SoundRemoveAction(this.model); + + @override + Future reduce() async { + await ApiService().soundApi.remove(model); + final List updatedList = List.of( + state.soundEvents.items, + growable: true, + )..remove(model); + + final SoundEventModel? selectedModel = + state.selectedSoundEvent?.id == model.id + ? null + : state.selectedSoundEvent; + + return _updateSoundEventsInState(state, updatedList, selectedModel); + } +} + +class SoundAddAction extends ReduxAction { + final SoundEventModel _model; + + SoundAddAction(this._model); + + @override + Future reduce() async { + final SoundEventModel databaseModel = await ApiService().soundApi.add( + _model, + ); + final List updatedList = List.of( + state.soundEvents.items, + growable: true, + )..add(databaseModel); + + return _updateSoundEventsInState(state, updatedList, databaseModel); + } +} + +class UpdateSoundAction extends ReduxAction { + final SoundEventModel newEntry; + + UpdateSoundAction(this.newEntry); + + @override + Future reduce() async => + state.copyWith(selectedSoundEvent: newEntry); +} + +class SoundDatabaseUpdate extends ReduxAction { + SoundDatabaseUpdate(); + + @override + Future reduce() async { + if (state.selectedSoundEvent == null) return null; + final SoundEventModel selected = state.selectedSoundEvent!; + final SoundEventModel dbModel = await ApiService().soundApi.update( + selected, + ); + final List updatedList = List.of( + state.soundEvents.items, + growable: true, + ); + final int index = updatedList.indexWhere( + (element) => element.id == selected.id, + ); + + if (index != -1) { + updatedList[index] = dbModel; + } + + return _updateSoundEventsInState(state, updatedList, dbModel); + } +} + +/// Internal action to manage the loading state for sound pagination. +/// +/// This private action controls the `isLoadingMoreSoundEvents` flag in the state, +/// preventing multiple simultaneous load-more requests. It's used internally +/// by InitSoundAction during pagination operations. +class _SetLoadMoreSoundEventModels extends ReduxAction { + final bool value; + + _SetLoadMoreSoundEventModels(this.value); + + @override + AppState reduce() => state.copyWith(isLoadingMoreSoundEvents: value); +} + +AppState _updateSoundEventsInState( + AppState state, + List newItems, + SoundEventModel? selectedAttribute, { + int? totalItems, +}) { + final updated = state.soundEvents.copyWith( + items: newItems, + totalItems: totalItems ?? state.soundEvents.totalItems, + totalPages: state.soundEvents.totalPages, + currentPage: state.soundEvents.currentPage, + pageSize: state.soundEvents.pageSize, + ); + return state.copyWith( + soundEvents: updated, + selectedSoundEvent: selectedAttribute, + ); +} diff --git a/lib/api/state/actions/sound/sound_file_actions.dart b/lib/api/state/actions/sound/sound_file_actions.dart new file mode 100644 index 00000000..d82e2c60 --- /dev/null +++ b/lib/api/state/actions/sound/sound_file_actions.dart @@ -0,0 +1,199 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:stelaris/api/api_service.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/api/paginated_result.dart'; +import 'package:stelaris/api/service/client/sound_client_api.dart'; +import 'package:stelaris/api/state/app_state.dart'; + +class InitSoundFileAction extends ReduxAction { + @override + Future reduce() async { + if (state.selectedSoundEvent == null) return null; + + final SoundEventModel model = state.selectedSoundEvent!; + + if (model.files.items.isNotEmpty) return null; + + final soundClient = ApiService().soundApi as SoundClientApi; + final PaginatedResult files = await soundClient.getFiles( + model.id!, + ); + return state.copyWith( + selectedSoundEvent: model.copyWith( + files: model.files.copyWith( + items: List.of(model.files.items, growable: true) + ..addAll(files.items), + currentPage: files.currentPage, + totalPages: files.totalPages, + totalItems: files.totalItems, + ), + ), + ); + } +} + +class SoundFileLinkAction extends ReduxAction { + final SoundFileSource source; + + SoundFileLinkAction(this.source); + + @override + Future reduce() async { + if (state.selectedSoundEvent == null) return null; + + final SoundEventModel soundModel = state.selectedSoundEvent!; + final List list = List.of( + soundModel.files.items, + growable: true, + ); + final SoundClientApi soundApi = ApiService().soundApi as SoundClientApi; + final SoundFileSource linkedFile = await soundApi.linkFile( + soundModel.id!, + source, + ); + + list.add(linkedFile); + + // Create a new PaginatedResult with the new list and updated counts + final PaginatedResult + updatedSources = soundModel.files.copyWith( + items: list, + totalItems: soundModel.files.totalItems + 1, // Adjust counts as necessary + // Potentially update totalPages if this new item pushes it to a new page + ); + return state.copyWith( + selectedSoundEvent: soundModel.copyWith(files: updatedSources), + ); + } +} + +class SoundFileUpdateAction extends ReduxAction { + final SoundFileSource soundFile; + + SoundFileUpdateAction(this.soundFile); + + @override + Future reduce() async { + if (state.selectedSoundEvent == null) return null; + + final SoundEventModel soundModel = state.selectedSoundEvent!; + final SoundClientApi soundApi = ApiService().soundApi as SoundClientApi; + + // Call the API to update the file + final SoundFileSource updatedFile = await soundApi.updateFile( + soundModel.id!, + soundFile, + ); + + // Create a new list with the updated item + final List list = List.of(soundModel.files.items); + final int index = list.indexWhere((file) => file.id == updatedFile.id); + if (index != -1) { + list[index] = updatedFile; + } + + // Create a new PaginatedResult with the updated list + final PaginatedResult updatedSources = soundModel.files + .copyWith(items: list); + + return state.copyWith( + selectedSoundEvent: soundModel.copyWith(files: updatedSources), + ); + } +} + +class SoundFileSourceDeleteAction extends ReduxAction { + final SoundFileSource soundFile; + + SoundFileSourceDeleteAction(this.soundFile); + + @override + Future reduce() async { + if (state.selectedSoundEvent == null) return null; + + final SoundEventModel model = state.selectedSoundEvent!; + final soundClient = ApiService().soundApi as SoundClientApi; + + final SoundFileSource deletedFile = await soundClient.deleteFile( + model.id!, + soundFile, + ); + + final PaginatedResult entries = model.files; + + final List updatedFiles = List.of( + entries.items, + growable: true, + ); + updatedFiles.removeWhere((file) => file.id == deletedFile.id); + + final updatedSoundEvent = model.copyWith( + files: entries.copyWith( + items: updatedFiles, + currentPage: entries.currentPage, + totalPages: entries.totalPages, + totalItems: entries.totalItems - 1, + ), + ); + + return state.copyWith(selectedSoundEvent: updatedSoundEvent); + } +} + +class LoadMoreSoundFiles extends ReduxAction { + LoadMoreSoundFiles({required this.pageToLoad, this.pageSize = 1}); + + final int pageToLoad; + final int pageSize; + + @override + void before() { + dispatch(SetSelectedSoundLoading(true)); + } + + @override + void after() { + dispatch(SetSelectedSoundLoading(false)); + } + + @override + Future reduce() async { + if (state.selectedSoundEvent == null) return null; + + final SoundEventModel model = state.selectedSoundEvent!; + final soundClient = ApiService().soundApi as SoundClientApi; + final PaginatedResult files = await soundClient.getFiles( + model.id!, + pageToLoad, + pageSize, + ); + final PaginatedResult entries = model.files; + + final updatedSoundEvent = model.copyWith( + files: entries.copyWith( + items: List.of(entries.items, growable: true)..addAll(files.items), + currentPage: files.currentPage, + totalPages: files.totalPages, + totalItems: files.totalItems, + ), + ); + + return state.copyWith(selectedSoundEvent: updatedSoundEvent); + } +} + +class SetSelectedSoundLoading extends ReduxAction { + SetSelectedSoundLoading(this.isLoading); + + final bool isLoading; + + @override + AppState? reduce() { + final sel = state.selectedSoundEvent; + if (sel == null) return null; + return state.copyWith( + selectedSoundEvent: sel.copyWith(isLoading: isLoading), + ); + } +} diff --git a/lib/api/state/app_state.dart b/lib/api/state/app_state.dart index 7734e33d..64cf2133 100644 --- a/lib/api/state/app_state.dart +++ b/lib/api/state/app_state.dart @@ -6,6 +6,7 @@ import 'package:stelaris/api/paginated_result.dart'; import 'package:stelaris/api/model/font_model.dart'; import 'package:stelaris/api/model/item_model.dart'; import 'package:stelaris/api/model/notification_model.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; import 'package:stelaris/api/model/theme/theme_settings.dart'; part 'app_state.g.dart'; @@ -71,6 +72,20 @@ abstract class AppState with _$AppState { ), ) PaginatedResult attributes, + @GenericPaginatedResultConverter( + fromJsonT: soundEventFromJson, + toJsonT: soundEventToJson, + ) + @Default( + PaginatedResult( + items: [], + totalItems: 0, + totalPages: 0, + currentPage: 1, + pageSize: 0, + ), + ) + PaginatedResult soundEvents, @JsonKey(includeToJson: false, includeFromJson: false) @Default(false) bool isLoadingAttributesMore, @@ -83,6 +98,9 @@ abstract class AppState with _$AppState { @JsonKey(includeToJson: false, includeFromJson: false) @Default(false) bool isLoadingMoreFonts, + @JsonKey(includeToJson: false, includeFromJson: false) + @Default(false) + bool isLoadingMoreSoundEvents, @Default(true) bool openNavigation, @Default( ThemeSettings( @@ -98,6 +116,7 @@ abstract class AppState with _$AppState { @JsonKey(includeToJson: false) NotificationModel? selectedNotification, @JsonKey(includeToJson: false) FontModel? selectedFont, @JsonKey(includeToJson: false) AttributeModel? selectedAttribute, + @JsonKey(includeToJson: false) SoundEventModel? selectedSoundEvent, }) = _AppState; factory AppState.fromJson(Map json) => diff --git a/lib/api/state/app_state.freezed.dart b/lib/api/state/app_state.freezed.dart index fc2695ca..74c61464 100644 --- a/lib/api/state/app_state.freezed.dart +++ b/lib/api/state/app_state.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$AppState { -@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult get items;@GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult get notifications;@GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult get fonts;@GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult get attributes;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingAttributesMore;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingMoreItems;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingMoreNotifications;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingMoreFonts; bool get openNavigation; ThemeSettings get themeSettings;@JsonKey(includeToJson: false) ItemModel? get selectedItem;@JsonKey(includeToJson: false) NotificationModel? get selectedNotification;@JsonKey(includeToJson: false) FontModel? get selectedFont;@JsonKey(includeToJson: false) AttributeModel? get selectedAttribute; +@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult get items;@GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult get notifications;@GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult get fonts;@GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult get attributes;@GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) PaginatedResult get soundEvents;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingAttributesMore;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingMoreItems;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingMoreNotifications;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingMoreFonts;@JsonKey(includeToJson: false, includeFromJson: false) bool get isLoadingMoreSoundEvents; bool get openNavigation; ThemeSettings get themeSettings;@JsonKey(includeToJson: false) ItemModel? get selectedItem;@JsonKey(includeToJson: false) NotificationModel? get selectedNotification;@JsonKey(includeToJson: false) FontModel? get selectedFont;@JsonKey(includeToJson: false) AttributeModel? get selectedAttribute;@JsonKey(includeToJson: false) SoundEventModel? get selectedSoundEvent; /// Create a copy of AppState /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $AppStateCopyWith get copyWith => _$AppStateCopyWithImpl(thi @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState&&(identical(other.items, items) || other.items == items)&&(identical(other.notifications, notifications) || other.notifications == notifications)&&(identical(other.fonts, fonts) || other.fonts == fonts)&&(identical(other.attributes, attributes) || other.attributes == attributes)&&(identical(other.isLoadingAttributesMore, isLoadingAttributesMore) || other.isLoadingAttributesMore == isLoadingAttributesMore)&&(identical(other.isLoadingMoreItems, isLoadingMoreItems) || other.isLoadingMoreItems == isLoadingMoreItems)&&(identical(other.isLoadingMoreNotifications, isLoadingMoreNotifications) || other.isLoadingMoreNotifications == isLoadingMoreNotifications)&&(identical(other.isLoadingMoreFonts, isLoadingMoreFonts) || other.isLoadingMoreFonts == isLoadingMoreFonts)&&(identical(other.openNavigation, openNavigation) || other.openNavigation == openNavigation)&&(identical(other.themeSettings, themeSettings) || other.themeSettings == themeSettings)&&(identical(other.selectedItem, selectedItem) || other.selectedItem == selectedItem)&&(identical(other.selectedNotification, selectedNotification) || other.selectedNotification == selectedNotification)&&(identical(other.selectedFont, selectedFont) || other.selectedFont == selectedFont)&&(identical(other.selectedAttribute, selectedAttribute) || other.selectedAttribute == selectedAttribute)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is AppState&&(identical(other.items, items) || other.items == items)&&(identical(other.notifications, notifications) || other.notifications == notifications)&&(identical(other.fonts, fonts) || other.fonts == fonts)&&(identical(other.attributes, attributes) || other.attributes == attributes)&&(identical(other.soundEvents, soundEvents) || other.soundEvents == soundEvents)&&(identical(other.isLoadingAttributesMore, isLoadingAttributesMore) || other.isLoadingAttributesMore == isLoadingAttributesMore)&&(identical(other.isLoadingMoreItems, isLoadingMoreItems) || other.isLoadingMoreItems == isLoadingMoreItems)&&(identical(other.isLoadingMoreNotifications, isLoadingMoreNotifications) || other.isLoadingMoreNotifications == isLoadingMoreNotifications)&&(identical(other.isLoadingMoreFonts, isLoadingMoreFonts) || other.isLoadingMoreFonts == isLoadingMoreFonts)&&(identical(other.isLoadingMoreSoundEvents, isLoadingMoreSoundEvents) || other.isLoadingMoreSoundEvents == isLoadingMoreSoundEvents)&&(identical(other.openNavigation, openNavigation) || other.openNavigation == openNavigation)&&(identical(other.themeSettings, themeSettings) || other.themeSettings == themeSettings)&&(identical(other.selectedItem, selectedItem) || other.selectedItem == selectedItem)&&(identical(other.selectedNotification, selectedNotification) || other.selectedNotification == selectedNotification)&&(identical(other.selectedFont, selectedFont) || other.selectedFont == selectedFont)&&(identical(other.selectedAttribute, selectedAttribute) || other.selectedAttribute == selectedAttribute)&&(identical(other.selectedSoundEvent, selectedSoundEvent) || other.selectedSoundEvent == selectedSoundEvent)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,items,notifications,fonts,attributes,isLoadingAttributesMore,isLoadingMoreItems,isLoadingMoreNotifications,isLoadingMoreFonts,openNavigation,themeSettings,selectedItem,selectedNotification,selectedFont,selectedAttribute); +int get hashCode => Object.hash(runtimeType,items,notifications,fonts,attributes,soundEvents,isLoadingAttributesMore,isLoadingMoreItems,isLoadingMoreNotifications,isLoadingMoreFonts,isLoadingMoreSoundEvents,openNavigation,themeSettings,selectedItem,selectedNotification,selectedFont,selectedAttribute,selectedSoundEvent); @override String toString() { - return 'AppState(items: $items, notifications: $notifications, fonts: $fonts, attributes: $attributes, isLoadingAttributesMore: $isLoadingAttributesMore, isLoadingMoreItems: $isLoadingMoreItems, isLoadingMoreNotifications: $isLoadingMoreNotifications, isLoadingMoreFonts: $isLoadingMoreFonts, openNavigation: $openNavigation, themeSettings: $themeSettings, selectedItem: $selectedItem, selectedNotification: $selectedNotification, selectedFont: $selectedFont, selectedAttribute: $selectedAttribute)'; + return 'AppState(items: $items, notifications: $notifications, fonts: $fonts, attributes: $attributes, soundEvents: $soundEvents, isLoadingAttributesMore: $isLoadingAttributesMore, isLoadingMoreItems: $isLoadingMoreItems, isLoadingMoreNotifications: $isLoadingMoreNotifications, isLoadingMoreFonts: $isLoadingMoreFonts, isLoadingMoreSoundEvents: $isLoadingMoreSoundEvents, openNavigation: $openNavigation, themeSettings: $themeSettings, selectedItem: $selectedItem, selectedNotification: $selectedNotification, selectedFont: $selectedFont, selectedAttribute: $selectedAttribute, selectedSoundEvent: $selectedSoundEvent)'; } @@ -48,11 +48,11 @@ abstract mixin class $AppStateCopyWith<$Res> { factory $AppStateCopyWith(AppState value, $Res Function(AppState) _then) = _$AppStateCopyWithImpl; @useResult $Res call({ -@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items,@GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications,@GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts,@GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, bool openNavigation, ThemeSettings themeSettings,@JsonKey(includeToJson: false) ItemModel? selectedItem,@JsonKey(includeToJson: false) NotificationModel? selectedNotification,@JsonKey(includeToJson: false) FontModel? selectedFont,@JsonKey(includeToJson: false) AttributeModel? selectedAttribute +@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items,@GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications,@GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts,@GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes,@GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) PaginatedResult soundEvents,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreSoundEvents, bool openNavigation, ThemeSettings themeSettings,@JsonKey(includeToJson: false) ItemModel? selectedItem,@JsonKey(includeToJson: false) NotificationModel? selectedNotification,@JsonKey(includeToJson: false) FontModel? selectedFont,@JsonKey(includeToJson: false) AttributeModel? selectedAttribute,@JsonKey(includeToJson: false) SoundEventModel? selectedSoundEvent }); -$ItemModelCopyWith<$Res>? get selectedItem;$NotificationModelCopyWith<$Res>? get selectedNotification;$FontModelCopyWith<$Res>? get selectedFont;$AttributeModelCopyWith<$Res>? get selectedAttribute; +$ItemModelCopyWith<$Res>? get selectedItem;$NotificationModelCopyWith<$Res>? get selectedNotification;$FontModelCopyWith<$Res>? get selectedFont;$AttributeModelCopyWith<$Res>? get selectedAttribute;$SoundEventModelCopyWith<$Res>? get selectedSoundEvent; } /// @nodoc @@ -65,23 +65,26 @@ class _$AppStateCopyWithImpl<$Res> /// Create a copy of AppState /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? items = null,Object? notifications = null,Object? fonts = null,Object? attributes = null,Object? isLoadingAttributesMore = null,Object? isLoadingMoreItems = null,Object? isLoadingMoreNotifications = null,Object? isLoadingMoreFonts = null,Object? openNavigation = null,Object? themeSettings = null,Object? selectedItem = freezed,Object? selectedNotification = freezed,Object? selectedFont = freezed,Object? selectedAttribute = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? items = null,Object? notifications = null,Object? fonts = null,Object? attributes = null,Object? soundEvents = null,Object? isLoadingAttributesMore = null,Object? isLoadingMoreItems = null,Object? isLoadingMoreNotifications = null,Object? isLoadingMoreFonts = null,Object? isLoadingMoreSoundEvents = null,Object? openNavigation = null,Object? themeSettings = null,Object? selectedItem = freezed,Object? selectedNotification = freezed,Object? selectedFont = freezed,Object? selectedAttribute = freezed,Object? selectedSoundEvent = freezed,}) { return _then(_self.copyWith( items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable as PaginatedResult,notifications: null == notifications ? _self.notifications : notifications // ignore: cast_nullable_to_non_nullable as PaginatedResult,fonts: null == fonts ? _self.fonts : fonts // ignore: cast_nullable_to_non_nullable as PaginatedResult,attributes: null == attributes ? _self.attributes : attributes // ignore: cast_nullable_to_non_nullable -as PaginatedResult,isLoadingAttributesMore: null == isLoadingAttributesMore ? _self.isLoadingAttributesMore : isLoadingAttributesMore // ignore: cast_nullable_to_non_nullable +as PaginatedResult,soundEvents: null == soundEvents ? _self.soundEvents : soundEvents // ignore: cast_nullable_to_non_nullable +as PaginatedResult,isLoadingAttributesMore: null == isLoadingAttributesMore ? _self.isLoadingAttributesMore : isLoadingAttributesMore // ignore: cast_nullable_to_non_nullable as bool,isLoadingMoreItems: null == isLoadingMoreItems ? _self.isLoadingMoreItems : isLoadingMoreItems // ignore: cast_nullable_to_non_nullable as bool,isLoadingMoreNotifications: null == isLoadingMoreNotifications ? _self.isLoadingMoreNotifications : isLoadingMoreNotifications // ignore: cast_nullable_to_non_nullable as bool,isLoadingMoreFonts: null == isLoadingMoreFonts ? _self.isLoadingMoreFonts : isLoadingMoreFonts // ignore: cast_nullable_to_non_nullable +as bool,isLoadingMoreSoundEvents: null == isLoadingMoreSoundEvents ? _self.isLoadingMoreSoundEvents : isLoadingMoreSoundEvents // ignore: cast_nullable_to_non_nullable as bool,openNavigation: null == openNavigation ? _self.openNavigation : openNavigation // ignore: cast_nullable_to_non_nullable as bool,themeSettings: null == themeSettings ? _self.themeSettings : themeSettings // ignore: cast_nullable_to_non_nullable as ThemeSettings,selectedItem: freezed == selectedItem ? _self.selectedItem : selectedItem // ignore: cast_nullable_to_non_nullable as ItemModel?,selectedNotification: freezed == selectedNotification ? _self.selectedNotification : selectedNotification // ignore: cast_nullable_to_non_nullable as NotificationModel?,selectedFont: freezed == selectedFont ? _self.selectedFont : selectedFont // ignore: cast_nullable_to_non_nullable as FontModel?,selectedAttribute: freezed == selectedAttribute ? _self.selectedAttribute : selectedAttribute // ignore: cast_nullable_to_non_nullable -as AttributeModel?, +as AttributeModel?,selectedSoundEvent: freezed == selectedSoundEvent ? _self.selectedSoundEvent : selectedSoundEvent // ignore: cast_nullable_to_non_nullable +as SoundEventModel?, )); } /// Create a copy of AppState @@ -132,6 +135,18 @@ $AttributeModelCopyWith<$Res>? get selectedAttribute { return $AttributeModelCopyWith<$Res>(_self.selectedAttribute!, (value) { return _then(_self.copyWith(selectedAttribute: value)); }); +}/// Create a copy of AppState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SoundEventModelCopyWith<$Res>? get selectedSoundEvent { + if (_self.selectedSoundEvent == null) { + return null; + } + + return $SoundEventModelCopyWith<$Res>(_self.selectedSoundEvent!, (value) { + return _then(_self.copyWith(selectedSoundEvent: value)); + }); } } @@ -214,10 +229,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function(@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items, @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications, @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts, @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, bool openNavigation, ThemeSettings themeSettings, @JsonKey(includeToJson: false) ItemModel? selectedItem, @JsonKey(includeToJson: false) NotificationModel? selectedNotification, @JsonKey(includeToJson: false) FontModel? selectedFont, @JsonKey(includeToJson: false) AttributeModel? selectedAttribute)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function(@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items, @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications, @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts, @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes, @GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) PaginatedResult soundEvents, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreSoundEvents, bool openNavigation, ThemeSettings themeSettings, @JsonKey(includeToJson: false) ItemModel? selectedItem, @JsonKey(includeToJson: false) NotificationModel? selectedNotification, @JsonKey(includeToJson: false) FontModel? selectedFont, @JsonKey(includeToJson: false) AttributeModel? selectedAttribute, @JsonKey(includeToJson: false) SoundEventModel? selectedSoundEvent)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _AppState() when $default != null: -return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_that.isLoadingAttributesMore,_that.isLoadingMoreItems,_that.isLoadingMoreNotifications,_that.isLoadingMoreFonts,_that.openNavigation,_that.themeSettings,_that.selectedItem,_that.selectedNotification,_that.selectedFont,_that.selectedAttribute);case _: +return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_that.soundEvents,_that.isLoadingAttributesMore,_that.isLoadingMoreItems,_that.isLoadingMoreNotifications,_that.isLoadingMoreFonts,_that.isLoadingMoreSoundEvents,_that.openNavigation,_that.themeSettings,_that.selectedItem,_that.selectedNotification,_that.selectedFont,_that.selectedAttribute,_that.selectedSoundEvent);case _: return orElse(); } @@ -235,10 +250,10 @@ return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_th /// } /// ``` -@optionalTypeArgs TResult when(TResult Function(@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items, @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications, @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts, @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, bool openNavigation, ThemeSettings themeSettings, @JsonKey(includeToJson: false) ItemModel? selectedItem, @JsonKey(includeToJson: false) NotificationModel? selectedNotification, @JsonKey(includeToJson: false) FontModel? selectedFont, @JsonKey(includeToJson: false) AttributeModel? selectedAttribute) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function(@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items, @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications, @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts, @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes, @GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) PaginatedResult soundEvents, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreSoundEvents, bool openNavigation, ThemeSettings themeSettings, @JsonKey(includeToJson: false) ItemModel? selectedItem, @JsonKey(includeToJson: false) NotificationModel? selectedNotification, @JsonKey(includeToJson: false) FontModel? selectedFont, @JsonKey(includeToJson: false) AttributeModel? selectedAttribute, @JsonKey(includeToJson: false) SoundEventModel? selectedSoundEvent) $default,) {final _that = this; switch (_that) { case _AppState(): -return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_that.isLoadingAttributesMore,_that.isLoadingMoreItems,_that.isLoadingMoreNotifications,_that.isLoadingMoreFonts,_that.openNavigation,_that.themeSettings,_that.selectedItem,_that.selectedNotification,_that.selectedFont,_that.selectedAttribute);case _: +return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_that.soundEvents,_that.isLoadingAttributesMore,_that.isLoadingMoreItems,_that.isLoadingMoreNotifications,_that.isLoadingMoreFonts,_that.isLoadingMoreSoundEvents,_that.openNavigation,_that.themeSettings,_that.selectedItem,_that.selectedNotification,_that.selectedFont,_that.selectedAttribute,_that.selectedSoundEvent);case _: throw StateError('Unexpected subclass'); } @@ -255,10 +270,10 @@ return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_th /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function(@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items, @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications, @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts, @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, bool openNavigation, ThemeSettings themeSettings, @JsonKey(includeToJson: false) ItemModel? selectedItem, @JsonKey(includeToJson: false) NotificationModel? selectedNotification, @JsonKey(includeToJson: false) FontModel? selectedFont, @JsonKey(includeToJson: false) AttributeModel? selectedAttribute)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function(@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items, @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications, @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts, @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes, @GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) PaginatedResult soundEvents, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, @JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreSoundEvents, bool openNavigation, ThemeSettings themeSettings, @JsonKey(includeToJson: false) ItemModel? selectedItem, @JsonKey(includeToJson: false) NotificationModel? selectedNotification, @JsonKey(includeToJson: false) FontModel? selectedFont, @JsonKey(includeToJson: false) AttributeModel? selectedAttribute, @JsonKey(includeToJson: false) SoundEventModel? selectedSoundEvent)? $default,) {final _that = this; switch (_that) { case _AppState() when $default != null: -return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_that.isLoadingAttributesMore,_that.isLoadingMoreItems,_that.isLoadingMoreNotifications,_that.isLoadingMoreFonts,_that.openNavigation,_that.themeSettings,_that.selectedItem,_that.selectedNotification,_that.selectedFont,_that.selectedAttribute);case _: +return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_that.soundEvents,_that.isLoadingAttributesMore,_that.isLoadingMoreItems,_that.isLoadingMoreNotifications,_that.isLoadingMoreFonts,_that.isLoadingMoreSoundEvents,_that.openNavigation,_that.themeSettings,_that.selectedItem,_that.selectedNotification,_that.selectedFont,_that.selectedAttribute,_that.selectedSoundEvent);case _: return null; } @@ -270,23 +285,26 @@ return $default(_that.items,_that.notifications,_that.fonts,_that.attributes,_th @JsonSerializable() class _AppState implements AppState { - const _AppState({@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) this.items = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) this.notifications = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) this.fonts = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) this.attributes = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingAttributesMore = false, @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingMoreItems = false, @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingMoreNotifications = false, @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingMoreFonts = false, this.openNavigation = true, this.themeSettings = const ThemeSettings(isDarkMode: false, primaryColor: Colors.blue, accentColor: Colors.blueAccent, fontScale: 1, useSystemTheme: true), @JsonKey(includeToJson: false) this.selectedItem, @JsonKey(includeToJson: false) this.selectedNotification, @JsonKey(includeToJson: false) this.selectedFont, @JsonKey(includeToJson: false) this.selectedAttribute}); + const _AppState({@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) this.items = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) this.notifications = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) this.fonts = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) this.attributes = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) this.soundEvents = const PaginatedResult(items: [], totalItems: 0, totalPages: 0, currentPage: 1, pageSize: 0), @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingAttributesMore = false, @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingMoreItems = false, @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingMoreNotifications = false, @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingMoreFonts = false, @JsonKey(includeToJson: false, includeFromJson: false) this.isLoadingMoreSoundEvents = false, this.openNavigation = true, this.themeSettings = const ThemeSettings(isDarkMode: false, primaryColor: Colors.blue, accentColor: Colors.blueAccent, fontScale: 1, useSystemTheme: true), @JsonKey(includeToJson: false) this.selectedItem, @JsonKey(includeToJson: false) this.selectedNotification, @JsonKey(includeToJson: false) this.selectedFont, @JsonKey(includeToJson: false) this.selectedAttribute, @JsonKey(includeToJson: false) this.selectedSoundEvent}); factory _AppState.fromJson(Map json) => _$AppStateFromJson(json); @override@JsonKey()@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) final PaginatedResult items; @override@JsonKey()@GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) final PaginatedResult notifications; @override@JsonKey()@GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) final PaginatedResult fonts; @override@JsonKey()@GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) final PaginatedResult attributes; +@override@JsonKey()@GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) final PaginatedResult soundEvents; @override@JsonKey(includeToJson: false, includeFromJson: false) final bool isLoadingAttributesMore; @override@JsonKey(includeToJson: false, includeFromJson: false) final bool isLoadingMoreItems; @override@JsonKey(includeToJson: false, includeFromJson: false) final bool isLoadingMoreNotifications; @override@JsonKey(includeToJson: false, includeFromJson: false) final bool isLoadingMoreFonts; +@override@JsonKey(includeToJson: false, includeFromJson: false) final bool isLoadingMoreSoundEvents; @override@JsonKey() final bool openNavigation; @override@JsonKey() final ThemeSettings themeSettings; @override@JsonKey(includeToJson: false) final ItemModel? selectedItem; @override@JsonKey(includeToJson: false) final NotificationModel? selectedNotification; @override@JsonKey(includeToJson: false) final FontModel? selectedFont; @override@JsonKey(includeToJson: false) final AttributeModel? selectedAttribute; +@override@JsonKey(includeToJson: false) final SoundEventModel? selectedSoundEvent; /// Create a copy of AppState /// with the given fields replaced by the non-null parameter values. @@ -301,16 +319,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState&&(identical(other.items, items) || other.items == items)&&(identical(other.notifications, notifications) || other.notifications == notifications)&&(identical(other.fonts, fonts) || other.fonts == fonts)&&(identical(other.attributes, attributes) || other.attributes == attributes)&&(identical(other.isLoadingAttributesMore, isLoadingAttributesMore) || other.isLoadingAttributesMore == isLoadingAttributesMore)&&(identical(other.isLoadingMoreItems, isLoadingMoreItems) || other.isLoadingMoreItems == isLoadingMoreItems)&&(identical(other.isLoadingMoreNotifications, isLoadingMoreNotifications) || other.isLoadingMoreNotifications == isLoadingMoreNotifications)&&(identical(other.isLoadingMoreFonts, isLoadingMoreFonts) || other.isLoadingMoreFonts == isLoadingMoreFonts)&&(identical(other.openNavigation, openNavigation) || other.openNavigation == openNavigation)&&(identical(other.themeSettings, themeSettings) || other.themeSettings == themeSettings)&&(identical(other.selectedItem, selectedItem) || other.selectedItem == selectedItem)&&(identical(other.selectedNotification, selectedNotification) || other.selectedNotification == selectedNotification)&&(identical(other.selectedFont, selectedFont) || other.selectedFont == selectedFont)&&(identical(other.selectedAttribute, selectedAttribute) || other.selectedAttribute == selectedAttribute)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppState&&(identical(other.items, items) || other.items == items)&&(identical(other.notifications, notifications) || other.notifications == notifications)&&(identical(other.fonts, fonts) || other.fonts == fonts)&&(identical(other.attributes, attributes) || other.attributes == attributes)&&(identical(other.soundEvents, soundEvents) || other.soundEvents == soundEvents)&&(identical(other.isLoadingAttributesMore, isLoadingAttributesMore) || other.isLoadingAttributesMore == isLoadingAttributesMore)&&(identical(other.isLoadingMoreItems, isLoadingMoreItems) || other.isLoadingMoreItems == isLoadingMoreItems)&&(identical(other.isLoadingMoreNotifications, isLoadingMoreNotifications) || other.isLoadingMoreNotifications == isLoadingMoreNotifications)&&(identical(other.isLoadingMoreFonts, isLoadingMoreFonts) || other.isLoadingMoreFonts == isLoadingMoreFonts)&&(identical(other.isLoadingMoreSoundEvents, isLoadingMoreSoundEvents) || other.isLoadingMoreSoundEvents == isLoadingMoreSoundEvents)&&(identical(other.openNavigation, openNavigation) || other.openNavigation == openNavigation)&&(identical(other.themeSettings, themeSettings) || other.themeSettings == themeSettings)&&(identical(other.selectedItem, selectedItem) || other.selectedItem == selectedItem)&&(identical(other.selectedNotification, selectedNotification) || other.selectedNotification == selectedNotification)&&(identical(other.selectedFont, selectedFont) || other.selectedFont == selectedFont)&&(identical(other.selectedAttribute, selectedAttribute) || other.selectedAttribute == selectedAttribute)&&(identical(other.selectedSoundEvent, selectedSoundEvent) || other.selectedSoundEvent == selectedSoundEvent)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,items,notifications,fonts,attributes,isLoadingAttributesMore,isLoadingMoreItems,isLoadingMoreNotifications,isLoadingMoreFonts,openNavigation,themeSettings,selectedItem,selectedNotification,selectedFont,selectedAttribute); +int get hashCode => Object.hash(runtimeType,items,notifications,fonts,attributes,soundEvents,isLoadingAttributesMore,isLoadingMoreItems,isLoadingMoreNotifications,isLoadingMoreFonts,isLoadingMoreSoundEvents,openNavigation,themeSettings,selectedItem,selectedNotification,selectedFont,selectedAttribute,selectedSoundEvent); @override String toString() { - return 'AppState(items: $items, notifications: $notifications, fonts: $fonts, attributes: $attributes, isLoadingAttributesMore: $isLoadingAttributesMore, isLoadingMoreItems: $isLoadingMoreItems, isLoadingMoreNotifications: $isLoadingMoreNotifications, isLoadingMoreFonts: $isLoadingMoreFonts, openNavigation: $openNavigation, themeSettings: $themeSettings, selectedItem: $selectedItem, selectedNotification: $selectedNotification, selectedFont: $selectedFont, selectedAttribute: $selectedAttribute)'; + return 'AppState(items: $items, notifications: $notifications, fonts: $fonts, attributes: $attributes, soundEvents: $soundEvents, isLoadingAttributesMore: $isLoadingAttributesMore, isLoadingMoreItems: $isLoadingMoreItems, isLoadingMoreNotifications: $isLoadingMoreNotifications, isLoadingMoreFonts: $isLoadingMoreFonts, isLoadingMoreSoundEvents: $isLoadingMoreSoundEvents, openNavigation: $openNavigation, themeSettings: $themeSettings, selectedItem: $selectedItem, selectedNotification: $selectedNotification, selectedFont: $selectedFont, selectedAttribute: $selectedAttribute, selectedSoundEvent: $selectedSoundEvent)'; } @@ -321,11 +339,11 @@ abstract mixin class _$AppStateCopyWith<$Res> implements $AppStateCopyWith<$Res> factory _$AppStateCopyWith(_AppState value, $Res Function(_AppState) _then) = __$AppStateCopyWithImpl; @override @useResult $Res call({ -@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items,@GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications,@GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts,@GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts, bool openNavigation, ThemeSettings themeSettings,@JsonKey(includeToJson: false) ItemModel? selectedItem,@JsonKey(includeToJson: false) NotificationModel? selectedNotification,@JsonKey(includeToJson: false) FontModel? selectedFont,@JsonKey(includeToJson: false) AttributeModel? selectedAttribute +@GenericPaginatedResultConverter(fromJsonT: itemModelFromJson, toJsonT: itemModelToJson) PaginatedResult items,@GenericPaginatedResultConverter(fromJsonT: notificationFromJson, toJsonT: notificationModelToJson) PaginatedResult notifications,@GenericPaginatedResultConverter(fromJsonT: fontFromJson, toJsonT: fontToJson) PaginatedResult fonts,@GenericPaginatedResultConverter(fromJsonT: attributeFromJson, toJsonT: attributeToJson) PaginatedResult attributes,@GenericPaginatedResultConverter(fromJsonT: soundEventFromJson, toJsonT: soundEventToJson) PaginatedResult soundEvents,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingAttributesMore,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreItems,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreNotifications,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreFonts,@JsonKey(includeToJson: false, includeFromJson: false) bool isLoadingMoreSoundEvents, bool openNavigation, ThemeSettings themeSettings,@JsonKey(includeToJson: false) ItemModel? selectedItem,@JsonKey(includeToJson: false) NotificationModel? selectedNotification,@JsonKey(includeToJson: false) FontModel? selectedFont,@JsonKey(includeToJson: false) AttributeModel? selectedAttribute,@JsonKey(includeToJson: false) SoundEventModel? selectedSoundEvent }); -@override $ItemModelCopyWith<$Res>? get selectedItem;@override $NotificationModelCopyWith<$Res>? get selectedNotification;@override $FontModelCopyWith<$Res>? get selectedFont;@override $AttributeModelCopyWith<$Res>? get selectedAttribute; +@override $ItemModelCopyWith<$Res>? get selectedItem;@override $NotificationModelCopyWith<$Res>? get selectedNotification;@override $FontModelCopyWith<$Res>? get selectedFont;@override $AttributeModelCopyWith<$Res>? get selectedAttribute;@override $SoundEventModelCopyWith<$Res>? get selectedSoundEvent; } /// @nodoc @@ -338,23 +356,26 @@ class __$AppStateCopyWithImpl<$Res> /// Create a copy of AppState /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? items = null,Object? notifications = null,Object? fonts = null,Object? attributes = null,Object? isLoadingAttributesMore = null,Object? isLoadingMoreItems = null,Object? isLoadingMoreNotifications = null,Object? isLoadingMoreFonts = null,Object? openNavigation = null,Object? themeSettings = null,Object? selectedItem = freezed,Object? selectedNotification = freezed,Object? selectedFont = freezed,Object? selectedAttribute = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? items = null,Object? notifications = null,Object? fonts = null,Object? attributes = null,Object? soundEvents = null,Object? isLoadingAttributesMore = null,Object? isLoadingMoreItems = null,Object? isLoadingMoreNotifications = null,Object? isLoadingMoreFonts = null,Object? isLoadingMoreSoundEvents = null,Object? openNavigation = null,Object? themeSettings = null,Object? selectedItem = freezed,Object? selectedNotification = freezed,Object? selectedFont = freezed,Object? selectedAttribute = freezed,Object? selectedSoundEvent = freezed,}) { return _then(_AppState( items: null == items ? _self.items : items // ignore: cast_nullable_to_non_nullable as PaginatedResult,notifications: null == notifications ? _self.notifications : notifications // ignore: cast_nullable_to_non_nullable as PaginatedResult,fonts: null == fonts ? _self.fonts : fonts // ignore: cast_nullable_to_non_nullable as PaginatedResult,attributes: null == attributes ? _self.attributes : attributes // ignore: cast_nullable_to_non_nullable -as PaginatedResult,isLoadingAttributesMore: null == isLoadingAttributesMore ? _self.isLoadingAttributesMore : isLoadingAttributesMore // ignore: cast_nullable_to_non_nullable +as PaginatedResult,soundEvents: null == soundEvents ? _self.soundEvents : soundEvents // ignore: cast_nullable_to_non_nullable +as PaginatedResult,isLoadingAttributesMore: null == isLoadingAttributesMore ? _self.isLoadingAttributesMore : isLoadingAttributesMore // ignore: cast_nullable_to_non_nullable as bool,isLoadingMoreItems: null == isLoadingMoreItems ? _self.isLoadingMoreItems : isLoadingMoreItems // ignore: cast_nullable_to_non_nullable as bool,isLoadingMoreNotifications: null == isLoadingMoreNotifications ? _self.isLoadingMoreNotifications : isLoadingMoreNotifications // ignore: cast_nullable_to_non_nullable as bool,isLoadingMoreFonts: null == isLoadingMoreFonts ? _self.isLoadingMoreFonts : isLoadingMoreFonts // ignore: cast_nullable_to_non_nullable +as bool,isLoadingMoreSoundEvents: null == isLoadingMoreSoundEvents ? _self.isLoadingMoreSoundEvents : isLoadingMoreSoundEvents // ignore: cast_nullable_to_non_nullable as bool,openNavigation: null == openNavigation ? _self.openNavigation : openNavigation // ignore: cast_nullable_to_non_nullable as bool,themeSettings: null == themeSettings ? _self.themeSettings : themeSettings // ignore: cast_nullable_to_non_nullable as ThemeSettings,selectedItem: freezed == selectedItem ? _self.selectedItem : selectedItem // ignore: cast_nullable_to_non_nullable as ItemModel?,selectedNotification: freezed == selectedNotification ? _self.selectedNotification : selectedNotification // ignore: cast_nullable_to_non_nullable as NotificationModel?,selectedFont: freezed == selectedFont ? _self.selectedFont : selectedFont // ignore: cast_nullable_to_non_nullable as FontModel?,selectedAttribute: freezed == selectedAttribute ? _self.selectedAttribute : selectedAttribute // ignore: cast_nullable_to_non_nullable -as AttributeModel?, +as AttributeModel?,selectedSoundEvent: freezed == selectedSoundEvent ? _self.selectedSoundEvent : selectedSoundEvent // ignore: cast_nullable_to_non_nullable +as SoundEventModel?, )); } @@ -406,6 +427,18 @@ $AttributeModelCopyWith<$Res>? get selectedAttribute { return $AttributeModelCopyWith<$Res>(_self.selectedAttribute!, (value) { return _then(_self.copyWith(selectedAttribute: value)); }); +}/// Create a copy of AppState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$SoundEventModelCopyWith<$Res>? get selectedSoundEvent { + if (_self.selectedSoundEvent == null) { + return null; + } + + return $SoundEventModelCopyWith<$Res>(_self.selectedSoundEvent!, (value) { + return _then(_self.copyWith(selectedSoundEvent: value)); + }); } } diff --git a/lib/api/state/app_state.g.dart b/lib/api/state/app_state.g.dart index e373ff76..a26932b8 100644 --- a/lib/api/state/app_state.g.dart +++ b/lib/api/state/app_state.g.dart @@ -55,6 +55,18 @@ _AppState _$AppStateFromJson(Map json) => _AppState( json['attributes'] as Map, (value) => AttributeModel.fromJson(value as Map), ), + soundEvents: json['soundEvents'] == null + ? const PaginatedResult( + items: [], + totalItems: 0, + totalPages: 0, + currentPage: 1, + pageSize: 0, + ) + : PaginatedResult.fromJson( + json['soundEvents'] as Map, + (value) => SoundEventModel.fromJson(value as Map), + ), openNavigation: json['openNavigation'] as bool? ?? true, themeSettings: json['themeSettings'] == null ? const ThemeSettings( @@ -81,6 +93,11 @@ _AppState _$AppStateFromJson(Map json) => _AppState( : AttributeModel.fromJson( json['selectedAttribute'] as Map, ), + selectedSoundEvent: json['selectedSoundEvent'] == null + ? null + : SoundEventModel.fromJson( + json['selectedSoundEvent'] as Map, + ), ); Map _$AppStateToJson(_AppState instance) => { @@ -88,6 +105,7 @@ Map _$AppStateToJson(_AppState instance) => { 'notifications': instance.notifications.toJson((value) => value), 'fonts': instance.fonts.toJson((value) => value), 'attributes': instance.attributes.toJson((value) => value), + 'soundEvents': instance.soundEvents.toJson((value) => value), 'openNavigation': instance.openNavigation, 'themeSettings': instance.themeSettings, }; diff --git a/lib/api/state/factory/sound/selected_sound_state.dart b/lib/api/state/factory/sound/selected_sound_state.dart new file mode 100644 index 00000000..4ed57040 --- /dev/null +++ b/lib/api/state/factory/sound/selected_sound_state.dart @@ -0,0 +1,80 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:flutter/foundation.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/api/paginated_result.dart'; +import 'package:stelaris/api/state/actions/sound/sound_file_actions.dart'; +import 'package:stelaris/api/state/app_state.dart'; +import 'package:stelaris/feature/sound/sound_general_page.dart'; + +/// The [SelectedSoundState] is a factory class that creates a view model for the currently selected sound event. +/// It extends [VmFactory] and is used to provide the selected sound event's details to the view. +class SelectedSoundState + extends VmFactory { + SelectedSoundState(); + + @override + SelectedSoundView fromStore() { + final selectedEvent = state.selectedSoundEvent; + + return SelectedSoundView( + selected: state.selectedSoundEvent!, + onLoadMoreSoundFiles: () { + if (selectedEvent != null && + selectedEvent.id != null && + selectedEvent.files.hasNextPage && + !selectedEvent.isLoading) { + dispatch( + LoadMoreSoundFiles( + pageToLoad: selectedEvent.files.currentPage + 1, + pageSize: selectedEvent.files.pageSize > 0 + ? selectedEvent.files.pageSize + : 20, + ), + ); + } + }, + ); + } +} + +/// The [SelectedSoundView] is a view model that represents the currently selected sound event. +/// It extends [Vm] and provides properties to access the selected sound event's details. +/// +/// It includes additional methods to help determine if the selected sound event has associated sound files +class SelectedSoundView extends Vm { + SelectedSoundView({ + required this.selected, + required this.onLoadMoreSoundFiles, + }) : super(equals: [selected, selected.files.items.length]); + + final SoundEventModel selected; + final VoidCallback onLoadMoreSoundFiles; + + /// Returns a boolean indicator if the selected sound event contains sound files or not. + bool get hasNoFiles => selected.files.items.isEmpty; + + /// Internal variable to hold the paginated result of sound files + PaginatedResult get _fileResults => selected.files; + + /// Returns a list of sound file sources associated with the selected sound event. + List get sources => _fileResults.items; + + /// Returns the count of sound files associated with the selected sound event. + int get fileCount => selected.files.items.length; + + /// Indication if there are more pages of sound files to load + bool get hasNextPage => _fileResults.hasNextPage; + + /// Returns the current page of sound files associated with the selected sound event. + bool get isLoadingFiles => selected.isLoading; + + /// Provides access to a specific sound file source by its index. + /// If the index is out of bounds, it throws a [RangeError]. + SoundFileSource operator [](int index) { + if (index < 0 || index >= fileCount) { + throw RangeError.index(index, sources, 'index', null, fileCount); + } + return sources[index]; + } +} diff --git a/lib/api/state/factory/sound/sound_vm_state.dart b/lib/api/state/factory/sound/sound_vm_state.dart new file mode 100644 index 00000000..f2acbe79 --- /dev/null +++ b/lib/api/state/factory/sound/sound_vm_state.dart @@ -0,0 +1,41 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; +import 'package:stelaris/api/state/app_state.dart'; +import 'package:stelaris/feature/sound/sound_page.dart'; + +class SoundVmFactory extends VmFactory { + SoundVmFactory(); + + @override + SoundViewModel fromStore() => SoundViewModel( + models: state.soundEvents.items, + selected: state.selectedSoundEvent, + hasNextPage: state.soundEvents.hasNextPage, + isLoadingMore: state.isLoadingMoreSoundEvents, + ); +} + +class SoundViewModel extends Vm { + SoundViewModel({ + required this.models, + required this.selected, + required this.hasNextPage, + required this.isLoadingMore, + }) : super(equals: [selected, models, hasNextPage, isLoadingMore]); + + final SoundEventModel? selected; + final List models; + final bool hasNextPage; + final bool isLoadingMore; + + bool isSelectedItem(SoundEventModel model) { + if (selected == null) return false; + + final selectedModel = selected!; + + if (selectedModel.id != null && model.id != null) { + return selectedModel.id == model.id; + } + return selectedModel.hashCode == model.hashCode; + } +} diff --git a/lib/api/util/navigation.dart b/lib/api/util/navigation.dart index 951c6b22..a9142085 100644 --- a/lib/api/util/navigation.dart +++ b/lib/api/util/navigation.dart @@ -25,7 +25,14 @@ enum NavigationEntry { '/fonts', Icons.font_download_outlined, Icons.font_download, - ); + ), + sound( + 'Sound', + '/sound', + Icons.volume_up_outlined, + Icons.volume_up, + ) + ; final String display; final String route; diff --git a/lib/feature/sound/card/folder_icon.dart b/lib/feature/sound/card/folder_icon.dart new file mode 100644 index 00000000..ab8664ae --- /dev/null +++ b/lib/feature/sound/card/folder_icon.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +/// The [FolderIcon] is a widget that displays a folder icon in the center of a container. +/// It is styled with padding, margin, and a background color that matches the theme's secondary container. +class FolderIcon extends StatelessWidget { + const FolderIcon({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.folder, + color: theme.colorScheme.onSecondaryContainer, + size: 28, + ), + ); + } +} diff --git a/lib/feature/sound/card/small_file_card.dart b/lib/feature/sound/card/small_file_card.dart new file mode 100644 index 00000000..69773bb4 --- /dev/null +++ b/lib/feature/sound/card/small_file_card.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/feature/sound/card/folder_icon.dart'; +import 'package:stelaris/feature/sound/modal/sound_file_modal_helper.dart'; + +class SmallFileCard extends StatelessWidget { + const SmallFileCard({required this.fileSource, super.key}); + + final SoundFileSource fileSource; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Card( + color: theme.colorScheme.surface, + surfaceTintColor: theme.colorScheme.surfaceTint, + elevation: 1, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => showSoundFileModal( + context: context, + create: false, + source: fileSource, + ), + child: const FolderIcon(), + ), + ); + } +} diff --git a/lib/feature/sound/card/sound_card_button.dart b/lib/feature/sound/card/sound_card_button.dart new file mode 100644 index 00000000..b7985748 --- /dev/null +++ b/lib/feature/sound/card/sound_card_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/feature/sound/modal/sound_file_modal_helper.dart'; + +/// The [SoundCardButton] is a widget that displays a specific button on a sound card which triggers a dialog to view sound file details. +/// It is styled with the theme's secondary container colors and has a rounded rectangle shape. +class SoundCardButton extends StatelessWidget { + const SoundCardButton({required this.source, super.key}); + + final SoundFileSource source; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.secondaryContainer, + foregroundColor: theme.colorScheme.onSecondaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + onPressed: () => showSoundFileModal( + context: context, + create: false, + source: source, + ), + child: const Text('View'), + ); + } +} diff --git a/lib/feature/sound/card/sound_file_card.dart b/lib/feature/sound/card/sound_file_card.dart new file mode 100644 index 00000000..294ca641 --- /dev/null +++ b/lib/feature/sound/card/sound_file_card.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/feature/sound/card/folder_icon.dart'; +import 'package:stelaris/feature/sound/card/small_file_card.dart'; +import 'package:stelaris/feature/sound/card/sound_card_button.dart'; + +class SoundFileCard extends StatelessWidget { + + static const double _fullCardMinWidth = 230; + + const SoundFileCard({required this.source, this.onDeleteRequested, super.key}); + + final SoundFileSource source; + final VoidCallback? onDeleteRequested; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return LayoutBuilder( + builder: (context, constraints) { + // If the card is too narrow, show only the icon centered in a card + if (constraints.maxWidth < _fullCardMinWidth) { + return SmallFileCard(fileSource: source); + } + // Otherwise, show the full card layout + return Card( + color: theme.colorScheme.surface, + surfaceTintColor: theme.colorScheme.surfaceTint, + elevation: 1, + child: Padding( + padding: const EdgeInsets.only(right: 16), + child: Row( + children: [ + const FolderIcon(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + source.name, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + source.name, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + SoundCardButton(source: source), + const SizedBox(width: 8), + if (onDeleteRequested != null) + OutlinedButton( + onPressed: onDeleteRequested, + child: const Text('Delete'), + ), + ], + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/feature/sound/modal/section/base_integer_section_field.dart b/lib/feature/sound/modal/section/base_integer_section_field.dart new file mode 100644 index 00000000..449b856c --- /dev/null +++ b/lib/feature/sound/modal/section/base_integer_section_field.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/util/formatter/min_value_formatter.dart'; + +class BaseIntegerField extends StatefulWidget { + final String label; + final int initialValue; + final int minValue; + final ValueChanged onChanged; + + const BaseIntegerField({ + required this.label, + required this.initialValue, + required this.minValue, + required this.onChanged, + super.key, + }); + + @override + State createState() => _BaseIntegerFieldState(); +} + +class _BaseIntegerFieldState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue.toString()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: _controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: widget.label, + border: const OutlineInputBorder(), + ), + inputFormatters: [ + MinValueFormatter(widget.minValue), + ], + validator: (v) { + if (v == null || v.isEmpty) return 'Enter a ${widget.label}'; + final val = int.tryParse(v); + if (val == null) return 'Enter a valid integer'; + return null; + }, + onChanged: (v) { + final parsed = int.tryParse(v); + if (parsed != null) widget.onChanged(parsed); + }, + ); + } +} \ No newline at end of file diff --git a/lib/feature/sound/modal/section/base_section.dart b/lib/feature/sound/modal/section/base_section.dart new file mode 100644 index 00000000..5f6962a9 --- /dev/null +++ b/lib/feature/sound/modal/section/base_section.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +/// A foundational container widget that provides consistent visual styling +/// for grouped content sections throughout the application. +/// +/// `BaseSection` serves as a reusable building block for creating visually +/// cohesive sections with standardized appearance. It applies a consistent +/// background color, rounded corners, and internal padding to any child widget, +/// making it ideal for forms, settings panels, content cards, and other +/// grouped UI elements. +/// +/// The widget automatically adapts to the current theme, using the +/// `surfaceContainerHighest` color from the Material 3 color scheme to +/// ensure proper contrast and accessibility across different themes. +/// +/// ## Usage +/// +/// ```dart +/// BaseSection( +/// child: Column( +/// children: [ +/// Text('Section Title'), +/// // Other section content... +/// ], +/// ), +/// ) +/// ``` +/// See also: +/// +/// * [Container], the underlying widget used for styling +/// * [Material], for more complex surface styling needs +/// * [Card], for elevated content sections +class BaseSection extends StatelessWidget { + /// Creates a styled container section with consistent visual appearance. + /// + /// The [child] parameter is required and represents the content to be + /// displayed within the styled container. + /// + /// ```dart + /// BaseSection( + /// child: Text('Hello, World!'), + /// ) + /// ``` + const BaseSection({ + required this.child, + this.title, + this.padding, + super.key, + }); + + /// The widget to be displayed inside the styled container. + /// + /// This can be any widget - from simple text to complex layouts. + /// The child will be wrapped with consistent padding and background styling. + final Widget child; + + /// Optional title shown above [child]. If null, only [child] is rendered. + final String? title; + + /// Optional padding override. Defaults to EdgeInsets.all(20). + final EdgeInsetsGeometry? padding; + + @override + Widget build(BuildContext context) { + // Extract the surface variant color from the current theme + // This ensures the section adapts to light/dark themes automatically + final surfaceVariant = Theme.of(context).colorScheme.surfaceContainerHighest; + + return Container( + decoration: BoxDecoration( + color: surfaceVariant, + borderRadius: BorderRadius.circular(16), + ), + padding: padding ?? const EdgeInsets.all(20), + child: title == null + ? child + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title!, + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/feature/sound/modal/section/sound_slider.dart b/lib/feature/sound/modal/section/sound_slider.dart new file mode 100644 index 00000000..97612088 --- /dev/null +++ b/lib/feature/sound/modal/section/sound_slider.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +/// A widget that combines a descriptive [Text] label with a [Slider]. +/// +/// `LabeledSlider` simplifies creating rows where a slider controls a value, +/// and that value is clearly labeled. It's designed for contexts like settings +/// screens or forms where users adjust parameters like volume, brightness, etc. +/// +/// The widget handles the common layout of placing the label to the left +/// and the slider to the right, expanding to fill available horizontal space. +/// +class SoundSliderRow extends StatelessWidget { + const SoundSliderRow({ + required this.label, + required this.value, + required this.onChanged, + this.onChangeEnd, + this.min = 0.0, + this.max = 1.0, + this.divisions = 10, + this.labelWidth = 100.0, + this.labelStyle, + this.valueDecimalPlaces = 2, + super.key, + }); + + final String label; + final double value; + final ValueChanged onChanged; + final ValueChanged? + onChangeEnd; // Optional: if you need to act on slider release + final double min; + final double max; + final int? divisions; + final double labelWidth; + final TextStyle? labelStyle; + final int valueDecimalPlaces; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + SizedBox( + width: labelWidth, + child: Text( + label, + style: labelStyle ?? Theme.of(context).textTheme.bodyLarge, + ), + ), + Expanded( + child: Slider( + value: value, + min: min, + max: max, + divisions: divisions, + label: value.toStringAsFixed(valueDecimalPlaces), + onChanged: onChanged, + onChangeEnd: onChangeEnd, // Pass it through + ), + ), + ], + ); + } +} diff --git a/lib/feature/sound/modal/section/string_field_section.dart b/lib/feature/sound/modal/section/string_field_section.dart new file mode 100644 index 00000000..c855638c --- /dev/null +++ b/lib/feature/sound/modal/section/string_field_section.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/feature/sound/modal/section/base_section.dart'; +import 'package:stelaris/util/typedefs.dart'; + +class StringInputSection extends StatefulWidget { + const StringInputSection({ + required this.onUpdate, + required this.initialValue, + super.key, + }); + + final String initialValue; + final ValueUpdate onUpdate; + + @override + State createState() => _StringInputSectionState(); +} + +class _StringInputSectionState extends State { + late final TextEditingController _controller; + final _borderRadius = BorderRadius.circular(8); + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.initialValue); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final outlineBorder = OutlineInputBorder( + borderRadius: _borderRadius, + borderSide: BorderSide(color: colorScheme.outline), + ); + return BaseSection( + title: 'Name', + child: TextField( + controller: _controller, + onChanged: (value) { + if (value.isNotEmpty && value.trim().isEmpty) return; + widget.onUpdate(value); + }, + decoration: InputDecoration( + hintText: 'Enter your sound name', + hintStyle: TextStyle(color: colorScheme.onSurfaceVariant), + border: outlineBorder, + suffixIcon: const Tooltip( + message: 'The name of the sound', + child: Icon(Icons.info_outline), + ), + enabledBorder: outlineBorder, + focusedBorder: OutlineInputBorder( + borderRadius: _borderRadius, + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + errorBorder: OutlineInputBorder( + borderRadius: _borderRadius, + borderSide: BorderSide(color: colorScheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: _borderRadius, + borderSide: BorderSide(color: colorScheme.error, width: 2), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + ), + ), + ); + } +} diff --git a/lib/feature/sound/modal/sound_file_modal.dart b/lib/feature/sound/modal/sound_file_modal.dart new file mode 100644 index 00000000..b698fb72 --- /dev/null +++ b/lib/feature/sound/modal/sound_file_modal.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/feature/base/button/cancel_button.dart'; +import 'package:stelaris/feature/sound/modal/section/base_section.dart'; +import 'package:stelaris/feature/sound/modal/type/integer_fields_section.dart'; +import 'package:stelaris/feature/sound/modal/type/sound_switch_section.dart'; +import 'package:stelaris/feature/sound/modal/type/volume_section.dart'; + +import 'section/string_field_section.dart'; + +class SoundFileModal extends StatefulWidget { + + const SoundFileModal({ + required this.create, + required this.onSave, + this.initialData, + super.key, + }); + + final void Function(SoundFileSource soundFile)? onSave; + final bool create; + final SoundFileSource? initialData; + + @override + State createState() => _SoundFileModalState(); +} + + +class _SoundFileModalState extends State { + late String _name; + late double _volume; + late double _pitch; + late int _weight; + late bool _stream; + late int _attenuationDistance; + late bool _preload; + late String _type; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + debugPrint('Read data from'); + if (widget.initialData != null) { + debugPrint('Exsts'); + debugPrint(widget.initialData!.toString()); + } + final data = widget.initialData; + _name = data?.name ?? ''; + _volume = data?.volume ?? 1; + _pitch = data?.pitch ?? 1; + _weight = data?.weight ?? 1; + _stream = true; + _attenuationDistance = data?.attenuationDistance ?? 16; + _preload = data?.preload ?? false; + _type = data?.type ?? 'file'; + } + + @override + Widget build(BuildContext context) { + final media = MediaQuery.of(context); + final isDense = media.size.height < 500; + final smallGap = isDense ? 12.0 : 16.0; + final largeGap = isDense ? 20.0 : 32.0; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 400, + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.create ? 'Create Sound' : 'Edit Sound', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + StringInputSection( + initialValue: _name, + onUpdate: (value) => _name = value, + ), + const SizedBox(height: 16), + VolumeSection( + initialPitch: _pitch, + initialVolume: _volume, + onPitchFinalized: (v) => setState(() => _pitch = v), + onVolumeFinalized: (v) => setState(() => _volume = v), + ), + SizedBox(height: smallGap), + IntegerFieldsSection( + weight: _weight, + attenuation: _attenuationDistance, + onWeightChanged: (v) => setState(() => _weight = v), + onAttenuationChanged: (v) => + setState(() => _attenuationDistance = v), + dense: isDense, + ), + SizedBox(height: smallGap), + // Combined Section: Switches + Type dropdown + BaseSection( + title: 'Options', + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LayoutBuilder( + builder: (context, constraints) { + final horizontal = constraints.maxWidth >= 420; + return SwitchesSection( + streamValue: _stream, + onStreamChanged: (v) => setState(() => _stream = v), + preloadValue: _preload, + onPreloadChanged: (v) => setState(() => _preload = v), + wrapInBaseSection: false, + vertical: !horizontal, + ); + }, + ), + SizedBox(height: smallGap), + const Divider(height: 1), + SizedBox(height: smallGap), + DropdownButtonFormField( + initialValue: _type, + decoration: const InputDecoration( + labelText: 'Type', + border: OutlineInputBorder(), + ), + items: const [ + DropdownMenuItem(value: 'file', child: Text('File')), + DropdownMenuItem(value: 'event', child: Text('Event')), + ], + onChanged: (v) => setState(() => _type = v ?? 'file'), + ), + ], + ), + ), + SizedBox(height: largeGap), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const CancelButton(), + const SizedBox(width: 12), + FilledButton( + onPressed: () { + if (_formKey.currentState?.validate() ?? false) { + widget.onSave?.call( + SoundFileSource( + id: widget.initialData?.id, + name: _name, + volume: _volume, + pitch: _pitch, + weight: _weight, + attenuationDistance: _attenuationDistance, + preload: _preload, + type: _type, + ), + ); + + Navigator.pop(context); + } + }, + child: const Text('Save'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/feature/sound/modal/sound_file_modal_helper.dart b/lib/feature/sound/modal/sound_file_modal_helper.dart new file mode 100644 index 00000000..8c4d600a --- /dev/null +++ b/lib/feature/sound/modal/sound_file_modal_helper.dart @@ -0,0 +1,34 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:flutter/material.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/api/state/actions/sound/sound_file_actions.dart'; +import 'package:stelaris/feature/sound/modal/sound_file_modal.dart'; + +/// Displays a modal dialog for creating or updating a [SoundFileSource]. +/// +/// This function simplifies showing the [SoundFileModal] and handles dispatching +/// the correct Redux action when the user saves. If [create] is true, it dispatches +/// a [SoundFileLinkAction] to add a new file. Otherwise, it dispatches a +/// [SoundFileUpdateAction] to modify an existing one. +/// +/// - [context]: The build context from which to launch the dialog. +/// - [create]: A boolean that determines whether the modal is for creating a new file. +/// - [source]: The initial [SoundFileSource] data, typically provided when updating an existing file. +void showSoundFileModal({ + required BuildContext context, + required bool create, + SoundFileSource? source, +}) { + showDialog( + context: context, + builder: (dialogContext) => SoundFileModal( + initialData: source, + create: create, + onSave: (soundFile) { + final action = + create ? SoundFileLinkAction(soundFile) : SoundFileUpdateAction(soundFile); + dialogContext.dispatch(action); + }, + ), + ); +} diff --git a/lib/feature/sound/modal/type/integer_fields_section.dart b/lib/feature/sound/modal/type/integer_fields_section.dart new file mode 100644 index 00000000..a34d7551 --- /dev/null +++ b/lib/feature/sound/modal/type/integer_fields_section.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/feature/sound/modal/section/base_section.dart'; +import 'package:stelaris/feature/sound/modal/section/base_integer_section_field.dart'; + +class IntegerFieldsSection extends StatelessWidget { + final int weight; + final int attenuation; + final int minValue; + final ValueChanged onWeightChanged; + final ValueChanged onAttenuationChanged; + final bool dense; + + const IntegerFieldsSection({ + required this.weight, + required this.attenuation, + required this.onWeightChanged, + required this.onAttenuationChanged, + this.minValue = 1, + this.dense = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + final verticalGap = dense ? 8.0 : 12.0; + return BaseSection( + title: 'Weight & Attenuation', + padding: dense ? const EdgeInsets.all(16) : null, + child: LayoutBuilder( + builder: (context, constraints) { + final canRow = !dense && constraints.maxWidth >= 520; + if (canRow) { + return Row( + children: [ + Expanded( + child: BaseIntegerField( + label: 'Weight', + initialValue: weight, + minValue: minValue, + onChanged: onWeightChanged, + ), + ), + const SizedBox(width: 16), + Expanded( + child: BaseIntegerField( + label: 'Attenuation Distance', + initialValue: attenuation, + minValue: minValue, + onChanged: onAttenuationChanged, + ), + ), + ], + ); + } + return Column( + children: [ + BaseIntegerField( + label: 'Weight', + initialValue: weight, + minValue: minValue, + onChanged: onWeightChanged, + ), + SizedBox(height: verticalGap), + BaseIntegerField( + label: 'Attenuation Distance', + initialValue: attenuation, + minValue: minValue, + onChanged: onAttenuationChanged, + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/feature/sound/modal/type/sound_switch_section.dart b/lib/feature/sound/modal/type/sound_switch_section.dart new file mode 100644 index 00000000..069ed119 --- /dev/null +++ b/lib/feature/sound/modal/type/sound_switch_section.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/feature/sound/modal/section/base_section.dart'; + +/// A section that exposes the Stream and Preload switches. +/// +/// By default it stacks both controls vertically (each rendered as a single +/// row with label + switch) and wraps the content inside a `BaseSection`. +/// You can disable the wrapper to embed this widget inside another section. +class SwitchesSection extends StatelessWidget { + final bool streamValue; + final ValueChanged onStreamChanged; + final bool preloadValue; + final ValueChanged onPreloadChanged; + + /// When true, the content is wrapped inside a `BaseSection`. + final bool wrapInBaseSection; + + /// If true, renders the two controls stacked vertically in a Column. + /// If false, renders them side-by-side in a Row. + final bool vertical; + + const SwitchesSection({ + required this.streamValue, + required this.onStreamChanged, + required this.preloadValue, + required this.onPreloadChanged, + this.wrapInBaseSection = true, + this.vertical = true, + super.key, + }); + + @override + Widget build(BuildContext context) { + final content = vertical + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildLabeledSwitchRow( + context: context, + label: 'Stream', + value: streamValue, + onChanged: onStreamChanged, + ), + const SizedBox(height: 12), + _buildLabeledSwitchRow( + context: context, + label: 'Preload', + value: preloadValue, + onChanged: onPreloadChanged, + ), + ], + ) + : Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: _buildLabeledSwitchRow( + context: context, + label: 'Stream', + value: streamValue, + onChanged: onStreamChanged, + ), + ), + const SizedBox(width: 24), + Expanded( + child: _buildLabeledSwitchRow( + context: context, + label: 'Preload', + value: preloadValue, + onChanged: onPreloadChanged, + ), + ), + ], + ); + + if (wrapInBaseSection) { + return BaseSection(child: content); + } + return content; + } + + /// Renders a single line with a text label on the left and the switch on the + /// right to use vertical space efficiently. + Widget _buildLabeledSwitchRow({ + required BuildContext context, + required String label, + required bool value, + required ValueChanged onChanged, + }) { + return Row( + children: [ + Expanded( + child: Text( + label, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + Switch( + value: value, + onChanged: onChanged, + ), + ], + ); + } +} diff --git a/lib/feature/sound/modal/type/volume_section.dart b/lib/feature/sound/modal/type/volume_section.dart new file mode 100644 index 00000000..d56636bd --- /dev/null +++ b/lib/feature/sound/modal/type/volume_section.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:stelaris/feature/sound/modal/section/base_section.dart'; +import 'package:stelaris/feature/sound/modal/section/sound_slider.dart'; + +/// A dedicated section for controlling sound volume and pitch settings. +/// +/// `VolumeSection` provides a visually grouped pair of sliders for adjusting +/// the volume and pitch of a sound. It manages the immediate state of these +/// sliders and reports the finalized values back to its parent widget +/// when the user finishes interacting with a slider. +/// +/// This widget is typically used within modals or settings panels where +/// detailed sound properties need to be configured. It applies a distinct +/// background and padding to visually separate these controls. +/// +class VolumeSection extends StatefulWidget { + const VolumeSection({ + required this.initialVolume, + required this.initialPitch, + required this.onVolumeFinalized, + required this.onPitchFinalized, + super.key, + }); + + final double initialVolume; + final double initialPitch; + final ValueChanged onVolumeFinalized; + final ValueChanged onPitchFinalized; + + @override + State createState() => _VolumeSectionState(); +} + +class _VolumeSectionState extends State { + late double _currentVolume; + late double _currentPitch; + + @override + void initState() { + super.initState(); + _currentVolume = widget.initialVolume; + _currentPitch = widget.initialPitch; + } + + @override + Widget build(BuildContext context) { + return BaseSection( + title: 'Volume & Pitch', + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SoundSliderRow( + label: 'Volume', + value: _currentVolume, + onChanged: (newVolume) { + setState(() { + _currentVolume = newVolume; + }); + }, + onChangeEnd: widget.onVolumeFinalized, + ), + const SizedBox(height: 12), + SoundSliderRow( + label: 'Pitch', + value: _currentPitch, + onChanged: (newPitch) { + setState(() { + _currentPitch = newPitch; + }); + }, + onChangeEnd: widget.onPitchFinalized, + ), + ], + ), + ); + } +} diff --git a/lib/feature/sound/sound_file_entries.dart b/lib/feature/sound/sound_file_entries.dart new file mode 100644 index 00000000..3d66cc01 --- /dev/null +++ b/lib/feature/sound/sound_file_entries.dart @@ -0,0 +1,153 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:flutter/material.dart'; +import 'package:stelaris/api/model/sound/sound_file_source.dart'; +import 'package:stelaris/api/paginated_result.dart'; +import 'package:stelaris/api/state/actions/sound/sound_actions.dart'; +import 'package:stelaris/api/state/actions/sound/sound_file_actions.dart'; +import 'package:stelaris/api/state/app_state.dart'; +import 'package:stelaris/api/state/factory/sound/selected_sound_state.dart'; +import 'package:stelaris/feature/base/chips/action_chips.dart'; +import 'package:stelaris/feature/base/empty_data_widget.dart'; +import 'package:stelaris/feature/sound/card/sound_file_card.dart'; +import 'package:stelaris/feature/sound/modal/sound_file_modal_helper.dart'; +import 'package:stelaris/util/constants.dart'; + +class SoundFileEntryPage extends StatefulWidget { + const SoundFileEntryPage({super.key}); + + @override + State createState() => _SoundFileEntriesState(); +} + +class _SoundFileEntriesState extends State { + final ScrollController _scrollController = ScrollController(); + SelectedSoundView? _vm; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StoreConnector( + vm: () => SelectedSoundState(), + onInit: (store) => store.dispatch(InitSoundFileAction()), + onDidChange: (context, store, viewModel) { + _vm = viewModel; + }, + builder: (context, vm) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + verticalSpacing25, + _getActionWidget(context), + const SizedBox(height: 16), + vm.hasNoFiles + ? const Flexible(flex: 1, child: EmptyDataWidget()) + : Expanded( + child: _buildListView(vm), + ), + ] + , + ); + }, + ); + } + + Widget _getActionWidget(BuildContext context) { + return ActionChips( + addCallback: () => showSoundFileModal(context: context, create: true), + saveCallback: () => context.dispatch(SoundDatabaseUpdate()), + ); + } + + Widget _buildListView(SelectedSoundView state) { + final PaginatedResult files = state.selected.files; + final hasFooter = state.isLoadingFiles || state.hasNextPage; + final itemCount = files.items.length + (hasFooter ? 1 : 0); + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(8), + itemCount: itemCount, + clipBehavior: Clip.none, + itemBuilder: (context, index) { + if (index >= files.items.length) { + return _buildFooter(state.isLoadingFiles); + } + return ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 220, + maxWidth: 400, + ), + child: SoundFileCard( + source: files.items[index], + onDeleteRequested: () => _confirmAndDelete(context, files.items[index]), + ), + ); + }, + ); + } + + void _onScroll() { + if (_vm == null) return; + + final SelectedSoundView view = _vm!; + + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200 && + view.hasNextPage && + !view.isLoadingFiles) { + view.onLoadMoreSoundFiles(); + } + } + + Widget _buildFooter(bool isLoading) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Center( + child: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const SizedBox.shrink(), + ), + ); + } + + Future _confirmAndDelete(BuildContext context, SoundFileSource source) async { + final bool? confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete file'), + content: const Text('Unlink this file from the sound event?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + + if (confirmed == true) { + context.dispatch(SoundFileSourceDeleteAction(source)); + } + } +} \ No newline at end of file diff --git a/lib/feature/sound/sound_general_page.dart b/lib/feature/sound/sound_general_page.dart new file mode 100644 index 00000000..01d0caf2 --- /dev/null +++ b/lib/feature/sound/sound_general_page.dart @@ -0,0 +1,152 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stelaris/api/state/actions/sound/sound_actions.dart'; +import 'package:stelaris/api/state/app_state.dart'; +import 'package:stelaris/api/state/factory/sound/selected_sound_state.dart'; +import 'package:stelaris/feature/base/button/positioned_save_button.dart'; +import 'package:stelaris/feature/base/cards/text_input_card.dart'; +import 'package:stelaris/util/constants.dart'; +import 'package:stelaris/util/functions.dart'; +import 'package:stelaris/util/l10n_ext.dart'; + +/// A widget that represents the general sound event management page. +/// +/// The [SoundGeneralPage] allows users to view and edit the details +/// of a selected sound event, including its name, material, title, description, +/// and frame type. It provides a form for input and a save button to commit changes. +class SoundGeneralPage extends StatefulWidget { + const SoundGeneralPage({super.key}); + + @override + State createState() => _SoundGeneralPageState(); +} + +class _SoundGeneralPageState extends State { + final _formKey = GlobalKey(); + final ScrollController _scrollController = ScrollController(); + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StoreConnector( + vm: () => SelectedSoundState(), + onDispose: (store) => + store.dispatch(RemoveSelectedSoundEvent(), notify: false), + builder: (context, vm) { + final selected = vm.selected; + return Form( + key: _formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Stack( + children: [ + Positioned.fill( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: 1, + child: LayoutBuilder( + builder: (context, constraints) { + return Scrollbar( + controller: _scrollController, + thumbVisibility: true, + trackVisibility: true, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 16, + runSpacing: 16, + children: [ + _buildTextField( + label: context.l10n.card_name, + currentValue: selected.variableName, + validator: (value) => + checkIfEmptyAndReturnErrorString( + value, + context, + ), + onChanged: (value) { + final newEntry = selected.copyWith( + variableName: value, + ); + context.dispatch(UpdateSoundAction(newEntry)); + }, + ), + _buildTextField( + label: 'Key', + currentValue: selected.keyName, + validator: (value) => + checkIfEmptyAndReturnErrorString( + value, + context, + ), + onChanged: (value) { + final newEntry = selected.copyWith( + keyName: value, + ); + context.dispatch(UpdateSoundAction(newEntry)); + }, + ), + _buildTextField( + label: 'Subtitle', + currentValue: selected.subTitle, + validator: (value) => + checkIfEmptyAndReturnErrorString( + value, + context, + ), + onChanged: (value) { + final newEntry = selected.copyWith( + subTitle: value, + ); + context.dispatch(UpdateSoundAction(newEntry)); + }, + ), + ], + ), + ), + ); + }, + ), + ), + ), + PositionedSaveButton.standard( + callback: () { + if (_formKey.currentState?.validate() ?? false) { + context.dispatch(SoundDatabaseUpdate()); + } + }, + ), + ], + ), + ); + }, + ); + } + + /// The method builds a reusable text input card for updating string values. + /// It takes different parameters to customize different aspects of the text field. + Widget _buildTextField({ + required String label, + required String? currentValue, + required void Function(String) onChanged, + String? Function(dynamic)? validator, + }) { + return TextInputCard( + display: label, + currentValue: currentValue ?? emptyString, + formatter: [FilteringTextInputFormatter.allow(stringPattern)], + formValidator: validator, + valueUpdate: (value) { + if (value != currentValue) { + onChanged(value); + } + }, + ); + } +} diff --git a/lib/feature/sound/sound_page.dart b/lib/feature/sound/sound_page.dart new file mode 100644 index 00000000..e42dfbf8 --- /dev/null +++ b/lib/feature/sound/sound_page.dart @@ -0,0 +1,102 @@ +import 'package:async_redux/async_redux.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:nil/nil.dart'; +import 'package:stelaris/api/model/sound/sound_event_model.dart'; +import 'package:stelaris/api/state/actions/sound/sound_actions.dart'; +import 'package:stelaris/api/state/app_state.dart'; +import 'package:stelaris/api/state/factory/sound/sound_vm_state.dart'; +import 'package:stelaris/feature/base/model_text.dart'; +import 'package:stelaris/feature/base/paginated_model_view_tab.dart'; +import 'package:stelaris/feature/dialogs/entry_update_dialog.dart'; +import 'package:stelaris/feature/sound/sound_file_entries.dart'; +import 'package:stelaris/feature/sound/sound_general_page.dart'; +import 'package:stelaris/util/constants.dart'; +import 'package:stelaris/util/functions.dart'; + +class SoundPage extends StatelessWidget { + const SoundPage({super.key}); + + @override + Widget build(BuildContext context) { + return StoreConnector( + vm: () => SoundVmFactory(), + onInit: (store) => store.dispatchAndWait(InitSoundAction()), + onDispose: (store) => + store.dispatch(RemoveSelectedSoundEvent(), notify: false), + builder: (context, vm) { + return PaginatedBaseModelViewTabs( + mapToDataModelItem: (value) => TextWidget(displayName: value.uiName), + openFunction: () => _openCreationDialog(context), + selectedItem: vm.selected, + mapToDeleteDialog: (value) => createDeleteText(value.uiName, context), + mapToDeleteSuccessfully: (value) { + context.dispatch(SoundRemoveAction(value)); + return true; + }, + callFunction: (model) => context.dispatch(SelectSoundAction(model)), + models: vm.models, + page: (page, model) => _mapPageToWidget(page, model), + compareFunction: (model) => vm.isSelectedItem(model), + tabs: _getTabs(), + tabPages: (pages) => pages, + isLoadingMore: vm.isLoadingMore, + hasMore: vm.hasNextPage, + onLoadMore: vm.hasNextPage && !vm.isLoadingMore + ? () => context.dispatch(InitSoundAction()) + : null, + ); + }, + ); + } + + Widget _mapPageToWidget(String value, SoundEventModel? listenable) { + if (value.trim().isEmpty) return nil; + if (listenable == null) return nil; + switch (value) { + case 'General': + // Add a key based on the selected item's ID to force a rebuild when the selected item changes + return SoundGeneralPage( + key: ValueKey('sound${listenable.id}'), + ); + case 'Entries': + return const SoundFileEntryPage(); + + } + return nil; + } + + void _openCreationDialog(BuildContext context) { + showDialog( + context: context, + useRootNavigator: false, + builder: (BuildContext context) { + return EntryUpdateDialog( + title: 'Create sound', + valueUpdate: (value) { + final model = SoundEventModel(uiName: value); + context.dispatch(SoundAddAction(model)); + Navigator.pop(context, true); + }, + formKey: GlobalKey(), + hintText: 'Example name', + formatters: [ + FilteringTextInputFormatter.allow(stringWithSpacePattern), + ], + formFieldValidator: (value) { + final input = value as String; + return checkIfEmptyAndReturnErrorString(input, context); + }, + clearFunction: (text) => text.trim().isNotEmpty, + ); + }, + ); + } + + List _getTabs() { + return [ + const Tab(child: Text('General')), + const Tab(child: Text('Entries')), + ]; + } +} diff --git a/lib/util/formatter/min_value_formatter.dart b/lib/util/formatter/min_value_formatter.dart new file mode 100644 index 00000000..11e5b253 --- /dev/null +++ b/lib/util/formatter/min_value_formatter.dart @@ -0,0 +1,45 @@ +import 'package:flutter/services.dart'; + +class MinValueFormatter extends TextInputFormatter { + final int min; + + MinValueFormatter(this.min); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + var text = newValue.text; + var selectionIndex = newValue.selection.end; // keep track of cursor + + if (text.isEmpty) { + text = min.toString(); + selectionIndex = text.length; + } else { + // Strip leading zeros (but not a single "0") + if (text.length > 1 && text.startsWith('0')) { + final withoutLeadingZeros = int.parse(text).toString(); + // Adjust cursor position by how many zeros we cut + selectionIndex -= (text.length - withoutLeadingZeros.length); + text = withoutLeadingZeros; + } + + // Enforce min + final parsed = int.tryParse(text); + if (parsed == null || parsed < min) { + text = min.toString(); + selectionIndex = text.length; + } + } + + // Clamp selection to valid range + selectionIndex = selectionIndex.clamp(0, text.length); + + return TextEditingValue( + text: text, + selection: TextSelection.collapsed(offset: selectionIndex), + composing: TextRange.empty, + ); + } +} diff --git a/lib/util/routes.dart b/lib/util/routes.dart index 012244d1..6d4440b4 100644 --- a/lib/util/routes.dart +++ b/lib/util/routes.dart @@ -6,6 +6,7 @@ import 'package:stelaris/feature/base/base_page.dart'; import 'package:stelaris/feature/font/font_page.dart'; import 'package:stelaris/feature/item/item_page.dart'; import 'package:stelaris/feature/notification/notification_page.dart'; +import 'package:stelaris/feature/sound/sound_page.dart'; final GoRouter router = GoRouter( initialLocation: NavigationEntry.attributes.route, @@ -58,5 +59,17 @@ final GoRouter router = GoRouter( ), ), ), + GoRoute( + path: NavigationEntry.sound.route, + pageBuilder: (context, state) => CustomTransitionPage( + child: const BasePage(child: SoundPage()), + key: state.pageKey, + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition( + opacity: animation, + child: child, + ), + ), + ), ], ); diff --git a/test/feature/sound/.gitkeep b/test/feature/sound/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/test/feature/sound/card/folder_icon_unit_test.dart b/test/feature/sound/card/folder_icon_unit_test.dart new file mode 100644 index 00000000..80c8e40d --- /dev/null +++ b/test/feature/sound/card/folder_icon_unit_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stelaris/feature/sound/card/folder_icon.dart'; + +void main() { + testWidgets('FolderIcon builds correctly with proper layout and style', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: FolderIcon())), + ); + + // FolderIcon widget exists + expect(find.byType(FolderIcon), findsOneWidget); + + // There should be exactly one Container + expect(find.byType(Container), findsOneWidget); + + // There should be exactly one Icon + final iconFinder = find.byType(Icon); + expect(iconFinder, findsOneWidget); + + // Verify the Icon widget’s properties + final Icon iconWidget = tester.widget(iconFinder); + expect(iconWidget.size, 28); + expect(iconWidget.icon, Icons.folder); + + // Check that color is set from theme + expect(iconWidget.color, isA()); + expect(iconWidget.color, isNotNull); + }); +} diff --git a/test/util.formatter/min_value_formatter_integration_test.dart b/test/util.formatter/min_value_formatter_integration_test.dart new file mode 100644 index 00000000..9360c0ae --- /dev/null +++ b/test/util.formatter/min_value_formatter_integration_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stelaris/util/formatter/min_value_formatter.dart'; + +void main() { + testWidgets('TextField with MinValueFormatter behaves correctly', + (tester) async { + final controller = TextEditingController(); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: TextField( + controller: controller, + inputFormatters: [MinValueFormatter(1)], + ), + ), + ), + ); + + // Enter "05" + await tester.enterText(find.byType(TextField), '05'); + expect(controller.text, '5'); + + // Enter "0" + await tester.enterText(find.byType(TextField), '0'); + expect(controller.text, '1'); // snapped to min + + // Enter "21" + await tester.enterText(find.byType(TextField), '21'); + expect(controller.text, '21'); + }); +} diff --git a/test/util.formatter/min_value_formatter_unit_test.dart b/test/util.formatter/min_value_formatter_unit_test.dart new file mode 100644 index 00000000..c17ab864 --- /dev/null +++ b/test/util.formatter/min_value_formatter_unit_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stelaris/util/formatter/min_value_formatter.dart'; + +void main() { + group('MinValueFormatter', () { + const min = 1; + final formatter = MinValueFormatter(min); + + TextEditingValue format(String oldText, String newText, {int? cursor}) { + return formatter.formatEditUpdate( + TextEditingValue( + text: oldText, + selection: TextSelection.collapsed(offset: oldText.length), + ), + TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: cursor ?? newText.length), + ), + ); + } + + test('empty input resets to min', () { + final result = format('2', ''); + expect(result.text, '1'); + expect(result.selection.baseOffset, '1'.length); + }); + + test('leading zero removed', () { + final result = format('', '01'); + expect(result.text, '1'); + expect(result.selection.baseOffset, 1); + }); + + test('below min resets to min', () { + final result = format('', '0'); + expect(result.text, '1'); + expect(result.selection.baseOffset, 1); + }); + + test('valid number stays as is', () { + final result = format('', '21'); + expect(result.text, '21'); + expect(result.selection.baseOffset, 2); + }); + + test('multiple leading zeros collapse', () { + final result = format('', '0007'); + expect(result.text, '7'); + expect(result.selection.baseOffset, 1); + }); + }); +}