feat: fastlane & screenshot scripts
This commit is contained in:
+7
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
+42
-6
@@ -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<OrientationAwareSetup> createState() => _OrientationAwareSetupState();
|
||||
}
|
||||
|
||||
class _OrientationAwareSetupState extends State<OrientationAwareSetup> {
|
||||
@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});
|
||||
|
||||
|
||||
@@ -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<void> _initializeSettings() async {
|
||||
@@ -96,4 +99,4 @@ class ThemeProvider extends ChangeNotifier {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,6 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
const SizedBox(height: 24),
|
||||
]),
|
||||
),
|
||||
@@ -101,5 +100,4 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<AuthScreen> {
|
||||
Future.delayed(const Duration(milliseconds: 100), _startAuthentication);
|
||||
}
|
||||
|
||||
void _handleDebugTap() {
|
||||
if (!kDebugMode) return;
|
||||
_showDebugTokenDialog();
|
||||
}
|
||||
|
||||
void _showDebugTokenDialog() {
|
||||
final tokenController = TextEditingController();
|
||||
String? errorMessage;
|
||||
|
||||
showDialog<void>(
|
||||
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<AuthScreen> {
|
||||
),
|
||||
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(
|
||||
|
||||
+375
-315
@@ -45,6 +45,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
int _currentHeroIndex = 0;
|
||||
Timer? _autoScrollTimer;
|
||||
late AnimationController _indicatorAnimationController;
|
||||
bool _isAutoScrollPaused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -67,9 +68,12 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
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<DiscoverScreen>
|
||||
);
|
||||
// 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<DiscoverScreen>
|
||||
_startAutoScroll();
|
||||
}
|
||||
|
||||
void _pauseAutoScroll() {
|
||||
setState(() {
|
||||
_isAutoScrollPaused = true;
|
||||
});
|
||||
_autoScrollTimer?.cancel();
|
||||
_indicatorAnimationController.stop();
|
||||
}
|
||||
|
||||
void _resumeAutoScroll() {
|
||||
setState(() {
|
||||
_isAutoScrollPaused = false;
|
||||
});
|
||||
_startAutoScroll();
|
||||
}
|
||||
|
||||
Future<void> _loadContent() async {
|
||||
appLogger.d('Loading discover content');
|
||||
setState(() {
|
||||
@@ -211,8 +232,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
if (confirm == true && mounted) {
|
||||
// Use comprehensive logout through UserProfileProvider
|
||||
final userProfileProvider = Provider.of<UserProfileProvider>(context, listen: false);
|
||||
final plexClientProvider = Provider.of<PlexClientProvider>(context, listen: false);
|
||||
final userProfileProvider = Provider.of<UserProfileProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final plexClientProvider = Provider.of<PlexClientProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
// Clear all user data and provider states
|
||||
await userProfileProvider.logout();
|
||||
@@ -432,62 +459,85 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
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<DiscoverScreen>
|
||||
? '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<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => VideoPlayerScreen(
|
||||
metadata: heroItem,
|
||||
userProfile: widget.userProfile,
|
||||
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
|
||||
Navigator.push<bool>(
|
||||
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<double>(
|
||||
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<PlexClientProvider>(
|
||||
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<double>(
|
||||
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<PlexClientProvider>(
|
||||
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<PlexClientProvider>(
|
||||
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<PlexClientProvider>(
|
||||
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<DiscoverScreen>
|
||||
.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<DiscoverScreen>
|
||||
? 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),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -269,12 +269,15 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -67,9 +67,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
|
||||
@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<LicensesScreen> {
|
||||
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<LicensesScreen> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _LicenseDetailScreen(
|
||||
mergedLicense: mergedLicense,
|
||||
),
|
||||
builder: (context) =>
|
||||
_LicenseDetailScreen(mergedLicense: mergedLicense),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -125,9 +121,7 @@ class _LicensesScreenState extends State<LicensesScreen> {
|
||||
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 {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,120 +772,125 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> {
|
||||
_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<PlexClientProvider>(
|
||||
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<PlexClientProvider>(
|
||||
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<Color>(
|
||||
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<Color>(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -47,9 +47,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
@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<SettingsScreen> {
|
||||
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<ThemeProvider>(
|
||||
@@ -117,9 +115,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Widget _buildAdvancedSection() {
|
||||
return Card(
|
||||
child: Column(
|
||||
@@ -189,9 +186,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _showThemeDialog(ThemeProvider themeProvider) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -302,7 +298,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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<SettingsScreen> {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _KeyboardShortcutsScreen(
|
||||
keyboardService: _keyboardService,
|
||||
),
|
||||
builder: (context) =>
|
||||
_KeyboardShortcutsScreen(keyboardService: _keyboardService),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -417,7 +411,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
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<SettingsScreen> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import 'settings_service.dart';
|
||||
class KeyboardShortcutsService {
|
||||
static KeyboardShortcutsService? _instance;
|
||||
late SettingsService _settingsService;
|
||||
Map<String, String> _shortcuts = {}; // Legacy string shortcuts for backward compatibility
|
||||
Map<String, String> _shortcuts =
|
||||
{}; // Legacy string shortcuts for backward compatibility
|
||||
Map<String, HotKey> _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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> 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<String, dynamic>;
|
||||
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<dynamic>).cast<String>();
|
||||
|
||||
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<HotKeyModifier>().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<HotKeyModifier>()
|
||||
.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<void> 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)),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,10 +240,7 @@ class StorageService {
|
||||
|
||||
// Clear all user-related data (for logout)
|
||||
Future<void> clearUserData() async {
|
||||
await Future.wait([
|
||||
clearCredentials(),
|
||||
clearLibraryPreferences(),
|
||||
]);
|
||||
await Future.wait([clearCredentials(), clearLibraryPreferences()]);
|
||||
}
|
||||
|
||||
// Update current user after switching
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
||||
_recordedHotKey = widget.currentHotKey;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
|
||||
+134
-69
@@ -97,72 +97,80 @@ class _MediaCardState extends State<MediaCard>
|
||||
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<MediaCard>
|
||||
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<MediaCard>
|
||||
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<MediaCard>
|
||||
},
|
||||
);
|
||||
} 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<SkeletonLoader> createState() => _SkeletonLoaderState();
|
||||
}
|
||||
|
||||
class _SkeletonLoaderState extends State<SkeletonLoader>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = Tween<double>(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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,7 +333,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMobile = PlatformDetector.isMobile(context);
|
||||
@@ -688,10 +687,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
|
||||
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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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}"
|
||||
Executable
+396
@@ -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
|
||||
Executable
+421
@@ -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
|
||||
Executable
+135
@@ -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 <command>"
|
||||
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
|
||||
Executable
+184
@@ -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
|
||||
}
|
||||
Executable
+59
@@ -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"
|
||||
Reference in New Issue
Block a user