From 73a991e3a85bcf18ececa4be31fcb40e781ba884 Mon Sep 17 00:00:00 2001 From: RarDog Date: Sun, 24 May 2026 13:28:22 +0300 Subject: [PATCH] Add Gitea update notifications --- lib/app/app.dart | 95 ++++++++++++- lib/app/app_version.dart | 2 + lib/backend/backend.dart | 2 + lib/backend/di/backend_providers.dart | 18 +++ lib/backend/models/app_update_info.dart | 23 ++++ lib/backend/services/settings_service.dart | 14 ++ lib/backend/services/update_service.dart | 125 ++++++++++++++++++ .../presentation/settings_screen.dart | 87 +++++++++++- pubspec.yaml | 2 +- 9 files changed, 365 insertions(+), 3 deletions(-) create mode 100644 lib/app/app_version.dart create mode 100644 lib/backend/models/app_update_info.dart create mode 100644 lib/backend/services/update_service.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index fd66dd4..840c278 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../backend/backend.dart'; +import '../core/utils/result.dart'; import 'router.dart'; import 'theme.dart'; @@ -36,12 +38,103 @@ class GelRuleApp extends ConsumerWidget { orElse: () => ThemeMode.dark, ), routerConfig: router, - builder: (context, child) => _DownloadOverlay(child: child), + builder: (context, child) => _AppOverlay(child: child), ), ); } } +class _AppOverlay extends ConsumerStatefulWidget { + const _AppOverlay({required this.child}); + + final Widget? child; + + @override + ConsumerState<_AppOverlay> createState() => _AppOverlayState(); +} + +class _AppOverlayState extends ConsumerState<_AppOverlay> { + bool _checkedUpdates = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_checkedUpdates) return; + _checkedUpdates = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _checkUpdates()); + } + + Future _checkUpdates() async { + final result = await ref.read(updateServiceProvider).checkForUpdates(); + if (!mounted || result is! Success || result.data == null) { + return; + } + await _showUpdateDialog(result.data!); + } + + Future _showUpdateDialog(AppUpdateInfo info) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('RuleGel ${info.version} is available'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(info.name), + if (info.body.trim().isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + info.body.trim(), + maxLines: 12, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () async { + await ref.read(updateServiceProvider).skipVersion(info); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('Skip this version'), + ), + TextButton( + onPressed: () async { + await ref.read(updateServiceProvider).remindLater(); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('Later'), + ), + FilledButton.icon( + onPressed: () async { + final url = Uri.tryParse(info.htmlUrl); + if (url != null) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + if (context.mounted) Navigator.pop(context); + }, + icon: const Icon(Icons.open_in_new_rounded), + label: const Text('Update'), + ), + ], + ), + ); + ref.invalidate(appSettingsProvider); + } + + @override + Widget build(BuildContext context) { + return _DownloadOverlay(child: widget.child); + } +} + class _DownloadOverlay extends ConsumerWidget { const _DownloadOverlay({required this.child}); diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart new file mode 100644 index 0000000..5cd46b1 --- /dev/null +++ b/lib/app/app_version.dart @@ -0,0 +1,2 @@ +const appDisplayVersion = '0.4.0'; +const appBuildNumber = 4; diff --git a/lib/backend/backend.dart b/lib/backend/backend.dart index bcfe531..0fd8f68 100644 --- a/lib/backend/backend.dart +++ b/lib/backend/backend.dart @@ -1,6 +1,7 @@ export 'di/backend_providers.dart'; export 'models/collection.dart'; export 'models/content_provider_config.dart'; +export 'models/app_update_info.dart'; export 'models/download_task.dart'; export 'models/favorite.dart'; export 'models/post.dart'; @@ -21,6 +22,7 @@ export 'services/feed_service.dart'; export 'services/provider_check_service.dart'; export 'services/search_service.dart'; export 'services/settings_service.dart'; +export 'services/update_service.dart'; export 'services/viewed_history_service.dart'; export 'utils/smart_blacklist.dart'; export 'utils/media_quality.dart'; diff --git a/lib/backend/di/backend_providers.dart b/lib/backend/di/backend_providers.dart index 573a234..2347568 100644 --- a/lib/backend/di/backend_providers.dart +++ b/lib/backend/di/backend_providers.dart @@ -1,4 +1,5 @@ import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/cache/cache_service.dart'; @@ -24,6 +25,7 @@ import '../services/feed_service.dart'; import '../services/provider_check_service.dart'; import '../services/search_service.dart'; import '../services/settings_service.dart'; +import '../services/update_service.dart'; import '../services/viewed_history_service.dart'; final appDatabaseProvider = FutureProvider((ref) async { @@ -158,3 +160,19 @@ final downloadTasksProvider = StreamProvider>((ref) { final manager = ref.watch(downloadManagerServiceProvider); return manager.stream; }); + +final updateServiceProvider = Provider((ref) { + return UpdateService( + Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 12), + receiveTimeout: const Duration(seconds: 12), + headers: const { + 'User-Agent': 'RuleGel/0.4 Flutter local booru browser', + 'Accept': 'application/json', + }, + ), + ), + ref.watch(settingsServiceProvider), + ); +}); diff --git a/lib/backend/models/app_update_info.dart b/lib/backend/models/app_update_info.dart new file mode 100644 index 0000000..beeed9b --- /dev/null +++ b/lib/backend/models/app_update_info.dart @@ -0,0 +1,23 @@ +class AppUpdateInfo { + const AppUpdateInfo({ + required this.version, + required this.tagName, + required this.name, + required this.body, + required this.htmlUrl, + required this.publishedAt, + this.apkUrl, + this.windowsInstallerUrl, + }); + + final String version; + final String tagName; + final String name; + final String body; + final String htmlUrl; + final DateTime? publishedAt; + final String? apkUrl; + final String? windowsInstallerUrl; + + bool get hasAssets => apkUrl != null || windowsInstallerUrl != null; +} diff --git a/lib/backend/services/settings_service.dart b/lib/backend/services/settings_service.dart index 1fabd2d..3ee87f2 100644 --- a/lib/backend/services/settings_service.dart +++ b/lib/backend/services/settings_service.dart @@ -33,6 +33,8 @@ class AppSettings { required this.lastFeedProviderIds, required this.lastFeedTopPeriod, required this.lastFeedScrollOffset, + this.skippedUpdateVersion, + this.lastUpdateCheckAt, this.defaultRatingFilter, this.lastFeedRating, }); @@ -59,6 +61,8 @@ class AppSettings { final List lastFeedProviderIds; final String lastFeedTopPeriod; final double lastFeedScrollOffset; + final String? skippedUpdateVersion; + final String? lastUpdateCheckAt; final String? defaultRatingFilter; final String? lastFeedRating; @@ -85,6 +89,8 @@ class AppSettings { lastFeedProviderIds: [], lastFeedTopPeriod: 'none', lastFeedScrollOffset: 0, + skippedUpdateVersion: null, + lastUpdateCheckAt: null, defaultRatingFilter: null, lastFeedRating: null, ); @@ -112,6 +118,8 @@ class AppSettings { List? lastFeedProviderIds, String? lastFeedTopPeriod, double? lastFeedScrollOffset, + String? skippedUpdateVersion, + String? lastUpdateCheckAt, String? defaultRatingFilter, String? lastFeedRating, bool clearLastFeedRating = false, @@ -141,6 +149,8 @@ class AppSettings { lastFeedProviderIds: lastFeedProviderIds ?? this.lastFeedProviderIds, lastFeedTopPeriod: lastFeedTopPeriod ?? this.lastFeedTopPeriod, lastFeedScrollOffset: lastFeedScrollOffset ?? this.lastFeedScrollOffset, + skippedUpdateVersion: skippedUpdateVersion ?? this.skippedUpdateVersion, + lastUpdateCheckAt: lastUpdateCheckAt ?? this.lastUpdateCheckAt, defaultRatingFilter: defaultRatingFilter ?? this.defaultRatingFilter, lastFeedRating: clearLastFeedRating ? null : lastFeedRating ?? this.lastFeedRating, @@ -170,6 +180,8 @@ class AppSettings { 'lastFeedProviderIds': lastFeedProviderIds, 'lastFeedTopPeriod': lastFeedTopPeriod, 'lastFeedScrollOffset': lastFeedScrollOffset, + 'skippedUpdateVersion': skippedUpdateVersion, + 'lastUpdateCheckAt': lastUpdateCheckAt, 'defaultRatingFilter': defaultRatingFilter, 'lastFeedRating': lastFeedRating, }; @@ -232,6 +244,8 @@ class AppSettings { lastFeedScrollOffset: (json['lastFeedScrollOffset'] as num?)?.toDouble() ?? defaults.lastFeedScrollOffset, + skippedUpdateVersion: json['skippedUpdateVersion'] as String?, + lastUpdateCheckAt: json['lastUpdateCheckAt'] as String?, defaultRatingFilter: json['defaultRatingFilter'] as String?, lastFeedRating: json['lastFeedRating'] as String?, ); diff --git a/lib/backend/services/update_service.dart b/lib/backend/services/update_service.dart new file mode 100644 index 0000000..4257163 --- /dev/null +++ b/lib/backend/services/update_service.dart @@ -0,0 +1,125 @@ +import 'package:dio/dio.dart'; + +import '../../app/app_version.dart'; +import '../../core/errors/failure.dart'; +import '../../core/utils/result.dart'; +import '../models/app_update_info.dart'; +import 'settings_service.dart'; + +class UpdateService { + UpdateService(this._dio, this._settingsService); + + static const latestReleaseUrl = + 'https://gitea.rardogsynapse.online/api/v1/repos/RarDog/RuleGelApp/releases/latest'; + + final Dio _dio; + final SettingsService _settingsService; + + Future> checkForUpdates({bool force = false}) async { + final settingsResult = await _settingsService.getSettings(); + final settings = settingsResult is Success + ? settingsResult.data + : AppSettings.defaults; + + if (!force && !_shouldCheck(settings)) return const Success(null); + + try { + final response = await _dio.get(latestReleaseUrl); + final data = Map.from(response.data as Map); + final info = _releaseFromJson(data); + final nextSettings = settings.copyWith( + lastUpdateCheckAt: DateTime.now().toIso8601String(), + ); + await _settingsService.updateSettings(nextSettings); + + final skipped = settings.skippedUpdateVersion == info.version || + settings.skippedUpdateVersion == info.tagName; + if (!force && skipped) return const Success(null); + if (!_isNewerVersion(info.version, appDisplayVersion)) { + return const Success(null); + } + return Success(info); + } catch (error) { + return Error( + Failure( + code: 'update_check_failed', + message: 'Could not check for updates', + details: error, + ), + ); + } + } + + Future> remindLater() async { + final result = await _settingsService.getSettings(); + if (result is Error) return Error(result.failure); + final settings = (result as Success).data; + return _settingsService.updateSettings( + settings.copyWith(lastUpdateCheckAt: DateTime.now().toIso8601String()), + ); + } + + Future> skipVersion(AppUpdateInfo info) async { + final result = await _settingsService.getSettings(); + if (result is Error) return Error(result.failure); + final settings = (result as Success).data; + return _settingsService.updateSettings( + settings.copyWith(skippedUpdateVersion: info.version), + ); + } + + bool _shouldCheck(AppSettings settings) { + final last = DateTime.tryParse(settings.lastUpdateCheckAt ?? ''); + if (last == null) return true; + return DateTime.now().difference(last) >= const Duration(hours: 24); + } + + AppUpdateInfo _releaseFromJson(Map json) { + final tag = (json['tag_name'] ?? '').toString(); + final assets = (json['assets'] as List?) ?? const []; + String? apkUrl; + String? installerUrl; + for (final item in assets.whereType()) { + final name = (item['name'] ?? '').toString().toLowerCase(); + final url = (item['browser_download_url'] ?? '').toString(); + if (url.isEmpty) continue; + if (name.endsWith('.apk')) apkUrl = url; + if (name.endsWith('.exe')) installerUrl = url; + } + return AppUpdateInfo( + version: _versionFromTag(tag), + tagName: tag, + name: (json['name'] ?? tag).toString(), + body: (json['body'] ?? '').toString(), + htmlUrl: (json['html_url'] ?? '').toString(), + publishedAt: DateTime.tryParse((json['published_at'] ?? '').toString()), + apkUrl: apkUrl, + windowsInstallerUrl: installerUrl, + ); + } + + static String _versionFromTag(String tag) { + final match = RegExp(r'v?(\d+\.\d+\.\d+)').firstMatch(tag); + return match?.group(1) ?? tag.replaceFirst(RegExp(r'^v'), ''); + } + + static bool _isNewerVersion(String latest, String current) { + final left = _parts(latest); + final right = _parts(current); + for (var i = 0; i < 3; i++) { + if (left[i] > right[i]) return true; + if (left[i] < right[i]) return false; + } + return false; + } + + static List _parts(String value) { + final match = RegExp(r'(\d+)\.(\d+)\.(\d+)').firstMatch(value); + if (match == null) return const [0, 0, 0]; + return [ + int.tryParse(match.group(1) ?? '') ?? 0, + int.tryParse(match.group(2) ?? '') ?? 0, + int.tryParse(match.group(3) ?? '') ?? 0, + ]; + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 9a86acd..19d2122 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../app/app_version.dart'; import '../../../backend/backend.dart'; +import '../../../core/utils/result.dart'; import '../../../shared/widgets/adaptive_scaffold.dart'; import '../../../shared/widgets/error_view.dart'; import 'settings_controller.dart'; @@ -177,8 +180,14 @@ class SettingsScreen extends ConsumerWidget { icon: const Icon(Icons.download_for_offline_rounded), label: const Text('Import settings and providers JSON'), ), + const SizedBox(height: 12), + FilledButton.tonalIcon( + onPressed: () => _checkUpdates(context, ref), + icon: const Icon(Icons.system_update_alt_rounded), + label: const Text('Check for updates'), + ), const SizedBox(height: 24), - const Text('Version 0.1.0'), + const Text('Version $appDisplayVersion'), ], ), ), @@ -220,6 +229,82 @@ class SettingsScreen extends ConsumerWidget { ); controller.dispose(); } + + Future _checkUpdates(BuildContext context, WidgetRef ref) async { + final messenger = ScaffoldMessenger.of(context); + messenger.showSnackBar( + const SnackBar(content: Text('Checking for updates...')), + ); + final result = + await ref.read(updateServiceProvider).checkForUpdates(force: true); + if (!context.mounted) return; + messenger.hideCurrentSnackBar(); + if (result is Error) { + messenger.showSnackBar( + SnackBar(content: Text(result.failure.message)), + ); + return; + } + final update = (result as Success).data; + if (update == null) { + messenger.showSnackBar( + const SnackBar(content: Text('RuleGel is up to date')), + ); + return; + } + await _updateDialog(context, ref, update); + } + + Future _updateDialog( + BuildContext context, + WidgetRef ref, + AppUpdateInfo info, + ) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('RuleGel ${info.version} is available'), + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: SingleChildScrollView( + child: Text( + info.body.trim().isEmpty ? info.name : info.body.trim(), + maxLines: 14, + overflow: TextOverflow.ellipsis, + ), + ), + ), + actions: [ + TextButton( + onPressed: () async { + await ref.read(updateServiceProvider).skipVersion(info); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('Skip this version'), + ), + TextButton( + onPressed: () async { + await ref.read(updateServiceProvider).remindLater(); + if (context.mounted) Navigator.pop(context); + }, + child: const Text('Later'), + ), + FilledButton.icon( + onPressed: () async { + final url = Uri.tryParse(info.htmlUrl); + if (url != null) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } + if (context.mounted) Navigator.pop(context); + }, + icon: const Icon(Icons.open_in_new_rounded), + label: const Text('Update'), + ), + ], + ), + ); + ref.invalidate(settingsControllerProvider); + } } class _TagListEditor extends StatefulWidget { diff --git a/pubspec.yaml b/pubspec.yaml index 2c06a25..88012e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: gel_rule_app description: Local backend/core layer for a Flutter booru browser app. publish_to: "none" -version: 0.1.0+1 +version: 0.4.0+4 environment: sdk: ">=3.3.0 <4.0.0"