697 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			697 lines
		
	
	
		
			22 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
| import 'dart:async';
 | |
| import 'dart:io';
 | |
| 
 | |
| import 'package:archive/archive.dart';
 | |
| import 'package:dio/dio.dart';
 | |
| import 'package:easy_localization/easy_localization.dart';
 | |
| import 'package:flutter/foundation.dart';
 | |
| import 'package:flutter/material.dart';
 | |
| import 'package:flutter_app_update/azhon_app_update.dart';
 | |
| import 'package:flutter_app_update/update_model.dart';
 | |
| import 'package:island/widgets/content/markdown.dart';
 | |
| import 'package:material_symbols_icons/symbols.dart';
 | |
| import 'package:package_info_plus/package_info_plus.dart';
 | |
| import 'package:path_provider/path_provider.dart';
 | |
| import 'package:path/path.dart' as path;
 | |
| import 'package:process_run/process_run.dart';
 | |
| import 'package:collection/collection.dart'; // Added for firstWhereOrNull
 | |
| import 'package:styled_widget/styled_widget.dart';
 | |
| import 'package:url_launcher/url_launcher.dart';
 | |
| import 'package:island/widgets/content/sheet.dart';
 | |
| import 'package:island/talker.dart';
 | |
| 
 | |
| /// Data model for a GitHub release we care about
 | |
| class GithubReleaseInfo {
 | |
|   final String tagName;
 | |
|   final String name;
 | |
|   final String body;
 | |
|   final String htmlUrl;
 | |
|   final DateTime createdAt;
 | |
|   final List<GithubReleaseAsset> assets;
 | |
| 
 | |
|   const GithubReleaseInfo({
 | |
|     required this.tagName,
 | |
|     required this.name,
 | |
|     required this.body,
 | |
|     required this.htmlUrl,
 | |
|     required this.createdAt,
 | |
|     this.assets = const [],
 | |
|   });
 | |
| }
 | |
| 
 | |
| /// Data model for a GitHub release asset
 | |
