feat: update checks

This commit is contained in:
edde746
2025-11-04 16:50:46 +01:00
parent 8e00ad0551
commit 3d5c7f7e3b
4 changed files with 405 additions and 6 deletions
+5 -5
View File
@@ -58,7 +58,7 @@ jobs:
EOF
- name: Build APK
run: flutter build apk --release
run: flutter build apk --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Clean up keystore
if: always()
@@ -118,7 +118,7 @@ jobs:
run: flutter pub get
- name: Build iOS (no codesign)
run: flutter build ios --release --no-codesign
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true
- name: Create IPA
run: |
@@ -176,7 +176,7 @@ jobs:
run: flutter pub get
- name: Build macOS
run: flutter build macos --release
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Import Code Signing Certificate
env:
@@ -298,7 +298,7 @@ jobs:
run: flutter pub get
- name: Build Windows
run: flutter build windows --release
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Build Windows Installer
run: .\windows\build-installer.ps1
@@ -361,7 +361,7 @@ jobs:
run: flutter pub get
- name: Build Linux
run: flutter build linux --release
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true
- name: Create Archive
run: |
+79 -1
View File
@@ -3,6 +3,7 @@ import 'dart:io' show Platform;
import 'package:media_kit/media_kit.dart';
import 'package:window_manager/window_manager.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'screens/main_screen.dart';
import 'screens/auth_screen.dart';
import 'services/storage_service.dart';
@@ -10,6 +11,7 @@ import 'services/plex_auth_service.dart';
import 'services/server_connection_service.dart';
import 'services/macos_titlebar_service.dart';
import 'services/fullscreen_state_manager.dart';
import 'services/update_service.dart';
import 'providers/user_profile_provider.dart';
import 'providers/plex_client_provider.dart';
import 'providers/theme_provider.dart';
@@ -126,6 +128,78 @@ class _SetupScreenState extends State<SetupScreen> {
_loadSavedCredentials();
}
void _checkForUpdatesOnStartup() async {
// Delay slightly to allow UI to settle
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
try {
final updateInfo = await UpdateService.checkForUpdatesOnStartup();
if (updateInfo != null && updateInfo['hasUpdate'] == true && mounted) {
_showUpdateDialog(updateInfo);
}
} catch (e) {
appLogger.e('Error checking for updates', error: e);
}
}
void _showUpdateDialog(Map<String, dynamic> updateInfo) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Update Available'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Version ${updateInfo['latestVersion']} is available',
style: Theme.of(dialogContext).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Current: ${updateInfo['currentVersion']}',
style: Theme.of(dialogContext).textTheme.bodySmall,
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.pop(dialogContext);
},
child: const Text('Later'),
),
TextButton(
onPressed: () async {
await UpdateService.skipVersion(updateInfo['latestVersion']);
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: const Text('Skip This Version'),
),
FilledButton(
onPressed: () async {
final url = Uri.parse(updateInfo['releaseUrl']);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
if (dialogContext.mounted) {
Navigator.pop(dialogContext);
}
},
child: const Text('View Release'),
),
],
);
},
);
}
Future<void> _loadSavedCredentials() async {
final storage = await StorageService.getInstance();
@@ -173,10 +247,14 @@ class _SetupScreenState extends State<SetupScreen> {
// Handle result
if (result.isSuccess) {
// Success! Set client in provider and navigate to main screen
// Success! Set client in provider
if (mounted) {
context.plexClient.setClient(result.client!);
// Check for updates BEFORE navigation to keep context valid
_checkForUpdatesOnStartup();
// Navigate to main screen after update check is initiated
if (mounted) {
Navigator.pushReplacement(
context,
+144
View File
@@ -1,10 +1,12 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:url_launcher/url_launcher.dart';
import '../providers/theme_provider.dart';
import '../providers/settings_provider.dart';
import '../services/settings_service.dart' as settings;
import '../services/keyboard_shortcuts_service.dart';
import '../services/update_service.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/hotkey_recorder_widget.dart';
import 'about_screen.dart';
@@ -27,6 +29,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
int _seekTimeSmall = 10;
int _seekTimeLarge = 30;
// Update checking state
bool _isCheckingForUpdate = false;
Map<String, dynamic>? _updateInfo;
@override
void initState() {
super.initState();
@@ -70,6 +76,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
const SizedBox(height: 24),
_buildAdvancedSection(),
const SizedBox(height: 24),
if (UpdateService.isUpdateCheckEnabled) ...[
_buildUpdateSection(),
const SizedBox(height: 24),
],
_buildAboutSection(),
const SizedBox(height: 24),
]),
@@ -259,6 +269,56 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildUpdateSection() {
final hasUpdate = _updateInfo != null && _updateInfo!['hasUpdate'] == true;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'Updates',
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
ListTile(
leading: Icon(
hasUpdate ? Icons.system_update : Icons.check_circle,
color: hasUpdate ? Colors.orange : null,
),
title: Text(
hasUpdate ? 'Update Available' : 'Check for Updates',
),
subtitle: hasUpdate
? Text('Version ${_updateInfo!['latestVersion']} is available')
: const Text('Check for the latest version on GitHub'),
trailing: _isCheckingForUpdate
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right),
onTap: _isCheckingForUpdate
? null
: () {
if (hasUpdate) {
_showUpdateDialog();
} else {
_checkForUpdates();
}
},
),
],
),
);
}
Widget _buildAboutSection() {
return Card(
child: ListTile(
@@ -585,6 +645,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Future<void> _checkForUpdates() async {
setState(() {
_isCheckingForUpdate = true;
});
try {
final updateInfo = await UpdateService.checkForUpdates();
if (mounted) {
setState(() {
_updateInfo = updateInfo;
_isCheckingForUpdate = false;
});
if (updateInfo == null || updateInfo['hasUpdate'] != true) {
// Show "no updates" message
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('You are on the latest version'),
duration: Duration(seconds: 2),
),
);
}
}
} catch (e) {
if (mounted) {
setState(() {
_isCheckingForUpdate = false;
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to check for updates'),
duration: Duration(seconds: 2),
),
);
}
}
}
void _showUpdateDialog() {
if (_updateInfo == null) return;
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Update Available'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Version ${_updateInfo!['latestVersion']} is available',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
'Current: ${_updateInfo!['currentVersion']}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
),
FilledButton(
onPressed: () async {
final url = Uri.parse(_updateInfo!['releaseUrl']);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
if (context.mounted) Navigator.pop(context);
},
child: const Text('View Release'),
),
],
);
},
);
}
void _showLibraryDensityDialog() {
final settingsProvider = context.read<SettingsProvider>();
showDialog(
+177
View File
@@ -0,0 +1,177 @@
import 'package:package_info_plus/package_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Service to check for new versions on GitHub
/// Only enabled when ENABLE_UPDATE_CHECK build flag is set
class UpdateService {
static final Logger _logger = Logger();
static const String _githubRepo = 'edde746/plezy';
// SharedPreferences keys
static const String _keySkippedVersion = 'update_skipped_version';
static const String _keyLastCheckTime = 'update_last_check_time';
// Check cooldown: 6 hours
static const Duration _checkCooldown = Duration(hours: 6);
/// Check if update checking is enabled via build flag
static bool get isUpdateCheckEnabled {
const enabled = bool.fromEnvironment('ENABLE_UPDATE_CHECK', defaultValue: false);
return enabled;
}
/// Skip a specific version
static Future<void> skipVersion(String version) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keySkippedVersion, version);
}
/// Get the skipped version
static Future<String?> getSkippedVersion() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_keySkippedVersion);
}
/// Clear skipped version
static Future<void> clearSkippedVersion() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keySkippedVersion);
}
/// Check if cooldown period has passed since last check
static Future<bool> shouldCheckForUpdates() async {
final prefs = await SharedPreferences.getInstance();
final lastCheckString = prefs.getString(_keyLastCheckTime);
if (lastCheckString == null) return true;
final lastCheck = DateTime.parse(lastCheckString);
final now = DateTime.now();
final timeSinceLastCheck = now.difference(lastCheck);
return timeSinceLastCheck >= _checkCooldown;
}
/// Update the last check timestamp
static Future<void> _updateLastCheckTime() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyLastCheckTime, DateTime.now().toIso8601String());
}
/// Check for updates on GitHub (manual check, ignores cooldown)
/// Returns a map with update info, or null if no update or error
static Future<Map<String, dynamic>?> checkForUpdates({bool silent = false}) async {
if (!isUpdateCheckEnabled) {
return null;
}
try {
final packageInfo = await PackageInfo.fromPlatform();
final currentVersion = packageInfo.version;
final dio = Dio();
final response = await dio.get(
'https://api.github.com/repos/$_githubRepo/releases/latest',
options: Options(
headers: {
'Accept': 'application/vnd.github+json',
},
),
);
if (response.statusCode == 200) {
final data = response.data;
final latestVersion = data['tag_name'] as String;
// Remove 'v' prefix if present
final cleanVersion = latestVersion.startsWith('v')
? latestVersion.substring(1)
: latestVersion;
final hasUpdate = _isNewerVersion(cleanVersion, currentVersion);
if (hasUpdate) {
// Check if this version was skipped (always check, regardless of silent mode)
final skippedVersion = await getSkippedVersion();
if (skippedVersion == cleanVersion) {
return null;
}
return {
'hasUpdate': true,
'currentVersion': currentVersion,
'latestVersion': cleanVersion,
'releaseUrl': data['html_url'] as String,
'releaseName': data['name'] as String? ?? 'Version $cleanVersion',
'releaseNotes': data['body'] as String? ?? '',
'publishedAt': data['published_at'] as String,
};
}
}
} catch (e) {
_logger.e('Failed to check for updates: $e');
}
return null;
}
/// Check for updates on startup (respects cooldown and skipped versions)
/// Returns update info if available, null otherwise
static Future<Map<String, dynamic>?> checkForUpdatesOnStartup() async {
if (!isUpdateCheckEnabled) {
return null;
}
// Check cooldown
if (!await shouldCheckForUpdates()) {
return null;
}
// Perform the check
final updateInfo = await checkForUpdates(silent: true);
// Update last check time
await _updateLastCheckTime();
return updateInfo;
}
/// Compare two version strings
/// Returns true if newVersion is newer than currentVersion
static bool _isNewerVersion(String newVersion, String currentVersion) {
try {
// Split by '.' and parse as integers
final newParts = newVersion.split('.').map((p) {
// Handle versions like "1.2.3+4" by taking only the numeric part
final numPart = p.split('+').first.split('-').first;
return int.tryParse(numPart) ?? 0;
}).toList();
final currentParts = currentVersion.split('.').map((p) {
final numPart = p.split('+').first.split('-').first;
return int.tryParse(numPart) ?? 0;
}).toList();
// Compare each part
final maxLength = newParts.length > currentParts.length
? newParts.length
: currentParts.length;
for (int i = 0; i < maxLength; i++) {
final newPart = i < newParts.length ? newParts[i] : 0;
final currentPart = i < currentParts.length ? currentParts[i] : 0;
if (newPart > currentPart) return true;
if (newPart < currentPart) return false;
}
return false; // Versions are equal
} catch (e) {
_logger.e('Error comparing versions: $e');
return false;
}
}
}