diff --git a/analysis_options.yaml b/analysis_options.yaml index 2e3c70e5..d6f4d7bf 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,3 +4,9 @@ analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" + plugins: + - dart_code_linter + +dart_code_linter: + rules: + - avoid-unused-parameters diff --git a/lib/constants/layout_constants.dart b/lib/constants/layout_constants.dart index 3420d2cd..91c5aaa6 100644 --- a/lib/constants/layout_constants.dart +++ b/lib/constants/layout_constants.dart @@ -11,15 +11,6 @@ class ScreenBreakpoints { static const double largeDesktop = 1600; } -/// Pagination constants -class PaginationConstants { - /// Default page size for library items - static const int defaultPageSize = 1000; - - /// Page size for search results - static const int searchPageSize = 50; -} - /// Grid layout constants class GridLayoutConstants { /// Maximum cross-axis extent for grid items in comfortable density mode @@ -44,54 +35,3 @@ class GridLayoutConstants { static const double crossAxisSpacing = 0; static const double mainAxisSpacing = 0; } - -/// Padding and spacing constants -class SpacingConstants { - /// Extra small spacing (4px) - static const double xs = 4; - - /// Small spacing (8px) - static const double sm = 8; - - /// Medium spacing (12px) - static const double md = 12; - - /// Large spacing (16px) - static const double lg = 16; - - /// Extra large spacing (24px) - static const double xl = 24; - - /// Double extra large spacing (32px) - static const double xxl = 32; -} - -/// Icon size constants -class IconSizeConstants { - /// Small icon size (16px) - static const double sm = 16; - - /// Medium icon size (24px) - static const double md = 24; - - /// Large icon size (32px) - static const double lg = 32; - - /// Extra large icon size (48px) - static const double xl = 48; -} - -/// Border radius constants -class BorderRadiusConstants { - /// Small border radius (4px) - static const double sm = 4; - - /// Medium border radius (8px) - static const double md = 8; - - /// Large border radius (12px) - static const double lg = 12; - - /// Extra large border radius (16px) - static const double xl = 16; -} diff --git a/lib/models/media_container.dart b/lib/models/media_container.dart deleted file mode 100644 index f77a29fe..00000000 --- a/lib/models/media_container.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; -import 'plex_metadata.dart'; -import 'plex_library.dart'; - -part 'media_container.g.dart'; - -@JsonSerializable() -class MediaContainer { - final int? size; - final int? totalSize; - final int? offset; - final String? identifier; - @JsonKey(name: 'Directory') - final List? directories; - @JsonKey(name: 'Metadata') - final List? metadata; - - MediaContainer({ - this.size, - this.totalSize, - this.offset, - this.identifier, - this.directories, - this.metadata, - }); - - factory MediaContainer.fromJson(Map json) => - _$MediaContainerFromJson(json); - - Map toJson() => _$MediaContainerToJson(this); -} diff --git a/lib/models/media_container.g.dart b/lib/models/media_container.g.dart deleted file mode 100644 index 2ed4e9e0..00000000 --- a/lib/models/media_container.g.dart +++ /dev/null @@ -1,31 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'media_container.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MediaContainer _$MediaContainerFromJson(Map json) => - MediaContainer( - size: (json['size'] as num?)?.toInt(), - totalSize: (json['totalSize'] as num?)?.toInt(), - offset: (json['offset'] as num?)?.toInt(), - identifier: json['identifier'] as String?, - directories: (json['Directory'] as List?) - ?.map((e) => PlexLibrary.fromJson(e as Map)) - .toList(), - metadata: (json['Metadata'] as List?) - ?.map((e) => PlexMetadata.fromJson(e as Map)) - .toList(), - ); - -Map _$MediaContainerToJson(MediaContainer instance) => - { - 'size': instance.size, - 'totalSize': instance.totalSize, - 'offset': instance.offset, - 'identifier': instance.identifier, - 'Directory': instance.directories, - 'Metadata': instance.metadata, - }; diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index 855bf68c..0140d816 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -337,7 +337,7 @@ class _LibraryBrowseTabState extends State value, ); - if (!mounted) return; + if (!context.mounted) return; Navigator.pop(context); _loadItems(); diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 50222340..512ef958 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -62,9 +62,6 @@ class _MediaDetailScreenState extends State { try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) { - throw Exception('No client available'); - } // Fetch full metadata with clearLogo and OnDeck episode final result = await client.getMetadataWithImagesAndOnDeck( @@ -127,9 +124,6 @@ class _MediaDetailScreenState extends State { try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) { - throw Exception('No client available'); - } final seasons = await client.getChildren(widget.metadata.ratingKey); // Preserve serverId for each season @@ -158,9 +152,6 @@ class _MediaDetailScreenState extends State { try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) { - throw Exception('No client available'); - } final metadata = await client.getMetadataWithImages( widget.metadata.ratingKey, @@ -206,9 +197,6 @@ class _MediaDetailScreenState extends State { try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) { - throw Exception('No client available'); - } // If seasons aren't loaded yet, wait for them or load them if (_seasons.isEmpty && !_isLoadingSeasons) { @@ -274,7 +262,6 @@ class _MediaDetailScreenState extends State { PlexMetadata metadata, ) async { final client = _getClientForMetadata(context); - if (client == null) return; final playbackState = context.read(); final itemType = metadata.type.toLowerCase(); @@ -403,13 +390,6 @@ class _MediaDetailScreenState extends State { Builder( builder: (context) { final client = _getClientForMetadata(context); - if (client == null) { - return Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ); - } return CachedNetworkImage( imageUrl: client.getThumbnailUrl(metadata.art), fit: BoxFit.cover, @@ -471,18 +451,6 @@ class _MediaDetailScreenState extends State { final client = _getClientForMetadata( context, ); - if (client == null) { - return Text( - metadata.title, - style: Theme.of(context) - .textTheme - .displaySmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ); - } return CachedNetworkImage( imageUrl: client.getThumbnailUrl( metadata.clearLogo, @@ -730,9 +698,6 @@ class _MediaDetailScreenState extends State { // Otherwise, play the first episode of the first season if (metadata.type.toLowerCase() == 'show') { if (_onDeckEpisode != null) { - final client = _getClientForMetadata(context); - if (client == null) return; - appLogger.d( 'Playing on deck episode: ${_onDeckEpisode!.title}', ); @@ -750,9 +715,6 @@ class _MediaDetailScreenState extends State { await _playFirstEpisode(); } } else { - final client = _getClientForMetadata(context); - if (client == null) return; - appLogger.d('Playing: ${metadata.title}'); // For movies or episodes, play directly await navigateToVideoPlayer( @@ -804,7 +766,6 @@ class _MediaDetailScreenState extends State { onPressed: () async { try { final client = _getClientForMetadata(context); - if (client == null) return; await client.markAsWatched(metadata.ratingKey); if (context.mounted) { @@ -844,7 +805,6 @@ class _MediaDetailScreenState extends State { onPressed: () async { try { final client = _getClientForMetadata(context); - if (client == null) return; await client.markAsUnwatched(metadata.ratingKey); if (context.mounted) { @@ -1124,16 +1084,6 @@ class _MediaDetailScreenState extends State { child: Builder( builder: (context) { final client = _getClientForMetadata(context); - if (client == null) { - return Container( - width: 80, - height: 120, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.movie, size: 32), - ); - } return CachedNetworkImage( imageUrl: client.getThumbnailUrl(season.thumb), width: 80, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ddd2db81..1e7fe5bc 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -186,7 +186,7 @@ class VideoPlayerScreenState extends State if (_isPlayerInitialized && mounted) { // Restore media metadata final client = _getClientForMetadata(context); - if (client != null && _mediaControlsManager != null) { + if (_mediaControlsManager != null) { _mediaControlsManager!.updateMetadata( metadata: widget.metadata, client: client, @@ -355,7 +355,6 @@ class VideoPlayerScreenState extends State if (!mounted || player == null) return; final client = _getClientForMetadata(context); - if (client == null) return; // Initialize progress tracker _progressTracker = PlaybackProgressTracker( @@ -452,7 +451,6 @@ class VideoPlayerScreenState extends State try { final client = _getClientForMetadata(context); - if (client == null) return; final playbackState = context.read(); @@ -518,7 +516,6 @@ class VideoPlayerScreenState extends State try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) return; // Load adjacent episodes using the service final adjacentEpisodes = await _episodeNavigation.loadAdjacentEpisodes( @@ -545,9 +542,9 @@ class VideoPlayerScreenState extends State try { // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) { - throw Exception('No client available'); - } + + // Capture profile settings before async gap + final profileSettings = context.profileSettings; // Initialize playback service final playbackService = PlaybackInitializationService( @@ -583,7 +580,7 @@ class VideoPlayerScreenState extends State // Initialize track selection service and apply tracks _trackSelectionService = TrackSelectionService( player: player!, - profileSettings: context.profileSettings, + profileSettings: profileSettings, metadata: widget.metadata, ); @@ -799,10 +796,6 @@ class VideoPlayerScreenState extends State if (!mounted) return; // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) { - appLogger.w('No client available to save audio language preference'); - return; - } await client.setMetadataPreferences( targetRatingKey, audioLanguage: languageCode, @@ -851,10 +844,6 @@ class VideoPlayerScreenState extends State if (!mounted) return; // Use server-specific client for this metadata final client = _getClientForMetadata(context); - if (client == null) { - appLogger.w('No client available to save subtitle language preference'); - return; - } await client.setMetadataPreferences( targetRatingKey, subtitleLanguage: languageCode, diff --git a/lib/services/server_connection_service.dart b/lib/services/server_connection_service.dart deleted file mode 100644 index e226d06b..00000000 --- a/lib/services/server_connection_service.dart +++ /dev/null @@ -1,356 +0,0 @@ -import 'dart:async'; - -import 'package:connectivity_plus/connectivity_plus.dart'; - -import 'plex_auth_service.dart'; -import 'storage_service.dart'; -import '../client/plex_client.dart'; -import '../config/plex_config.dart'; -import '../models/plex_user_profile.dart'; -import '../utils/app_logger.dart'; - -/// Result of a server connection attempt -class ServerConnectionResult { - final PlexClient? client; - final PlexUserProfile? userProfile; - final String? error; - - ServerConnectionResult({this.client, this.userProfile, this.error}); - - bool get isSuccess => client != null; -} - -/// Service for handling optimized server connections -/// Implements fast-first connection with background optimization -class ServerConnectionService { - static StreamSubscription>? - _connectivitySubscription; - static Future? _activeOptimization; - static PlexServer? _activeServer; - static PlexClient? _activeClient; - - /// Connect to a Plex server with optimized connection testing - /// - /// Returns immediately with first working connection, then continues - /// testing in background to find the optimal connection. - /// - /// Parameters: - /// - [server]: The PlexServer to connect to - /// - [clientIdentifier]: Client identifier for the PlexClient - /// - [plexToken]: Optional plex.tv token to save to storage - /// - [verifyServer]: Whether to verify server is accessible before returning - /// - [fetchUserProfile]: Whether to fetch and cache user profile - /// - [onProgress]: Callback for progress updates (e.g., show/hide loading) - static Future connectToServer( - PlexServer server, { - required String clientIdentifier, - String? plexToken, - bool verifyServer = false, - bool fetchUserProfile = false, - void Function(String message)? onProgress, - }) async { - final storage = await StorageService.getInstance(); - - final connectionStream = server - .findBestWorkingConnection() - .asBroadcastStream(); - PlexClient? client; - final serverId = server.clientIdentifier; - - final optimizationSubscription = connectionStream - .skip(1) - .listen( - (connection) async { - await _handleOptimizedConnection( - connection: connection, - storage: storage, - server: server, - serverId: serverId, - client: client, - reason: 'initial_latency_sweep', - ); - }, - onError: (error, stackTrace) { - appLogger.w( - 'Background connection optimization error', - error: error, - stackTrace: stackTrace, - ); - }, - ); - - try { - final connection = await connectionStream.first; - - if (onProgress != null) { - onProgress('Connected to ${connection.displayType} endpoint'); - } - - // Save server information to storage - await storage.saveServerData(server.toJson()); - await storage.saveServerUrl(connection.uri); - await storage.saveServerEndpoint(serverId, connection.uri); - await storage.saveServerAccessToken(server.accessToken); - - // Save plex token if provided - if (plexToken != null) { - await storage.savePlexToken(plexToken); - } - - // Create client with working connection - final cachedEndpoint = storage.getServerEndpoint(serverId); - final prioritizedEndpoints = server.prioritizedEndpointUrls( - preferredFirst: cachedEndpoint ?? connection.uri, - ); - final config = await PlexConfig.create( - baseUrl: connection.uri, - token: server.accessToken, - clientIdentifier: clientIdentifier, - ); - client = PlexClient( - config, - prioritizedEndpoints: prioritizedEndpoints, - onEndpointChanged: (newUrl) async { - await storage.saveServerUrl(newUrl); - await storage.saveServerEndpoint(serverId, newUrl); - appLogger.i( - 'Updated stored server URL after failover', - error: newUrl, - ); - }, - ); - - // Fetch machine identifier and cache it in config - try { - final machineId = await client.getMachineIdentifier(); - if (machineId != null) { - client.config = config.copyWith(machineIdentifier: machineId); - appLogger.d('Cached machine identifier: $machineId'); - } - } catch (e) { - appLogger.w('Failed to fetch machine identifier', error: e); - // Continue without it - buildMetadataUri will fallback to fetching it - } - - // Verify server is accessible if requested - if (verifyServer) { - try { - await client.getServerIdentity(); - } catch (e) { - await optimizationSubscription.cancel(); - appLogger.w('Server identity verification failed', error: e); - await storage.clearCredentials(); - return ServerConnectionResult(error: 'Server is not accessible: $e'); - } - } - - // Fetch user profile if requested - PlexUserProfile? userProfile; - if (fetchUserProfile && plexToken != null) { - userProfile = await _fetchUserProfile(plexToken); - } - - // Return success result while optimization continues in background - _activeServer = server; - _activeClient = client; - _startConnectivityMonitoring(server); - - return ServerConnectionResult(client: client, userProfile: userProfile); - } on StateError catch (e, stackTrace) { - await optimizationSubscription.cancel(); - appLogger.e( - 'No working connections found for this server', - error: e, - stackTrace: stackTrace, - ); - return ServerConnectionResult( - error: 'No working connections found for this server', - ); - } catch (e, stackTrace) { - await optimizationSubscription.cancel(); - appLogger.e( - 'Error connecting to server', - error: e, - stackTrace: stackTrace, - ); - return ServerConnectionResult(error: 'Connection failed: $e'); - } - } - - /// Fetch user profile from Plex API - static Future _fetchUserProfile(String plexToken) async { - appLogger.d('Fetching user profile from Plex API'); - try { - final authService = await PlexAuthService.create(); - final profile = await authService.getUserProfile(plexToken); - - appLogger.i( - 'Successfully fetched user profile from API', - error: { - 'autoSelectAudio': profile.autoSelectAudio, - 'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set', - 'autoSelectSubtitle': profile.autoSelectSubtitle, - 'defaultSubtitleLanguage': - profile.defaultSubtitleLanguage ?? 'not set', - 'defaultSubtitleForced': profile.defaultSubtitleForced, - }, - ); - - return profile; - } catch (e) { - appLogger.w('Failed to fetch user profile from API', error: e); - return null; - } - } - - static void _startConnectivityMonitoring(PlexServer server) { - _connectivitySubscription?.cancel(); - final connectivity = Connectivity(); - _connectivitySubscription = connectivity.onConnectivityChanged.listen( - (results) { - final status = results.isNotEmpty - ? results.first - : ConnectivityResult.none; - if (status == ConnectivityResult.none) { - appLogger.w( - 'Connectivity lost, pausing optimization until network returns', - ); - return; - } - appLogger.d( - 'Connectivity change detected, triggering endpoint optimization', - error: { - 'status': status.name, - 'interfaces': results.map((r) => r.name).toList(), - }, - ); - _activeServer = server; - _triggerReoptimization(reason: 'connectivity:${status.name}'); - }, - onError: (error, stackTrace) { - appLogger.w( - 'Connectivity listener error', - error: error, - stackTrace: stackTrace, - ); - }, - ); - } - - static void _triggerReoptimization({required String reason}) { - if (_activeServer == null) { - appLogger.d( - 'Optimization trigger ignored because there is no active server', - error: {'reason': reason}, - ); - return; - } - - if (_activeOptimization != null) { - appLogger.d( - 'Optimization already running, skipping new trigger', - error: {'reason': reason}, - ); - return; - } - - _activeOptimization = - _runOptimization( - server: _activeServer!, - client: _activeClient, - reason: reason, - ).whenComplete(() { - _activeOptimization = null; - }); - } - - static Future _runOptimization({ - required PlexServer server, - required PlexClient? client, - required String reason, - }) async { - final storage = await StorageService.getInstance(); - final serverId = server.clientIdentifier; - try { - appLogger.d( - 'Starting background connection optimization run', - error: {'reason': reason}, - ); - await for (final connection in server.findBestWorkingConnection()) { - await _handleOptimizedConnection( - connection: connection, - storage: storage, - server: server, - serverId: serverId, - client: client, - reason: reason, - ); - } - } catch (e, stackTrace) { - appLogger.w( - 'Background connection optimization failed', - error: e, - stackTrace: stackTrace, - ); - } - } - - static Future _handleOptimizedConnection({ - required PlexConnection connection, - required StorageService storage, - required PlexServer server, - required String serverId, - required PlexClient? client, - required String reason, - }) async { - final previousUrl = storage.getServerEndpoint(serverId); - final isNewEndpoint = previousUrl != connection.uri; - - await storage.saveServerUrl(connection.uri); - await storage.saveServerEndpoint(serverId, connection.uri); - appLogger.d( - 'Evaluated optimized endpoint candidate', - error: { - 'uri': connection.uri, - 'displayType': connection.displayType, - 'reason': reason, - 'isNewEndpoint': isNewEndpoint, - }, - ); - - if (client != null) { - final prioritizedEndpoints = server.prioritizedEndpointUrls( - preferredFirst: connection.uri, - ); - await client.updateEndpointPreferences( - prioritizedEndpoints, - switchToFirst: isNewEndpoint, - ); - if (isNewEndpoint) { - appLogger.i( - 'Active client switched to optimized endpoint', - error: {'uri': connection.uri, 'reason': reason}, - ); - } - } else if (isNewEndpoint) { - appLogger.i( - 'Stored optimized endpoint for future sessions', - error: {'uri': connection.uri, 'reason': reason}, - ); - } - - if (isNewEndpoint && !connection.uri.startsWith('https://')) { - final upgraded = await server.upgradeConnectionToHttps(connection); - if (upgraded != null && upgraded.uri != connection.uri) { - await _handleOptimizedConnection( - connection: upgraded, - storage: storage, - server: server, - serverId: serverId, - client: client, - reason: '$reason:https-upgrade', - ); - } - } - } -} diff --git a/lib/utils/collection_playlist_play_helper.dart b/lib/utils/collection_playlist_play_helper.dart index bbf2cf47..c8287e0e 100644 --- a/lib/utils/collection_playlist_play_helper.dart +++ b/lib/utils/collection_playlist_play_helper.dart @@ -25,12 +25,8 @@ Future playCollectionOrPlaylist({ } String ratingKey = item.ratingKey; - String? serverId = isCollection - ? (item as PlexMetadata).serverId - : (item as PlexPlaylist).serverId; - String? serverName = isCollection - ? (item as PlexMetadata).serverName - : (item as PlexPlaylist).serverName; + String? serverId = item.serverId; + String? serverName = item.serverName; final PlayQueueResponse? playQueue; if (isCollection) { diff --git a/lib/utils/user_switching_utils.dart b/lib/utils/user_switching_utils.dart deleted file mode 100644 index 38827aa0..00000000 --- a/lib/utils/user_switching_utils.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import '../models/plex_home_user.dart'; -import '../i18n/strings.g.dart'; -import 'provider_extensions.dart'; - -class UserSwitchingUtils { - static Future switchToUser( - BuildContext context, - PlexHomeUser user, { - bool popOnSuccess = false, - }) async { - final userProvider = context.userProfile; - - final success = await userProvider.switchToUser(user, context); - - if (success && context.mounted && popOnSuccess) { - Navigator.of(context).pop(); - } else if (!success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.failedToSwitchProfile(displayName: user.displayName), - ), - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); - } - - return success; - } -} diff --git a/lib/widgets/context_menu_wrapper.dart b/lib/widgets/context_menu_wrapper.dart index 474daf2f..c4f540a2 100644 --- a/lib/widgets/context_menu_wrapper.dart +++ b/lib/widgets/context_menu_wrapper.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import '../utils/platform_detector.dart'; -import '../i18n/strings.g.dart'; /// A menu action item for context menus class ContextMenuItem { @@ -22,171 +20,3 @@ class ContextMenuItem { this.isDestructive = false, }); } - -/// A wrapper widget that shows context menus differently based on platform. -/// On mobile (iOS/Android): Shows a bottom sheet on long-press -/// On desktop (Windows/macOS/Linux): Shows a popup menu on right-click or long-press -class ContextMenuWrapper extends StatefulWidget { - final Widget child; - final List menuItems; - final Function(String)? onMenuItemSelected; - final VoidCallback? onTap; - final String? title; - final bool forceBottomSheet; - - const ContextMenuWrapper({ - super.key, - required this.child, - required this.menuItems, - this.onMenuItemSelected, - this.onTap, - this.title, - this.forceBottomSheet = false, - }); - - @override - State createState() => _ContextMenuWrapperState(); -} - -class _ContextMenuWrapperState extends State { - Offset _tapPosition = Offset.zero; - - void _storeTapPosition(TapDownDetails details) { - _tapPosition = details.globalPosition; - } - - Future _showConfirmationDialog({ - required String title, - required String message, - required bool isDestructive, - }) async { - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: Text(title), - content: Text(message), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(t.common.cancel), - ), - TextButton( - onPressed: () => Navigator.pop(context, true), - style: isDestructive - ? TextButton.styleFrom(foregroundColor: Colors.red) - : null, - child: Text(t.common.confirm), - ), - ], - ), - ); - return result ?? false; - } - - Future _showContextMenu(BuildContext context) async { - final useBottomSheet = - widget.forceBottomSheet || PlatformDetector.isMobile(context); - String? selected; - - if (useBottomSheet) { - // Mobile: Show bottom sheet - selected = await showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (widget.title != null) - Padding( - padding: const EdgeInsets.all(16), - child: Text( - widget.title!, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ...widget.menuItems.map( - (item) => ListTile( - leading: Icon(item.icon), - title: Text(item.label), - onTap: () => Navigator.pop(context, item.value), - ), - ), - ], - ), - ), - ); - } else { - // Desktop: Show popup menu - final RenderBox overlay = - Overlay.of(context).context.findRenderObject() as RenderBox; - final overlayRect = Rect.fromPoints( - _tapPosition, - _tapPosition.translate(1, 1), - ); - - final menuItems = widget.menuItems - .map( - (item) => PopupMenuItem( - value: item.value, - child: Row( - children: [ - Icon(item.icon, size: 20), - const SizedBox(width: 12), - Expanded(child: Text(item.label)), - ], - ), - ), - ) - .toList(); - - selected = await showMenu( - context: context, - position: RelativeRect.fromRect( - overlayRect, - Offset.zero & overlay.size, - ), - items: menuItems, - elevation: 8, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - popUpAnimationStyle: AnimationStyle( - duration: const Duration(milliseconds: 150), - reverseDuration: const Duration(milliseconds: 100), - ), - ); - } - - if (selected != null && widget.onMenuItemSelected != null) { - // Find the selected item to check if confirmation is needed - final selectedItem = widget.menuItems.firstWhere( - (item) => item.value == selected, - ); - - if (selectedItem.requiresConfirmation) { - final confirmed = await _showConfirmationDialog( - title: selectedItem.confirmationTitle ?? t.dialog.confirmAction, - message: selectedItem.confirmationMessage ?? t.dialog.areYouSure, - isDestructive: selectedItem.isDestructive, - ); - - if (!confirmed) return; - } - - widget.onMenuItemSelected!(selected); - } - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: widget.onTap, - onTapDown: _storeTapPosition, - onLongPress: () => _showContextMenu(context), - onSecondaryTapDown: _storeTapPosition, - onSecondaryTap: () => _showContextMenu(context), - child: widget.child, - ); - } -} diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index 2d39a8ba..6dfb7a21 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -3,58 +3,6 @@ import '../utils/desktop_window_padding.dart'; import '../services/fullscreen_state_manager.dart'; import 'app_bar_back_button.dart'; -/// A custom app bar that automatically handles desktop window controls spacing. -/// Use this instead of AppBar for consistent desktop platform behavior. -class DesktopAppBar extends StatelessWidget implements PreferredSizeWidget { - final Widget? title; - final List? actions; - final Widget? leading; - final bool automaticallyImplyLeading; - final double? elevation; - final Color? backgroundColor; - final Color? surfaceTintColor; - final Color? shadowColor; - final double? scrolledUnderElevation; - - const DesktopAppBar({ - super.key, - this.title, - this.actions, - this.leading, - this.automaticallyImplyLeading = true, - this.elevation, - this.backgroundColor, - this.surfaceTintColor, - this.shadowColor, - this.scrolledUnderElevation, - }); - - @override - Widget build(BuildContext context) { - final appBar = AppBar( - title: title != null - ? DesktopTitleBarPadding( - leftPadding: leading != null ? 0 : null, - child: title!, - ) - : null, - actions: DesktopAppBarHelper.buildAdjustedActions(actions), - leading: DesktopAppBarHelper.buildAdjustedLeading(leading), - automaticallyImplyLeading: automaticallyImplyLeading, - elevation: elevation, - backgroundColor: backgroundColor, - surfaceTintColor: surfaceTintColor, - shadowColor: shadowColor, - scrolledUnderElevation: scrolledUnderElevation, - ); - - return DesktopAppBarHelper.wrapWithGestureDetector(appBar); - } - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} - /// A custom sliver app bar that automatically handles desktop window controls spacing. /// Use this instead of SliverAppBar for consistent desktop platform behavior. class DesktopSliverAppBar extends StatelessWidget { @@ -142,66 +90,6 @@ class DesktopSliverAppBar extends StatelessWidget { } } -/// Convenient wrapper for DesktopAppBar with built-in back button handling -class PlexAppBar extends StatelessWidget implements PreferredSizeWidget { - final Widget? title; - final List? actions; - final VoidCallback? onBackPressed; - final double? elevation; - final Color? backgroundColor; - final Color? surfaceTintColor; - final Color? shadowColor; - final double? scrolledUnderElevation; - - const PlexAppBar({ - super.key, - this.title, - this.actions, - this.onBackPressed, - this.elevation, - this.backgroundColor, - this.surfaceTintColor, - this.shadowColor, - this.scrolledUnderElevation, - }); - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: FullscreenStateManager(), - builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - - return DesktopAppBar( - key: ValueKey('plex_app_bar_$isFullscreen'), - title: title, - actions: actions, - leading: _shouldShowBackButton(context) - ? AppBarBackButton( - style: BackButtonStyle.plain, - onPressed: onBackPressed, - ) - : null, - automaticallyImplyLeading: false, - elevation: elevation, - backgroundColor: backgroundColor, - surfaceTintColor: surfaceTintColor, - shadowColor: shadowColor, - scrolledUnderElevation: scrolledUnderElevation, - ); - }, - ); - } - - bool _shouldShowBackButton(BuildContext context) { - final parentRoute = ModalRoute.of(context); - return parentRoute?.canPop ?? false; - } - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} - /// Convenient wrapper for DesktopSliverAppBar with built-in back button handling class CustomAppBar extends StatelessWidget { final Widget? title; diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 936491b5..69f2dec9 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -678,13 +678,6 @@ Widget _buildPosterImage(BuildContext context, dynamic item) { return Builder( builder: (context) { final client = _getClientForItem(context, item); - if (client == null) { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } return CachedNetworkImage( imageUrl: client.getThumbnailUrl(posterUrl!), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index f5c12bee..527a74b6 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -440,9 +440,8 @@ class _MediaContextMenuState extends State { if (ratingKey == null) return; final client = _getClientForItem(); - if (client == null) return; - try { + try{ final metadata = await client.getMetadata(ratingKey); if (metadata != null && context.mounted) { await Navigator.push( @@ -463,9 +462,8 @@ class _MediaContextMenuState extends State { /// Show file info bottom sheet Future _showFileInfo(BuildContext context) async { final client = _getClientForItem(); - if (client == null) return; - try { + try{ // Show loading indicator if (context.mounted) { showDialog( @@ -518,7 +516,6 @@ class _MediaContextMenuState extends State { /// Handle shuffle play using play queues Future _handleShufflePlayWithQueue(BuildContext context) async { final client = _getClientForItem(); - if (client == null) return; final metadata = widget.item as PlexMetadata; final playbackState = context.read(); @@ -690,9 +687,8 @@ class _MediaContextMenuState extends State { /// Show dialog to select playlist and add item Future _showAddToPlaylistDialog(BuildContext context) async { final client = _getClientForItem(); - if (client == null) return; - try { + try{ final metadata = widget.item as PlexMetadata; final itemType = metadata.type.toLowerCase(); @@ -801,9 +797,8 @@ class _MediaContextMenuState extends State { /// Show dialog to select collection and add item Future _showAddToCollectionDialog(BuildContext context) async { final client = _getClientForItem(); - if (client == null) return; - try { + try{ final metadata = widget.item as PlexMetadata; final itemType = metadata.type.toLowerCase(); @@ -890,6 +885,8 @@ class _MediaContextMenuState extends State { final itemUri = await client.buildMetadataUri(metadata.ratingKey); appLogger.d('Built URI for $itemType: $itemUri'); + if (!context.mounted) return; + if (result == '_create_new') { // Create new collection flow final collectionName = await showDialog( @@ -1023,7 +1020,6 @@ class _MediaContextMenuState extends State { PlexMetadata metadata, ) async { final client = _getClientForItem(); - if (client == null) return; if (widget.collectionId == null) { appLogger.e('Cannot remove from collection: collectionId is null'); @@ -1099,7 +1095,6 @@ class _MediaContextMenuState extends State { bool isPlaylist, ) async { final client = _getClientForItem(); - if (client == null) return; await playCollectionOrPlaylist( context: context, @@ -1116,7 +1111,6 @@ class _MediaContextMenuState extends State { bool isPlaylist, ) async { final client = _getClientForItem(); - if (client == null) return; await playCollectionOrPlaylist( context: context, @@ -1133,7 +1127,6 @@ class _MediaContextMenuState extends State { bool isPlaylist, ) async { final client = _getClientForItem(); - if (client == null) return; final itemTitle = widget.item.title; final itemTypeLabel = isCollection diff --git a/lib/widgets/profile_selector.dart b/lib/widgets/profile_selector.dart deleted file mode 100644 index fd7d3070..00000000 --- a/lib/widgets/profile_selector.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../models/plex_home_user.dart'; -import '../providers/user_profile_provider.dart'; -import '../utils/user_switching_utils.dart'; -import 'user_avatar_widget.dart'; -import '../screens/profile_switch_screen.dart'; - -class ProfileSelector extends StatelessWidget { - final double avatarSize; - final bool showCurrentUserOnly; - - const ProfileSelector({ - super.key, - this.avatarSize = 32, - this.showCurrentUserOnly = false, - }); - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, userProvider, child) { - if (userProvider.currentUser == null) { - return const SizedBox.shrink(); - } - - if (showCurrentUserOnly || !userProvider.hasMultipleUsers) { - // Show only current user avatar - return UserAvatarWidget( - user: userProvider.currentUser!, - size: avatarSize, - onTap: userProvider.hasMultipleUsers - ? () => _showProfileSwitchDialog(context) - : null, - ); - } - - // Show horizontal list of users - return SizedBox( - height: avatarSize + 8, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: userProvider.home?.users.length ?? 0, - separatorBuilder: (context, index) => const SizedBox(width: 8), - itemBuilder: (context, index) { - final users = userProvider.home!.users; - final user = users[index]; - final isCurrentUser = user.uuid == userProvider.currentUser?.uuid; - - return Container( - decoration: isCurrentUser - ? BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: Theme.of(context).colorScheme.primary, - width: 2, - ), - ) - : null, - child: Padding( - padding: EdgeInsets.all(isCurrentUser ? 2 : 0), - child: UserAvatarWidget( - user: user, - size: avatarSize - (isCurrentUser ? 4 : 0), - onTap: isCurrentUser - ? () => _showProfileSwitchDialog(context) - : () => _switchToUser(context, user), - ), - ), - ); - }, - ), - ); - }, - ); - } - - void _showProfileSwitchDialog(BuildContext context) { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const ProfileSwitchScreen()), - ); - } - - void _switchToUser(BuildContext context, PlexHomeUser user) async { - await UserSwitchingUtils.switchToUser(context, user); - } -} diff --git a/lib/widgets/profile_switch_dialog.dart b/lib/widgets/profile_switch_dialog.dart deleted file mode 100644 index f4f947b6..00000000 --- a/lib/widgets/profile_switch_dialog.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../models/plex_home_user.dart'; -import '../providers/user_profile_provider.dart'; -import '../utils/user_switching_utils.dart'; -import 'profile_list_tile.dart'; -import '../i18n/strings.g.dart'; - -class ProfileSwitchDialog extends StatelessWidget { - const ProfileSwitchDialog({super.key}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Consumer( - builder: (context, userProvider, child) { - final users = userProvider.home?.users ?? []; - - return AlertDialog( - content: SizedBox( - width: double.maxFinite, - height: users.isEmpty ? 100 : (users.length * 72.0).clamp(100, 400), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (userProvider.isLoading) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else if (users.isEmpty) - Expanded( - child: Center(child: Text(t.profile.noUsersAvailable)), - ) - else - Expanded( - child: ListView.builder( - shrinkWrap: true, - itemCount: users.length, - itemBuilder: (context, index) { - final user = users[index]; - final isCurrentUser = - user.uuid == userProvider.currentUser?.uuid; - - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Card( - child: ProfileListTile( - user: user, - isCurrentUser: isCurrentUser, - onTap: () => _switchToUser(context, user), - ), - ), - ); - }, - ), - ), - - if (userProvider.error != null) - Padding( - padding: const EdgeInsets.only(top: 16), - child: Text( - userProvider.error!, - style: TextStyle( - color: theme.colorScheme.error, - fontSize: 12, - ), - textAlign: TextAlign.center, - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(t.common.cancel), - ), - ], - ); - }, - ); - } - - void _switchToUser(BuildContext context, PlexHomeUser user) async { - await UserSwitchingUtils.switchToUser(context, user, popOnSuccess: true); - } -} diff --git a/lib/widgets/server_badge.dart b/lib/widgets/server_badge.dart index f74cb425..aac3865c 100644 --- a/lib/widgets/server_badge.dart +++ b/lib/widgets/server_badge.dart @@ -26,7 +26,7 @@ class ServerBadge extends StatelessWidget { final theme = Theme.of(context); final bgColor = - backgroundColor ?? theme.colorScheme.primaryContainer.withOpacity(0.8); + backgroundColor ?? theme.colorScheme.primaryContainer.withValues(alpha: 0.8); final fgColor = textColor ?? theme.colorScheme.onPrimaryContainer; final displayText = showFullName @@ -62,58 +62,3 @@ class ServerBadge extends StatelessWidget { return Tooltip(message: serverName!, child: badge); } } - -/// Server section header for grouping content by server -class ServerSectionHeader extends StatelessWidget { - final String serverName; - final bool isOnline; - final VoidCallback? onTap; - final Widget? trailing; - - const ServerSectionHeader({ - super.key, - required this.serverName, - this.isOnline = true, - this.onTap, - this.trailing, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5), - child: Row( - children: [ - // Server status indicator - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: isOnline ? Colors.green : Colors.grey, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 12), - // Server name - Expanded( - child: Text( - serverName, - style: theme.textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, - ), - ), - ), - // Optional trailing widget - if (trailing != null) trailing!, - ], - ), - ), - ); - } -} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index 2fa21a73..d0b1e57f 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -96,13 +96,6 @@ class ChapterSheet extends StatelessWidget { child: Builder( builder: (context) { final client = _getClientForChapters(context); - if (client == null) { - return const Icon( - Icons.image, - color: Colors.white54, - size: 34, - ); - } return Image.network( client.getThumbnailUrl(chapter.thumb), width: 60, diff --git a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart b/lib/widgets/video_controls/sheets/playback_speed_sheet.dart deleted file mode 100644 index ce727db3..00000000 --- a/lib/widgets/video_controls/sheets/playback_speed_sheet.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; -import 'base_video_control_sheet.dart'; - -/// Bottom sheet for selecting playback speed -class PlaybackSpeedSheet extends StatelessWidget { - final Player player; - - const PlaybackSpeedSheet({super.key, required this.player}); - - static void show(BuildContext context, Player player) { - BaseVideoControlSheet.showSheet( - context: context, - builder: (context) => PlaybackSpeedSheet(player: player), - ); - } - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: player.stream.rate, - initialData: player.state.rate, - builder: (context, snapshot) { - final currentRate = snapshot.data ?? 1.0; - - // Define available playback speeds - final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; - - return BaseVideoControlSheet( - title: 'Playback Speed', - icon: Icons.speed, - child: ListView.builder( - itemCount: speeds.length, - itemBuilder: (context, index) { - final speed = speeds[index]; - final isSelected = (currentRate - speed).abs() < 0.01; - - // Format speed label - final label = speed == 1.0 - ? 'Normal' - : '${speed.toStringAsFixed(2)}x'; - - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, - onTap: () { - player.setRate(speed); - Navigator.pop(context); - }, - ); - }, - ), - ); - }, - ); - } -} diff --git a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart b/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart deleted file mode 100644 index 649d7096..00000000 --- a/lib/widgets/video_controls/sheets/sleep_timer_sheet.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; -import '../../../i18n/strings.g.dart'; -import '../../../services/settings_service.dart'; -import '../../../services/sleep_timer_service.dart'; -import 'base_video_control_sheet.dart'; -import '../widgets/sleep_timer_content.dart'; - -/// Bottom sheet for sleep timer configuration -class SleepTimerSheet extends StatelessWidget { - final Player player; - final int defaultDuration; - - const SleepTimerSheet({ - super.key, - required this.player, - required this.defaultDuration, - }); - - static void show(BuildContext context, Player player) async { - final settingsService = await SettingsService.getInstance(); - final defaultDuration = settingsService.getSleepTimerDuration(); - - if (!context.mounted) return; - - BaseVideoControlSheet.showSheet( - context: context, - builder: (context) => - SleepTimerSheet(player: player, defaultDuration: defaultDuration), - ); - } - - @override - Widget build(BuildContext context) { - final sleepTimer = SleepTimerService(); - - return ListenableBuilder( - listenable: sleepTimer, - builder: (context, _) { - return BaseVideoControlSheet( - title: t.videoControls.sleepTimer, - icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined, - iconColor: sleepTimer.isActive ? Colors.amber : null, - child: SleepTimerContent( - player: player, - sleepTimer: sleepTimer, - defaultDuration: defaultDuration, - onCancel: () => Navigator.pop(context), - ), - ); - }, - ); - } -} diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 71ea012f..8b6dcad1 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -346,7 +346,6 @@ class _PlexVideoControlsState extends State Future _loadChapters() async { final client = _getClientForMetadata(); - if (client == null) return; final chapters = await client.getChapters(widget.metadata.ratingKey); if (mounted) { @@ -359,7 +358,6 @@ class _PlexVideoControlsState extends State Future _loadMarkers() async { final client = _getClientForMetadata(); - if (client == null) return; final markers = await client.getMarkers(widget.metadata.ratingKey); diff --git a/pubspec.lock b/pubspec.lock index 5395d2be..a8b745e9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.7.1" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" archive: dependency: transitive description: @@ -217,6 +233,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" csv: dependency: transitive description: @@ -225,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + dart_code_linter: + dependency: "direct dev" + description: + name: dart_code_linter + sha256: "9456e0a7508b0d76be301dc2f73be37e601019e85c5f59e6a5cc460af90f7dcd" + url: "https://pub.dev" + source: hosted + version: "3.1.1" dart_style: dependency: transitive description: @@ -416,6 +448,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: transitive description: @@ -801,6 +841,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" provider: dependency: "direct main" description: @@ -817,6 +865,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + pub_updater: + dependency: transitive + description: + name: pub_updater + sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" + url: "https://pub.dev" + source: hosted + version: "0.5.0" pubspec_parse: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b429b9a2..452b0afa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -75,6 +75,7 @@ dev_dependencies: json_serializable: ^6.7.1 flutter_launcher_icons: ^0.14.4 slang_build_runner: ^3.31.0 + dart_code_linter: ^3.1.1 flutter: uses-material-design: true