From 50bc245677cf274a3adb94be0628b88b6072d7ce Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 2 May 2026 01:58:50 +0200 Subject: [PATCH] refactor: share low-risk UI and tracker helpers --- lib/screens/main_screen.dart | 58 ++---------- lib/screens/settings/settings_screen.dart | 43 ++------- lib/services/plex_mappers.dart | 32 +++---- .../trackers/anilist/anilist_session.dart | 11 ++- lib/services/trackers/mal/mal_session.dart | 15 ++-- .../trackers/simkl/simkl_session.dart | 12 ++- .../trackers/tracker_session_utils.dart | 16 ++++ lib/services/trakt/trakt_session.dart | 12 +-- lib/utils/json_utils.dart | 9 ++ lib/utils/scroll_utils.dart | 15 ++++ lib/utils/update_dialog.dart | 63 +++++++++++++ .../video_controls/sheets/chapter_sheet.dart | 15 ++-- .../video_controls/sheets/queue_sheet.dart | 15 ++-- .../sheets/sheet_column_header.dart | 23 +++++ .../video_controls/sheets/track_sheet.dart | 88 +++++-------------- .../sheets/version_quality_sheet.dart | 57 +++--------- .../trackers/tracker_session_utils_test.dart | 26 ++++++ test/utils/json_utils_test.dart | 25 ++++++ 18 files changed, 270 insertions(+), 265 deletions(-) create mode 100644 lib/services/trackers/tracker_session_utils.dart create mode 100644 lib/utils/update_dialog.dart create mode 100644 lib/widgets/video_controls/sheets/sheet_column_header.dart create mode 100644 test/services/trackers/tracker_session_utils_test.dart diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 10b2ea99..c76cec52 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -6,17 +6,16 @@ import 'package:flutter/services.dart' show HardwareKeyboard, KeyDownEvent, KeyUpEvent, LogicalKeyboardKey, SystemNavigator; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import '../i18n/strings.g.dart'; import '../services/update_service.dart'; import '../utils/app_logger.dart'; import '../widgets/auth_error_banner.dart'; -import '../widgets/dialog_action_button.dart'; import '../utils/dialogs.dart'; import '../utils/provider_extensions.dart'; import '../utils/platform_detector.dart'; import '../utils/snackbar_helper.dart'; +import '../utils/update_dialog.dart'; import '../utils/video_player_navigation.dart'; import '../main.dart'; import '../mixins/refreshable.dart'; @@ -459,59 +458,20 @@ class _MainScreenState extends State with RouteAware, WindowListener final updateInfo = await UpdateService.checkForUpdatesOnStartup(); if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) { - _showUpdateDialog(updateInfo); + await _showUpdateDialog(updateInfo); } } catch (e) { appLogger.e('Error checking for updates', error: e); } } - void _showUpdateDialog(Map updateInfo) { - showDialog( - context: context, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: Text(t.update.available), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.update.versionAvailable(version: updateInfo['latestVersion']), - style: Theme.of(dialogContext).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - t.update.currentVersion(version: updateInfo['currentVersion']), - style: Theme.of(dialogContext).textTheme.bodySmall, - ), - ], - ), - actions: [ - DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: t.common.later), - DialogActionButton( - onPressed: () async { - await UpdateService.skipVersion(updateInfo['latestVersion']); - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - label: t.update.skipVersion, - ), - DialogActionButton( - onPressed: () async { - final url = Uri.parse(updateInfo['releaseUrl']); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } - if (dialogContext.mounted) Navigator.pop(dialogContext); - }, - label: t.update.viewRelease, - isPrimary: true, - ), - ], - ); - }, - ); - } + Future _showUpdateDialog(Map updateInfo) => showUpdateAvailableDialog( + context, + updateInfo, + title: t.update.available, + dismissLabel: t.common.later, + showSkipVersion: true, + ); /// Set up the Watch Together navigation callback for guests void _setupWatchTogetherCallback() { diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 418640ba..1fea9fbf 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -31,6 +31,7 @@ import '../../services/update_service.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; +import '../../utils/update_dialog.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/dialog_action_button.dart'; import '../../widgets/settings_section.dart'; @@ -879,44 +880,10 @@ class _SettingsScreenState extends State with FocusableTab { } void _showUpdateDialog() { - if (_updateInfo == null) return; - - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(t.settings.updateAvailable), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.update.versionAvailable(version: _updateInfo!['latestVersion']), - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 8), - Text( - t.update.currentVersion(version: _updateInfo!['currentVersion']), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - actions: [ - DialogActionButton(onPressed: () => Navigator.pop(context), label: t.common.close), - DialogActionButton( - onPressed: () async { - final url = Uri.parse(_updateInfo!['releaseUrl']); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } - if (context.mounted) Navigator.pop(context); - }, - label: t.update.viewRelease, - isPrimary: true, - ), - ], - ); - }, + final updateInfo = _updateInfo; + if (updateInfo == null) return; + unawaited( + showUpdateAvailableDialog(context, updateInfo, title: t.settings.updateAvailable, dismissLabel: t.common.close), ); } } diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 66967c44..696a208e 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -33,16 +33,6 @@ import '../utils/obfuscation_utils.dart'; /// `com.plexapp.agents.none://` and new-style `tv.plex.agents.none://`. const _unmatchedAgentMarker = 'agents.none://'; -List? _tagsFromJsonList(List? json) => - json?.cast>().map((e) => e['tag'] as String).toList(); - -bool? _flexibleBoolNullable(Object? v) => switch (v) { - final bool b => b, - final int n => n == 1, - final String s => s == '1', - _ => null, -}; - class PlexRoleDto { final int? id; final String? filter; @@ -103,8 +93,8 @@ class PlexMediaVersionDto { height: flexibleInt(json['height']), container: json['container']?.toString(), partKey: partKey, - accessible: _flexibleBoolNullable(part?['accessible']), - exists: _flexibleBoolNullable(part?['exists']), + accessible: flexibleBoolNullable(part?['accessible']), + exists: flexibleBoolNullable(part?['exists']), ); } } @@ -516,15 +506,15 @@ class PlexMetadataDto { childCount: flexibleInt(json['childCount']), role: roleList, mediaVersions: mediaList, - genre: _tagsFromJsonList(json['Genre'] as List?), - director: _tagsFromJsonList(json['Director'] as List?), - writer: _tagsFromJsonList(json['Writer'] as List?), - producer: _tagsFromJsonList(json['Producer'] as List?), - country: _tagsFromJsonList(json['Country'] as List?), - collection: _tagsFromJsonList(json['Collection'] as List?), - label: _tagsFromJsonList(json['Label'] as List?), - style: _tagsFromJsonList(json['Style'] as List?), - mood: _tagsFromJsonList(json['Mood'] as List?), + genre: stringListFromRaw(json['Genre'], mapKey: 'tag'), + director: stringListFromRaw(json['Director'], mapKey: 'tag'), + writer: stringListFromRaw(json['Writer'], mapKey: 'tag'), + producer: stringListFromRaw(json['Producer'], mapKey: 'tag'), + country: stringListFromRaw(json['Country'], mapKey: 'tag'), + collection: stringListFromRaw(json['Collection'], mapKey: 'tag'), + label: stringListFromRaw(json['Label'], mapKey: 'tag'), + style: stringListFromRaw(json['Style'], mapKey: 'tag'), + mood: stringListFromRaw(json['Mood'], mapKey: 'tag'), audioLanguage: json['audioLanguage'] as String?, subtitleLanguage: json['subtitleLanguage'] as String?, subtitleMode: flexibleInt(json['subtitleMode']), diff --git a/lib/services/trackers/anilist/anilist_session.dart b/lib/services/trackers/anilist/anilist_session.dart index 0ee81b4b..79add8aa 100644 --- a/lib/services/trackers/anilist/anilist_session.dart +++ b/lib/services/trackers/anilist/anilist_session.dart @@ -1,6 +1,5 @@ -import 'dart:convert'; - import '../oauth_proxy_client.dart'; +import '../tracker_session_utils.dart'; /// Immutable AniList OAuth session. /// @@ -14,7 +13,7 @@ class AnilistSession { const AnilistSession({required this.accessToken, required this.expiresAt, required this.createdAt, this.username}); - bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt; + bool get isExpired => isTrackerTokenExpired(expiresAt); AnilistSession copyWith({String? accessToken, int? expiresAt, String? username, int? createdAt}) { return AnilistSession( @@ -43,11 +42,11 @@ class AnilistSession { /// and have no refresh; when the proxy doesn't echo an explicit expiry we /// default to the documented year. factory AnilistSession.fromProxyResult(OAuthProxyResult r) { - final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final createdAt = trackerSessionNowEpochSeconds(); final expiresIn = r.expiresIn ?? 365 * 24 * 60 * 60; return AnilistSession(accessToken: r.accessToken, expiresAt: createdAt + expiresIn, createdAt: createdAt); } - String encode() => json.encode(toJson()); - static AnilistSession decode(String raw) => AnilistSession.fromJson(json.decode(raw) as Map); + String encode() => encodeTrackerSessionJson(toJson()); + static AnilistSession decode(String raw) => decodeTrackerSessionJson(raw, AnilistSession.fromJson); } diff --git a/lib/services/trackers/mal/mal_session.dart b/lib/services/trackers/mal/mal_session.dart index f7ff7c14..78d28e1b 100644 --- a/lib/services/trackers/mal/mal_session.dart +++ b/lib/services/trackers/mal/mal_session.dart @@ -1,6 +1,5 @@ -import 'dart:convert'; - import '../oauth_proxy_client.dart'; +import '../tracker_session_utils.dart'; /// Immutable MyAnimeList OAuth session. /// @@ -21,8 +20,8 @@ class MalSession { this.username, }); - bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt; - bool get needsRefresh => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt - 300; + bool get isExpired => isTrackerTokenExpired(expiresAt); + bool get needsRefresh => trackerTokenNeedsRefresh(expiresAt); MalSession copyWith({String? accessToken, String? refreshToken, int? expiresAt, String? username, int? createdAt}) { return MalSession( @@ -52,7 +51,7 @@ class MalSession { /// Build a session from MAL's `/oauth2/token` response. factory MalSession.fromTokenResponse(Map json) { - final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final createdAt = trackerSessionNowEpochSeconds(); final expiresIn = (json['expires_in'] as num).toInt(); return MalSession( accessToken: json['access_token'] as String, @@ -65,7 +64,7 @@ class MalSession { /// Build a session from an OAuth-proxy result. MAL's refresh_token is /// required for the 31-day refresh loop. factory MalSession.fromProxyResult(OAuthProxyResult r) { - final createdAt = DateTime.now().millisecondsSinceEpoch ~/ 1000; + final createdAt = trackerSessionNowEpochSeconds(); final expiresIn = r.expiresIn ?? 31 * 24 * 60 * 60; return MalSession( accessToken: r.accessToken, @@ -75,6 +74,6 @@ class MalSession { ); } - String encode() => json.encode(toJson()); - static MalSession decode(String raw) => MalSession.fromJson(json.decode(raw) as Map); + String encode() => encodeTrackerSessionJson(toJson()); + static MalSession decode(String raw) => decodeTrackerSessionJson(raw, MalSession.fromJson); } diff --git a/lib/services/trackers/simkl/simkl_session.dart b/lib/services/trackers/simkl/simkl_session.dart index 91b90dd8..239a9ab3 100644 --- a/lib/services/trackers/simkl/simkl_session.dart +++ b/lib/services/trackers/simkl/simkl_session.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import '../tracker_session_utils.dart'; /// Immutable Simkl OAuth session. /// @@ -27,11 +27,9 @@ class SimklSession { /// Build a session from Simkl's device-code `/oauth/pin/` response. /// Simkl doesn't expose a creation timestamp so we stamp "now". - factory SimklSession.fromTokenResponse(Map json) => SimklSession( - accessToken: json['access_token'] as String, - createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000, - ); + factory SimklSession.fromTokenResponse(Map json) => + SimklSession(accessToken: json['access_token'] as String, createdAt: trackerSessionNowEpochSeconds()); - String encode() => json.encode(toJson()); - static SimklSession decode(String raw) => SimklSession.fromJson(json.decode(raw) as Map); + String encode() => encodeTrackerSessionJson(toJson()); + static SimklSession decode(String raw) => decodeTrackerSessionJson(raw, SimklSession.fromJson); } diff --git a/lib/services/trackers/tracker_session_utils.dart b/lib/services/trackers/tracker_session_utils.dart new file mode 100644 index 00000000..b99ee847 --- /dev/null +++ b/lib/services/trackers/tracker_session_utils.dart @@ -0,0 +1,16 @@ +import 'dart:convert' as convert; + +/// Current epoch time in seconds, matching tracker OAuth expiry fields. +int trackerSessionNowEpochSeconds() => DateTime.now().millisecondsSinceEpoch ~/ 1000; + +bool isTrackerTokenExpired(int expiresAt, {int? nowSeconds}) => + (nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt; + +bool trackerTokenNeedsRefresh(int expiresAt, {int refreshWindowSeconds = 300, int? nowSeconds}) => + (nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt - refreshWindowSeconds; + +String encodeTrackerSessionJson(Map value) => convert.json.encode(value); + +T decodeTrackerSessionJson(String raw, T Function(Map json) fromJson) { + return fromJson(convert.json.decode(raw) as Map); +} diff --git a/lib/services/trakt/trakt_session.dart b/lib/services/trakt/trakt_session.dart index 8bde107e..e22be7c7 100644 --- a/lib/services/trakt/trakt_session.dart +++ b/lib/services/trakt/trakt_session.dart @@ -1,4 +1,4 @@ -import 'dart:convert'; +import '../trackers/tracker_session_utils.dart'; /// Immutable Trakt OAuth session. /// @@ -30,10 +30,10 @@ class TraktSession { }); /// Whether the access token has already expired. - bool get isExpired => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt; + bool get isExpired => isTrackerTokenExpired(expiresAt); /// Whether the access token will expire in the next 5 minutes. - bool get needsRefresh => DateTime.now().millisecondsSinceEpoch ~/ 1000 >= expiresAt - 300; + bool get needsRefresh => trackerTokenNeedsRefresh(expiresAt); TraktSession copyWith({ String? accessToken, @@ -76,7 +76,7 @@ class TraktSession { /// Build a session from Trakt's `/oauth/token` or `/oauth/device/token` response, /// which uses `expires_in` (relative seconds) rather than `expires_at`. factory TraktSession.fromTokenResponse(Map json) { - final createdAt = (json['created_at'] as num?)?.toInt() ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; + final createdAt = (json['created_at'] as num?)?.toInt() ?? trackerSessionNowEpochSeconds(); final expiresIn = (json['expires_in'] as num).toInt(); return TraktSession( accessToken: json['access_token'] as String, @@ -87,6 +87,6 @@ class TraktSession { ); } - String encode() => json.encode(toJson()); - static TraktSession decode(String raw) => TraktSession.fromJson(json.decode(raw) as Map); + String encode() => encodeTrackerSessionJson(toJson()); + static TraktSession decode(String raw) => decodeTrackerSessionJson(raw, TraktSession.fromJson); } diff --git a/lib/utils/json_utils.dart b/lib/utils/json_utils.dart index 316494c4..c3194d84 100644 --- a/lib/utils/json_utils.dart +++ b/lib/utils/json_utils.dart @@ -18,6 +18,15 @@ bool flexibleBool(Object? v) => switch (v) { _ => false, }; +/// Parse a value that may be [bool], [int] (0/1), or [String] ('1') to [bool]. +/// Returns `null` for `null` or unrecognised values. +bool? flexibleBoolNullable(Object? v) => switch (v) { + final bool b => b, + final int n => n == 1, + final String s => s == '1', + _ => null, +}; + /// Parse a value that may be [double], [num], or [String] to [double]. double? flexibleDouble(Object? v) => switch (v) { final num n => n.toDouble(), diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index f4d259e4..7cee80a4 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -35,6 +35,21 @@ void scrollToCurrentItem(ScrollController controller, GlobalKey firstItemKey, in }); } +/// Owns the boilerplate for one-time initial scrolling in selectable lists. +class InitialItemScrollController { + final GlobalKey firstItemKey = GlobalKey(); + final ScrollController controller = ScrollController(); + bool _didInitialScroll = false; + + void maybeScrollTo(int? selectedIndex) { + if (_didInitialScroll || selectedIndex == null || selectedIndex <= 0) return; + _didInitialScroll = true; + scrollToCurrentItem(controller, firstItemKey, selectedIndex); + } + + void dispose() => controller.dispose(); +} + /// Scroll a horizontal list to center the item at the given index. /// /// Assumes items are laid out with [leadingPadding] before the first item, diff --git a/lib/utils/update_dialog.dart b/lib/utils/update_dialog.dart new file mode 100644 index 00000000..c9af2c71 --- /dev/null +++ b/lib/utils/update_dialog.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../i18n/strings.g.dart'; +import '../services/update_service.dart'; +import '../widgets/dialog_action_button.dart'; + +Future showUpdateAvailableDialog( + BuildContext context, + Map updateInfo, { + required String title, + required String dismissLabel, + bool showSkipVersion = false, +}) { + return showDialog( + context: context, + builder: (dialogContext) { + final latestVersion = updateInfo['latestVersion'] as String; + final releaseUrl = updateInfo['releaseUrl'] as String; + + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.update.versionAvailable(version: latestVersion), + style: Theme.of(dialogContext).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + t.update.currentVersion(version: updateInfo['currentVersion']), + style: Theme.of(dialogContext).textTheme.bodySmall, + ), + ], + ), + actions: [ + DialogActionButton(onPressed: () => Navigator.pop(dialogContext), label: dismissLabel), + if (showSkipVersion) + DialogActionButton( + onPressed: () async { + await UpdateService.skipVersion(latestVersion); + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + label: t.update.skipVersion, + ), + DialogActionButton( + onPressed: () async { + final url = Uri.parse(releaseUrl); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + if (dialogContext.mounted) Navigator.pop(dialogContext); + }, + label: t.update.viewRelease, + isPrimary: true, + ), + ], + ); + }, + ); +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 52d60fa0..4c508541 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -41,13 +41,11 @@ class ChapterSheet extends StatefulWidget { } class _ChapterSheetState extends State { - final _firstItemKey = GlobalKey(); - final _scrollController = ScrollController(); - bool _didInitialScroll = false; + final _initialScroll = InitialItemScrollController(); @override void dispose() { - _scrollController.dispose(); + _initialScroll.dispose(); super.dispose(); } @@ -82,13 +80,10 @@ class _ChapterSheetState extends State { child: Text(t.videoControls.noChaptersAvailable, style: TextStyle(color: tokens(context).textMuted)), ); } else { - if (!_didInitialScroll && currentChapterIndex != null && currentChapterIndex > 0) { - _didInitialScroll = true; - scrollToCurrentItem(_scrollController, _firstItemKey, currentChapterIndex); - } + _initialScroll.maybeScrollTo(currentChapterIndex); content = ListView.builder( - controller: _scrollController, + controller: _initialScroll.controller, itemCount: widget.chapters.length, itemBuilder: (context, index) { final chapter = widget.chapters[index]; @@ -100,7 +95,7 @@ class _ChapterSheetState extends State { : null; return FocusableListTile( - key: index == 0 ? _firstItemKey : null, + key: index == 0 ? _initialScroll.firstItemKey : null, leading: chapter.thumb != null ? SizedBox( width: 60, diff --git a/lib/widgets/video_controls/sheets/queue_sheet.dart b/lib/widgets/video_controls/sheets/queue_sheet.dart index d4ea7a1c..47d1746a 100644 --- a/lib/widgets/video_controls/sheets/queue_sheet.dart +++ b/lib/widgets/video_controls/sheets/queue_sheet.dart @@ -28,13 +28,11 @@ class QueueSheet extends StatefulWidget { } class _QueueSheetState extends State { - final _firstItemKey = GlobalKey(); - final _scrollController = ScrollController(); - bool _didInitialScroll = false; + final _initialScroll = InitialItemScrollController(); @override void dispose() { - _scrollController.dispose(); + _initialScroll.dispose(); super.dispose(); } @@ -52,13 +50,10 @@ class _QueueSheetState extends State { ); } else { final currentIndex = items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); - if (!_didInitialScroll && currentIndex > 0) { - _didInitialScroll = true; - scrollToCurrentItem(_scrollController, _firstItemKey, currentIndex); - } + _initialScroll.maybeScrollTo(currentIndex); content = ListView.builder( - controller: _scrollController, + controller: _initialScroll.controller, itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; @@ -66,7 +61,7 @@ class _QueueSheetState extends State { final primaryColor = Theme.of(context).colorScheme.primary; return FocusableListTile( - key: index == 0 ? _firstItemKey : null, + key: index == 0 ? _initialScroll.firstItemKey : null, leading: _buildThumbnail(context, item, isCurrent), title: Text( item.title ?? '', diff --git a/lib/widgets/video_controls/sheets/sheet_column_header.dart b/lib/widgets/video_controls/sheets/sheet_column_header.dart new file mode 100644 index 00000000..fad81170 --- /dev/null +++ b/lib/widgets/video_controls/sheets/sheet_column_header.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class SheetColumnHeader extends StatelessWidget { + final String label; + + const SheetColumnHeader({super.key, required this.label}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + label, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 4690d6b0..0a2a5449 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -10,6 +10,7 @@ import '../../../widgets/app_icon.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; +import 'sheet_column_header.dart'; import 'subtitle_search_sheet.dart'; import '../helpers/track_filter_helper.dart'; import '../helpers/track_selection_helper.dart'; @@ -188,40 +189,33 @@ class _SourceAudioColumn extends StatefulWidget { } class _SourceAudioColumnState extends State<_SourceAudioColumn> { - final _firstItemKey = GlobalKey(); - final _scrollController = ScrollController(); - bool _didInitialScroll = false; + final _initialScroll = InitialItemScrollController(); @override void dispose() { - _scrollController.dispose(); + _initialScroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final selectedId = widget.selectedStreamId; - if (!_didInitialScroll && selectedId != null) { - final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId); - if (selectedIndex > 0) { - _didInitialScroll = true; - scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex); - } - } + final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId); + _initialScroll.maybeScrollTo(selectedIndex); return Column( children: [ - if (widget.showHeader) _ColumnHeader(label: t.videoControls.audioLabel), + if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), Expanded( child: ListView.builder( - controller: _scrollController, + controller: _initialScroll.controller, itemCount: widget.tracks.length, itemBuilder: (context, index) { final track = widget.tracks[index]; final isSelected = track.id == selectedId; return TrackSelectionHelper.buildTrackTile( context: context, - key: index == 0 ? _firstItemKey : null, + key: index == 0 ? _initialScroll.firstItemKey : null, label: track.label, isSelected: isSelected, onTap: () { @@ -257,34 +251,26 @@ class _AudioColumn extends StatefulWidget { } class _AudioColumnState extends State<_AudioColumn> { - final _firstItemKey = GlobalKey(); - final _scrollController = ScrollController(); - bool _didInitialScroll = false; + final _initialScroll = InitialItemScrollController(); @override void dispose() { - _scrollController.dispose(); + _initialScroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final selectedId = widget.selection.audio?.id ?? ''; - - if (!_didInitialScroll) { - final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId); - if (selectedIndex > 0) { - _didInitialScroll = true; - scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex); - } - } + final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId); + _initialScroll.maybeScrollTo(selectedIndex); return Column( children: [ - if (widget.showHeader) _ColumnHeader(label: t.videoControls.audioLabel), + if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), Expanded( child: ListView.builder( - controller: _scrollController, + controller: _initialScroll.controller, itemCount: widget.tracks.length, itemBuilder: (context, index) { final track = widget.tracks[index]; @@ -297,7 +283,7 @@ class _AudioColumnState extends State<_AudioColumn> { ); return TrackSelectionHelper.buildTrackTile( context: context, - key: index == 0 ? _firstItemKey : null, + key: index == 0 ? _initialScroll.firstItemKey : null, label: label, isSelected: track.id == selectedId, onTap: () { @@ -348,13 +334,11 @@ class _SubtitleColumn extends StatefulWidget { } class _SubtitleColumnState extends State<_SubtitleColumn> { - final _firstItemKey = GlobalKey(); - final _scrollController = ScrollController(); - bool _didInitialScroll = false; + final _initialScroll = InitialItemScrollController(); @override void dispose() { - _scrollController.dispose(); + _initialScroll.dispose(); super.dispose(); } @@ -368,28 +352,22 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { // +1 for "Off" row final itemCount = widget.tracks.length + 1; - if (!_didInitialScroll && !isOffSelected) { - // +1 because index 0 is the "Off" row - final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1; - if (selectedIndex > 0) { - _didInitialScroll = true; - scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex); - } - } + final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1; + _initialScroll.maybeScrollTo(selectedIndex); return Column( children: [ - if (widget.showHeader) _ColumnHeader(label: t.videoControls.subtitlesLabel), + if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), Expanded( child: ListView.builder( - controller: _scrollController, + controller: _initialScroll.controller, itemCount: itemCount, itemBuilder: (context, index) { // "Off" row if (index == 0) { return TrackSelectionHelper.buildOffTile( context: context, - key: _firstItemKey, + key: _initialScroll.firstItemKey, isSelected: isOffSelected, onTap: () { // Turning off primary also clears secondary @@ -500,25 +478,3 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { ); } } - -class _ColumnHeader extends StatelessWidget { - final String label; - - const _ColumnHeader({required this.label}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - label, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - ); - } -} diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart index 3a6a478c..2e3c2050 100644 --- a/lib/widgets/video_controls/sheets/version_quality_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -10,6 +10,7 @@ import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; +import 'sheet_column_header.dart'; /// Combined sheet for selecting the media [version] (left) and transcode /// [quality] preset (right). The version column is hidden when there is only @@ -137,35 +138,30 @@ class _VersionColumn extends StatefulWidget { } class _VersionColumnState extends State<_VersionColumn> { - final _firstItemKey = GlobalKey(); - final _scrollController = ScrollController(); - bool _didInitialScroll = false; + final _initialScroll = InitialItemScrollController(); @override void dispose() { - _scrollController.dispose(); + _initialScroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - if (!_didInitialScroll && widget.selectedIndex > 0) { - _didInitialScroll = true; - scrollToCurrentItem(_scrollController, _firstItemKey, widget.selectedIndex); - } + _initialScroll.maybeScrollTo(widget.selectedIndex); return Column( children: [ - if (widget.showHeader) _ColumnHeader(label: t.videoControls.versionColumnHeader), + if (widget.showHeader) SheetColumnHeader(label: t.videoControls.versionColumnHeader), Expanded( child: ListView.builder( - controller: _scrollController, + controller: _initialScroll.controller, itemCount: widget.versions.length, itemBuilder: (context, index) { final version = widget.versions[index]; final isSelected = index == widget.selectedIndex; return _SelectionTile( - key: index == 0 ? _firstItemKey : null, + key: index == 0 ? _initialScroll.firstItemKey : null, label: version.displayLabel, isSelected: isSelected, onTap: () => widget.onSelected(index), @@ -202,13 +198,11 @@ class _QualityColumn extends StatefulWidget { } class _QualityColumnState extends State<_QualityColumn> { - final _firstItemKey = GlobalKey(); - final _scrollController = ScrollController(); - bool _didInitialScroll = false; + final _initialScroll = InitialItemScrollController(); @override void dispose() { - _scrollController.dispose(); + _initialScroll.dispose(); super.dispose(); } @@ -217,17 +211,14 @@ class _QualityColumnState extends State<_QualityColumn> { final presets = TranscodeQualityPreset.displayOrder; final selectedIndex = presets.indexOf(widget.selected); - if (!_didInitialScroll && selectedIndex > 0) { - _didInitialScroll = true; - scrollToCurrentItem(_scrollController, _firstItemKey, selectedIndex); - } + _initialScroll.maybeScrollTo(selectedIndex); return Column( children: [ - if (widget.showHeader) _ColumnHeader(label: t.videoControls.qualityColumnHeader), + if (widget.showHeader) SheetColumnHeader(label: t.videoControls.qualityColumnHeader), Expanded( child: ListView.builder( - controller: _scrollController, + controller: _initialScroll.controller, itemCount: presets.length, itemBuilder: (context, index) { final preset = presets[index]; @@ -243,7 +234,7 @@ class _QualityColumnState extends State<_QualityColumn> { ); return _SelectionTile( - key: index == 0 ? _firstItemKey : null, + key: index == 0 ? _initialScroll.firstItemKey : null, label: qualityPresetLabel(preset), trailingText: trailing, isSelected: isSelected, @@ -302,25 +293,3 @@ class _SelectionTile extends StatelessWidget { ); } } - -class _ColumnHeader extends StatelessWidget { - final String label; - - const _ColumnHeader({required this.label}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - label, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - ); - } -} diff --git a/test/services/trackers/tracker_session_utils_test.dart b/test/services/trackers/tracker_session_utils_test.dart new file mode 100644 index 00000000..4d13ee88 --- /dev/null +++ b/test/services/trackers/tracker_session_utils_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/services/trackers/tracker_session_utils.dart'; + +void main() { + group('tracker token expiry helpers', () { + test('detects expired token', () { + expect(isTrackerTokenExpired(100, nowSeconds: 100), isTrue); + expect(isTrackerTokenExpired(101, nowSeconds: 100), isFalse); + }); + + test('detects refresh window', () { + expect(trackerTokenNeedsRefresh(400, nowSeconds: 100), isTrue); + expect(trackerTokenNeedsRefresh(401, nowSeconds: 100), isFalse); + expect(trackerTokenNeedsRefresh(110, refreshWindowSeconds: 10, nowSeconds: 100), isTrue); + }); + }); + + group('tracker session json codec', () { + test('round-trips through provided factory', () { + final encoded = encodeTrackerSessionJson({'access_token': 'abc', 'created_at': 123}); + final decoded = decodeTrackerSessionJson(encoded, (json) => json); + + expect(decoded, {'access_token': 'abc', 'created_at': 123}); + }); + }); +} diff --git a/test/utils/json_utils_test.dart b/test/utils/json_utils_test.dart index 79aca55b..f7f24bbb 100644 --- a/test/utils/json_utils_test.dart +++ b/test/utils/json_utils_test.dart @@ -60,6 +60,31 @@ void main() { }); }); + group('flexibleBoolNullable', () { + test('returns bool as-is', () { + expect(flexibleBoolNullable(true), isTrue); + expect(flexibleBoolNullable(false), isFalse); + }); + + test('maps 1 to true, other ints to false', () { + expect(flexibleBoolNullable(1), isTrue); + expect(flexibleBoolNullable(0), isFalse); + expect(flexibleBoolNullable(2), isFalse); + }); + + test("maps '1' string to true, other strings to false", () { + expect(flexibleBoolNullable('1'), isTrue); + expect(flexibleBoolNullable('0'), isFalse); + expect(flexibleBoolNullable('true'), isFalse); + }); + + test('returns null for null and unsupported types', () { + expect(flexibleBoolNullable(null), isNull); + expect(flexibleBoolNullable(1.0), isNull); + expect(flexibleBoolNullable({}), isNull); + }); + }); + group('flexibleDouble', () { test('parses num as double', () { expect(flexibleDouble(1.5), 1.5);