From 9ff0d6265147d5985b7f460952c964bf4dc9f72e Mon Sep 17 00:00:00 2001 From: HRiggs Date: Sun, 14 Dec 2025 23:11:50 -0500 Subject: [PATCH] check update --- .gitea/workflows/release.yml | 30 +- flutter_app/UPDATE_SYSTEM.md | 61 ++++ flutter_app/lib/main.dart | 8 +- .../lib/providers/update_provider.dart | 95 ++++++ flutter_app/lib/screens/home_screen.dart | 48 ++- flutter_app/lib/services/storage_service.dart | 11 + flutter_app/lib/services/update_service.dart | 255 +++++++++++++++ .../lib/widgets/update_notification.dart | 308 ++++++++++++++++++ flutter_app/pubspec.lock | 90 ++++- flutter_app/pubspec.yaml | 9 + flutter_app/test/update_service_test.dart | 69 ++++ 11 files changed, 970 insertions(+), 14 deletions(-) create mode 100644 flutter_app/UPDATE_SYSTEM.md create mode 100644 flutter_app/lib/providers/update_provider.dart create mode 100644 flutter_app/lib/services/update_service.dart create mode 100644 flutter_app/lib/widgets/update_notification.dart create mode 100644 flutter_app/test/update_service_test.dart diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6c50dba..7ad8bd9 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -77,6 +77,9 @@ jobs: build-android: runs-on: ubuntu-latest needs: get-version + env: + ANDROID_HOME: /opt/android-sdk-linux + ANDROID_SDK_ROOT: /opt/android-sdk-linux steps: - name: Checkout repository uses: actions/checkout@v4 @@ -90,14 +93,15 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 with: - api-level: 34 - build-tools: 34.0.0 - cmake: 3.22.1 - ndk: 25.1.8937393 - - - name: Accept Android SDK licenses - run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true - + cmdline-tools-version: 11076708 + + - name: Install Android SDK components + run: | + echo "Installing Android SDK components..." + $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install "platform-tools" "platforms;android-34" "build-tools;34.0.0" + echo "Android SDK setup complete" + export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin + - name: Setup Flutter uses: subosito/flutter-action@v2 with: @@ -105,9 +109,15 @@ jobs: channel: 'stable' cache: true - - name: Accept Flutter Android licenses - run: flutter doctor --android-licenses || true + - name: Accept Android licenses + run: yes | flutter doctor --android-licenses + - name: Verify Android SDK + run: | + echo "ANDROID_HOME: $ANDROID_HOME" + echo "ANDROID_SDK_ROOT: $ANDROID_SDK_ROOT" + ls -la $ANDROID_HOME || echo "ANDROID_HOME not found" + - name: Flutter doctor run: flutter doctor -v diff --git a/flutter_app/UPDATE_SYSTEM.md b/flutter_app/UPDATE_SYSTEM.md new file mode 100644 index 0000000..beac992 --- /dev/null +++ b/flutter_app/UPDATE_SYSTEM.md @@ -0,0 +1,61 @@ +# Update System + +The rmtPocketWatcher Flutter app includes an automatic update checking system that monitors the Gitea repository for new releases. + +## How It Works + +1. **Automatic Checks**: The app checks for updates every 4 hours automatically +2. **Manual Checks**: Users can manually check for updates using the refresh button in the title bar +3. **Version Comparison**: Compares current app version (from pubspec.yaml) with latest release tag +4. **Platform Detection**: Automatically detects the appropriate download asset for the current platform + +## Components + +### UpdateService +- Fetches releases from Gitea RSS feed: `https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases.rss` +- Parses XML/RSS format to extract release information +- Compares version numbers and generates platform-specific download URLs + +### UpdateProvider +- Manages update checking state and notifications +- Stores last check time and dismissed updates in SharedPreferences +- Provides reactive state for UI components + +### UpdateNotificationBanner +- Shows a dismissible banner when updates are available +- Displays version information and release details +- Provides direct links to download or view release page + +## Configuration + +The system is configured to work with your Gitea repository structure: +- Repository: `LambdaBankingConglomerate/rmtPocketWatcher` +- RSS Feed: `https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases.rss` +- Release tags should follow semantic versioning (e.g., `v1.0.0`, `v1.2.3`) +- Expected asset naming convention: + - Windows: `rmtPocketWatcher-windows-x64.exe`, `rmtPocketWatcher-windows-x64.msi` + - macOS: `rmtPocketWatcher-macos.dmg` + - Linux: `rmtPocketWatcher-linux.appimage` + - Android: `rmtPocketWatcher-android.apk` + +## Usage + +The update system is automatically initialized when the app starts: + +```dart +// In HomeScreen initState +context.read().initialize(); +context.read().startPeriodicChecks(); +``` + +Users will see: +1. A notification banner when updates are available +2. An update icon in the title bar (desktop) +3. A detailed dialog with release notes and download options + +## Privacy & Security + +- Only checks public release information +- No personal data is transmitted +- Downloads are handled by the system browser/app store +- Users control when to update (no forced updates) \ No newline at end of file diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart index 33b81f2..9b7dc20 100644 --- a/flutter_app/lib/main.dart +++ b/flutter_app/lib/main.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:window_manager/window_manager.dart'; import 'providers/price_provider.dart'; +import 'providers/update_provider.dart'; import 'screens/home_screen.dart'; import 'services/notification_service.dart'; import 'widgets/loading_screen.dart'; @@ -61,8 +62,11 @@ class _MyAppState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (_) => PriceProvider(), + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => PriceProvider()), + ChangeNotifierProvider(create: (_) => UpdateProvider()), + ], child: MaterialApp( title: 'rmtPocketWatcher', debugShowCheckedModeBanner: false, diff --git a/flutter_app/lib/providers/update_provider.dart b/flutter_app/lib/providers/update_provider.dart new file mode 100644 index 0000000..6649994 --- /dev/null +++ b/flutter_app/lib/providers/update_provider.dart @@ -0,0 +1,95 @@ +import 'package:flutter/foundation.dart'; +import '../services/update_service.dart'; +import '../services/storage_service.dart'; + +class UpdateProvider extends ChangeNotifier { + final UpdateService _updateService = UpdateService(); + final StorageService _storageService = StorageService(); + + UpdateInfo? _availableUpdate; + bool _isChecking = false; + DateTime? _lastChecked; + bool _updateDismissed = false; + + UpdateInfo? get availableUpdate => _availableUpdate; + bool get isChecking => _isChecking; + DateTime? get lastChecked => _lastChecked; + bool get hasUpdate => _availableUpdate != null && !_updateDismissed; + + /// Check for updates manually + Future checkForUpdates({bool force = false}) async { + if (_isChecking) return; + + // Don't check too frequently unless forced + if (!force && _lastChecked != null) { + final timeSinceLastCheck = DateTime.now().difference(_lastChecked!); + if (timeSinceLastCheck.inHours < 1) { + if (kDebugMode) { + print('Skipping update check - last checked ${timeSinceLastCheck.inMinutes} minutes ago'); + } + return; + } + } + + _isChecking = true; + notifyListeners(); + + try { + final updateInfo = await _updateService.checkForUpdates(); + _availableUpdate = updateInfo; + _lastChecked = DateTime.now(); + _updateDismissed = false; + + // Save last check time + await _storageService.setString('last_update_check', _lastChecked!.toIso8601String()); + + if (updateInfo != null) { + if (kDebugMode) { + print('Update available: ${updateInfo.latestVersion}'); + } + + // Check if this version was already dismissed + final dismissedVersion = await _storageService.getString('dismissed_update_version'); + if (dismissedVersion == updateInfo.latestVersion) { + _updateDismissed = true; + } + } + } catch (e) { + if (kDebugMode) { + print('Error checking for updates: $e'); + } + } finally { + _isChecking = false; + notifyListeners(); + } + } + + /// Dismiss the current update notification + Future dismissUpdate() async { + if (_availableUpdate != null) { + _updateDismissed = true; + await _storageService.setString('dismissed_update_version', _availableUpdate!.latestVersion); + notifyListeners(); + } + } + + /// Initialize the provider and load saved state + Future initialize() async { + // Load last check time + final lastCheckString = await _storageService.getString('last_update_check'); + if (lastCheckString != null) { + _lastChecked = DateTime.tryParse(lastCheckString); + } + + // Check for updates on startup (but don't force it) + await checkForUpdates(); + } + + /// Check for updates automatically in the background + void startPeriodicChecks() { + // Check every 4 hours + Stream.periodic(const Duration(hours: 4)).listen((_) { + checkForUpdates(); + }); + } +} \ No newline at end of file diff --git a/flutter_app/lib/screens/home_screen.dart b/flutter_app/lib/screens/home_screen.dart index d449374..3cef986 100644 --- a/flutter_app/lib/screens/home_screen.dart +++ b/flutter_app/lib/screens/home_screen.dart @@ -2,13 +2,30 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/price_provider.dart'; +import '../providers/update_provider.dart'; import '../widgets/price_chart.dart'; import '../widgets/alerts_panel.dart'; import '../widgets/vendor_table.dart'; +import '../widgets/update_notification.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + @override + void initState() { + super.initState(); + // Initialize update provider + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().initialize(); + context.read().startPeriodicChecks(); + }); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -42,6 +59,33 @@ class HomeScreen extends StatelessWidget { ), ), const SizedBox(width: 16), + // Update check button + Consumer( + builder: (context, updateProvider, child) { + return IconButton( + icon: updateProvider.isChecking + ? const SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Color(0xFF50E3C2), + ), + ) + : Icon( + updateProvider.hasUpdate ? Icons.system_update : Icons.refresh, + color: updateProvider.hasUpdate ? const Color(0xFF50E3C2) : Colors.white, + size: 16, + ), + onPressed: updateProvider.isChecking + ? null + : () => updateProvider.checkForUpdates(force: true), + tooltip: updateProvider.hasUpdate + ? 'Update available' + : 'Check for updates', + ); + }, + ), IconButton( icon: const Icon(Icons.minimize, color: Colors.white, size: 16), onPressed: () { @@ -64,6 +108,8 @@ class HomeScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // Update notification banner + const UpdateNotificationBanner(), // Top stats row - Bloomberg style Consumer( builder: (context, provider, child) { diff --git a/flutter_app/lib/services/storage_service.dart b/flutter_app/lib/services/storage_service.dart index e43eab1..b7371ef 100644 --- a/flutter_app/lib/services/storage_service.dart +++ b/flutter_app/lib/services/storage_service.dart @@ -51,4 +51,15 @@ class StorageService { final prefs = await SharedPreferences.getInstance(); await prefs.setDouble(_customAuecKey, amount); } + + // Generic string storage methods for update checking + Future getString(String key) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(key); + } + + Future setString(String key, String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(key, value); + } } diff --git a/flutter_app/lib/services/update_service.dart b/flutter_app/lib/services/update_service.dart new file mode 100644 index 0000000..8a7b781 --- /dev/null +++ b/flutter_app/lib/services/update_service.dart @@ -0,0 +1,255 @@ +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:xml/xml.dart'; + +class UpdateService { + static const String _releasesRssUrl = 'https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases.rss'; + + /// Check if an update is available by comparing current version with latest release + Future checkForUpdates() async { + try { + // Get current app version + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + + if (kDebugMode) { + print('Current app version: $currentVersion'); + print('Checking for updates at: $_releasesRssUrl'); + } + + // Fetch latest release from Gitea RSS feed + final response = await http.get( + Uri.parse(_releasesRssUrl), + headers: { + 'Accept': 'application/rss+xml, application/xml, text/xml', + 'User-Agent': 'rmtPocketWatcher/$currentVersion', + }, + ); + + if (response.statusCode == 200) { + final document = XmlDocument.parse(response.body); + final items = document.findAllElements('item'); + + if (items.isEmpty) { + if (kDebugMode) { + print('No releases found in RSS feed'); + } + return null; + } + + // Get the latest release (first item in RSS feed) + final latestItem = items.first; + final title = latestItem.findElements('title').first.innerText; + final link = latestItem.findElements('link').first.innerText; + final description = latestItem.findElements('description').firstOrNull?.innerText ?? ''; + final pubDate = latestItem.findElements('pubDate').first.innerText; + + // Extract version from title (assuming format like "v1.2.3" or "Release v1.2.3") + final versionMatch = RegExp(r'v?(\d+\.\d+\.\d+)').firstMatch(title); + if (versionMatch == null) { + if (kDebugMode) { + print('Could not extract version from title: $title'); + } + return null; + } + + final latestVersion = versionMatch.group(1)!; + + if (kDebugMode) { + print('Latest release version: $latestVersion'); + print('Release title: $title'); + } + + // Compare versions + if (isNewerVersion(latestVersion, currentVersion)) { + return UpdateInfo( + currentVersion: currentVersion, + latestVersion: latestVersion, + releaseUrl: link, + releaseName: title, + releaseNotes: description, + publishedAt: _parseRssDate(pubDate), + assets: _generateAssetUrls(latestVersion), // Generate expected asset URLs + ); + } else { + if (kDebugMode) { + print('App is up to date'); + } + return null; + } + } else { + if (kDebugMode) { + print('Failed to fetch RSS feed: ${response.statusCode}'); + print('Response: ${response.body}'); + } + return null; + } + } catch (e) { + if (kDebugMode) { + print('Error checking for updates: $e'); + } + return null; + } + } + + /// Extract version number from git tag (e.g., "v1.2.3" -> "1.2.3") + @visibleForTesting + String extractVersionFromTag(String tag) { + return tag.startsWith('v') ? tag.substring(1) : tag; + } + + /// Compare two version strings (e.g., "1.2.3" vs "1.2.2") + @visibleForTesting + bool isNewerVersion(String latest, String current) { + final latestParts = latest.split('.').map(int.parse).toList(); + final currentParts = current.split('.').map(int.parse).toList(); + + // Ensure both have same number of parts + while (latestParts.length < currentParts.length) { + latestParts.add(0); + } + while (currentParts.length < latestParts.length) { + currentParts.add(0); + } + + for (int i = 0; i < latestParts.length; i++) { + if (latestParts[i] > currentParts[i]) { + return true; + } else if (latestParts[i] < currentParts[i]) { + return false; + } + } + + return false; // Versions are equal + } + + /// Parse RSS date format to DateTime + DateTime _parseRssDate(String rssDate) { + try { + // RSS dates are typically in RFC 2822 format + // Example: "Mon, 02 Jan 2006 15:04:05 MST" + return DateTime.parse(rssDate); + } catch (e) { + if (kDebugMode) { + print('Failed to parse RSS date: $rssDate, error: $e'); + } + return DateTime.now(); + } + } + + /// Generate expected asset URLs based on version and platform + List _generateAssetUrls(String version) { + final baseUrl = 'https://git.hudsonriggs.systems/LambdaBankingConglomerate/rmtPocketWatcher/releases/download/v$version'; + + return [ + ReleaseAsset( + name: 'rmtPocketWatcher-windows-x64.exe', + downloadUrl: '$baseUrl/rmtPocketWatcher-windows-x64.exe', + size: 0, // Unknown size from RSS + contentType: 'application/octet-stream', + ), + ReleaseAsset( + name: 'rmtPocketWatcher-windows-x64.msi', + downloadUrl: '$baseUrl/rmtPocketWatcher-windows-x64.msi', + size: 0, + contentType: 'application/octet-stream', + ), + ReleaseAsset( + name: 'rmtPocketWatcher-macos.dmg', + downloadUrl: '$baseUrl/rmtPocketWatcher-macos.dmg', + size: 0, + contentType: 'application/octet-stream', + ), + ReleaseAsset( + name: 'rmtPocketWatcher-linux.appimage', + downloadUrl: '$baseUrl/rmtPocketWatcher-linux.appimage', + size: 0, + contentType: 'application/octet-stream', + ), + ReleaseAsset( + name: 'rmtPocketWatcher-android.apk', + downloadUrl: '$baseUrl/rmtPocketWatcher-android.apk', + size: 0, + contentType: 'application/vnd.android.package-archive', + ), + ]; + } +} + +class UpdateInfo { + final String currentVersion; + final String latestVersion; + final String releaseUrl; + final String releaseName; + final String releaseNotes; + final DateTime publishedAt; + final List assets; + + UpdateInfo({ + required this.currentVersion, + required this.latestVersion, + required this.releaseUrl, + required this.releaseName, + required this.releaseNotes, + required this.publishedAt, + required this.assets, + }); + + /// Get the appropriate download asset for the current platform + ReleaseAsset? getAssetForCurrentPlatform() { + if (kIsWeb) return null; + + String platformPattern; + switch (defaultTargetPlatform) { + case TargetPlatform.windows: + platformPattern = r'windows|win|\.exe$|\.msi$'; + break; + case TargetPlatform.macOS: + platformPattern = r'macos|mac|darwin|\.dmg$|\.pkg$'; + break; + case TargetPlatform.linux: + platformPattern = r'linux|\.deb$|\.rpm$|\.appimage$'; + break; + case TargetPlatform.android: + platformPattern = r'android|\.apk$'; + break; + case TargetPlatform.iOS: + platformPattern = r'ios|\.ipa$'; + break; + default: + return null; + } + + final regex = RegExp(platformPattern, caseSensitive: false); + + for (final asset in assets) { + if (regex.hasMatch(asset.name)) { + return asset; + } + } + + return null; + } +} + +class ReleaseAsset { + final String name; + final String downloadUrl; + final int size; + final String contentType; + + ReleaseAsset({ + required this.name, + required this.downloadUrl, + required this.size, + required this.contentType, + }); + + String get formattedSize { + if (size < 1024) return '${size}B'; + if (size < 1024 * 1024) return '${(size / 1024).toStringAsFixed(1)}KB'; + if (size < 1024 * 1024 * 1024) return '${(size / (1024 * 1024)).toStringAsFixed(1)}MB'; + return '${(size / (1024 * 1024 * 1024)).toStringAsFixed(1)}GB'; + } +} \ No newline at end of file diff --git a/flutter_app/lib/widgets/update_notification.dart b/flutter_app/lib/widgets/update_notification.dart new file mode 100644 index 0000000..37dd83f --- /dev/null +++ b/flutter_app/lib/widgets/update_notification.dart @@ -0,0 +1,308 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../providers/update_provider.dart'; +import '../services/update_service.dart'; + +class UpdateNotificationBanner extends StatelessWidget { + const UpdateNotificationBanner({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, updateProvider, child) { + if (!updateProvider.hasUpdate) { + return const SizedBox.shrink(); + } + + final update = updateProvider.availableUpdate!; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: const Color(0xFF1A4B3A), // Dark green background + border: Border.all(color: const Color(0xFF50E3C2), width: 1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + Icons.system_update, + color: Color(0xFF50E3C2), + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Update Available: v${update.latestVersion}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + Text( + 'Current: v${update.currentVersion}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + ], + ), + ), + TextButton( + onPressed: () => _showUpdateDialog(context, update), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF50E3C2), + ), + child: const Text('View'), + ), + IconButton( + onPressed: () => updateProvider.dismissUpdate(), + icon: const Icon(Icons.close, color: Colors.white70, size: 18), + tooltip: 'Dismiss', + ), + ], + ), + ); + }, + ); + } + + void _showUpdateDialog(BuildContext context, UpdateInfo update) { + showDialog( + context: context, + builder: (context) => UpdateDialog(update: update), + ); + } +} + +class UpdateDialog extends StatelessWidget { + final UpdateInfo update; + + const UpdateDialog({super.key, required this.update}); + + @override + Widget build(BuildContext context) { + final asset = update.getAssetForCurrentPlatform(); + + return AlertDialog( + backgroundColor: const Color(0xFF1A1F3A), + title: Row( + children: [ + const Icon(Icons.system_update, color: Color(0xFF50E3C2)), + const SizedBox(width: 8), + Text( + 'Update Available', + style: const TextStyle(color: Colors.white), + ), + ], + ), + content: SizedBox( + width: 500, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildVersionInfo(), + const SizedBox(height: 16), + _buildReleaseInfo(), + if (update.releaseNotes.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildReleaseNotes(), + ], + if (asset != null) ...[ + const SizedBox(height: 16), + _buildDownloadInfo(asset), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Later', style: TextStyle(color: Colors.white70)), + ), + if (asset != null) + ElevatedButton( + onPressed: () => _downloadUpdate(asset.downloadUrl), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF50E3C2), + foregroundColor: Colors.black, + ), + child: const Text('Download'), + ), + ElevatedButton( + onPressed: () => _openReleasePage(update.releaseUrl), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF50E3C2), + foregroundColor: Colors.black, + ), + child: const Text('View Release'), + ), + ], + ); + } + + Widget _buildVersionInfo() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0A0E27), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF50E3C2).withValues(alpha: 0.3)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Current Version', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + 'v${update.currentVersion}', + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + const Icon(Icons.arrow_forward, color: Color(0xFF50E3C2)), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text( + 'Latest Version', + style: TextStyle(color: Colors.white70, fontSize: 12), + ), + Text( + 'v${update.latestVersion}', + style: const TextStyle(color: Color(0xFF50E3C2), fontSize: 16, fontWeight: FontWeight.bold), + ), + ], + ), + ], + ), + ); + } + + Widget _buildReleaseInfo() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + update.releaseName, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Released ${_formatDate(update.publishedAt)}', + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ); + } + + Widget _buildReleaseNotes() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Release Notes', + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0A0E27), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF50E3C2).withValues(alpha: 0.3)), + ), + child: Text( + update.releaseNotes, + style: const TextStyle(color: Colors.white70, fontSize: 12), + maxLines: 8, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + + Widget _buildDownloadInfo(ReleaseAsset asset) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0A0E27), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFF50E3C2).withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.download, color: Color(0xFF50E3C2), size: 20), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + asset.name, + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + Text( + asset.formattedSize, + style: const TextStyle(color: Colors.white70, fontSize: 12), + ), + ], + ), + ), + ], + ), + ); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final difference = now.difference(date); + + if (difference.inDays > 0) { + return '${difference.inDays} day${difference.inDays == 1 ? '' : 's'} ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours} hour${difference.inHours == 1 ? '' : 's'} ago'; + } else { + return '${difference.inMinutes} minute${difference.inMinutes == 1 ? '' : 's'} ago'; + } + } + + void _downloadUpdate(String downloadUrl) async { + final uri = Uri.parse(downloadUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + } + + void _openReleasePage(String releaseUrl) async { + final uri = Uri.parse(releaseUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + } +} \ No newline at end of file diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index c5f4777..a45465b 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -264,6 +264,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -549,6 +565,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + url: "https://pub.dev" + source: hosted + version: "6.3.6" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -581,6 +661,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.5" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" window_manager: dependency: "direct main" description: @@ -598,7 +686,7 @@ packages: source: hosted version: "1.1.0" xml: - dependency: transitive + dependency: "direct main" description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 1b0aff0..125ad6b 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -66,6 +66,15 @@ dependencies: # System tray (desktop) tray_manager: ^0.2.3 + + # Package info for version checking + package_info_plus: ^8.0.2 + + # URL launcher for opening release pages + url_launcher: ^6.3.1 + + # XML parsing for RSS feeds + xml: ^6.5.0 dev_dependencies: flutter_test: diff --git a/flutter_app/test/update_service_test.dart b/flutter_app/test/update_service_test.dart new file mode 100644 index 0000000..6142136 --- /dev/null +++ b/flutter_app/test/update_service_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:rmtpocketwatcher/services/update_service.dart'; + +void main() { + group('UpdateService', () { + late UpdateService updateService; + + setUp(() { + updateService = UpdateService(); + }); + + test('version extraction works correctly', () { + // Test version extraction + expect(updateService.extractVersionFromTag('v1.2.3'), equals('1.2.3')); + expect(updateService.extractVersionFromTag('1.2.3'), equals('1.2.3')); + expect(updateService.extractVersionFromTag('v2.0.0'), equals('2.0.0')); + }); + + test('newer version detection works correctly', () { + expect(updateService.isNewerVersion('1.2.4', '1.2.3'), isTrue); + expect(updateService.isNewerVersion('1.3.0', '1.2.9'), isTrue); + expect(updateService.isNewerVersion('2.0.0', '1.9.9'), isTrue); + expect(updateService.isNewerVersion('1.2.3', '1.2.3'), isFalse); + expect(updateService.isNewerVersion('1.2.2', '1.2.3'), isFalse); + expect(updateService.isNewerVersion('1.1.9', '1.2.0'), isFalse); + }); + + test('platform asset detection works', () { + final assets = [ + ReleaseAsset( + name: 'rmtPocketWatcher-windows-x64.exe', + downloadUrl: 'https://example.com/windows.exe', + size: 1024, + contentType: 'application/octet-stream', + ), + ReleaseAsset( + name: 'rmtPocketWatcher-macos.dmg', + downloadUrl: 'https://example.com/macos.dmg', + size: 2048, + contentType: 'application/octet-stream', + ), + ReleaseAsset( + name: 'rmtPocketWatcher-linux.appimage', + downloadUrl: 'https://example.com/linux.appimage', + size: 3072, + contentType: 'application/octet-stream', + ), + ]; + + final updateInfo = UpdateInfo( + currentVersion: '1.0.0', + latestVersion: '1.1.0', + releaseUrl: 'https://example.com/release', + releaseName: 'Test Release', + releaseNotes: 'Test notes', + publishedAt: DateTime.now(), + assets: assets, + ); + + // This test would need to mock the platform detection + // For now, just verify the asset list is properly structured + expect(updateInfo.assets.length, equals(3)); + expect(updateInfo.assets.first.name, contains('windows')); + }); + }); +} + +// Note: The methods extractVersionFromTag and isNewerVersion are now public +// and marked with @visibleForTesting for testing purposes \ No newline at end of file