| class GithubReleaseAsset {
 | |
|   final String name;
 | |
|   final String browserDownloadUrl;
 | |
| 
 | |
|   const GithubReleaseAsset({
 | |
|     required this.name,
 | |
|     required this.browserDownloadUrl,
 | |
|   });
 | |
| 
 | |
|   factory GithubReleaseAsset.fromJson(Map<String, dynamic> json) {
 | |
|     return GithubReleaseAsset(
 | |
|       name: json['name'] as String,
 | |
|       browserDownloadUrl: json['browser_download_url'] as String,
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| /// Parses version and build number from "x.y.z+build"
 | |
| class _ParsedVersion implements Comparable<_ParsedVersion> {
 | |
|   final int major;
 | |
|   final int minor;
 | |
|   final int patch;
 | |
|   final int build;
 | |
| 
 | |
|   const _ParsedVersion(this.major, this.minor, this.patch, this.build);
 | |
| 
 | |
|   static _ParsedVersion? tryParse(String input) {
 | |
|     // Expect format like 0.0.0+00 (build after '+'). Allow missing build as 0.
 | |
|     final partsPlus = input.split('+');
 | |
|     final core = partsPlus[0].trim();
 | |
|     final buildStr = partsPlus.length > 1 ? partsPlus[1].trim() : '0';
 | |
|     final coreParts = core.split('.');
 | |
|     if (coreParts.length != 3) return null;
 | |
| 
 | |
|     final major = int.tryParse(coreParts[0]) ?? 0;
 | |
|     final minor = int.tryParse(coreParts[1]) ?? 0;
 | |
|     final patch = int.tryParse(coreParts[2]) ?? 0;
 | |
|     final build = int.tryParse(buildStr) ?? 0;
 | |
| 
 | |
|     return _ParsedVersion(major, minor, patch, build);
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   int compareTo(_ParsedVersion other) {
 | |
|     if (major != other.major) return major.compareTo(other.major);
 | |
|     if (minor != other.minor) return minor.compareTo(other.minor);
 | |
|     if (patch != other.patch) return patch.compareTo(other.patch);
 | |
|     return build.compareTo(other.build);
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   String toString() => '$major.$minor.$patch+$build';
 | |
| }
 | |
| 
 | |
| class UpdateService {
 | |
|   UpdateService({Dio? dio, this.useProxy = false})
 | |
|     : _dio =
 | |
|           dio ??
 | |
|           Dio(
 | |
|             BaseOptions(
 | |
|               headers: {
 | |
|                 // Identify the app to GitHub; avoids some rate-limits and adds clarity
 | |
|                 'Accept': 'application/vnd.github+json',
 | |
|                 'User-Agent': 'solian-update-checker',
 | |
|               },
 | |
|               connectTimeout: const Duration(seconds: 10),
 | |
|               receiveTimeout: const Duration(seconds: 15),
 | |
|             ),
 | |
|           );
 | |
| 
 | |
|   final Dio _dio;
 | |
|   final bool useProxy;
 | |
| 
 | |
|   static const _proxyBaseUrl = 'https://ghfast.top/';
 | |
| 
 | |
|   static const _releasesLatestApi =
 | |
|       'https://api.github.com/repos/solsynth/solian/releases/latest';
 | |
| 
 | |
|   /// Checks GitHub for the latest release and compares against the current app version.
 | |
|   /// If update is available, shows a bottom sheet with changelog and an action to open release page.
 | |
|   Future<void> checkForUpdates(BuildContext context) async {
 | |
|     talker.info('[Update] Checking for updates...');
 | |
|     try {
 | |
|       final release = await fetchLatestRelease();
 | |
|       if (release == null) {
 | |
|         talker.info('[Update] No latest release found or could not fetch.');
 | |
|         return;
 | |
|       }
 | |
|       talker.info('[Update] Fetched latest release: ${release.tagName}');
 | |
| 
 | |
|       final info = await PackageInfo.fromPlatform();
 | |
|       final localVersionStr = '${info.version}+${info.buildNumber}';
 | |
|       talker.info('[Update] Local app version: $localVersionStr');
 | |
| 
 | |
|       final latest = _ParsedVersion.tryParse(release.tagName);
 | |
|       final local = _ParsedVersion.tryParse(localVersionStr);
 | |
| 
 | |
|       if (latest == null || local == null) {
 | |
|         talker.info(
 | |
|           '[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr',
 | |
|         );
 | |
|         // If parsing fails, do nothing silently
 | |
|         return;
 | |
|       }
 | |
|       talker.info('[Update] Parsed versions. Latest: $latest, Local: $local');
 | |
| 
 | |
|       final needsUpdate = latest.compareTo(local) > 0;
 | |
|       if (!needsUpdate) {
 | |
|         talker.info('[Update] App is up to date. No update needed.');
 | |
|         return;
 | |
|       }
 | |
|       talker.info('[Update] Update available! Latest: $latest, Local: $local');
 | |
| 
 | |
|       if (!context.mounted) {
 | |
|         talker.info('[Update] Context not mounted, cannot show update sheet.');
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Delay to ensure UI is ready (if called at startup)
 | |
|       await Future.delayed(const Duration(milliseconds: 100));
 | |
| 
 | |
|       if (context.mounted) {
 | |
|         await showUpdateSheet(context, release);
 | |
|         talker.info('[Update] Update sheet shown.');
 | |
|       }
 | |
|     } catch (e) {
 | |
|       talker.error('[Update] Error checking for updates: $e');
 | |
|       // Ignore errors (network, api, etc.)
 | |
|       return;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /// Manually show the update sheet with a provided release.
 | |
|   /// Useful for About page or testing.
 | |
|   Future<void> showUpdateSheet(
 | |
|     BuildContext context,
 | |
|     GithubReleaseInfo release,
 | |
|   ) async {
 | |
|     if (!context.mounted) return;
 | |
|     await showModalBottomSheet(
 | |
|       context: context,
 | |
|       isScrollControlled: true,
 | |
|       useRootNavigator: true,
 | |
|       builder: (ctx) {
 | |
|         String? androidUpdateUrl;
 | |
|         String? windowsUpdateUrl;
 | |
|         if (Platform.isAndroid) {
 | |
|           androidUpdateUrl = _getAndroidUpdateUrl(release.assets);
 | |
|         }
 | |
|         if (Platform.isWindows) {
 | |
|           windowsUpdateUrl = _getWindowsUpdateUrl();
 | |
|         }
 | |
|         return _UpdateSheet(
 | |
|           release: release,
 | |
|           onOpen: () async {
 | |
|             final uri = Uri.parse(release.htmlUrl);
 | |
|             if (await canLaunchUrl(uri)) {
 | |
|               await launchUrl(uri, mode: LaunchMode.externalApplication);
 | |
|             }
 | |
|           },
 | |
|           androidUpdateUrl: androidUpdateUrl,
 | |
|           windowsUpdateUrl: windowsUpdateUrl,
 | |
|           useProxy: useProxy, // Pass the useProxy flag
 | |
|         );
 | |
|       },
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   String? _getAndroidUpdateUrl(List<GithubReleaseAsset> assets) {
 | |
|     final arm64 = assets.firstWhereOrNull(
 | |
|       (asset) => asset.name == 'app-arm64-v8a-release.apk',
 | |
|     );
 | |
|     final armeabi = assets.firstWhereOrNull(
 | |
|       (asset) => asset.name == 'app-armeabi-v7a-release.apk',
 | |
|     );
 | |
|     final x86_64 = assets.firstWhereOrNull(
 | |
|       (asset) => asset.name == 'app-x86_64-release.apk',
 | |
|     );
 | |
| 
 | |
|     // Prioritize arm64, then armeabi, then x86_64
 | |
|     if (arm64 != null) {
 | |
|       return 'https://fs.solsynth.dev/d/official/solian/${arm64.name}';
 | |
|     } else if (armeabi != null) {
 | |
|       return 'https://fs.solsynth.dev/d/official/solian/${armeabi.name}';
 | |
|     } else if (x86_64 != null) {
 | |
|       return 'https://fs.solsynth.dev/d/official/solian/${x86_64.name}';
 | |
|     }
 | |
|     return null;
 | |
|   }
 | |
| 
 | |
|   String _getWindowsUpdateUrl() {
 | |
|     return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip';
 | |
|   }
 | |
| 
 | |
|   /// Performs automatic Windows update: download, extract, and install
 | |
|   Future<void> performAutomaticWindowsUpdate(
 | |
|     BuildContext context,
 | |
|     String url,
 | |
|   ) async {
 | |
|     if (!context.mounted) return;
 | |
| 
 | |
|     showDialog(
 | |
|       context: context,
 | |
|       barrierDismissible: false,
 | |
|       builder: (context) => _WindowsUpdateDialog(
 | |
|         updateUrl: url,
 | |
|         onComplete: () {
 | |
|           // Close the update sheet
 | |
|           Navigator.of(context).pop();
 | |
|         },
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /// Fetch the latest release info from GitHub.
 | |
|   /// Public so other screens (e.g., About) can manually trigger update checks.
 | |
|   Future<GithubReleaseInfo?> fetchLatestRelease() async {
 | |
|     final apiEndpoint =
 | |
|         useProxy
 | |
|             ? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}'
 | |
|             : _releasesLatestApi;
 | |
| 
 | |
|     talker.info(
 | |
|       '[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)',
 | |
|     );
 | |
|     final resp = await _dio.get(apiEndpoint);
 | |
|     if (resp.statusCode != 200) {
 | |
|       talker.error(
 | |
|         '[Update] Failed to fetch latest release. Status code: ${resp.statusCode}',
 | |
|       );
 | |
|       return null;
 | |
|     }
 | |
|     final data = resp.data as Map<String, dynamic>;
 | |
|     talker.info('[Update] Successfully fetched release data.');
 | |
| 
 | |
|     final tagName = (data['tag_name'] ?? '').toString();
 | |
|     final name = (data['name'] ?? tagName).toString();
 | |
|     final body = (data['body'] ?? '').toString();
 | |
|     final htmlUrl = (data['html_url'] ?? '').toString();
 | |
|     final createdAtStr = (data['created_at'] ?? '').toString();
 | |
|     final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now();
 | |
|     final assetsData =
 | |
|         (data['assets'] as List<dynamic>?)
 | |
|             ?.map((e) => GithubReleaseAsset.fromJson(e as Map<String, dynamic>))
 | |
|             .toList() ??
 | |
|         [];
 | |
| 
 | |
|     if (tagName.isEmpty || htmlUrl.isEmpty) {
 | |
|       talker.error(
 | |
|         '[Update] Missing tag_name or html_url in release data. TagName: "$tagName", HtmlUrl: "$htmlUrl"',
 | |
|       );
 | |
|       return null;
 | |
|     }
 | |
| 
 | |
|     talker.info('[Update] Returning GithubReleaseInfo for tag: $tagName');
 | |
|     return GithubReleaseInfo(
 | |
|       tagName: tagName,
 | |
|       name: name,
 | |
|       body: body,
 | |
|       htmlUrl: htmlUrl,
 | |
|       createdAt: createdAt,
 | |
|       assets: assetsData,
 | |
|     );
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _WindowsUpdateDialog extends StatefulWidget {
 | |
|   const _WindowsUpdateDialog({
 | |
|     required this.updateUrl,
 | |
|     required this.onComplete,
 | |
|   });
 | |
| 
 | |
|   final String updateUrl;
 | |
|   final VoidCallback onComplete;
 | |
| 
 | |
|   @override
 | |
|   State<_WindowsUpdateDialog> createState() => _WindowsUpdateDialogState();
 | |
| }
 | |
| 
 | |
| class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
 | |
|   final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
 | |
|   final ValueNotifier<String> messageNotifier = ValueNotifier<String>('Downloading installer...');
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     _startUpdate();
 | |
|   }
 | |
| 
 | |
|   Future<void> _startUpdate() async {
 | |
|     try {
 | |
|       // Step 1: Download
 | |
|       final zipPath = await _downloadWindowsInstaller(
 | |
|         widget.updateUrl,
 | |
|         onProgress: (received, total) {
 | |
|           if (total == -1) {
 | |
|             progressNotifier.value = null;
 | |
|           } else {
 | |
|             progressNotifier.value = received / total;
 | |
|           }
 | |
|         },
 | |
|       );
 | |
|       if (zipPath == null) {
 | |
|         _showError('Failed to download installer');
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Step 2: Extract
 | |
|       messageNotifier.value = 'Extracting installer...';
 | |
|       progressNotifier.value = null; // Indeterminate for extraction
 | |
| 
 | |
|       final extractDir = await _extractWindowsInstaller(zipPath);
 | |
|       if (extractDir == null) {
 | |
|         _showError('Failed to extract installer');
 | |
|         return;
 | |
|       }
 | |
| 
 | |
|       // Step 3: Run installer
 | |
|       messageNotifier.value = 'Running installer...';
 | |
| 
 | |
|       final success = await _runWindowsInstaller(extractDir);
 | |
|       if (!mounted) return;
 | |
| 
 | |
|       if (success) {
 | |
|         messageNotifier.value = 'Update Complete';
 | |
|         progressNotifier.value = 1.0;
 | |
|         await Future.delayed(const Duration(seconds: 2));
 | |
|         if (mounted) {
 | |
|           Navigator.of(context).pop();
 | |
|           widget.onComplete();
 | |
|         }
 | |
|       } else {
 | |
|         _showError('Failed to run installer');
 | |
|       }
 | |
| 
 | |
|       // Cleanup
 | |
|       try {
 | |
|         await File(zipPath).delete();
 | |
|         await Directory(extractDir).delete(recursive: true);
 | |
|       } catch (e) {
 | |
|         talker.error('[Update] Error cleaning up temporary files: $e');
 | |
|       }
 | |
|     } catch (e) {
 | |
|       _showError('Update failed: $e');
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _showError(String message) {
 | |
|     if (!mounted) return;
 | |
|     Navigator.of(context).pop();
 | |
|     showDialog(
 | |
|       context: context,
 | |
|       builder: (context) => AlertDialog(
 | |
|         title: const Text('Update Failed'),
 | |
|         content: Text(message),
 | |
|         actions: [
 | |
|           TextButton(
 | |
|             onPressed: () => Navigator.of(context).pop(),
 | |
|             child: const Text('OK'),
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     return AlertDialog(
 | |
|       title: const Text('Installing Update'),
 | |
|       content: Column(
 | |
|         mainAxisSize: MainAxisSize.min,
 | |
|         crossAxisAlignment: CrossAxisAlignment.start,
 | |
|         children: [
 | |
|           ValueListenableBuilder<double?>(
 | |
|             valueListenable: progressNotifier,
 | |
|             builder: (context, progress, child) {
 | |
|               return LinearProgressIndicator(value: progress);
 | |
|             },
 | |
|           ),
 | |
|           const SizedBox(height: 16),
 | |
|           ValueListenableBuilder<String>(
 | |
|             valueListenable: messageNotifier,
 | |
|             builder: (context, message, child) {
 | |
|               return Text(message);
 | |
|             },
 | |
|           ),
 | |
|         ],
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   /// Downloads the Windows installer ZIP file
 | |
|   Future<String?> _downloadWindowsInstaller(
 | |
|     String url, {
 | |
|     void Function(int received, int total)? onProgress,
 | |
|   }) async {
 | |
|     try {
 | |
|       talker.info('[Update] Starting Windows installer download from: $url');
 | |
| 
 | |
|       final tempDir = await getTemporaryDirectory();
 | |
|       final fileName =
 | |
|           'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip';
 | |
|       final filePath = path.join(tempDir.path, fileName);
 | |
| 
 | |
|       final response = await Dio().download(
 | |
|         url,
 | |
|         filePath,
 | |
|         onReceiveProgress: (received, total) {
 | |
|           if (total != -1) {
 | |
|             talker.info(
 | |
|               '[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%',
 | |
|             );
 | |
|           }
 | |
|           onProgress?.call(received, total);
 | |
|         },
 | |
|       );
 | |
| 
 | |
|       if (response.statusCode == 200) {
 | |
|         talker.info('[Update] Windows installer downloaded successfully to: $filePath');
 | |
|         return filePath;
 | |
|       } else {
 | |
|         talker.error(
 | |
|           '[Update] Failed to download Windows installer. Status: ${response.statusCode}',
 | |
|         );
 | |
|         return null;
 | |
|       }
 | |
|     } catch (e) {
 | |
|       talker.error('[Update] Error downloading Windows installer: $e');
 | |
|       return null;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /// Extracts the ZIP file to a temporary directory
 | |
|   Future<String?> _extractWindowsInstaller(String zipPath) async {
 | |
|     try {
 | |
|       talker.info('[Update] Extracting Windows installer from: $zipPath');
 | |
| 
 | |
|       final tempDir = await getTemporaryDirectory();
 | |
|       final extractDir = path.join(
 | |
|         tempDir.path,
 | |
|         'solian-installer-${DateTime.now().millisecondsSinceEpoch}',
 | |
|       );
 | |
| 
 | |
|       final zipFile = File(zipPath);
 | |
|       final bytes = await zipFile.readAsBytes();
 | |
|       final archive = ZipDecoder().decodeBytes(bytes);
 | |
| 
 | |
|       for (final file in archive) {
 | |
|         final filename = file.name;
 | |
|         if (file.isFile) {
 | |
|           final data = file.content as List<int>;
 | |
|           final filePath = path.join(extractDir, filename);
 | |
|           await Directory(path.dirname(filePath)).create(recursive: true);
 | |
|           await File(filePath).writeAsBytes(data);
 | |
|         } else {
 | |
|           final dirPath = path.join(extractDir, filename);
 | |
|           await Directory(dirPath).create(recursive: true);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       talker.info('[Update] Windows installer extracted successfully to: $extractDir');
 | |
|       return extractDir;
 | |
|     } catch (e) {
 | |
|       talker.error('[Update] Error extracting Windows installer: $e');
 | |
|       return null;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /// Runs the setup.exe file
 | |
|   Future<bool> _runWindowsInstaller(String extractDir) async {
 | |
|     try {
 | |
|       talker.info('[Update] Running Windows installer from: $extractDir');
 | |
| 
 | |
|       final dir = Directory(extractDir);
 | |
|       final exeFiles = dir
 | |
|           .listSync()
 | |
|           .where((f) => f is File && f.path.endsWith('.exe'))
 | |
|           .toList();
 | |
| 
 | |
|       if (exeFiles.isEmpty) {
 | |
|         talker.info('[Update] No .exe file found in extracted directory');
 | |
|         return false;
 | |
|       }
 | |
| 
 | |
|       final setupExePath = exeFiles.first.path;
 | |
|       talker.info('[Update] Found installer executable: $setupExePath');
 | |
| 
 | |
|       final shell = Shell();
 | |
|       final results = await shell.run(setupExePath);
 | |
|       final result = results.first;
 | |
| 
 | |
|       if (result.exitCode == 0) {
 | |
|         talker.info('[Update] Windows installer completed successfully');
 | |
|         return true;
 | |
|       } else {
 | |
|         talker.error(
 | |
|           '[Update] Windows installer failed with exit code: ${result.exitCode}',
 | |
|         );
 | |
|         talker.error('[Update] Installer output: ${result.stdout}');
 | |
|         talker.error('[Update] Installer errors: ${result.stderr}');
 | |
|         return false;
 | |
|       }
 | |
|     } catch (e) {
 | |
|       talker.error('[Update] Error running Windows installer: $e');
 | |
|       return false;
 | |
|     }
 | |
|   }
 | |
| }
 | |
| 
 | |
| class _UpdateSheet extends StatefulWidget {
 | |
|   const _UpdateSheet({
 | |
|     required this.release,
 | |
|     required this.onOpen,
 | |
|     this.androidUpdateUrl,
 | |
|     this.windowsUpdateUrl,
 | |
|     this.useProxy = false,
 | |
|   });
 | |
| 
 | |
|   final String? androidUpdateUrl;
 | |
|   final String? windowsUpdateUrl;
 | |
|   final bool useProxy;
 | |
|   final GithubReleaseInfo release;
 | |
|   final VoidCallback onOpen;
 | |
| 
 | |
|   @override
 | |
|   State<_UpdateSheet> createState() => _UpdateSheetState();
 | |
| }
 | |
| 
 | |
| class _UpdateSheetState extends State<_UpdateSheet> {
 | |
|   late bool _useProxy;
 | |
| 
 | |
|   @override
 | |
|   void initState() {
 | |
|     super.initState();
 | |
|     _useProxy = widget.useProxy;
 | |
|   }
 | |
| 
 | |
|   Future<void> _installUpdate(String url) async {
 | |
|     String downloadUrl = url;
 | |
|     if (_useProxy) {
 | |
|       final fileName = url.split('/').last;
 | |
|       downloadUrl = 'https://fs.solsynth.dev/d/rainyun02/solian/$fileName';
 | |
|     }
 | |
| 
 | |
|     UpdateModel model = UpdateModel(
 | |
|       downloadUrl,
 | |
|       "solian-update-${widget.release.tagName}.apk",
 | |
|       "launcher_icon",
 | |
|       'https://apps.apple.com/us/app/solian/id6499032345',
 | |
|     );
 | |
|     AzhonAppUpdate.update(model);
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   Widget build(BuildContext context) {
 | |
|     final theme = Theme.of(context);
 | |
|     return SheetScaffold(
 | |
|       titleText: 'updateAvailable'.tr(),
 | |
|       child: Padding(
 | |
|         padding: EdgeInsets.only(
 | |
|           bottom: 16 + MediaQuery.of(context).padding.bottom,
 | |
|         ),
 | |
|         child: Column(
 | |
|           crossAxisAlignment: CrossAxisAlignment.start,
 | |
|           children: [
 | |
|             Column(
 | |
|               crossAxisAlignment: CrossAxisAlignment.start,
 | |
|               children: [
 | |
|                 Text(
 | |
|                   widget.release.name,
 | |
|                   style: theme.textTheme.titleMedium,
 | |
|                 ).bold(),
 | |
|                 Text(widget.release.tagName).fontSize(12),
 | |
|               ],
 | |
|             ).padding(vertical: 16, horizontal: 16),
 | |
|             const Divider(height: 1),
 | |
|             Expanded(
 | |
|               child: SingleChildScrollView(
 | |
|                 padding: const EdgeInsets.symmetric(
 | |
|                   horizontal: 16,
 | |
|                   vertical: 16,
 | |
|                 ),
 | |
|                 child: MarkdownTextContent(
 | |
|                   content:
 | |
|                       widget.release.body.isEmpty
 | |
|                           ? 'noChangelogProvided'.tr()
 | |
|                           : widget.release.body,
 | |
|                 ),
 | |
|               ),
 | |
|             ),
 | |
|             if (!kIsWeb && Platform.isAndroid)
 | |
|               SwitchListTile(
 | |
|                 title: Text('useSecondarySourceForDownload'.tr()),
 | |
|                 value: _useProxy,
 | |
|                 onChanged: (value) {
 | |
|                   setState(() {
 | |
|                     _useProxy = value;
 | |
|                   });
 | |
|                 },
 | |
|               ).padding(horizontal: 8),
 | |
|             Column(
 | |
|               children: [
 | |
|                 Row(
 | |
|                   spacing: 8,
 | |
|                   children: [
 | |
|                     if (!kIsWeb &&
 | |
|                         Platform.isAndroid &&
 | |
|                         widget.androidUpdateUrl != null)
 | |
|                       Expanded(
 | |
|                         child: FilledButton.icon(
 | |
|                           onPressed: () {
 | |
|                             talker.info(widget.androidUpdateUrl!);
 | |
|                             _installUpdate(widget.androidUpdateUrl!);
 | |
|                           },
 | |
|                           icon: const Icon(Symbols.update),
 | |
|                           label: Text('installUpdate'.tr()),
 | |
|                         ),
 | |
|                       ),
 | |
|                     if (!kIsWeb &&
 | |
|                         Platform.isWindows &&
 | |
|                         widget.windowsUpdateUrl != null)
 | |
|                       Expanded(
 | |
|                         child: FilledButton.icon(
 | |
|                           onPressed: () {
 | |
|                             // Access the UpdateService instance to call the automatic update method
 | |
|                             final updateService = UpdateService(
 | |
|                               useProxy: widget.useProxy,
 | |
|                             );
 | |
|                             updateService.performAutomaticWindowsUpdate(
 | |
|                               context,
 | |
|                               widget.windowsUpdateUrl!,
 | |
|                             );
 | |
|                           },
 | |
|                           icon: const Icon(Symbols.update),
 | |
|                           label: Text('installUpdate'.tr()),
 | |
|                         ),
 | |
|                       ),
 | |
|                     Expanded(
 | |
|                       child: FilledButton.icon(
 | |
|                         onPressed: widget.onOpen,
 | |
|                         icon: const Icon(Icons.open_in_new),
 | |
|                         label: Text('openReleasePage'.tr()),
 | |
|                       ),
 | |
|                     ),
 | |
|                   ],
 | |
|                 ),
 | |
|               ],
 | |
|             ).padding(horizontal: 16),
 | |
|           ],
 | |
|         ),
 | |
|       ),
 | |
|     );
 | |
|   }
 | |
| }
 |