refactor: reduce settings and mapper boilerplate
This commit is contained in:
@@ -17,6 +17,58 @@ class DialogOption<T> {
|
||||
const DialogOption({required this.value, required this.title, this.subtitle});
|
||||
}
|
||||
|
||||
typedef _SettingsDialogContentBuilder =
|
||||
Widget Function(
|
||||
BuildContext dialogContext,
|
||||
BuildContext contentContext,
|
||||
StateSetter setDialogState,
|
||||
FocusNode saveFocusNode,
|
||||
);
|
||||
|
||||
typedef _SettingsDialogActionsBuilder = List<Widget> Function(BuildContext dialogContext, StateSetter setDialogState);
|
||||
|
||||
void _showSettingsInputDialog({
|
||||
required BuildContext context,
|
||||
required String title,
|
||||
required _SettingsDialogContentBuilder contentBuilder,
|
||||
required Future<bool> Function(BuildContext dialogContext) onSave,
|
||||
_SettingsDialogActionsBuilder? leadingActionsBuilder,
|
||||
VoidCallback? onDispose,
|
||||
}) {
|
||||
final saveFocusNode = FocusNode();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: contentBuilder(dialogContext, context, setDialogState, saveFocusNode),
|
||||
actions: [
|
||||
...?leadingActionsBuilder?.call(dialogContext, setDialogState),
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () async {
|
||||
final shouldClose = await onSave(dialogContext);
|
||||
if (shouldClose && dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
label: t.common.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
saveFocusNode.dispose();
|
||||
onDispose?.call();
|
||||
});
|
||||
}
|
||||
|
||||
/// Shows a selection dialog with focusable rows for dpad/keyboard navigation.
|
||||
/// Used for settings with 5+ options (language, buffer size, etc.).
|
||||
Future<T?> showSelectionDialog<T>({
|
||||
@@ -103,59 +155,43 @@ void _showNumericInputDialogTV({
|
||||
required Future<void> Function(int value) onSave,
|
||||
}) {
|
||||
int spinnerValue = currentValue;
|
||||
final saveFocusNode = FocusNode();
|
||||
|
||||
showDialog(
|
||||
_showSettingsInputDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TvNumberSpinner(
|
||||
value: spinnerValue,
|
||||
min: min,
|
||||
max: max,
|
||||
suffix: suffixText,
|
||||
autofocus: true,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
spinnerValue = value;
|
||||
});
|
||||
},
|
||||
onConfirm: () => saveFocusNode.requestFocus(),
|
||||
onCancel: () => Navigator.pop(dialogContext),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.settings.durationHint(min: min, max: max),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () async {
|
||||
await onSave(spinnerValue);
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
},
|
||||
label: t.common.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
title: title,
|
||||
contentBuilder: (dialogContext, context, setDialogState, saveFocusNode) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TvNumberSpinner(
|
||||
value: spinnerValue,
|
||||
min: min,
|
||||
max: max,
|
||||
suffix: suffixText,
|
||||
autofocus: true,
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
spinnerValue = value;
|
||||
});
|
||||
},
|
||||
onConfirm: () => saveFocusNode.requestFocus(),
|
||||
onCancel: () => Navigator.pop(dialogContext),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
t.settings.durationHint(min: min, max: max),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((_) => saveFocusNode.dispose());
|
||||
onSave: (_) async {
|
||||
await onSave(spinnerValue);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showNumericInputDialogStandard({
|
||||
@@ -170,66 +206,47 @@ void _showNumericInputDialogStandard({
|
||||
}) {
|
||||
final controller = TextEditingController(text: currentValue.toString());
|
||||
String? errorText;
|
||||
final saveFocusNode = FocusNode();
|
||||
|
||||
showDialog(
|
||||
_showSettingsInputDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: labelText,
|
||||
hintText: t.settings.durationHint(min: min, max: max),
|
||||
errorText: errorText,
|
||||
suffixText: suffixText,
|
||||
),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onEditingComplete: () {
|
||||
saveFocusNode.requestFocus();
|
||||
},
|
||||
onChanged: (value) {
|
||||
final parsed = int.tryParse(value);
|
||||
setDialogState(() {
|
||||
if (parsed == null) {
|
||||
errorText = t.settings.validationErrorEnterNumber;
|
||||
} else if (parsed < min || parsed > max) {
|
||||
errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase());
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () async {
|
||||
final parsed = int.tryParse(controller.text);
|
||||
if (parsed != null && parsed >= min && parsed <= max) {
|
||||
await onSave(parsed);
|
||||
if (dialogContext.mounted) {
|
||||
Navigator.pop(dialogContext);
|
||||
}
|
||||
}
|
||||
},
|
||||
label: t.common.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
title: title,
|
||||
contentBuilder: (_, _, setDialogState, saveFocusNode) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: labelText,
|
||||
hintText: t.settings.durationHint(min: min, max: max),
|
||||
errorText: errorText,
|
||||
suffixText: suffixText,
|
||||
),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onEditingComplete: () {
|
||||
saveFocusNode.requestFocus();
|
||||
},
|
||||
onChanged: (value) {
|
||||
final parsed = int.tryParse(value);
|
||||
setDialogState(() {
|
||||
if (parsed == null) {
|
||||
errorText = t.settings.validationErrorEnterNumber;
|
||||
} else if (parsed < min || parsed > max) {
|
||||
errorText = t.settings.validationErrorDuration(min: min, max: max, unit: labelText.toLowerCase());
|
||||
} else {
|
||||
errorText = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
controller.dispose();
|
||||
saveFocusNode.dispose();
|
||||
});
|
||||
onSave: (_) async {
|
||||
final parsed = int.tryParse(controller.text);
|
||||
if (parsed == null || parsed < min || parsed > max) return false;
|
||||
await onSave(parsed);
|
||||
return true;
|
||||
},
|
||||
onDispose: controller.dispose,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert `#RRGGBB` (or `#AARRGGBB`) hex to [Color]. Defaults to black on parse error.
|
||||
@@ -301,35 +318,21 @@ void _showColorInputDialogTV({
|
||||
required Future<void> Function(String hex) onSave,
|
||||
}) {
|
||||
Color picked = hexToColor(currentHex);
|
||||
final saveFocusNode = FocusNode();
|
||||
showDialog(
|
||||
_showSettingsInputDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: TvColorPicker(
|
||||
initialColor: picked,
|
||||
onColorChanged: (c) => setDialogState(() => picked = c),
|
||||
onConfirm: () => saveFocusNode.requestFocus(),
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () async {
|
||||
await onSave(colorToHex(picked));
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext);
|
||||
},
|
||||
label: t.common.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
title: title,
|
||||
contentBuilder: (_, _, setDialogState, saveFocusNode) {
|
||||
return TvColorPicker(
|
||||
initialColor: picked,
|
||||
onColorChanged: (c) => setDialogState(() => picked = c),
|
||||
onConfirm: () => saveFocusNode.requestFocus(),
|
||||
);
|
||||
},
|
||||
).then((_) => saveFocusNode.dispose());
|
||||
onSave: (_) async {
|
||||
await onSave(colorToHex(picked));
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a text input dialog with regex validation and reset-to-default support.
|
||||
@@ -342,57 +345,43 @@ void showRegexInputDialog({
|
||||
}) {
|
||||
final controller = TextEditingController(text: currentValue);
|
||||
String? errorText;
|
||||
final saveFocusNode = FocusNode();
|
||||
|
||||
showDialog(
|
||||
_showSettingsInputDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: Text(title),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'Regex', errorText: errorText),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onEditingComplete: () => saveFocusNode.requestFocus(),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
try {
|
||||
RegExp(value, caseSensitive: false);
|
||||
errorText = null;
|
||||
} catch (_) {
|
||||
errorText = t.settings.invalidRegex;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
DialogActionButton(
|
||||
onPressed: () {
|
||||
controller.text = defaultValue;
|
||||
setDialogState(() => errorText = null);
|
||||
},
|
||||
label: t.settings.resetToDefault,
|
||||
),
|
||||
DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.cancel),
|
||||
DialogActionButton(
|
||||
focusNode: saveFocusNode,
|
||||
onPressed: () async {
|
||||
if (errorText != null) return;
|
||||
await onSave(controller.text);
|
||||
if (dialogContext.mounted) Navigator.pop(dialogContext);
|
||||
},
|
||||
label: t.common.save,
|
||||
),
|
||||
],
|
||||
);
|
||||
title: title,
|
||||
contentBuilder: (_, _, setDialogState, saveFocusNode) {
|
||||
return TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(labelText: 'Regex', errorText: errorText),
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onEditingComplete: () => saveFocusNode.requestFocus(),
|
||||
onChanged: (value) {
|
||||
setDialogState(() {
|
||||
try {
|
||||
RegExp(value, caseSensitive: false);
|
||||
errorText = null;
|
||||
} catch (_) {
|
||||
errorText = t.settings.invalidRegex;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
controller.dispose();
|
||||
saveFocusNode.dispose();
|
||||
});
|
||||
leadingActionsBuilder: (_, setDialogState) => [
|
||||
DialogActionButton(
|
||||
onPressed: () {
|
||||
controller.text = defaultValue;
|
||||
setDialogState(() => errorText = null);
|
||||
},
|
||||
label: t.settings.resetToDefault,
|
||||
),
|
||||
],
|
||||
onSave: (_) async {
|
||||
if (errorText != null) return false;
|
||||
await onSave(controller.text);
|
||||
return true;
|
||||
},
|
||||
onDispose: controller.dispose,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// The client wraps the static methods with per-instance image-URL
|
||||
// resolution and server-tagging.
|
||||
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../media/media_backend.dart';
|
||||
@@ -29,30 +30,35 @@ import '../utils/json_utils.dart';
|
||||
import '../utils/obfuscation_utils.dart';
|
||||
import 'file_info_parser.dart';
|
||||
|
||||
part 'plex_mappers.g.dart';
|
||||
|
||||
/// Shared suffix of both unmatched-agent URL schemes: legacy
|
||||
/// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`.
|
||||
const _unmatchedAgentMarker = 'agents.none://';
|
||||
|
||||
Map<String, dynamic> _obfuscatePlaylistJson(Map<String, dynamic> json) {
|
||||
final copy = Map<String, dynamic>.from(json);
|
||||
for (final key in const ['title', 'summary']) {
|
||||
if (copy[key] is String) copy[key] = obfuscateText(copy[key] as String);
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexRoleDto {
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? id;
|
||||
final String? filter;
|
||||
final String tag;
|
||||
final String? tagKey;
|
||||
final String? role;
|
||||
final String? thumb;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? count;
|
||||
|
||||
const PlexRoleDto({this.id, this.filter, required this.tag, this.tagKey, this.role, this.thumb, this.count});
|
||||
|
||||
factory PlexRoleDto.fromJson(Map<String, dynamic> json) => PlexRoleDto(
|
||||
id: flexibleInt(json['id']),
|
||||
filter: json['filter'] as String?,
|
||||
tag: json['tag'] as String,
|
||||
tagKey: json['tagKey'] as String?,
|
||||
role: json['role'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
count: flexibleInt(json['count']),
|
||||
);
|
||||
factory PlexRoleDto.fromJson(Map<String, dynamic> json) => _$PlexRoleDtoFromJson(json);
|
||||
}
|
||||
|
||||
class PlexMediaVersionDto {
|
||||
@@ -99,19 +105,29 @@ class PlexMediaVersionDto {
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexLibraryDto {
|
||||
@JsonKey(readValue: readStringField, defaultValue: '')
|
||||
final String key;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String title;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String type;
|
||||
final String? agent;
|
||||
final String? scanner;
|
||||
final String? language;
|
||||
final String? uuid;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? updatedAt;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? createdAt;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? hidden;
|
||||
@JsonKey(includeFromJson: false)
|
||||
final String? serverId;
|
||||
@JsonKey(includeFromJson: false)
|
||||
final String? serverName;
|
||||
@JsonKey(includeFromJson: false)
|
||||
final bool isShared;
|
||||
|
||||
const PlexLibraryDto({
|
||||
@@ -130,20 +146,7 @@ class PlexLibraryDto {
|
||||
this.isShared = false,
|
||||
});
|
||||
|
||||
factory PlexLibraryDto.fromJson(Map<String, dynamic> json) {
|
||||
return PlexLibraryDto(
|
||||
key: json['key']?.toString() ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
agent: json['agent'] as String?,
|
||||
scanner: json['scanner'] as String?,
|
||||
language: json['language'] as String?,
|
||||
uuid: json['uuid'] as String?,
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
createdAt: flexibleInt(json['createdAt']),
|
||||
hidden: flexibleInt(json['hidden']),
|
||||
);
|
||||
}
|
||||
factory PlexLibraryDto.fromJson(Map<String, dynamic> json) => _$PlexLibraryDtoFromJson(json);
|
||||
|
||||
PlexLibraryDto copyWith({String? serverId, String? serverName, bool? isShared}) {
|
||||
return PlexLibraryDto(
|
||||
@@ -166,25 +169,40 @@ class PlexLibraryDto {
|
||||
String get globalKey => serverId != null ? buildGlobalKey(serverId!, key) : key;
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class PlexPlaylistDto {
|
||||
@JsonKey(readValue: readStringField, defaultValue: '')
|
||||
final String ratingKey;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String key;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String type;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String title;
|
||||
final String? summary;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool smart;
|
||||
@JsonKey(defaultValue: '')
|
||||
final String playlistType;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? duration;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? leafCount;
|
||||
final String? composite;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? addedAt;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? updatedAt;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? lastViewedAt;
|
||||
@JsonKey(fromJson: flexibleInt)
|
||||
final int? viewCount;
|
||||
final String? content;
|
||||
final String? guid;
|
||||
final String? thumb;
|
||||
@JsonKey(includeFromJson: false)
|
||||
final String? serverId;
|
||||
@JsonKey(includeFromJson: false)
|
||||
final String? serverName;
|
||||
|
||||
const PlexPlaylistDto({
|
||||
@@ -209,33 +227,8 @@ class PlexPlaylistDto {
|
||||
this.serverName,
|
||||
});
|
||||
|
||||
factory PlexPlaylistDto.fromJson(Map<String, dynamic> json) {
|
||||
String? title = json['title'] as String?;
|
||||
String? summary = json['summary'] as String?;
|
||||
if (kBlurArtwork) {
|
||||
if (title != null) title = obfuscateText(title);
|
||||
if (summary != null) summary = obfuscateText(summary);
|
||||
}
|
||||
return PlexPlaylistDto(
|
||||
ratingKey: json['ratingKey']?.toString() ?? '',
|
||||
key: json['key'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
title: title ?? '',
|
||||
summary: summary,
|
||||
smart: json['smart'] as bool? ?? false,
|
||||
playlistType: json['playlistType'] as String? ?? '',
|
||||
duration: flexibleInt(json['duration']),
|
||||
leafCount: flexibleInt(json['leafCount']),
|
||||
composite: json['composite'] as String?,
|
||||
addedAt: flexibleInt(json['addedAt']),
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
lastViewedAt: flexibleInt(json['lastViewedAt']),
|
||||
viewCount: flexibleInt(json['viewCount']),
|
||||
content: json['content'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
}
|
||||
factory PlexPlaylistDto.fromJson(Map<String, dynamic> json) =>
|
||||
_$PlexPlaylistDtoFromJson(kBlurArtwork ? _obfuscatePlaylistJson(json) : json);
|
||||
|
||||
PlexPlaylistDto copyWith({String? serverId, String? serverName}) {
|
||||
return PlexPlaylistDto(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'plex_mappers.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
PlexRoleDto _$PlexRoleDtoFromJson(Map<String, dynamic> json) => PlexRoleDto(
|
||||
id: flexibleInt(json['id']),
|
||||
filter: json['filter'] as String?,
|
||||
tag: json['tag'] as String,
|
||||
tagKey: json['tagKey'] as String?,
|
||||
role: json['role'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
count: flexibleInt(json['count']),
|
||||
);
|
||||
|
||||
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) => PlexLibraryDto(
|
||||
key: readStringField(json, 'key') as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
agent: json['agent'] as String?,
|
||||
scanner: json['scanner'] as String?,
|
||||
language: json['language'] as String?,
|
||||
uuid: json['uuid'] as String?,
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
createdAt: flexibleInt(json['createdAt']),
|
||||
hidden: flexibleInt(json['hidden']),
|
||||
);
|
||||
|
||||
PlexPlaylistDto _$PlexPlaylistDtoFromJson(Map<String, dynamic> json) => PlexPlaylistDto(
|
||||
ratingKey: readStringField(json, 'ratingKey') as String? ?? '',
|
||||
key: json['key'] as String? ?? '',
|
||||
type: json['type'] as String? ?? '',
|
||||
title: json['title'] as String? ?? '',
|
||||
summary: json['summary'] as String?,
|
||||
smart: json['smart'] as bool? ?? false,
|
||||
playlistType: json['playlistType'] as String? ?? '',
|
||||
duration: flexibleInt(json['duration']),
|
||||
leafCount: flexibleInt(json['leafCount']),
|
||||
composite: json['composite'] as String?,
|
||||
addedAt: flexibleInt(json['addedAt']),
|
||||
updatedAt: flexibleInt(json['updatedAt']),
|
||||
lastViewedAt: flexibleInt(json['lastViewedAt']),
|
||||
viewCount: flexibleInt(json['viewCount']),
|
||||
content: json['content'] as String?,
|
||||
guid: json['guid'] as String?,
|
||||
thumb: json['thumb'] as String?,
|
||||
);
|
||||
@@ -1,8 +1,7 @@
|
||||
import '../tracker_account_store.dart';
|
||||
import 'anilist_session.dart';
|
||||
|
||||
final TrackerAccountStore<AnilistSession> anilistAccountStore = TrackerAccountStore<AnilistSession>(
|
||||
final TrackerAccountStore<AnilistSession> anilistAccountStore = createTrackerAccountStore<AnilistSession>(
|
||||
baseKey: 'anilist_session',
|
||||
decode: AnilistSession.decode,
|
||||
encode: (s) => s.encode(),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../tracker_session_utils.dart';
|
||||
///
|
||||
/// Implicit grant — no refresh token. Tokens are valid for 1 year; on expiry
|
||||
/// the user must re-auth.
|
||||
class AnilistSession {
|
||||
class AnilistSession with EncodedTrackerSession {
|
||||
final String accessToken;
|
||||
final int expiresAt;
|
||||
final String? username;
|
||||
@@ -24,6 +24,7 @@ class AnilistSession {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'expires_at': expiresAt,
|
||||
@@ -47,6 +48,5 @@ class AnilistSession {
|
||||
return AnilistSession(accessToken: r.accessToken, expiresAt: createdAt + expiresIn, createdAt: createdAt);
|
||||
}
|
||||
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
static AnilistSession decode(String raw) => decodeTrackerSessionJson(raw, AnilistSession.fromJson);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import '../tracker_account_store.dart';
|
||||
import 'mal_session.dart';
|
||||
|
||||
final TrackerAccountStore<MalSession> malAccountStore = TrackerAccountStore<MalSession>(
|
||||
final TrackerAccountStore<MalSession> malAccountStore = createTrackerAccountStore<MalSession>(
|
||||
baseKey: 'mal_session',
|
||||
decode: MalSession.decode,
|
||||
encode: (s) => s.encode(),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../tracker_session_utils.dart';
|
||||
///
|
||||
/// Access tokens expire in ~31 days. Refresh token rotates with each refresh
|
||||
/// (rare but documented in MAL's API contract).
|
||||
class MalSession {
|
||||
class MalSession with EncodedTrackerSession {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresAt;
|
||||
@@ -33,6 +33,7 @@ class MalSession {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
@@ -74,6 +75,5 @@ class MalSession {
|
||||
);
|
||||
}
|
||||
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
static MalSession decode(String raw) => decodeTrackerSessionJson(raw, MalSession.fromJson);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import '../tracker_account_store.dart';
|
||||
import 'simkl_session.dart';
|
||||
|
||||
final TrackerAccountStore<SimklSession> simklAccountStore = TrackerAccountStore<SimklSession>(
|
||||
final TrackerAccountStore<SimklSession> simklAccountStore = createTrackerAccountStore<SimklSession>(
|
||||
baseKey: 'simkl_session',
|
||||
decode: SimklSession.decode,
|
||||
encode: (s) => s.encode(),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import '../tracker_session_utils.dart';
|
||||
///
|
||||
/// Simkl access tokens don't expire (per their docs), so there's no
|
||||
/// refresh_token — just the bearer and a display name for the settings UI.
|
||||
class SimklSession {
|
||||
class SimklSession with EncodedTrackerSession {
|
||||
final String accessToken;
|
||||
final String? username;
|
||||
final int createdAt;
|
||||
@@ -17,6 +17,7 @@ class SimklSession {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {'access_token': accessToken, 'username': username, 'created_at': createdAt};
|
||||
|
||||
factory SimklSession.fromJson(Map<String, dynamic> json) => SimklSession(
|
||||
@@ -30,6 +31,5 @@ class SimklSession {
|
||||
factory SimklSession.fromTokenResponse(Map<String, dynamic> json) =>
|
||||
SimklSession(accessToken: json['access_token'] as String, createdAt: trackerSessionNowEpochSeconds());
|
||||
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
static SimklSession decode(String raw) => decodeTrackerSessionJson(raw, SimklSession.fromJson);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import '../base_shared_preferences_service.dart';
|
||||
import 'tracker_session_utils.dart';
|
||||
|
||||
/// Per-Plex-profile session persistence for any tracker service.
|
||||
///
|
||||
@@ -44,3 +45,10 @@ class TrackerAccountStore<T> {
|
||||
await prefs.remove(_scopedKey(userUuid));
|
||||
}
|
||||
}
|
||||
|
||||
TrackerAccountStore<T> createTrackerAccountStore<T extends EncodedTrackerSession>({
|
||||
required String baseKey,
|
||||
required T Function(String raw) decode,
|
||||
}) {
|
||||
return TrackerAccountStore<T>(baseKey: baseKey, decode: decode, encode: (session) => session.encode());
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ bool isTrackerTokenExpired(int expiresAt, {int? nowSeconds}) =>
|
||||
bool trackerTokenNeedsRefresh(int expiresAt, {int refreshWindowSeconds = 300, int? nowSeconds}) =>
|
||||
(nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt - refreshWindowSeconds;
|
||||
|
||||
mixin EncodedTrackerSession {
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
String encode() => encodeTrackerSessionJson(toJson());
|
||||
}
|
||||
|
||||
String encodeTrackerSessionJson(Map<String, dynamic> value) => convert.json.encode(value);
|
||||
|
||||
T decodeTrackerSessionJson<T>(String raw, T Function(Map<String, dynamic> json) fromJson) {
|
||||
|
||||
@@ -338,6 +338,30 @@ void main() {
|
||||
expect(lib.hidden, isTrue);
|
||||
});
|
||||
|
||||
test('library DTO keeps generated parsing flexible and ignores client-only fields', () {
|
||||
final dto = PlexLibraryDto.fromJson({
|
||||
'key': 7,
|
||||
'title': 'Music',
|
||||
'type': 'artist',
|
||||
'updatedAt': '1700000000',
|
||||
'createdAt': '1600000000',
|
||||
'hidden': '1',
|
||||
'serverId': 'ignored-server',
|
||||
'serverName': 'Ignored Server',
|
||||
'isShared': true,
|
||||
});
|
||||
|
||||
expect(dto.key, '7');
|
||||
expect(dto.title, 'Music');
|
||||
expect(dto.type, 'artist');
|
||||
expect(dto.updatedAt, 1700000000);
|
||||
expect(dto.createdAt, 1600000000);
|
||||
expect(dto.hidden, 1);
|
||||
expect(dto.serverId, isNull);
|
||||
expect(dto.serverName, isNull);
|
||||
expect(dto.isShared, isFalse);
|
||||
});
|
||||
|
||||
test('library with missing title/type falls back to empty strings', () {
|
||||
// Past regression: bare `as String` casts on title/type in
|
||||
// PlexLibraryDto.fromJson would throw TypeError when Plex omitted
|
||||
@@ -486,6 +510,36 @@ void main() {
|
||||
expect(p.smart, isFalse);
|
||||
expect(p.playlistType, '');
|
||||
});
|
||||
|
||||
test('playlist DTO keeps generated parsing flexible and ignores client-only fields', () {
|
||||
final dto = PlexPlaylistDto.fromJson({
|
||||
'ratingKey': 888,
|
||||
'title': 'Recently Added',
|
||||
'duration': '14400000',
|
||||
'leafCount': '5',
|
||||
'addedAt': '1600000000',
|
||||
'updatedAt': '1700000000',
|
||||
'lastViewedAt': '1750000000',
|
||||
'viewCount': '3',
|
||||
'serverId': 'ignored-server',
|
||||
'serverName': 'Ignored Server',
|
||||
});
|
||||
|
||||
expect(dto.ratingKey, '888');
|
||||
expect(dto.key, '');
|
||||
expect(dto.type, '');
|
||||
expect(dto.title, 'Recently Added');
|
||||
expect(dto.smart, isFalse);
|
||||
expect(dto.playlistType, '');
|
||||
expect(dto.duration, 14400000);
|
||||
expect(dto.leafCount, 5);
|
||||
expect(dto.addedAt, 1600000000);
|
||||
expect(dto.updatedAt, 1700000000);
|
||||
expect(dto.lastViewedAt, 1750000000);
|
||||
expect(dto.viewCount, 3);
|
||||
expect(dto.serverId, isNull);
|
||||
expect(dto.serverName, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('PlexMappers DTO direct entry points', () {
|
||||
@@ -522,5 +576,34 @@ void main() {
|
||||
expect(v.container, 'mp4');
|
||||
expect(v.parts.single.streamPath, '/library/parts/42/file.mp4');
|
||||
});
|
||||
|
||||
test('metadata toJson remains scalar-only for cache overlays', () {
|
||||
const dto = PlexMetadataDto(
|
||||
ratingKey: '1',
|
||||
title: 'Test',
|
||||
serverId: _serverId,
|
||||
role: [PlexRoleDto(tag: 'Actor')],
|
||||
genre: ['Action'],
|
||||
mediaVersions: [PlexMediaVersionDto(id: 42, partKey: '/library/parts/42/file.mp4')],
|
||||
);
|
||||
|
||||
final json = dto.toJson();
|
||||
|
||||
expect(json, containsPair('ratingKey', '1'));
|
||||
expect(json, containsPair('title', 'Test'));
|
||||
expect(json, isNot(contains('serverId')));
|
||||
expect(json, isNot(contains('Role')));
|
||||
expect(json, isNot(contains('Genre')));
|
||||
expect(json, isNot(contains('Media')));
|
||||
});
|
||||
|
||||
test('metadata copyWith preserves nullable values when null is passed', () {
|
||||
const dto = PlexMetadataDto(ratingKey: '1', title: 'Test', summary: 'Summary');
|
||||
|
||||
final copied = dto.copyWith(title: null, summary: null);
|
||||
|
||||
expect(copied.title, 'Test');
|
||||
expect(copied.summary, 'Summary');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_session.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_session.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_session.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session_utils.dart';
|
||||
|
||||
void main() {
|
||||
@@ -22,5 +25,44 @@ void main() {
|
||||
|
||||
expect(decoded, {'access_token': 'abc', 'created_at': 123});
|
||||
});
|
||||
|
||||
test('round-trips AniList sessions through shared encode mixin', () {
|
||||
const session = AnilistSession(accessToken: 'anilist-at', expiresAt: 2000, username: 'alice', createdAt: 1000);
|
||||
|
||||
final decoded = AnilistSession.decode(session.encode());
|
||||
|
||||
expect(decoded.accessToken, 'anilist-at');
|
||||
expect(decoded.expiresAt, 2000);
|
||||
expect(decoded.username, 'alice');
|
||||
expect(decoded.createdAt, 1000);
|
||||
});
|
||||
|
||||
test('round-trips MAL sessions through shared encode mixin', () {
|
||||
const session = MalSession(
|
||||
accessToken: 'mal-at',
|
||||
refreshToken: 'mal-rt',
|
||||
expiresAt: 2000,
|
||||
username: 'bob',
|
||||
createdAt: 1000,
|
||||
);
|
||||
|
||||
final decoded = MalSession.decode(session.encode());
|
||||
|
||||
expect(decoded.accessToken, 'mal-at');
|
||||
expect(decoded.refreshToken, 'mal-rt');
|
||||
expect(decoded.expiresAt, 2000);
|
||||
expect(decoded.username, 'bob');
|
||||
expect(decoded.createdAt, 1000);
|
||||
});
|
||||
|
||||
test('round-trips Simkl sessions through shared encode mixin', () {
|
||||
const session = SimklSession(accessToken: 'simkl-at', username: 'carol', createdAt: 1000);
|
||||
|
||||
final decoded = SimklSession.decode(session.encode());
|
||||
|
||||
expect(decoded.accessToken, 'simkl-at');
|
||||
expect(decoded.username, 'carol');
|
||||
expect(decoded.createdAt, 1000);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user