refactor: reduce settings and mapper boilerplate

This commit is contained in:
edde746
2026-05-02 09:05:23 +02:00
parent 130e0d6f40
commit 7fc136f592
13 changed files with 408 additions and 240 deletions
+167 -178
View File
@@ -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,
);
}
+43 -50
View File
@@ -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(
+50
View File
@@ -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(),
);
+2 -2
View File
@@ -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) {
+83
View File
@@ -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);
});
});
}