diff --git a/.gitignore b/.gitignore index 85e9078c..a4582ff9 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,10 @@ app.*.map.json # Code duplication reports duplication-report/ -# Test credentials (do not commit!) -.env.test +.env.* +.env +!.env.example + +**/fastlane/**/ +**/fastlane/report.xml +maestro/**/*.png diff --git a/android/fastlane/Fastfile b/android/fastlane/Fastfile new file mode 100644 index 00000000..40b895cf --- /dev/null +++ b/android/fastlane/Fastfile @@ -0,0 +1,63 @@ +require 'dotenv/load' +Dotenv.load("../../.env") + +default_platform(:android) + +platform :android do + desc "Deploy a new version to the Google Play" + lane :deploy do + gradle(task: "clean bundleRelease") + + # Get version code from pubspec.yaml + pubspec_path = "#{ENV['PWD']}/../pubspec.yaml" + pubspec_content = File.read(pubspec_path) + version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/) + + if version_match + version_code = version_match[2].to_i + UI.message("Using version code: #{version_code}") + else + UI.user_error!("Could not extract version code from pubspec.yaml") + end + + upload_to_play_store( + version_code: version_code, + skip_upload_changelogs: true, + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true + ) + end + + desc "Build release AAB" + lane :build do + # Navigate to Flutter project root and build + sh("cd #{ENV['PWD']}/.. && flutter build appbundle") + end + + desc "Build and deploy to Google Play Store" + lane :release do + # Build the Flutter app + sh("cd #{ENV['PWD']}/.. && flutter build appbundle") + + # Get version code from pubspec.yaml + pubspec_path = "#{ENV['PWD']}/../pubspec.yaml" + pubspec_content = File.read(pubspec_path) + version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/) + + if version_match + version_code = version_match[2].to_i + UI.message("Using version code: #{version_code}") + else + UI.user_error!("Could not extract version code from pubspec.yaml") + end + + # Upload to Play Store + upload_to_play_store( + track: "internal", + release_status: "draft", + version_code: version_code, + aab: "../build/app/outputs/bundle/release/app-release.aab" + ) + end +end \ No newline at end of file diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile new file mode 100644 index 00000000..57829379 --- /dev/null +++ b/ios/fastlane/Fastfile @@ -0,0 +1,49 @@ +require 'dotenv/load' +Dotenv.load("../../.env") + +default_platform(:ios) + +platform :ios do + desc "Push a new beta build to TestFlight" + lane :beta do + build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + upload_to_testflight + end + + desc "Push a new release build to the App Store" + lane :release do + build_app(workspace: "Runner.xcworkspace", scheme: "Runner") + upload_to_app_store + end + + desc "Build release IPA" + lane :build do + # Navigate to Flutter project root and build + sh("cd #{ENV['PWD']}/.. && flutter build ipa") + end + + desc "Build and deploy to TestFlight" + lane :deploy_testflight do + # Build the Flutter app + sh("cd #{ENV['PWD']}/.. && flutter build ipa") + + # Upload to TestFlight + upload_to_testflight( + skip_waiting_for_build_processing: true + ) + end + + desc "Build and deploy to App Store" + lane :deploy_appstore do + # Build the Flutter app + sh("cd #{ENV['PWD']}/.. && flutter build ipa") + + # Upload to App Store + upload_to_app_store( + ipa: "../build/ios/ipa/Plezy.ipa", + force: true, + submit_for_review: false, + skip_screenshots: true, # https://github.com/fastlane/fastlane/issues/29735 + ) + end +end \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index ea4ef4d5..fe380d0c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,6 +17,7 @@ import 'providers/theme_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; +import 'utils/platform_detector.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -32,11 +33,7 @@ void main() async { // Initialize MediaKit MediaKit.ensureInitialized(); - // Lock orientation to portrait for all screens except video player - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); + // Note: Orientation will be set dynamically based on device type in MainApp await StorageService.getInstance(); @@ -76,7 +73,7 @@ class MainApp extends StatelessWidget { darkTheme: themeProvider.darkTheme, themeMode: themeProvider.materialThemeMode, navigatorObservers: [routeObserver], - home: const SetupScreen(), + home: const OrientationAwareSetup(), ); }, ), @@ -84,6 +81,45 @@ class MainApp extends StatelessWidget { } } +class OrientationAwareSetup extends StatefulWidget { + const OrientationAwareSetup({super.key}); + + @override + State createState() => _OrientationAwareSetupState(); +} + +class _OrientationAwareSetupState extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _setOrientationPreferences(); + } + + void _setOrientationPreferences() { + // Only lock orientation to portrait for phones + // Allow all orientations for tablets and desktop + if (PlatformDetector.isPhone(context)) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } else { + // For tablets and desktop, allow all orientations + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + } + + @override + Widget build(BuildContext context) { + return const SetupScreen(); + } +} + class SetupScreen extends StatefulWidget { const SetupScreen({super.key}); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 3fb984ab..b166daf4 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -8,16 +8,19 @@ class ThemeProvider extends ChangeNotifier { late Brightness _systemBrightness; ThemeProvider() { - _systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; + _systemBrightness = + WidgetsBinding.instance.platformDispatcher.platformBrightness; _initializeSettings(); // Listen to system theme changes - WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = () { - _systemBrightness = WidgetsBinding.instance.platformDispatcher.platformBrightness; - if (_themeMode == settings.ThemeMode.system) { - notifyListeners(); - } - }; + WidgetsBinding.instance.platformDispatcher.onPlatformBrightnessChanged = + () { + _systemBrightness = + WidgetsBinding.instance.platformDispatcher.platformBrightness; + if (_themeMode == settings.ThemeMode.system) { + notifyListeners(); + } + }; } Future _initializeSettings() async { @@ -96,4 +99,4 @@ class ThemeProvider extends ChangeNotifier { break; } } -} \ No newline at end of file +} diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 00fbb90a..a7f28bf1 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -92,7 +92,6 @@ class _AboutScreenState extends State { ), ), - const SizedBox(height: 24), ]), ), @@ -101,5 +100,4 @@ class _AboutScreenState extends State { ), ); } - } diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 7610975e..952dffd7 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; import '../services/plex_auth_service.dart'; @@ -110,6 +112,96 @@ class _AuthScreenState extends State { Future.delayed(const Duration(milliseconds: 100), _startAuthentication); } + void _handleDebugTap() { + if (!kDebugMode) return; + _showDebugTokenDialog(); + } + + void _showDebugTokenDialog() { + final tokenController = TextEditingController(); + String? errorMessage; + + showDialog( + context: context, + builder: (BuildContext context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: const Text('Debug: Enter Plex Token'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: tokenController, + decoration: InputDecoration( + labelText: 'Plex Auth Token', + hintText: 'Enter your Plex.tv token', + errorText: errorMessage, + border: const OutlineInputBorder(), + ), + obscureText: true, + maxLines: 1, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () async { + final token = tokenController.text.trim(); + if (token.isEmpty) { + setDialogState(() { + errorMessage = 'Please enter a token'; + }); + return; + } + + final navigator = Navigator.of(context); + + try { + final isValid = await _authService.verifyToken(token); + if (!isValid) { + setDialogState(() { + errorMessage = 'Invalid token'; + }); + return; + } + + // Store the token + final storage = await StorageService.getInstance(); + await storage.savePlexToken(token); + + // Close dialog and navigate + if (mounted) { + navigator.pop(); + navigator.pushReplacement( + MaterialPageRoute( + builder: (context) => ServerSelectionScreen( + authService: _authService, + plexToken: token, + ), + ), + ); + } + } catch (e) { + setDialogState(() { + errorMessage = 'Failed to verify token: $e'; + }); + } + }, + child: const Text('Authenticate'), + ), + ], + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -158,6 +250,24 @@ class _AuthScreenState extends State { ), child: const Text('Sign in with Plex'), ), + if (kDebugMode) ...[ + const SizedBox(height: 12), + OutlinedButton( + onPressed: _handleDebugTap, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + side: BorderSide( + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.5), + ), + ), + child: const Text( + 'Debug: Enter Token', + style: TextStyle(fontSize: 12), + ), + ), + ], if (_errorMessage != null) ...[ const SizedBox(height: 16), Text( diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index ab71929d..cc0fe33f 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -45,6 +45,7 @@ class _DiscoverScreenState extends State int _currentHeroIndex = 0; Timer? _autoScrollTimer; late AnimationController _indicatorAnimationController; + bool _isAutoScrollPaused = false; @override void initState() { @@ -67,9 +68,12 @@ class _DiscoverScreenState extends State } void _startAutoScroll() { + if (_isAutoScrollPaused) return; + _indicatorAnimationController.forward(from: 0.0); _autoScrollTimer = Timer.periodic(const Duration(seconds: 5), (timer) { - if (_onDeck.isEmpty || !_heroController.hasClients) return; + if (_onDeck.isEmpty || !_heroController.hasClients || _isAutoScrollPaused) + return; final nextPage = (_currentHeroIndex + 1) % _onDeck.length; _heroController.animateToPage( @@ -79,7 +83,9 @@ class _DiscoverScreenState extends State ); // Wait for page transition to complete before resetting progress Future.delayed(const Duration(milliseconds: 500), () { - _indicatorAnimationController.forward(from: 0.0); + if (!_isAutoScrollPaused) { + _indicatorAnimationController.forward(from: 0.0); + } }); }); } @@ -89,6 +95,21 @@ class _DiscoverScreenState extends State _startAutoScroll(); } + void _pauseAutoScroll() { + setState(() { + _isAutoScrollPaused = true; + }); + _autoScrollTimer?.cancel(); + _indicatorAnimationController.stop(); + } + + void _resumeAutoScroll() { + setState(() { + _isAutoScrollPaused = false; + }); + _startAutoScroll(); + } + Future _loadContent() async { appLogger.d('Loading discover content'); setState(() { @@ -211,8 +232,14 @@ class _DiscoverScreenState extends State if (confirm == true && mounted) { // Use comprehensive logout through UserProfileProvider - final userProfileProvider = Provider.of(context, listen: false); - final plexClientProvider = Provider.of(context, listen: false); + final userProfileProvider = Provider.of( + context, + listen: false, + ); + final plexClientProvider = Provider.of( + context, + listen: false, + ); // Clear all user data and provider states await userProfileProvider.logout(); @@ -432,62 +459,85 @@ class _DiscoverScreenState extends State return _buildHeroItem(_onDeck[index]); }, ), - // Page indicators with animated progress + // Page indicators with animated progress and pause/play button Positioned( bottom: 16, - left: 0, + left: -26, right: 0, child: Row( mainAxisAlignment: MainAxisAlignment.center, - children: List.generate(_onDeck.length, (index) { - final isActive = _currentHeroIndex == index; - if (isActive) { - // Animated progress indicator for active page - return AnimatedBuilder( - animation: _indicatorAnimationController, - builder: (context, child) { - // Fill width animates from 8px to 24px - final fillWidth = - 8.0 + (16.0 * _indicatorAnimationController.value); - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 24, - height: 8, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(4), - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: fillWidth, - height: 8, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(4), + children: [ + // Pause/Play button + GestureDetector( + onTap: () { + if (_isAutoScrollPaused) { + _resumeAutoScroll(); + } else { + _pauseAutoScroll(); + } + }, + child: Icon( + _isAutoScrollPaused ? Icons.play_arrow : Icons.pause, + color: Colors.white, + size: 18, + semanticLabel: + '${_isAutoScrollPaused ? 'Play' : 'Pause'} auto-scroll', + ), + ), + // Spacer to separate indicators from button + const SizedBox(width: 8), + // Page indicators + ...List.generate(_onDeck.length, (index) { + final isActive = _currentHeroIndex == index; + if (isActive) { + // Animated progress indicator for active page + return AnimatedBuilder( + animation: _indicatorAnimationController, + builder: (context, child) { + // Fill width animates from 8px to 24px + final fillWidth = + 8.0 + + (16.0 * _indicatorAnimationController.value); + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 24, + height: 8, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(4), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: fillWidth, + height: 8, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + ), ), ), - ), - ); - }, - ); - } else { - // Static indicator for inactive pages - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: 8, - height: 8, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(4), - ), - ); - } - }), + ); + }, + ); + } else { + // Static indicator for inactive pages + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(4), + ), + ); + } + }), + ], ), ), ], @@ -507,182 +557,157 @@ class _DiscoverScreenState extends State ? 'Movie' : 'TV Show'; - return GestureDetector( - onTap: () { - final clientProvider = context.plexClient; - final client = clientProvider.client; - if (client == null) return; + return Semantics( + label: "media-hero-${heroItem.ratingKey}", + identifier: "media-hero-${heroItem.ratingKey}", + button: true, + hint: "Tap to play ${heroItem.title}", + child: GestureDetector( + onTap: () { + final clientProvider = context.plexClient; + final client = clientProvider.client; + if (client == null) return; - appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: heroItem, - userProfile: widget.userProfile, + appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => VideoPlayerScreen( + metadata: heroItem, + userProfile: widget.userProfile, + ), ), + ); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.3), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], ), - ); - }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.3), - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Stack( - fit: StackFit.expand, - children: [ - // Background Image with fade/zoom animation and parallax - if (heroItem.art != null || heroItem.grandparentArt != null) - AnimatedBuilder( - animation: _scrollController, - builder: (context, child) { - final scrollOffset = _scrollController.hasClients - ? _scrollController.offset - : 0.0; - return Transform.translate( - offset: Offset(0, scrollOffset * 0.3), - child: child, - ); - }, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 800), - curve: Curves.easeOut, - builder: (context, value, child) { - return Transform.scale( - scale: 1.0 + (0.1 * (1 - value)), - child: Opacity(opacity: value, child: child), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + fit: StackFit.expand, + children: [ + // Background Image with fade/zoom animation and parallax + if (heroItem.art != null || heroItem.grandparentArt != null) + AnimatedBuilder( + animation: _scrollController, + builder: (context, child) { + final scrollOffset = _scrollController.hasClients + ? _scrollController.offset + : 0.0; + return Transform.translate( + offset: Offset(0, scrollOffset * 0.3), + child: child, ); }, - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ); - } - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl( - heroItem.art ?? heroItem.grandparentArt, - ), - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 800), + curve: Curves.easeOut, + builder: (context, value, child) { + return Transform.scale( + scale: 1.0 + (0.1 * (1 - value)), + child: Opacity(opacity: value, child: child), ); }, + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl( + heroItem.art ?? heroItem.grandparentArt, + ), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ); + }, + ), + ), + ) + else + Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + + // Gradient Overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.9), + ], + stops: const [0.0, 0.5, 1.0], ), ), - ) - else - Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, ), - // Gradient Overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - Colors.black.withValues(alpha: 0.9), - ], - stops: const [0.0, 0.5, 1.0], - ), - ), - ), - - // Content with responsive alignment - Positioned( - bottom: isLargeScreen ? 80 : 50, - left: 0, - right: isLargeScreen ? 200 : 0, - child: Padding( - padding: EdgeInsets.symmetric( - horizontal: isLargeScreen ? 40 : 16, - ), - child: Column( - crossAxisAlignment: isLargeScreen - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - // Show logo or name/title - if (heroItem.clearLogo != null) - SizedBox( - height: 120, - width: 400, - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return Container(); - } - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl( - heroItem.clearLogo, - ), - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - alignment: isLargeScreen - ? Alignment.bottomLeft - : Alignment.bottomCenter, - placeholder: (context, url) => Align( - alignment: isLargeScreen - ? Alignment.centerLeft - : Alignment.center, - child: Text( - showName, - style: Theme.of(context) - .textTheme - .displaySmall - ?.copyWith( - color: Colors.white.withValues( - alpha: 0.3, - ), - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black.withValues( - alpha: 0.5, - ), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen - ? TextAlign.left - : TextAlign.center, + // Content with responsive alignment + Positioned( + bottom: isLargeScreen ? 80 : 50, + left: 0, + right: isLargeScreen ? 200 : 0, + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isLargeScreen ? 40 : 16, + ), + child: Column( + crossAxisAlignment: isLargeScreen + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Show logo or name/title + if (heroItem.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return Container(); + } + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl( + heroItem.clearLogo, ), - ), - errorWidget: (context, url, error) { - // Fallback to text if logo fails to load - return Align( + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + alignment: isLargeScreen + ? Alignment.bottomLeft + : Alignment.bottomCenter, + placeholder: (context, url) => Align( alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center, @@ -692,7 +717,9 @@ class _DiscoverScreenState extends State .textTheme .displaySmall ?.copyWith( - color: Colors.white, + color: Colors.white.withValues( + alpha: 0.3, + ), fontWeight: FontWeight.bold, shadows: [ Shadow( @@ -709,111 +736,144 @@ class _DiscoverScreenState extends State ? TextAlign.left : TextAlign.center, ), - ); - }, - ); - }, - ), - ) - else - Text( - showName, - style: Theme.of(context).textTheme.displaySmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black.withValues(alpha: 0.5), - blurRadius: 8, ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen - ? TextAlign.left - : TextAlign.center, - ), - - // Metadata as dot-separated text with content type - if (heroItem.year != null || - heroItem.contentRating != null || - heroItem.rating != null) ...[ - const SizedBox(height: 16), - Text( - [ - contentTypeLabel, - if (heroItem.rating != null) - '★ ${(heroItem.rating! / 10).toStringAsFixed(1)}', - if (heroItem.contentRating != null) - heroItem.contentRating!, - if (heroItem.year != null) heroItem.year.toString(), - ].join(' • '), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - textAlign: isLargeScreen - ? TextAlign.left - : TextAlign.center, - ), - ], - - // On small screens: show button before summary - if (!isLargeScreen) ...[ - const SizedBox(height: 20), - _buildSmartPlayButton(heroItem), - ], - - // Summary with episode info (Apple TV style) - if (heroItem.summary != null) ...[ - const SizedBox(height: 12), - RichText( - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen - ? TextAlign.left - : TextAlign.center, - text: TextSpan( - style: const TextStyle( - color: Colors.white70, - fontSize: 14, - height: 1.4, + errorWidget: (context, url, error) { + // Fallback to text if logo fails to load + return Align( + alignment: isLargeScreen + ? Alignment.centerLeft + : Alignment.center, + child: Text( + showName, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black + .withValues(alpha: 0.5), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: isLargeScreen + ? TextAlign.left + : TextAlign.center, + ), + ); + }, + ); + }, ), - children: [ - if (isEpisode && - heroItem.parentIndex != null && - heroItem.index != null) - TextSpan( - text: - 'S${heroItem.parentIndex}, E${heroItem.index}: ', - style: const TextStyle( - fontWeight: FontWeight.bold, - color: Colors.white, - ), + ) + else + Text( + showName, + style: Theme.of(context).textTheme.displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], ), - TextSpan( - text: heroItem.summary?.isNotEmpty == true - ? heroItem.summary! - : 'No description available', - ), - ], + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: isLargeScreen + ? TextAlign.left + : TextAlign.center, ), - ), - ], - // On large screens: show button after summary - if (isLargeScreen) ...[ - const SizedBox(height: 20), - _buildSmartPlayButton(heroItem), + // Metadata as dot-separated text with content type + if (heroItem.year != null || + heroItem.contentRating != null || + heroItem.rating != null) ...[ + const SizedBox(height: 16), + Text( + [ + contentTypeLabel, + if (heroItem.rating != null) + '★ ${(heroItem.rating! / 10).toStringAsFixed(1)}', + if (heroItem.contentRating != null) + heroItem.contentRating!, + if (heroItem.year != null) + heroItem.year.toString(), + ].join(' • '), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + textAlign: isLargeScreen + ? TextAlign.left + : TextAlign.center, + ), + ], + + // On small screens: show button before summary + if (!isLargeScreen) ...[ + const SizedBox(height: 20), + _buildSmartPlayButton(heroItem), + ], + + // Summary with episode info (Apple TV style) + if (heroItem.summary != null) ...[ + const SizedBox(height: 12), + RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: isLargeScreen + ? TextAlign.left + : TextAlign.center, + text: TextSpan( + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + height: 1.4, + ), + children: [ + if (isEpisode && + heroItem.parentIndex != null && + heroItem.index != null) + TextSpan( + text: + 'S${heroItem.parentIndex}, E${heroItem.index}: ', + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + TextSpan( + text: heroItem.summary?.isNotEmpty == true + ? heroItem.summary! + : 'No description available', + ), + ], + ), + ), + ], + + // On large screens: show button after summary + if (isLargeScreen) ...[ + const SizedBox(height: 20), + _buildSmartPlayButton(heroItem), + ], ], - ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 04089ab7..46e9b832 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -269,12 +269,15 @@ class _LibrariesScreenState extends State icon: Badge( label: Text('${_selectedFilters.length}'), isLabelVisible: _selectedFilters.isNotEmpty, - child: const Icon(Icons.filter_list), + child: const Icon( + Icons.filter_list, + semanticLabel: 'Filters', + ), ), onPressed: _showFiltersBottomSheet, ), IconButton( - icon: const Icon(Icons.refresh), + icon: const Icon(Icons.refresh, semanticLabel: 'Refresh'), onPressed: () => _loadLibraryContent(_selectedLibraryIndex), ), ], diff --git a/lib/screens/licenses_screen.dart b/lib/screens/licenses_screen.dart index 4f17fc51..94df8fdb 100644 --- a/lib/screens/licenses_screen.dart +++ b/lib/screens/licenses_screen.dart @@ -67,9 +67,7 @@ class _LicensesScreenState extends State { @override Widget build(BuildContext context) { if (_isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( @@ -79,30 +77,29 @@ class _LicensesScreenState extends State { SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final mergedLicense = _mergedLicenses[index]; - final packageName = mergedLicense.packageName; + delegate: SliverChildBuilderDelegate((context, index) { + final mergedLicense = _mergedLicenses[index]; + final packageName = mergedLicense.packageName; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text( - packageName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text( + packageName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), - subtitle: mergedLicense.licenseEntries.length > 1 - ? Text('${mergedLicense.licenseEntries.length} licenses') - : null, - trailing: const Icon(Icons.chevron_right), - onTap: () => _showLicenseDetail(mergedLicense), ), - ); - }, - childCount: _mergedLicenses.length, - ), + subtitle: mergedLicense.licenseEntries.length > 1 + ? Text( + '${mergedLicense.licenseEntries.length} licenses', + ) + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () => _showLicenseDetail(mergedLicense), + ), + ); + }, childCount: _mergedLicenses.length), ), ), ], @@ -114,9 +111,8 @@ class _LicensesScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => _LicenseDetailScreen( - mergedLicense: mergedLicense, - ), + builder: (context) => + _LicenseDetailScreen(mergedLicense: mergedLicense), ), ); } @@ -125,9 +121,7 @@ class _LicensesScreenState extends State { class _LicenseDetailScreen extends StatelessWidget { final MergedLicenseEntry mergedLicense; - const _LicenseDetailScreen({ - required this.mergedLicense, - }); + const _LicenseDetailScreen({required this.mergedLicense}); @override Widget build(BuildContext context) { @@ -137,10 +131,7 @@ class _LicenseDetailScreen extends StatelessWidget { return Scaffold( body: CustomScrollView( slivers: [ - CustomAppBar( - title: Text(packageName), - pinned: true, - ), + CustomAppBar(title: Text(packageName), pinned: true), SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( @@ -155,9 +146,8 @@ class _LicenseDetailScreen extends StatelessWidget { children: [ Text( 'Related Packages', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), Text( @@ -186,10 +176,11 @@ class _LicenseDetailScreen extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - isMultipleLicenses ? 'License ${index + 1}' : 'License', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + isMultipleLicenses + ? 'License ${index + 1}' + : 'License', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 16), ...license.paragraphs.map((paragraph) { @@ -198,7 +189,9 @@ class _LicenseDetailScreen extends StatelessWidget { child: SelectableText( paragraph.text, style: TextStyle( - fontFamily: paragraph.indent > 0 ? 'monospace' : null, + fontFamily: paragraph.indent > 0 + ? 'monospace' + : null, fontSize: 14, ), ), @@ -220,4 +213,4 @@ class _LicenseDetailScreen extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 796a1b5e..eb357d2a 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -772,120 +772,125 @@ class _MediaDetailScreenState extends State { _updateWatchState(); } }, - child: InkWell( - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Season poster - if (season.thumb != null) - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - 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, - height: 120, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - width: 80, - height: 120, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - width: 80, - height: 120, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.movie, size: 32), - ), - ); - }, - ), - ) - else - Container( - width: 80, - height: 120, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, + child: Semantics( + label: "media-season-${season.ratingKey}", + identifier: "media-season-${season.ratingKey}", + button: true, + hint: "Tap to view ${season.title}", + child: InkWell( + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Season poster + if (season.thumb != null) + ClipRRect( borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.movie, size: 32), - ), - const SizedBox(width: 16), - - // Season info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - season.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), + child: Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + 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, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ); + }, ), - const SizedBox(height: 4), - if (season.leafCount != null) + ) + else + Container( + width: 80, + height: 120, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.movie, size: 32), + ), + const SizedBox(width: 16), + + // Season info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - '${season.leafCount} episodes', - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey), + season.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), ), - const SizedBox(height: 8), - if (season.viewedLeafCount != null && - season.leafCount != null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 200, - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: - season.viewedLeafCount! / - season.leafCount!, - backgroundColor: tokens(context).outline, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, + const SizedBox(height: 4), + if (season.leafCount != null) + Text( + '${season.leafCount} episodes', + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 8), + if (season.viewedLeafCount != null && + season.leafCount != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: + season.viewedLeafCount! / + season.leafCount!, + backgroundColor: tokens(context).outline, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + minHeight: 6, ), - minHeight: 6, ), ), - ), - const SizedBox(height: 4), - Text( - '${season.viewedLeafCount}/${season.leafCount} watched', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: Colors.grey), - ), - ], - ), - ], + const SizedBox(height: 4), + Text( + '${season.viewedLeafCount}/${season.leafCount} watched', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Colors.grey), + ), + ], + ), + ], + ), ), - ), - const Icon(Icons.chevron_right), - ], + const Icon(Icons.chevron_right), + ], + ), ), ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 3e2752d8..78808679 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -47,9 +47,7 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { if (_isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( @@ -87,9 +85,9 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'Appearance', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), Consumer( @@ -117,9 +115,9 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'Video Playback', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), SwitchListTile( @@ -162,9 +160,9 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'Keyboard Shortcuts', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), ListTile( @@ -179,7 +177,6 @@ class _SettingsScreenState extends State { ); } - Widget _buildAdvancedSection() { return Card( child: Column( @@ -189,9 +186,9 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), child: Text( 'Advanced', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), ), SwitchListTile( @@ -242,7 +239,6 @@ class _SettingsScreenState extends State { ); } - void _showThemeDialog(ThemeProvider themeProvider) { showDialog( context: context, @@ -302,7 +298,6 @@ class _SettingsScreenState extends State { ); } - void _showBufferSizeDialog(bool isVideo) { final currentSize = isVideo ? _videoBufferSize : _audioBufferSize; final title = isVideo ? 'Video Buffer Size' : 'Audio Buffer Size'; @@ -353,9 +348,8 @@ class _SettingsScreenState extends State { Navigator.push( context, MaterialPageRoute( - builder: (context) => _KeyboardShortcutsScreen( - keyboardService: _keyboardService, - ), + builder: (context) => + _KeyboardShortcutsScreen(keyboardService: _keyboardService), ), ); } @@ -417,7 +411,9 @@ class _SettingsScreenState extends State { if (mounted) { navigator.pop(); messenger.showSnackBar( - const SnackBar(content: Text('Settings reset successfully')), + const SnackBar( + content: Text('Settings reset successfully'), + ), ); // Reload settings _loadSettings(); @@ -430,7 +426,6 @@ class _SettingsScreenState extends State { }, ); } - } class _KeyboardShortcutsScreen extends StatefulWidget { @@ -439,7 +434,8 @@ class _KeyboardShortcutsScreen extends StatefulWidget { const _KeyboardShortcutsScreen({required this.keyboardService}); @override - State<_KeyboardShortcutsScreen> createState() => _KeyboardShortcutsScreenState(); + State<_KeyboardShortcutsScreen> createState() => + _KeyboardShortcutsScreenState(); } class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { @@ -463,9 +459,7 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { @override Widget build(BuildContext context) { if (_isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } return Scaffold( @@ -482,7 +476,9 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { await _loadHotkeys(); if (mounted) { messenger.showSnackBar( - const SnackBar(content: Text('Shortcuts reset to defaults')), + const SnackBar( + content: Text('Shortcuts reset to defaults'), + ), ); } }, @@ -493,34 +489,38 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { SliverPadding( padding: const EdgeInsets.all(16), sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final actions = _hotkeys.keys.toList(); - final action = actions[index]; - final hotkey = _hotkeys[action]!; + delegate: SliverChildBuilderDelegate((context, index) { + final actions = _hotkeys.keys.toList(); + final action = actions[index]; + final hotkey = _hotkeys[action]!; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(widget.keyboardService.getActionDisplayName(action)), - subtitle: Text(action), - trailing: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - border: Border.all(color: Theme.of(context).dividerColor), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - widget.keyboardService.formatHotkey(hotkey), - style: const TextStyle(fontFamily: 'monospace'), - ), - ), - onTap: () => _editHotkey(action, hotkey), + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text( + widget.keyboardService.getActionDisplayName(action), ), - ); - }, - childCount: _hotkeys.length, - ), + subtitle: Text(action), + trailing: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).dividerColor, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + widget.keyboardService.formatHotkey(hotkey), + style: const TextStyle(fontFamily: 'monospace'), + ), + ), + onTap: () => _editHotkey(action, hotkey), + ), + ); + }, childCount: _hotkeys.length), ), ), ], @@ -540,12 +540,16 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { final messenger = ScaffoldMessenger.of(context); // Check for conflicts - final existingAction = widget.keyboardService.getActionForHotkey(newHotkey); + final existingAction = widget.keyboardService.getActionForHotkey( + newHotkey, + ); if (existingAction != null && existingAction != action) { navigator.pop(); messenger.showSnackBar( SnackBar( - content: Text('Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}'), + content: Text( + 'Shortcut already assigned to ${widget.keyboardService.getActionDisplayName(existingAction)}', + ), ), ); return; @@ -564,7 +568,9 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { messenger.showSnackBar( SnackBar( - content: Text('Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}'), + content: Text( + 'Shortcut updated for ${widget.keyboardService.getActionDisplayName(action)}', + ), ), ); } @@ -574,4 +580,4 @@ class _KeyboardShortcutsScreenState extends State<_KeyboardShortcutsScreen> { }, ); } -} \ No newline at end of file +} diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 6f7db110..412c28dc 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -7,7 +7,8 @@ import 'settings_service.dart'; class KeyboardShortcutsService { static KeyboardShortcutsService? _instance; late SettingsService _settingsService; - Map _shortcuts = {}; // Legacy string shortcuts for backward compatibility + Map _shortcuts = + {}; // Legacy string shortcuts for backward compatibility Map _hotkeys = {}; // New HotKey objects KeyboardShortcutsService._(); @@ -24,7 +25,8 @@ class KeyboardShortcutsService { _settingsService = await SettingsService.getInstance(); // Ensure settings service is fully initialized before loading data await Future.delayed(Duration.zero); // Allow event loop to complete - _shortcuts = _settingsService.getKeyboardShortcuts(); // Keep for legacy compatibility + _shortcuts = _settingsService + .getKeyboardShortcuts(); // Keep for legacy compatibility _hotkeys = await _settingsService.getKeyboardHotkeys(); // Primary method } @@ -135,8 +137,6 @@ class KeyboardShortcutsService { return modifiers.isEmpty ? keyName : '${modifiers.join(' + ')} + $keyName'; } - - // Handle keyboard input for video player KeyEventResult handleVideoPlayerKeyEvent( KeyEvent event, @@ -207,8 +207,16 @@ class KeyboardShortcutsService { continue; } - _executeAction(action, player, onToggleFullscreen, onToggleSubtitles, - onNextAudioTrack, onNextSubtitleTrack, onNextChapter, onPreviousChapter); + _executeAction( + action, + player, + onToggleFullscreen, + onToggleSubtitles, + onNextAudioTrack, + onNextSubtitleTrack, + onNextChapter, + onPreviousChapter, + ); return KeyEventResult.handled; } } @@ -366,6 +374,6 @@ class KeyboardShortcutsService { final bModifiers = Set.from(b.modifiers ?? []); return aModifiers.length == bModifiers.length && - aModifiers.every((modifier) => bModifiers.contains(modifier)); + aModifiers.every((modifier) => bModifiers.contains(modifier)); } -} \ No newline at end of file +} diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 26a1db53..a451d286 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -40,11 +40,12 @@ class SettingsService { ThemeMode getThemeMode() { final modeString = _prefs.getString(_keyThemeMode); - return ThemeMode.values - .firstWhere((mode) => mode.name == modeString, orElse: () => ThemeMode.system); + return ThemeMode.values.firstWhere( + (mode) => mode.name == modeString, + orElse: () => ThemeMode.system, + ); } - // Debug Logging Future setEnableDebugLogging(bool enabled) async { await _prefs.setBool(_keyEnableDebugLogging, enabled); @@ -78,7 +79,8 @@ class SettingsService { } bool getEnableHardwareDecoding() { - return _prefs.getBool(_keyEnableHardwareDecoding) ?? true; // Default enabled + return _prefs.getBool(_keyEnableHardwareDecoding) ?? + true; // Default enabled } // Preferred Video Codec @@ -130,13 +132,22 @@ class SettingsService { 'volume_down': HotKey(key: PhysicalKeyboardKey.arrowDown), 'seek_forward': HotKey(key: PhysicalKeyboardKey.arrowRight), 'seek_backward': HotKey(key: PhysicalKeyboardKey.arrowLeft), - 'seek_forward_large': HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), - 'seek_backward_large': HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), + 'seek_forward_large': HotKey( + key: PhysicalKeyboardKey.arrowRight, + modifiers: [HotKeyModifier.shift], + ), + 'seek_backward_large': HotKey( + key: PhysicalKeyboardKey.arrowLeft, + modifiers: [HotKeyModifier.shift], + ), 'fullscreen_toggle': HotKey(key: PhysicalKeyboardKey.keyF), 'mute_toggle': HotKey(key: PhysicalKeyboardKey.keyM), 'subtitle_toggle': HotKey(key: PhysicalKeyboardKey.keyS), 'audio_track_next': HotKey(key: PhysicalKeyboardKey.keyA), - 'subtitle_track_next': HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), + 'subtitle_track_next': HotKey( + key: PhysicalKeyboardKey.keyS, + modifiers: [HotKeyModifier.shift], + ), 'chapter_next': HotKey(key: PhysicalKeyboardKey.keyN), 'chapter_previous': HotKey(key: PhysicalKeyboardKey.keyP), 'speed_increase': HotKey(key: PhysicalKeyboardKey.equal), @@ -156,7 +167,9 @@ class SettingsService { try { final decoded = json.decode(jsonString) as Map; - final shortcuts = decoded.map((key, value) => MapEntry(key, value.toString())); + final shortcuts = decoded.map( + (key, value) => MapEntry(key, value.toString()), + ); // Merge with defaults to ensure all keys exist final defaults = getDefaultKeyboardShortcuts(); @@ -252,28 +265,35 @@ class SettingsService { final keyString = data['key'] as String; final modifierNames = (data['modifiers'] as List).cast(); - final modifiers = modifierNames.map((name) { - switch (name) { - case 'alt': - return HotKeyModifier.alt; - case 'control': - return HotKeyModifier.control; - case 'shift': - return HotKeyModifier.shift; - case 'meta': - return HotKeyModifier.meta; - case 'capsLock': - return HotKeyModifier.capsLock; - case 'fn': - return HotKeyModifier.fn; - default: - return null; - } - }).where((m) => m != null).cast().toList(); + final modifiers = modifierNames + .map((name) { + switch (name) { + case 'alt': + return HotKeyModifier.alt; + case 'control': + return HotKeyModifier.control; + case 'shift': + return HotKeyModifier.shift; + case 'meta': + return HotKeyModifier.meta; + case 'capsLock': + return HotKeyModifier.capsLock; + case 'fn': + return HotKeyModifier.fn; + default: + return null; + } + }) + .where((m) => m != null) + .cast() + .toList(); final key = _findKeyByString(keyString); if (key != null) { - return HotKey(key: key, modifiers: modifiers.isNotEmpty ? modifiers : null); + return HotKey( + key: key, + modifiers: modifiers.isNotEmpty ? modifiers : null, + ); } } catch (e) { // Ignore deserialization errors @@ -283,7 +303,6 @@ class SettingsService { // Helper method to find PhysicalKeyboardKey by string representation PhysicalKeyboardKey? _findKeyByString(String keyString) { - // Handle exact string matches first for better performance const keyMap = { 'PhysicalKeyboardKey#0002c': PhysicalKeyboardKey.space, @@ -310,7 +329,9 @@ class SettingsService { // Alternative approach: extract USB HID usage code from the toString() output // Format: PhysicalKeyboardKey#ec9ed(usbHidUsage: "0x0007002c", debugName: "Space") try { - final usbHidMatch = RegExp(r'usbHidUsage: "0x([0-9a-fA-F]+)"').firstMatch(keyString); + final usbHidMatch = RegExp( + r'usbHidUsage: "0x([0-9a-fA-F]+)"', + ).firstMatch(keyString); if (usbHidMatch != null) { final usbHidCode = usbHidMatch.group(1)!.toLowerCase(); @@ -434,18 +455,30 @@ class SettingsService { for (int i = 1; i <= 12; i++) { if (keyString.contains('f$i') || keyString.contains('F$i')) { switch (i) { - case 1: return PhysicalKeyboardKey.f1; - case 2: return PhysicalKeyboardKey.f2; - case 3: return PhysicalKeyboardKey.f3; - case 4: return PhysicalKeyboardKey.f4; - case 5: return PhysicalKeyboardKey.f5; - case 6: return PhysicalKeyboardKey.f6; - case 7: return PhysicalKeyboardKey.f7; - case 8: return PhysicalKeyboardKey.f8; - case 9: return PhysicalKeyboardKey.f9; - case 10: return PhysicalKeyboardKey.f10; - case 11: return PhysicalKeyboardKey.f11; - case 12: return PhysicalKeyboardKey.f12; + case 1: + return PhysicalKeyboardKey.f1; + case 2: + return PhysicalKeyboardKey.f2; + case 3: + return PhysicalKeyboardKey.f3; + case 4: + return PhysicalKeyboardKey.f4; + case 5: + return PhysicalKeyboardKey.f5; + case 6: + return PhysicalKeyboardKey.f6; + case 7: + return PhysicalKeyboardKey.f7; + case 8: + return PhysicalKeyboardKey.f8; + case 9: + return PhysicalKeyboardKey.f9; + case 10: + return PhysicalKeyboardKey.f10; + case 11: + return PhysicalKeyboardKey.f11; + case 12: + return PhysicalKeyboardKey.f12; } } } @@ -454,35 +487,63 @@ class SettingsService { for (int i = 0; i <= 9; i++) { if (keyString.contains('digit$i') || keyString.contains('Digit$i')) { switch (i) { - case 0: return PhysicalKeyboardKey.digit0; - case 1: return PhysicalKeyboardKey.digit1; - case 2: return PhysicalKeyboardKey.digit2; - case 3: return PhysicalKeyboardKey.digit3; - case 4: return PhysicalKeyboardKey.digit4; - case 5: return PhysicalKeyboardKey.digit5; - case 6: return PhysicalKeyboardKey.digit6; - case 7: return PhysicalKeyboardKey.digit7; - case 8: return PhysicalKeyboardKey.digit8; - case 9: return PhysicalKeyboardKey.digit9; + case 0: + return PhysicalKeyboardKey.digit0; + case 1: + return PhysicalKeyboardKey.digit1; + case 2: + return PhysicalKeyboardKey.digit2; + case 3: + return PhysicalKeyboardKey.digit3; + case 4: + return PhysicalKeyboardKey.digit4; + case 5: + return PhysicalKeyboardKey.digit5; + case 6: + return PhysicalKeyboardKey.digit6; + case 7: + return PhysicalKeyboardKey.digit7; + case 8: + return PhysicalKeyboardKey.digit8; + case 9: + return PhysicalKeyboardKey.digit9; } } } // Try letter keys A-Z (both upper and lower case patterns) const letterKeys = { - 'A': PhysicalKeyboardKey.keyA, 'B': PhysicalKeyboardKey.keyB, 'C': PhysicalKeyboardKey.keyC, - 'D': PhysicalKeyboardKey.keyD, 'E': PhysicalKeyboardKey.keyE, 'F': PhysicalKeyboardKey.keyF, - 'G': PhysicalKeyboardKey.keyG, 'H': PhysicalKeyboardKey.keyH, 'I': PhysicalKeyboardKey.keyI, - 'J': PhysicalKeyboardKey.keyJ, 'K': PhysicalKeyboardKey.keyK, 'L': PhysicalKeyboardKey.keyL, - 'M': PhysicalKeyboardKey.keyM, 'N': PhysicalKeyboardKey.keyN, 'O': PhysicalKeyboardKey.keyO, - 'P': PhysicalKeyboardKey.keyP, 'Q': PhysicalKeyboardKey.keyQ, 'R': PhysicalKeyboardKey.keyR, - 'S': PhysicalKeyboardKey.keyS, 'T': PhysicalKeyboardKey.keyT, 'U': PhysicalKeyboardKey.keyU, - 'V': PhysicalKeyboardKey.keyV, 'W': PhysicalKeyboardKey.keyW, 'X': PhysicalKeyboardKey.keyX, - 'Y': PhysicalKeyboardKey.keyY, 'Z': PhysicalKeyboardKey.keyZ, + 'A': PhysicalKeyboardKey.keyA, + 'B': PhysicalKeyboardKey.keyB, + 'C': PhysicalKeyboardKey.keyC, + 'D': PhysicalKeyboardKey.keyD, + 'E': PhysicalKeyboardKey.keyE, + 'F': PhysicalKeyboardKey.keyF, + 'G': PhysicalKeyboardKey.keyG, + 'H': PhysicalKeyboardKey.keyH, + 'I': PhysicalKeyboardKey.keyI, + 'J': PhysicalKeyboardKey.keyJ, + 'K': PhysicalKeyboardKey.keyK, + 'L': PhysicalKeyboardKey.keyL, + 'M': PhysicalKeyboardKey.keyM, + 'N': PhysicalKeyboardKey.keyN, + 'O': PhysicalKeyboardKey.keyO, + 'P': PhysicalKeyboardKey.keyP, + 'Q': PhysicalKeyboardKey.keyQ, + 'R': PhysicalKeyboardKey.keyR, + 'S': PhysicalKeyboardKey.keyS, + 'T': PhysicalKeyboardKey.keyT, + 'U': PhysicalKeyboardKey.keyU, + 'V': PhysicalKeyboardKey.keyV, + 'W': PhysicalKeyboardKey.keyW, + 'X': PhysicalKeyboardKey.keyX, + 'Y': PhysicalKeyboardKey.keyY, + 'Z': PhysicalKeyboardKey.keyZ, }; for (final entry in letterKeys.entries) { - if (keyString.contains('key${entry.key}') || keyString.contains('Key${entry.key}')) { + if (keyString.contains('key${entry.key}') || + keyString.contains('Key${entry.key}')) { return entry.value; } } @@ -491,7 +552,6 @@ class SettingsService { } } - // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ @@ -528,7 +588,9 @@ class SettingsService { 'preferredVideoCodec': getPreferredVideoCodec(), 'preferredAudioCodec': getPreferredAudioCodec(), 'keyboardShortcuts': getKeyboardShortcuts(), - 'keyboardHotkeys': hotkeys.map((key, value) => MapEntry(key, _serializeHotKey(value))), + 'keyboardHotkeys': hotkeys.map( + (key, value) => MapEntry(key, _serializeHotKey(value)), + ), }; } -} \ No newline at end of file +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 0e6b676e..7d569b97 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -240,10 +240,7 @@ class StorageService { // Clear all user-related data (for logout) Future clearUserData() async { - await Future.wait([ - clearCredentials(), - clearLibraryPreferences(), - ]); + await Future.wait([clearCredentials(), clearLibraryPreferences()]); } // Update current user after switching diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 85238ad8..43d3f514 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:math'; /// Utility class for platform detection class PlatformDetector { @@ -13,4 +14,24 @@ class PlatformDetector { static bool isDesktop(BuildContext context) { return !isMobile(context); } + + /// Detects if the device is likely a tablet based on screen size + /// Uses diagonal screen size to determine if device is a tablet + static bool isTablet(BuildContext context) { + final data = MediaQuery.of(context); + final size = data.size; + final diagonal = sqrt(size.width * size.width + size.height * size.height); + final devicePixelRatio = data.devicePixelRatio; + + // Convert diagonal from logical pixels to inches (assuming 160 DPI as baseline) + final diagonalInches = diagonal / (devicePixelRatio * 160 / 2.54); + + // Consider devices with diagonal >= 7 inches as tablets + return diagonalInches >= 7.0; + } + + /// Detects if the device is a phone (mobile but not tablet) + static bool isPhone(BuildContext context) { + return isMobile(context) && !isTablet(context); + } } diff --git a/lib/widgets/hotkey_recorder_widget.dart b/lib/widgets/hotkey_recorder_widget.dart index 8726bdc6..39b4611b 100644 --- a/lib/widgets/hotkey_recorder_widget.dart +++ b/lib/widgets/hotkey_recorder_widget.dart @@ -28,7 +28,6 @@ class _HotKeyRecorderWidgetState extends State { _recordedHotKey = widget.currentHotKey; } - @override Widget build(BuildContext context) { return AlertDialog( diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 1b942598..e7f1d9f1 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -97,72 +97,80 @@ class _MediaCardState extends State metadata: widget.item, onRefresh: widget.onRefresh, onTap: () => _handleTap(context), - child: InkWell( - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Poster - if (widget.height != null) - SizedBox( - width: double.infinity, - height: widget.height, - child: _buildPosterWithOverlay(context), - ) - else - Expanded(child: _buildPosterWithOverlay(context)), - const SizedBox(height: 4), - // Text content - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Text( - widget.item.displayTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - height: 1.1, - ), - ), - if (widget.item.displaySubtitle != null) + child: Semantics( + label: "media-card-${widget.item.ratingKey}", + identifier: "media-card-${widget.item.ratingKey}", + button: true, + child: InkWell( + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Poster + if (widget.height != null) + SizedBox( + width: double.infinity, + height: widget.height, + child: _buildPosterWithOverlay(context), + ) + else + Expanded(child: _buildPosterWithOverlay(context)), + const SizedBox(height: 4), + // Text content + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ Text( - widget.item.displaySubtitle!, + widget.item.displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ) - else if (widget.item.parentTitle != null) - Text( - widget.item.parentTitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ) - else if (widget.item.year != null) - Text( - '${widget.item.year}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, height: 1.1, ), ), - ], - ), - ], + if (widget.item.displaySubtitle != null) + Text( + widget.item.displaySubtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ) + else if (widget.item.parentTitle != null) + Text( + widget.item.parentTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ) + else if (widget.item.year != null) + Text( + '${widget.item.year}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ), + ], + ), + ], + ), ), ), ), @@ -188,9 +196,10 @@ class _MediaCardState extends State builder: (context, clientProvider, child) { final client = clientProvider.client; if (client == null) { - return Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.movie, size: 40)), + return const SkeletonLoader( + child: Center( + child: Icon(Icons.movie, size: 40, color: Colors.white54), + ), ); } @@ -201,9 +210,7 @@ class _MediaCardState extends State height: double.infinity, filterQuality: FilterQuality.medium, fadeInDuration: const Duration(milliseconds: 300), - placeholder: (context, url) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - ), + placeholder: (context, url) => const SkeletonLoader(), errorWidget: (context, url, error) => Container( color: Theme.of(context).colorScheme.surfaceContainerHighest, child: const Center(child: Icon(Icons.broken_image, size: 40)), @@ -212,9 +219,10 @@ class _MediaCardState extends State }, ); } else { - return Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: const Center(child: Icon(Icons.movie, size: 40)), + return const SkeletonLoader( + child: Center( + child: Icon(Icons.movie, size: 40, color: Colors.white54), + ), ); } } @@ -278,3 +286,60 @@ class _PosterOverlay extends StatelessWidget { ); } } + +/// Skeleton loader widget with subtle opacity pulse animation +class SkeletonLoader extends StatefulWidget { + final Widget? child; + final BorderRadius? borderRadius; + + const SkeletonLoader({super.key, this.child, this.borderRadius}); + + @override + State createState() => _SkeletonLoaderState(); +} + +class _SkeletonLoaderState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _animation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + ); + _animation = Tween(begin: 0.3, end: 0.7).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + _animationController.repeat(reverse: true); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Semantics( + label: "skeleton-loader", + identifier: "skeleton-loader", + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest + .withValues(alpha: _animation.value), + borderRadius: widget.borderRadius ?? BorderRadius.circular(8), + ), + child: widget.child, + ), + ); + }, + ); + } +} diff --git a/lib/widgets/plex_video_controls.dart b/lib/widgets/plex_video_controls.dart index 25850012..b709d664 100644 --- a/lib/widgets/plex_video_controls.dart +++ b/lib/widgets/plex_video_controls.dart @@ -333,7 +333,6 @@ class _PlexVideoControlsState extends State } } - @override Widget build(BuildContext context) { final isMobile = PlatformDetector.isMobile(context); @@ -688,10 +687,7 @@ class _PlexVideoControlsState extends State final isMacOS = Platform.isMacOS; final topBar = Padding( - padding: EdgeInsets.only( - left: leftPadding, - right: 16, - ), + padding: EdgeInsets.only(left: leftPadding, right: 16), child: Row( children: [ AppBarBackButton( diff --git a/lib/widgets/server_list_tile.dart b/lib/widgets/server_list_tile.dart index dff2650e..788eb20e 100644 --- a/lib/widgets/server_list_tile.dart +++ b/lib/widgets/server_list_tile.dart @@ -19,66 +19,84 @@ class ServerListTile extends StatelessWidget { Widget build(BuildContext context) { final isOnline = server.isOnline; - return ListTile( - leading: Icon( - Icons.dns, - color: isOnline - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), - ), - title: Text(server.name), - subtitle: Row( - children: [ - Icon( - isOnline ? Icons.circle : Icons.circle_outlined, - size: 10, - color: isOnline ? Colors.green : Colors.grey, - ), - const SizedBox(width: 4), - Text( - isOnline ? 'Online' : 'Offline', - style: TextStyle( - fontSize: 12, - color: isOnline ? Colors.green : Colors.grey, - ), - ), - const SizedBox(width: 8), - Text( - '•', - style: TextStyle( - fontSize: 12, - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.5), - ), - ), - const SizedBox(width: 8), - Text( - server.owned ? 'Owned' : 'Shared', - style: const TextStyle(fontSize: 12), - ), - ], - ), - trailing: isCurrentServer - ? Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(12), - ), - child: Text( - 'CURRENT', - style: TextStyle( - fontSize: 10, - color: Theme.of(context).colorScheme.onPrimary, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, + return Semantics( + selected: isCurrentServer, + identifier: server.name, + label: server.name, + child: ListTile( + key: ValueKey(server.name), + leading: Icon( + Icons.dns, + color: isOnline + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), + semanticLabel: 'Server', + ), + title: Text(server.name), + subtitle: Semantics( + label: + '${isOnline ? 'Online' : 'Offline'}, ${server.owned ? 'Owned' : 'Shared'}', + excludeSemantics: true, + child: Row( + children: [ + Semantics( + excludeSemantics: true, + child: Icon( + isOnline ? Icons.circle : Icons.circle_outlined, + size: 10, + color: isOnline ? Colors.green : Colors.grey, ), ), - ) - : (showTrailingIcon ? const Icon(Icons.chevron_right) : null), - onTap: isCurrentServer ? null : onTap, - enabled: !isCurrentServer, + const SizedBox(width: 4), + Text( + isOnline ? 'Online' : 'Offline', + style: TextStyle( + fontSize: 12, + color: isOnline ? Colors.green : Colors.grey, + ), + ), + const SizedBox(width: 8), + Semantics( + excludeSemantics: true, + child: Text( + '•', + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.5), + ), + ), + ), + const SizedBox(width: 8), + Text( + server.owned ? 'Owned' : 'Shared', + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + trailing: isCurrentServer + ? Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'CURRENT', + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ) + : (showTrailingIcon ? const Icon(Icons.chevron_right) : null), + onTap: isCurrentServer ? null : onTap, + enabled: !isCurrentServer, + ), ); } } diff --git a/maestro/auth.yaml b/maestro/auth.yaml new file mode 100644 index 00000000..6082ae20 --- /dev/null +++ b/maestro/auth.yaml @@ -0,0 +1,7 @@ +appId: com.edde746.plezy +--- +- tapOn: "Debug: Enter Token" +- tapOn: "Plex Auth Token" +- inputText: ${MAESTRO_PLEX_TOKEN} +- tapOn: "Authenticate" +- tapOn: ${MAESTRO_PLEX_SERVER_NAME} diff --git a/maestro/screenshots.yaml b/maestro/screenshots.yaml new file mode 100644 index 00000000..a4edd921 --- /dev/null +++ b/maestro/screenshots.yaml @@ -0,0 +1,49 @@ +appId: com.edde746.plezy +env: + PREFIX: ./${maestro.platform}/ +--- +- evalScript: ${output.suffix = '-' + Date.now().toString()} +- launchApp: + clearState: true + stopApp: false +- runFlow: auth.yaml +- assertVisible: + id: "media-hero-${MAESTRO_EPISODE}" +- tapOn: "Pause auto-scroll" +- waitForAnimationToEnd +- tapOn: + text: "Play auto-scroll" + waitToSettleTimeoutMs: 800 +- takeScreenshot: "${PREFIX}1-home${output.suffix}" +- tapOn: "Libraries.*" +- tapOn: + text: "TV Shows" + waitToSettleTimeoutMs: 200 +- runFlow: + when: + true: ${MAESTRO_COLLECTION != ""} + commands: + - tapOn: "Filters" + - tapOn: "Collection" + - tapOn: "${MAESTRO_COLLECTION}" +- repeat: + while: + visible: "skeleton-loader" + commands: + - assertNotVisible: "skeleton-loader" + +- takeScreenshot: "${PREFIX}2-library${output.suffix}" +- scrollUntilVisible: + element: + id: "media-card-${MAESTRO_SHOW}" +- tapOn: + id: "media-card-${MAESTRO_SHOW}" +- waitForAnimationToEnd: + timeout: 5000 +- takeScreenshot: "${PREFIX}3-media-card${output.suffix}" +- scrollUntilVisible: + element: + id: "media-season-.*" +- tapOn: + id: "media-season-.*" +- takeScreenshot: "${PREFIX}4-season${output.suffix}" diff --git a/scripts/android_screenshots.sh b/scripts/android_screenshots.sh new file mode 100755 index 00000000..1782a8a1 --- /dev/null +++ b/scripts/android_screenshots.sh @@ -0,0 +1,396 @@ +#!/bin/bash + +# Android Screenshot Automation Script +# Starts emulators, runs Flutter app, executes maestro tests, and organizes screenshots + +set -e # Exit on any error + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +MAESTRO_DIR="$PROJECT_ROOT/maestro" +FASTLANE_IMAGES_DIR="$PROJECT_ROOT/android/fastlane/metadata/android/en-GB/images" +ENV_FILE="$PROJECT_ROOT/.env" + +# Source common screenshot functions +source "${SCRIPT_DIR}/screenshot_common.sh" + +# Check if required tools are installed +check_dependencies() { + # Check common dependencies first + if ! check_common_dependencies; then + exit 1 + fi + + log_info "Checking Android-specific dependencies..." + + # Try to find emulator binary + if ! command -v emulator &> /dev/null; then + # Try common Android SDK locations + POSSIBLE_PATHS=( + "$ANDROID_HOME/emulator/emulator" + "$ANDROID_SDK_ROOT/emulator/emulator" + "~/Android/Sdk/emulator/emulator" + "~/Library/Android/sdk/emulator/emulator" + ) + + EMULATOR_PATH="" + for path in "${POSSIBLE_PATHS[@]}"; do + if [ -f "$path" ]; then + EMULATOR_PATH="$path" + break + fi + done + + if [ -z "$EMULATOR_PATH" ]; then + log_error "Android emulator not found. Please ensure Android SDK is installed and ANDROID_HOME is set." + exit 1 + fi + + # Create an alias for emulator + alias emulator="$EMULATOR_PATH" + fi + + log_success "All dependencies found" +} + +# Get list of available AVDs +get_avds() { + # Parse flutter emulators output to get Android emulator IDs + flutter emulators 2>/dev/null | grep "android$" | awk '{print $1}' +} + +# Stop all running emulators +stop_emulators() { + log_info "Stopping any running emulators..." + + # Get list of running emulators + local running_emulators=$(adb devices | grep emulator | cut -f1) + + if [ -n "$running_emulators" ]; then + for emulator in $running_emulators; do + log_info "Stopping emulator $emulator" + adb -s "$emulator" emu kill 2>/dev/null || true + done + + # Wait for emulators to fully stop - check periodically + log_info "Waiting for emulators to fully shut down..." + local timeout=30 # 30 seconds timeout + local elapsed=0 + + while [ $elapsed -lt $timeout ]; do + local still_running=$(adb devices | grep emulator | cut -f1) + if [ -z "$still_running" ]; then + log_success "All emulators stopped" + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + + # Show progress every 6 seconds + if [ $((elapsed % 6)) -eq 0 ]; then + log_info "Still waiting for emulators to stop... (${elapsed}s elapsed)" + fi + done + + log_warning "Timeout waiting for emulators to stop completely" + else + log_info "No emulators are currently running" + fi +} + +# Start emulator and wait for it to be ready +start_emulator() { + local avd_name="$1" + local device_type="$2" + + log_info "Starting $device_type emulator: $avd_name" + + # Double-check if any emulator is still running + local running_emulators=$(adb devices | grep emulator | cut -f1) + if [ -n "$running_emulators" ]; then + log_warning "Emulator still running: $running_emulators" + log_info "Forcing stop before starting new emulator..." + stop_emulators + fi + + # Start emulator in background with proper output redirection + log_info "Launching emulator..." + flutter emulators --launch "$avd_name" > /dev/null 2>&1 & + local emulator_pid=$! + + log_info "Waiting for $device_type emulator to boot..." + + # Wait for emulator to appear in adb devices + local timeout=120 # 2 minutes timeout + local elapsed=0 + + while [ $elapsed -lt $timeout ]; do + if adb devices | grep -q "emulator.*device"; then + log_success "$device_type emulator is ready" + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + + # Show progress every 10 seconds + if [ $((elapsed % 10)) -eq 0 ]; then + log_info "Still waiting... (${elapsed}s elapsed)" + fi + done + + log_error "Timeout waiting for $device_type emulator to start" + return 1 +} + +# Detect device type based on screen size +detect_device_type() { + local device_id="$1" + + # Get screen density and size + local density=$(adb -s "$device_id" shell wm density | cut -d: -f2 | tr -d ' ') + local size=$(adb -s "$device_id" shell wm size | cut -d: -f2 | tr -d ' ') + + # Extract width and height + local width=$(echo "$size" | cut -d'x' -f1) + local height=$(echo "$size" | cut -d'x' -f2) + + # Calculate diagonal in inches (approximate) + local diagonal_pixels=$(echo "sqrt($width*$width + $height*$height)" | bc -l) + local diagonal_inches=$(echo "$diagonal_pixels / $density" | bc -l) + + # Convert to integer for comparison + local diagonal_int=$(echo "$diagonal_inches" | cut -d'.' -f1) + + if [ "$diagonal_int" -ge 9 ]; then + echo "tablet" + else + echo "phone" + fi +} + +# Run Flutter app on specified device +run_flutter_app() { + local device_id="$1" + local device_type="$2" + + log_info "Running Flutter app on $device_type ($device_id)" + + cd "$PROJECT_ROOT" + flutter run -d "$device_id" --hot & + local flutter_pid=$! + + # Wait for app to be installed and launched + sleep 30 + + # Check if app is running + if adb -s "$device_id" shell pm list packages | grep -q "com.edde746.plezy"; then + log_success "Flutter app is running on $device_type" + return 0 + else + log_error "Failed to start Flutter app on $device_type" + return 1 + fi +} + +# Detect device type based on image dimensions +detect_device_type() { + local image_path="$1" + + local dimensions=$(get_image_dimensions "$image_path") + if [ -z "$dimensions" ]; then + log_warning "Could not get dimensions for $image_path" + echo "phone" # Default to phone + return + fi + + local width=$(echo "$dimensions" | cut -d',' -f1) + local height=$(echo "$dimensions" | cut -d',' -f2) + + # Determine shorter and longer sides + local shorter_side=$((width < height ? width : height)) + local longer_side=$((width > height ? width : height)) + + # Calculate diagonal using shell arithmetic (approximate) + # For simplicity, we'll use the shorter side as the main criteria + # Tablets typically have shorter side >= 1200px + # Phones typically have shorter side < 1200px + + if [ "$shorter_side" -ge 1200 ]; then + echo "tablet" + else + echo "phone" + fi +} + +# Organize screenshots into correct fastlane folders +organize_screenshots() { + log_info "Organizing screenshots using ffprobe analysis..." + + # Create directories if they don't exist + mkdir -p "$FASTLANE_IMAGES_DIR/phoneScreenshots" + mkdir -p "$FASTLANE_IMAGES_DIR/sevenInchScreenshots" + mkdir -p "$FASTLANE_IMAGES_DIR/tenInchScreenshots" + + # Find all screenshot files generated by maestro + local android_screenshots_dir="$MAESTRO_DIR/android" + if [ ! -d "$android_screenshots_dir" ]; then + log_warning "No Android screenshots directory found at $android_screenshots_dir" + return 1 + fi + + local screenshots=($(find "$android_screenshots_dir" -name "*.png" | sort)) + + if [ ${#screenshots[@]} -eq 0 ]; then + log_warning "No Android screenshots found in $android_screenshots_dir" + return 1 + fi + + log_info "Found ${#screenshots[@]} screenshots" + + # Group screenshots by device type + local phone_screenshots=() + local tablet_screenshots=() + + for screenshot in "${screenshots[@]}"; do + local device_type=$(detect_device_type "$screenshot") + log_image_info "$screenshot" "$device_type" + + if [ "$device_type" = "tablet" ]; then + tablet_screenshots+=("$screenshot") + else + phone_screenshots+=("$screenshot") + fi + done + + # Copy and rename phone screenshots + local phone_count=1 + for screenshot in "${phone_screenshots[@]}"; do + if [ $phone_count -gt 4 ]; then + break # Limit to 4 screenshots + fi + + local target_name="${phone_count}_en-GB.png" + + # Copy to phone directory + cp "$screenshot" "$FASTLANE_IMAGES_DIR/phoneScreenshots/$target_name" + + # Copy to seven inch directory (phone screenshots go here too) + cp "$screenshot" "$FASTLANE_IMAGES_DIR/sevenInchScreenshots/$target_name" + + log_success "Copied phone screenshot $phone_count: $(basename "$screenshot")" + phone_count=$((phone_count + 1)) + done + + # Copy and rename tablet screenshots + local tablet_count=1 + for screenshot in "${tablet_screenshots[@]}"; do + if [ $tablet_count -gt 4 ]; then + break # Limit to 4 screenshots + fi + + local target_name="${tablet_count}_en-GB.png" + cp "$screenshot" "$FASTLANE_IMAGES_DIR/tenInchScreenshots/$target_name" + + log_success "Copied tablet screenshot $tablet_count: $(basename "$screenshot")" + tablet_count=$((tablet_count + 1)) + done + + log_success "Organized $((phone_count-1)) phone screenshots and $((tablet_count-1)) tablet screenshots" +} + +# Cleanup function +cleanup() { + log_info "Cleaning up..." + + # Kill any running Flutter processes + pkill -f "flutter run" 2>/dev/null || true + + # Stop emulators using our function + stop_emulators + + log_success "Cleanup completed" +} + +# Main execution +main() { + log_info "Starting Android screenshot automation..." + + # Set up cleanup trap + trap cleanup EXIT INT TERM + + # Clean up old screenshots at the very beginning + clean_old_screenshots "android ios" + + # Check dependencies + check_dependencies + + # Stop any running emulators first + stop_emulators + + # Get available AVDs + log_info "Getting available Android Virtual Devices..." + + # Store AVDs in an array, handling potential spaces in names + local avds_raw=$(get_avds) + if [ -z "$avds_raw" ]; then + log_error "No Android Virtual Devices found. Please create AVDs first." + log_info "Create AVDs using: flutter emulators --create" + exit 1 + fi + + # Convert to array (each line is an AVD) + IFS=$'\n' read -d '' -r -a avds <<< "$avds_raw" || true + + log_info "Found ${#avds[@]} Android AVD(s): ${avds[*]}" + + # For now, we'll use the first two AVDs as phone and tablet + # In a real scenario, you'd want to specify which AVDs to use + local phone_avd="${avds[0]}" + local tablet_avd="${avds[1]:-${avds[0]}}" # Use first AVD if only one available + + if [ ${#avds[@]} -eq 1 ]; then + log_warning "Only one AVD available. Using it for both phone and tablet tests." + fi + + # Start phone emulator + start_emulator "$phone_avd" "phone" + + # Get device ID for phone + local phone_device=$(adb devices | grep emulator | head -n1 | cut -f1) + + # Run Flutter app on phone + run_flutter_app "$phone_device" "phone" + + # Run maestro tests for phone + MAESTRO_DEVICE="$phone_device" run_maestro_tests + + # If we have a different tablet AVD, start it + if [ "$phone_avd" != "$tablet_avd" ]; then + # Stop phone emulator + log_info "Switching from phone to tablet emulator" + stop_emulators + + # Start tablet emulator + start_emulator "$tablet_avd" "tablet" + + # Get device ID for tablet + local tablet_device=$(adb devices | grep emulator | head -n1 | cut -f1) + + # Run Flutter app on tablet + run_flutter_app "$tablet_device" "tablet" + + # Run maestro tests for tablet + MAESTRO_DEVICE="$tablet_device" run_maestro_tests + fi + + # Organize screenshots + organize_screenshots + + log_success "Android screenshot automation completed successfully!" +} + +# Run main function if script is executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/scripts/ios_screenshots.sh b/scripts/ios_screenshots.sh new file mode 100755 index 00000000..e70d29f8 --- /dev/null +++ b/scripts/ios_screenshots.sh @@ -0,0 +1,421 @@ +#!/bin/bash + +# iOS Screenshot Automation Script +# Starts simulators, runs Flutter app, executes maestro tests, and organizes screenshots + +set -e # Exit on any error + +# Configuration +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +MAESTRO_DIR="$PROJECT_ROOT/maestro" +FASTLANE_SCREENSHOTS_DIR="$PROJECT_ROOT/ios/fastlane/screenshots/en-US" +ENV_FILE="$PROJECT_ROOT/.env" + +# Source common screenshot functions +source "${SCRIPT_DIR}/screenshot_common.sh" + +# Check if required tools are installed +check_dependencies() { + # Check common dependencies first + if ! check_common_dependencies; then + exit 1 + fi + + log_info "Checking iOS-specific dependencies..." + + if ! command -v xcrun &> /dev/null; then + log_error "xcrun is not installed - Xcode is required" + exit 1 + fi + + log_success "All dependencies found" +} + +# Get list of available iOS simulators +get_simulators() { + xcrun simctl list devices available -j | python3 -c " +import sys, json +data = json.load(sys.stdin) +for runtime, devices in data['devices'].items(): + if 'iOS' in runtime and devices: + for device in devices: + if device['isAvailable']: + print(f\"{device['udid']}|{device['name']}\") +" 2>/dev/null || echo "" +} + +# Stop all running simulators +stop_simulators() { + log_info "Stopping any running simulators..." + + # Get list of running simulators + local running_simulators=$(xcrun simctl list devices | grep "Booted" | grep -oE "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}" || echo "") + + if [ -n "$running_simulators" ]; then + while IFS= read -r simulator; do + log_info "Stopping simulator $simulator" + xcrun simctl shutdown "$simulator" 2>/dev/null || true + done <<< "$running_simulators" + + # Wait for simulators to fully stop + log_info "Waiting for simulators to fully shut down..." + sleep 3 + log_success "All simulators stopped" + else + log_info "No simulators are currently running" + fi +} + +# Start simulator and wait for it to be ready +start_simulator() { + local simulator_udid="$1" + local simulator_name="$2" + local device_type="$3" + + log_info "Starting $device_type simulator: $simulator_name" + + # Double-check if any simulator is still running + local running_simulators=$(xcrun simctl list devices | grep "Booted" | grep -oE "[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}" || echo "") + if [ -n "$running_simulators" ]; then + log_warning "Simulator still running" + log_info "Forcing stop before starting new simulator..." + stop_simulators + fi + + # Start simulator + log_info "Launching simulator..." + xcrun simctl boot "$simulator_udid" 2>/dev/null || log_warning "Simulator may already be booting" + + log_info "Waiting for $device_type simulator to be ready..." + + # Wait for simulator to boot + local timeout=120 # 2 minutes timeout + local elapsed=0 + + while [ $elapsed -lt $timeout ]; do + local state=$(xcrun simctl list devices | grep "$simulator_udid" | grep -oE "(Booted|Shutdown)") + if [ "$state" = "Booted" ]; then + # Give it a bit more time to fully initialize + sleep 5 + log_success "$device_type simulator is ready" + return 0 + fi + sleep 2 + elapsed=$((elapsed + 2)) + + # Show progress every 10 seconds + if [ $((elapsed % 10)) -eq 0 ]; then + log_info "Still waiting... (${elapsed}s elapsed)" + fi + done + + log_error "Timeout waiting for $device_type simulator to start" + return 1 +} + +# Rotate simulator to landscape +rotate_simulator_landscape() { + local simulator_udid="$1" + + log_info "Rotating simulator to landscape orientation..." + + # Open Simulator app and bring it to front + open -a Simulator + sleep 2 + + # Use AppleScript to send rotation keyboard shortcut (Cmd+Left arrow rotates counterclockwise) + # Key code 123 is left arrow + osascript -e 'tell application "Simulator" to activate' \ + -e 'tell application "System Events" to key code 123 using command down' + + sleep 2 + log_success "Simulator rotated to landscape" +} + +# Detect device type based on image dimensions for iOS +detect_ios_device_type() { + local image_path="$1" + + local dimensions=$(get_image_dimensions "$image_path") + if [ -z "$dimensions" ]; then + log_warning "Could not get dimensions for $image_path" + echo "iphone_6_5" # Default + return + fi + + local width=$(echo "$dimensions" | cut -d',' -f1) + local height=$(echo "$dimensions" | cut -d',' -f2) + + # Determine shorter and longer sides (for portrait orientation) + local shorter_side=$((width < height ? width : height)) + local longer_side=$((width > height ? width : height)) + + # iOS App Store screenshot sizes (based on shorter side in portrait) + # Reference: https://help.apple.com/app-store-connect/#/devd274dd925 + + # iPad Pro 12.9" (3rd gen and later): 2048 x 2732 + if [ "$shorter_side" -ge 2000 ] && [ "$longer_side" -ge 2700 ]; then + echo "ipad_pro_12_9_3rd_gen" + # iPad Pro 12.9" (2nd gen): 2048 x 2732 + elif [ "$shorter_side" -ge 2000 ] && [ "$longer_side" -ge 2700 ]; then + echo "ipad_pro_12_9_2nd_gen" + # iPhone 6.9" display (iPhone Air Pro Max, 16 Pro Max, etc.): 1320 x 2868 + elif [ "$shorter_side" -ge 1300 ] && [ "$longer_side" -ge 2800 ]; then + echo "iphone_6_9" + # iPhone 6.7" display (iPhone 14 Pro Max, etc.): 1290 x 2796 + elif [ "$shorter_side" -ge 1250 ] && [ "$longer_side" -ge 2700 ]; then + echo "iphone_6_7" + # iPhone 6.3" display (iPhone 17, 16 Pro, 15 Pro, 14 Pro): 1206 x 2622 + elif [ "$shorter_side" -ge 1200 ] && [ "$shorter_side" -lt 1242 ] && [ "$longer_side" -ge 2600 ] && [ "$longer_side" -lt 2688 ]; then + echo "iphone_6_3" + # iPhone 6.5" display (iPhone 14 Plus, 13 Pro Max, 11 Pro Max, XS Max, etc.): 1242 x 2688 or 1284 x 2778 + elif [ "$shorter_side" -ge 1200 ] && [ "$longer_side" -ge 2600 ]; then + echo "iphone_6_5" + # iPhone 5.5" display (iPhone 8 Plus, etc.): 1242 x 2208 + elif [ "$shorter_side" -ge 1200 ] && [ "$longer_side" -ge 2200 ]; then + echo "iphone_5_5" + else + # Default to 6.5" for unknown iPhone sizes + echo "iphone_6_5" + fi +} + +# Run Flutter app on specified device +run_flutter_app() { + local device_id="$1" + local device_type="$2" + + log_info "Running Flutter app on $device_type ($device_id)" + + cd "$PROJECT_ROOT" + flutter run -d "$device_id" --hot & + local flutter_pid=$! + + # Wait for app to be installed and launched + log_info "Waiting for Flutter app to launch..." + sleep 40 # iOS typically takes longer to launch + + # Check if simulator is still running + local state=$(xcrun simctl list devices | grep "$device_id" | grep -oE "(Booted|Shutdown)") + if [ "$state" = "Booted" ]; then + log_success "Flutter app should be running on $device_type" + return 0 + else + log_error "Simulator not running anymore" + return 1 + fi +} + +# Map device type to fastlane naming convention +get_fastlane_device_name() { + local device_type="$1" + + case "$device_type" in + ipad_pro_12_9_3rd_gen|ipad_pro_12_9_2nd_gen) + echo "IPAD_PRO_3GEN_129" + ;; + iphone_6_9) + echo "IPHONE_69" + ;; + iphone_6_7) + echo "IPHONE_67" + ;; + iphone_6_5) + echo "IPHONE_65" + ;; + iphone_6_3) + echo "IPHONE_63" + ;; + iphone_5_5) + echo "IPHONE_55" + ;; + *) + echo "IPHONE_67" # Default to 6.7" + ;; + esac +} + +# Organize screenshots into correct fastlane folders +organize_screenshots() { + log_info "Organizing screenshots using ffprobe analysis..." + + # Ensure target directory exists + mkdir -p "$FASTLANE_SCREENSHOTS_DIR" + + # Find all screenshot files generated by maestro + local ios_screenshots_dir="$MAESTRO_DIR/ios" + if [ ! -d "$ios_screenshots_dir" ]; then + log_warning "No iOS screenshots directory found at $ios_screenshots_dir" + return 1 + fi + + local screenshots=($(find "$ios_screenshots_dir" -name "*.png" | sort)) + + if [ ${#screenshots[@]} -eq 0 ]; then + log_warning "No iOS screenshots found in $ios_screenshots_dir" + return 1 + fi + + log_info "Found ${#screenshots[@]} screenshots" + + # First pass: detect and log all device types + local device_types=() + for screenshot in "${screenshots[@]}"; do + local device_type=$(detect_ios_device_type "$screenshot") + log_image_info "$screenshot" "$device_type" + + # Add to device_types if not already present + local found=0 + for dt in "${device_types[@]}"; do + if [ "$dt" = "$device_type" ]; then + found=1 + break + fi + done + if [ $found -eq 0 ]; then + device_types+=("$device_type") + fi + done + + # Second pass: copy and rename screenshots for each device type + for device_type in "${device_types[@]}"; do + local fastlane_name=$(get_fastlane_device_name "$device_type") + local count=0 + + for screenshot in "${screenshots[@]}"; do + local detected_type=$(detect_ios_device_type "$screenshot") + + # Only process screenshots matching this device type + if [ "$detected_type" = "$device_type" ]; then + # Format: {index}_APP_{DEVICE_TYPE}_{index}.png + local target_name="${count}_APP_${fastlane_name}_${count}.png" + cp "$screenshot" "$FASTLANE_SCREENSHOTS_DIR/$target_name" + + log_success "Copied $device_type screenshot: $(basename "$screenshot") -> $target_name" + count=$((count + 1)) + fi + done + done + + log_success "Screenshot organization completed" +} + +# Cleanup function +cleanup() { + log_info "Cleaning up..." + + # Kill any running Flutter processes + pkill -f "flutter run" 2>/dev/null || true + + # Stop simulators + stop_simulators + + log_success "Cleanup completed" +} + +# Main execution +main() { + log_info "Starting iOS screenshot automation..." + + # Set up cleanup trap + trap cleanup EXIT INT TERM + + # Clean up old screenshots at the very beginning + clean_old_screenshots "ios" + + # Clean up old fastlane screenshots to prevent mixing old/new with different naming + log_info "Cleaning old fastlane screenshots..." + if [ -d "$FASTLANE_SCREENSHOTS_DIR" ]; then + rm -rf "$FASTLANE_SCREENSHOTS_DIR"/* + log_success "Fastlane screenshots directory cleaned" + fi + + # Check dependencies + check_dependencies + + # Stop any running simulators first + stop_simulators + + # Get available simulators + log_info "Getting available iOS Simulators..." + + local simulators_raw=$(get_simulators) + if [ -z "$simulators_raw" ]; then + log_error "No iOS Simulators found. Please install simulators via Xcode." + exit 1 + fi + + # Parse simulators into arrays + declare -a simulator_udids + declare -a simulator_names + + while IFS='|' read -r udid name; do + simulator_udids+=("$udid") + simulator_names+=("$name") + done <<< "$simulators_raw" + + log_info "Found ${#simulator_udids[@]} iOS Simulator(s)" + + # Find iPhone Air and iPad Pro 13" simulators + local iphone_idx=-1 + local ipad_idx=-1 + + for i in "${!simulator_names[@]}"; do + if [[ "${simulator_names[$i]}" =~ iPhone\ Air ]] && [ $iphone_idx -eq -1 ]; then + iphone_idx=$i + log_info "Found iPhone Air: ${simulator_names[$i]}" + elif [[ "${simulator_names[$i]}" =~ iPad.*13 ]] && [ $ipad_idx -eq -1 ]; then + ipad_idx=$i + log_info "Found iPad Pro 13\": ${simulator_names[$i]}" + fi + done + + if [ $iphone_idx -eq -1 ]; then + log_error "iPhone Air simulator not found. Please create it in Xcode." + exit 1 + fi + + if [ $ipad_idx -eq -1 ]; then + log_error "iPad Pro 13\" simulator not found. Please create it in Xcode." + exit 1 + fi + + log_info "Using iPhone: ${simulator_names[$iphone_idx]}" + log_info "Using iPad: ${simulator_names[$ipad_idx]}" + + # Start iPhone Air simulator + start_simulator "${simulator_udids[$iphone_idx]}" "${simulator_names[$iphone_idx]}" "iPhone Air" + + # Run Flutter app on iPhone Air + run_flutter_app "${simulator_udids[$iphone_idx]}" "iPhone Air" + + # Run maestro tests for iPhone Air + run_maestro_tests "${simulator_udids[$iphone_idx]}" + + # Stop iPhone simulator and switch to iPad + log_info "Switching from iPhone Air to iPad Pro 13\" simulator" + stop_simulators + + # Start iPad Pro 13" simulator + start_simulator "${simulator_udids[$ipad_idx]}" "${simulator_names[$ipad_idx]}" "iPad Pro 13\"" + + # Rotate iPad to landscape + # rotate_simulator_landscape "${simulator_udids[$ipad_idx]}" + + # Run Flutter app on iPad + run_flutter_app "${simulator_udids[$ipad_idx]}" "iPad Pro 13\"" + + # Run maestro tests for iPad + run_maestro_tests "${simulator_udids[$ipad_idx]}" + + # Organize screenshots + organize_screenshots + + log_success "iOS screenshot automation completed successfully!" +} + +# Run main function if script is executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 00000000..a271fb9d --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,135 @@ +#!/bin/bash + +# Plezy Release Automation Script +# This script automates the release process for both iOS and Android + +set -e + +# Get the script's directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$SCRIPT_DIR/.." + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Load environment variables from .env +if [ -f "$PROJECT_ROOT/.env" ]; then + # Export variables without executing the file + set -a + source "$PROJECT_ROOT/.env" + set +a + echo -e "${GREEN}✅ Loaded environment variables from .env${NC}" +else + echo -e "${RED}❌ Error: .env file not found${NC}" + exit 1 +fi + +# Function to show help +show_help() { + echo -e "${BLUE}Plezy Release Automation${NC}" + echo "" + echo "Usage: ./scripts/release.sh " + echo "" + echo "Available commands:" + echo " sync - Sync iOS release notes to Android changelog" + echo " android - Build and release to Google Play Store" + echo " ios - Build and release to App Store" + echo " all - Sync changelogs and release to both platforms" + echo " clean - Clean build artifacts" + echo " help - Show this help message" + echo "" + echo "Prerequisites:" + echo " - .env file must be configured with credentials" + echo " - iOS release notes updated in ios/fastlane/metadata/en-US/release_notes.txt" + echo " - Version bumped in pubspec.yaml" + echo "" + echo "Example:" + echo " ./scripts/release.sh all" +} + +# Function to sync changelogs +sync_changelogs() { + echo -e "${YELLOW}📝 Syncing changelogs...${NC}" + "$SCRIPT_DIR/sync_changelogs.sh" +} + +# Function to release Android +release_android() { + echo -e "${GREEN}🤖 Building and releasing Android app...${NC}" + cd "$PROJECT_ROOT/android" + fastlane release + cd "$PROJECT_ROOT" +} + +# Function to release iOS +release_ios() { + echo -e "${GREEN}🍎 Building and releasing iOS app...${NC}" + cd "$PROJECT_ROOT/ios" + fastlane deploy_appstore + cd "$PROJECT_ROOT" +} + +# Function to release all +release_all() { + sync_changelogs + echo "" + release_android + echo "" + release_ios + echo "" + echo -e "${GREEN}✅ Release complete for both platforms!${NC}" + echo "" + echo -e "${YELLOW}Next steps:${NC}" + echo " 1. Check Google Play Console for the draft release" + echo " 2. Check App Store Connect for the uploaded build" + echo " 3. Submit for review when ready" +} + +# Function to clean build artifacts +clean() { + echo -e "${YELLOW}🧹 Cleaning build artifacts...${NC}" + cd "$PROJECT_ROOT/android" + ./gradlew clean 2>/dev/null || true + cd "$PROJECT_ROOT/ios" + xcodebuild clean -workspace Runner.xcworkspace -scheme Runner 2>/dev/null || true + cd "$PROJECT_ROOT" + flutter clean + echo -e "${GREEN}✅ Clean complete!${NC}" +} + +# Main script logic +if [ $# -eq 0 ]; then + show_help + exit 0 +fi + +case "$1" in + sync) + sync_changelogs + ;; + android) + release_android + ;; + ios) + release_ios + ;; + all) + release_all + ;; + clean) + clean + ;; + help|--help|-h) + show_help + ;; + *) + echo -e "${RED}❌ Unknown command: $1${NC}" + echo "" + show_help + exit 1 + ;; +esac diff --git a/scripts/screenshot_common.sh b/scripts/screenshot_common.sh new file mode 100755 index 00000000..c726bc38 --- /dev/null +++ b/scripts/screenshot_common.sh @@ -0,0 +1,184 @@ +#!/bin/bash + +# Screenshot Automation Common Functions +# Shared functionality between Android and iOS screenshot scripts + +# This script should be sourced, not executed directly +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "This script should be sourced, not executed directly" + exit 1 +fi + +# Configuration - these should be set before sourcing this file, but defaults provided +SCRIPT_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +PROJECT_ROOT="${PROJECT_ROOT:-$(dirname "$SCRIPT_DIR")}" +MAESTRO_DIR="${MAESTRO_DIR:-$PROJECT_ROOT/maestro}" +ENV_FILE="${ENV_FILE:-$PROJECT_ROOT/.env}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Logging functions +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check common dependencies (flutter, maestro, dotenv, ffprobe) +check_common_dependencies() { + log_info "Checking common dependencies..." + + local all_found=true + + if ! command -v flutter &> /dev/null; then + log_error "Flutter is not installed or not in PATH" + all_found=false + fi + + if ! command -v maestro &> /dev/null; then + log_error "Maestro is not installed or not in PATH" + all_found=false + fi + + if ! command -v dotenv &> /dev/null; then + log_error "dotenv is not installed or not in PATH" + all_found=false + fi + + if ! command -v ffprobe &> /dev/null; then + log_error "ffprobe is not installed or not in PATH (part of ffmpeg)" + all_found=false + fi + + if [ "$all_found" = false ]; then + return 1 + fi + + log_success "Common dependencies found" + return 0 +} + +# Get image dimensions using ffprobe +get_image_dimensions() { + local image_path="$1" + ffprobe -v quiet -select_streams v:0 -show_entries stream=width,height -of csv=p=0 "$image_path" 2>/dev/null +} + +# Log image information for debugging +log_image_info() { + local image_path="$1" + local device_type="$2" + + local dimensions=$(get_image_dimensions "$image_path") + local filename=$(basename "$image_path") + + if [ -n "$dimensions" ]; then + local width=$(echo "$dimensions" | cut -d',' -f1) + local height=$(echo "$dimensions" | cut -d',' -f2) + log_info "Screenshot: $filename - ${width}x${height} - $device_type" + else + log_warning "Could not analyze: $filename - assuming $device_type" + fi +} + +# Clean up old maestro screenshots for specified platform(s) +# Usage: clean_old_screenshots "android" or clean_old_screenshots "ios" or clean_old_screenshots "android ios" +clean_old_screenshots() { + local platforms="$1" + + log_info "Cleaning up old screenshots..." + + for platform in $platforms; do + local platform_dir="$MAESTRO_DIR/$platform" + + if [ -d "$platform_dir" ]; then + local old_count=$(find "$platform_dir" -name "*.png" 2>/dev/null | wc -l) + if [ "$old_count" -gt 0 ]; then + log_info "Removing $old_count old screenshot(s) from maestro/$platform/" + rm -f "$platform_dir"/*.png + else + log_info "No old screenshots found in maestro/$platform/" + fi + else + log_info "No $platform screenshot directory found" + fi + done + + log_success "Screenshot cleanup completed" +} + +# Execute maestro tests +# Usage: run_maestro_tests [device_id] +run_maestro_tests() { + local device_id="${1:-}" + + if [ -n "$device_id" ]; then + log_info "Running maestro screenshot tests on device $device_id..." + else + log_info "Running maestro screenshot tests..." + fi + + # Check if .env file exists + if [ ! -f "$ENV_FILE" ]; then + log_error "Environment file not found at $ENV_FILE" + log_info "Please create a .env file with required Maestro variables" + return 1 + fi + + log_info "Using environment file: $ENV_FILE" + cd "$MAESTRO_DIR" + + # Run maestro tests with optional device specification + if [ -n "$device_id" ]; then + log_info "Executing: MAESTRO_DEVICE=$device_id dotenv -f $ENV_FILE run maestro test screenshots.yaml" + MAESTRO_DEVICE="$device_id" dotenv -f "$ENV_FILE" run maestro test screenshots.yaml + else + log_info "Executing: dotenv -f $ENV_FILE run maestro test screenshots.yaml" + dotenv -f "$ENV_FILE" run maestro test screenshots.yaml + fi + + local maestro_exit_code=$? + if [ $maestro_exit_code -eq 0 ]; then + log_success "Maestro tests completed successfully" + return 0 + else + log_error "Maestro tests failed with exit code $maestro_exit_code" + return 1 + fi +} + +# Run Flutter app on specified device +# Platform-specific validation should be done in the calling script +run_flutter_app() { + local device_id="$1" + local device_type="$2" + local wait_time="${3:-30}" # Optional wait time, default 30s + + log_info "Running Flutter app on $device_type ($device_id)" + + cd "$PROJECT_ROOT" + flutter run -d "$device_id" --hot & + local flutter_pid=$! + + # Wait for app to be installed and launched + log_info "Waiting for Flutter app to launch..." + sleep "$wait_time" + + log_success "Flutter app should be running on $device_type" + return 0 +} diff --git a/scripts/sync_changelogs.sh b/scripts/sync_changelogs.sh new file mode 100755 index 00000000..9c1a31b7 --- /dev/null +++ b/scripts/sync_changelogs.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# Sync changelogs between iOS and Android +# This script copies the iOS release notes to the Android changelog for the current version + +set -e + +# Get the script's directory +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +PROJECT_ROOT="$SCRIPT_DIR/.." + +# Colors for output +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +echo -e "${GREEN}📝 Syncing changelogs between iOS and Android${NC}" + +# Extract version code from pubspec.yaml +PUBSPEC_PATH="$PROJECT_ROOT/pubspec.yaml" +if [ ! -f "$PUBSPEC_PATH" ]; then + echo -e "${RED}Error: pubspec.yaml not found at $PUBSPEC_PATH${NC}" + exit 1 +fi + +VERSION_CODE=$(grep -E "version:\s*(.+)\+(\d+)" "$PUBSPEC_PATH" | sed -E 's/.*\+([0-9]+).*/\1/') + +if [ -z "$VERSION_CODE" ]; then + echo -e "${RED}Error: Could not extract version code from pubspec.yaml${NC}" + exit 1 +fi + +echo -e "${YELLOW}Current version code: $VERSION_CODE${NC}" + +# Define paths +IOS_RELEASE_NOTES="$PROJECT_ROOT/ios/fastlane/metadata/en-US/release_notes.txt" +ANDROID_CHANGELOG="$PROJECT_ROOT/android/fastlane/metadata/android/en-GB/changelogs/$VERSION_CODE.txt" + +# Check if iOS release notes exist +if [ ! -f "$IOS_RELEASE_NOTES" ]; then + echo -e "${RED}Error: iOS release notes not found at $IOS_RELEASE_NOTES${NC}" + exit 1 +fi + +# Create Android changelogs directory if it doesn't exist +ANDROID_CHANGELOGS_DIR="$(dirname "$ANDROID_CHANGELOG")" +mkdir -p "$ANDROID_CHANGELOGS_DIR" + +# Copy iOS release notes to Android changelog +cp "$IOS_RELEASE_NOTES" "$ANDROID_CHANGELOG" + +echo -e "${GREEN}✅ Successfully synced changelog!${NC}" +echo -e " iOS release notes → Android changelog ($VERSION_CODE.txt)" +echo "" +echo -e "${YELLOW}Next steps:${NC}" +echo "1. Review the changelog at: $ANDROID_CHANGELOG" +echo "2. Commit the changes" +echo "3. Run fastlane to deploy"