From 2c71dde2281617598ede0f26a0df3f03b9c34d1c Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Sun, 1 Jun 2025 21:36:50 +0200 Subject: [PATCH] feature: Added update notification and download links per platform (#362) Co-authored-by: PartyDonut --- lib/l10n/app_en.arb | 13 +- .../settings/client_settings_model.dart | 2 + .../client_settings_model.freezed.dart | 49 +++++- .../settings/client_settings_model.g.dart | 4 + lib/providers/update_provider.dart | 77 +++++++++ lib/providers/update_provider.freezed.dart | 163 ++++++++++++++++++ lib/providers/update_provider.g.dart | 24 +++ lib/screens/settings/about_settings_page.dart | 3 + lib/screens/settings/settings_screen.dart | 19 +- .../widgets/settings_message_box.dart | 2 +- .../widgets/settings_update_information.dart | 134 ++++++++++++++ lib/util/update_checker.dart | 150 ++++++++++++++++ .../components/settings_user_icon.dart | 37 +++- .../components/side_navigation_bar.dart | 2 +- pubspec.lock | 40 +++++ pubspec.yaml | 1 + 16 files changed, 712 insertions(+), 8 deletions(-) create mode 100644 lib/providers/update_provider.dart create mode 100644 lib/providers/update_provider.freezed.dart create mode 100644 lib/providers/update_provider.g.dart create mode 100644 lib/screens/settings/widgets/settings_update_information.dart create mode 100644 lib/util/update_checker.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c0934c6..26bfb35 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1224,5 +1224,16 @@ "playbackType": "Playback type", "playbackTypeDirect": "Direct", "playbackTypeTranscode": "Transcode", - "playbackTypeOffline": "Offline" + "playbackTypeOffline": "Offline", + "latestReleases": "Latest releases", + "autoCheckForUpdates": "Periodically check for updates", + "newReleaseFoundTitle": "Update {newRelease} available!", + "@newReleaseFoundTitle": { + "placeholders": { + "newRelease": { + "type": "String" + } + } + }, + "newUpdateFoundOnGithub": "Found a new update on Github" } \ No newline at end of file diff --git a/lib/models/settings/client_settings_model.dart b/lib/models/settings/client_settings_model.dart index 163ad28..6b084a0 100644 --- a/lib/models/settings/client_settings_model.dart +++ b/lib/models/settings/client_settings_model.dart @@ -34,6 +34,8 @@ class ClientSettingsModel with _$ClientSettingsModel { @Default(false) bool showAllCollectionTypes, @Default(2) int maxConcurrentDownloads, @Default(DynamicSchemeVariant.rainbow) DynamicSchemeVariant schemeVariant, + @Default(true) bool checkForUpdates, + String? lastViewedUpdate, int? libraryPageSize, }) = _ClientSettingsModel; diff --git a/lib/models/settings/client_settings_model.freezed.dart b/lib/models/settings/client_settings_model.freezed.dart index 4cc3063..9bc6489 100644 --- a/lib/models/settings/client_settings_model.freezed.dart +++ b/lib/models/settings/client_settings_model.freezed.dart @@ -40,6 +40,8 @@ mixin _$ClientSettingsModel { bool get showAllCollectionTypes => throw _privateConstructorUsedError; int get maxConcurrentDownloads => throw _privateConstructorUsedError; DynamicSchemeVariant get schemeVariant => throw _privateConstructorUsedError; + bool get checkForUpdates => throw _privateConstructorUsedError; + String? get lastViewedUpdate => throw _privateConstructorUsedError; int? get libraryPageSize => throw _privateConstructorUsedError; /// Serializes this ClientSettingsModel to a JSON map. @@ -78,6 +80,8 @@ abstract class $ClientSettingsModelCopyWith<$Res> { bool showAllCollectionTypes, int maxConcurrentDownloads, DynamicSchemeVariant schemeVariant, + bool checkForUpdates, + String? lastViewedUpdate, int? libraryPageSize}); } @@ -115,6 +119,8 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel> Object? showAllCollectionTypes = null, Object? maxConcurrentDownloads = null, Object? schemeVariant = null, + Object? checkForUpdates = null, + Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, }) { return _then(_value.copyWith( @@ -194,6 +200,14 @@ class _$ClientSettingsModelCopyWithImpl<$Res, $Val extends ClientSettingsModel> ? _value.schemeVariant : schemeVariant // ignore: cast_nullable_to_non_nullable as DynamicSchemeVariant, + checkForUpdates: null == checkForUpdates + ? _value.checkForUpdates + : checkForUpdates // ignore: cast_nullable_to_non_nullable + as bool, + lastViewedUpdate: freezed == lastViewedUpdate + ? _value.lastViewedUpdate + : lastViewedUpdate // ignore: cast_nullable_to_non_nullable + as String?, libraryPageSize: freezed == libraryPageSize ? _value.libraryPageSize : libraryPageSize // ignore: cast_nullable_to_non_nullable @@ -230,6 +244,8 @@ abstract class _$$ClientSettingsModelImplCopyWith<$Res> bool showAllCollectionTypes, int maxConcurrentDownloads, DynamicSchemeVariant schemeVariant, + bool checkForUpdates, + String? lastViewedUpdate, int? libraryPageSize}); } @@ -265,6 +281,8 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res> Object? showAllCollectionTypes = null, Object? maxConcurrentDownloads = null, Object? schemeVariant = null, + Object? checkForUpdates = null, + Object? lastViewedUpdate = freezed, Object? libraryPageSize = freezed, }) { return _then(_$ClientSettingsModelImpl( @@ -344,6 +362,14 @@ class __$$ClientSettingsModelImplCopyWithImpl<$Res> ? _value.schemeVariant : schemeVariant // ignore: cast_nullable_to_non_nullable as DynamicSchemeVariant, + checkForUpdates: null == checkForUpdates + ? _value.checkForUpdates + : checkForUpdates // ignore: cast_nullable_to_non_nullable + as bool, + lastViewedUpdate: freezed == lastViewedUpdate + ? _value.lastViewedUpdate + : lastViewedUpdate // ignore: cast_nullable_to_non_nullable + as String?, libraryPageSize: freezed == libraryPageSize ? _value.libraryPageSize : libraryPageSize // ignore: cast_nullable_to_non_nullable @@ -376,6 +402,8 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel this.showAllCollectionTypes = false, this.maxConcurrentDownloads = 2, this.schemeVariant = DynamicSchemeVariant.rainbow, + this.checkForUpdates = true, + this.lastViewedUpdate, this.libraryPageSize}) : super._(); @@ -437,11 +465,16 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel @JsonKey() final DynamicSchemeVariant schemeVariant; @override + @JsonKey() + final bool checkForUpdates; + @override + final String? lastViewedUpdate; + @override final int? libraryPageSize; @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, libraryPageSize: $libraryPageSize)'; + return 'ClientSettingsModel(syncPath: $syncPath, position: $position, size: $size, timeOut: $timeOut, nextUpDateCutoff: $nextUpDateCutoff, themeMode: $themeMode, themeColor: $themeColor, amoledBlack: $amoledBlack, blurPlaceHolders: $blurPlaceHolders, blurUpcomingEpisodes: $blurUpcomingEpisodes, selectedLocale: $selectedLocale, enableMediaKeys: $enableMediaKeys, posterSize: $posterSize, pinchPosterZoom: $pinchPosterZoom, mouseDragSupport: $mouseDragSupport, requireWifi: $requireWifi, showAllCollectionTypes: $showAllCollectionTypes, maxConcurrentDownloads: $maxConcurrentDownloads, schemeVariant: $schemeVariant, checkForUpdates: $checkForUpdates, lastViewedUpdate: $lastViewedUpdate, libraryPageSize: $libraryPageSize)'; } @override @@ -470,6 +503,8 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel ..add( DiagnosticsProperty('maxConcurrentDownloads', maxConcurrentDownloads)) ..add(DiagnosticsProperty('schemeVariant', schemeVariant)) + ..add(DiagnosticsProperty('checkForUpdates', checkForUpdates)) + ..add(DiagnosticsProperty('lastViewedUpdate', lastViewedUpdate)) ..add(DiagnosticsProperty('libraryPageSize', libraryPageSize)); } @@ -514,6 +549,10 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel other.maxConcurrentDownloads == maxConcurrentDownloads) && (identical(other.schemeVariant, schemeVariant) || other.schemeVariant == schemeVariant) && + (identical(other.checkForUpdates, checkForUpdates) || + other.checkForUpdates == checkForUpdates) && + (identical(other.lastViewedUpdate, lastViewedUpdate) || + other.lastViewedUpdate == lastViewedUpdate) && (identical(other.libraryPageSize, libraryPageSize) || other.libraryPageSize == libraryPageSize)); } @@ -541,6 +580,8 @@ class _$ClientSettingsModelImpl extends _ClientSettingsModel showAllCollectionTypes, maxConcurrentDownloads, schemeVariant, + checkForUpdates, + lastViewedUpdate, libraryPageSize ]); @@ -582,6 +623,8 @@ abstract class _ClientSettingsModel extends ClientSettingsModel { final bool showAllCollectionTypes, final int maxConcurrentDownloads, final DynamicSchemeVariant schemeVariant, + final bool checkForUpdates, + final String? lastViewedUpdate, final int? libraryPageSize}) = _$ClientSettingsModelImpl; _ClientSettingsModel._() : super._(); @@ -628,6 +671,10 @@ abstract class _ClientSettingsModel extends ClientSettingsModel { @override DynamicSchemeVariant get schemeVariant; @override + bool get checkForUpdates; + @override + String? get lastViewedUpdate; + @override int? get libraryPageSize; /// Create a copy of ClientSettingsModel diff --git a/lib/models/settings/client_settings_model.g.dart b/lib/models/settings/client_settings_model.g.dart index ae036e7..8483f95 100644 --- a/lib/models/settings/client_settings_model.g.dart +++ b/lib/models/settings/client_settings_model.g.dart @@ -41,6 +41,8 @@ _$ClientSettingsModelImpl _$$ClientSettingsModelImplFromJson( schemeVariant: $enumDecodeNullable( _$DynamicSchemeVariantEnumMap, json['schemeVariant']) ?? DynamicSchemeVariant.rainbow, + checkForUpdates: json['checkForUpdates'] as bool? ?? true, + lastViewedUpdate: json['lastViewedUpdate'] as String?, libraryPageSize: (json['libraryPageSize'] as num?)?.toInt(), ); @@ -66,6 +68,8 @@ Map _$$ClientSettingsModelImplToJson( 'showAllCollectionTypes': instance.showAllCollectionTypes, 'maxConcurrentDownloads': instance.maxConcurrentDownloads, 'schemeVariant': _$DynamicSchemeVariantEnumMap[instance.schemeVariant]!, + 'checkForUpdates': instance.checkForUpdates, + 'lastViewedUpdate': instance.lastViewedUpdate, 'libraryPageSize': instance.libraryPageSize, }; diff --git a/lib/providers/update_provider.dart b/lib/providers/update_provider.dart new file mode 100644 index 0000000..33cb71e --- /dev/null +++ b/lib/providers/update_provider.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:collection/collection.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/util/update_checker.dart'; + +part 'update_provider.freezed.dart'; +part 'update_provider.g.dart'; + +Set get _directUpdatePlatforms => { + TargetPlatform.linux, + TargetPlatform.macOS, + TargetPlatform.windows, + }; + +final hasNewUpdateProvider = Provider((ref) { + //Disable update notification for platforms that are updated outside of Github. + if (!_directUpdatePlatforms.contains(defaultTargetPlatform) || kIsWeb) { + return false; + } + return ref.watch(clientSettingsProvider.select((value) => value.lastViewedUpdate)) != + ref.watch(updateProvider.select((value) => value.latestRelease?.version)); +}); + +@Riverpod(keepAlive: true) +class Update extends _$Update { + final updateChecker = UpdateChecker(); + + Timer? _timer; + + @override + UpdatesModel build() { + final checkForUpdates = ref.watch(clientSettingsProvider.select((value) => value.checkForUpdates)); + + if (!checkForUpdates) { + _timer?.cancel(); + return UpdatesModel(); + } + ref.onDispose(() { + _timer?.cancel(); + }); + + _timer?.cancel(); + + _timer = Timer.periodic(const Duration(minutes: 30), (timer) { + _fetchLatest(); + }); + + _fetchLatest(); + + return UpdatesModel(); + } + + Future> _fetchLatest() async { + final latest = await updateChecker.fetchRecentReleases(); + state = state.copyWith( + lastRelease: latest, + ); + return latest; + } +} + +@Freezed(toJson: false, fromJson: false) +class UpdatesModel with _$UpdatesModel { + const UpdatesModel._(); + + factory UpdatesModel({ + @Default([]) List lastRelease, + }) = _UpdatesModel; + + ReleaseInfo? get latestRelease => lastRelease.firstWhereOrNull((value) => value.isNewerThanCurrent); +} diff --git a/lib/providers/update_provider.freezed.dart b/lib/providers/update_provider.freezed.dart new file mode 100644 index 0000000..5cc066e --- /dev/null +++ b/lib/providers/update_provider.freezed.dart @@ -0,0 +1,163 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'update_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$UpdatesModel { + List get lastRelease => throw _privateConstructorUsedError; + + /// Create a copy of UpdatesModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $UpdatesModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $UpdatesModelCopyWith<$Res> { + factory $UpdatesModelCopyWith( + UpdatesModel value, $Res Function(UpdatesModel) then) = + _$UpdatesModelCopyWithImpl<$Res, UpdatesModel>; + @useResult + $Res call({List lastRelease}); +} + +/// @nodoc +class _$UpdatesModelCopyWithImpl<$Res, $Val extends UpdatesModel> + implements $UpdatesModelCopyWith<$Res> { + _$UpdatesModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of UpdatesModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? lastRelease = null, + }) { + return _then(_value.copyWith( + lastRelease: null == lastRelease + ? _value.lastRelease + : lastRelease // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$UpdatesModelImplCopyWith<$Res> + implements $UpdatesModelCopyWith<$Res> { + factory _$$UpdatesModelImplCopyWith( + _$UpdatesModelImpl value, $Res Function(_$UpdatesModelImpl) then) = + __$$UpdatesModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({List lastRelease}); +} + +/// @nodoc +class __$$UpdatesModelImplCopyWithImpl<$Res> + extends _$UpdatesModelCopyWithImpl<$Res, _$UpdatesModelImpl> + implements _$$UpdatesModelImplCopyWith<$Res> { + __$$UpdatesModelImplCopyWithImpl( + _$UpdatesModelImpl _value, $Res Function(_$UpdatesModelImpl) _then) + : super(_value, _then); + + /// Create a copy of UpdatesModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? lastRelease = null, + }) { + return _then(_$UpdatesModelImpl( + lastRelease: null == lastRelease + ? _value._lastRelease + : lastRelease // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$UpdatesModelImpl extends _UpdatesModel with DiagnosticableTreeMixin { + _$UpdatesModelImpl({final List lastRelease = const []}) + : _lastRelease = lastRelease, + super._(); + + final List _lastRelease; + @override + @JsonKey() + List get lastRelease { + if (_lastRelease is EqualUnmodifiableListView) return _lastRelease; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_lastRelease); + } + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'UpdatesModel(lastRelease: $lastRelease)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'UpdatesModel')) + ..add(DiagnosticsProperty('lastRelease', lastRelease)); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$UpdatesModelImpl && + const DeepCollectionEquality() + .equals(other._lastRelease, _lastRelease)); + } + + @override + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(_lastRelease)); + + /// Create a copy of UpdatesModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$UpdatesModelImplCopyWith<_$UpdatesModelImpl> get copyWith => + __$$UpdatesModelImplCopyWithImpl<_$UpdatesModelImpl>(this, _$identity); +} + +abstract class _UpdatesModel extends UpdatesModel { + factory _UpdatesModel({final List lastRelease}) = + _$UpdatesModelImpl; + _UpdatesModel._() : super._(); + + @override + List get lastRelease; + + /// Create a copy of UpdatesModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$UpdatesModelImplCopyWith<_$UpdatesModelImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/providers/update_provider.g.dart b/lib/providers/update_provider.g.dart new file mode 100644 index 0000000..ad48090 --- /dev/null +++ b/lib/providers/update_provider.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'update_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$updateHash() => r'97f7aca4e255d654a9295e1e9e019536faf6455e'; + +/// See also [Update]. +@ProviderFor(Update) +final updateProvider = NotifierProvider.internal( + Update.new, + name: r'updateProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$updateHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$Update = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/screens/settings/about_settings_page.dart b/lib/screens/settings/about_settings_page.dart index 47952a2..8ed94a8 100644 --- a/lib/screens/settings/about_settings_page.dart +++ b/lib/screens/settings/about_settings_page.dart @@ -7,6 +7,7 @@ import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/screens/crash_screen/crash_screen.dart'; import 'package:fladder/screens/settings/settings_scaffold.dart'; +import 'package:fladder/screens/settings/widgets/settings_update_information.dart'; import 'package:fladder/screens/shared/fladder_icon.dart'; import 'package:fladder/screens/shared/fladder_logo.dart'; import 'package:fladder/screens/shared/media/external_urls.dart'; @@ -42,6 +43,7 @@ class AboutSettingsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final applicationInfo = ref.watch(applicationInfoProvider); + return SettingsScaffold( label: "", items: [ @@ -114,6 +116,7 @@ class AboutSettingsPage extends ConsumerWidget { ) ], ), + const SettingsUpdateInformation(), ].addInBetween(const SizedBox(height: 16)), ); } diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 937e5f5..dc0a70f 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -7,6 +7,7 @@ import 'package:window_manager/window_manager.dart'; import 'package:fladder/providers/arguments_provider.dart'; import 'package:fladder/providers/auth_provider.dart'; +import 'package:fladder/providers/update_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/settings/quick_connect_window.dart'; @@ -99,6 +100,10 @@ class _SettingsScreenState extends ConsumerState { final quickConnectAvailable = ref.watch(userProvider.select((value) => value?.serverConfiguration?.quickConnectAvailable ?? false)); + final newRelease = ref.watch(updateProvider.select((value) => value.latestRelease)); + + final hasNewUpdate = ref.watch(hasNewUpdateProvider); + return Padding( padding: EdgeInsets.only(left: AdaptiveLayout.of(context).sideBarWidth), child: Container( @@ -109,6 +114,18 @@ class _SettingsScreenState extends ConsumerState { showBackButtonNested: true, showUserIcon: true, items: [ + if (hasNewUpdate && newRelease != null) ...[ + Card( + color: context.colors.secondaryContainer, + child: SettingsListTile( + label: Text(context.localized.newReleaseFoundTitle(newRelease.version)), + subLabel: Text(context.localized.newUpdateFoundOnGithub), + icon: IconsaxPlusLinear.information, + onTap: () => navigateTo(const AboutSettingsRoute()), + ), + ), + const SizedBox(height: 8), + ], SettingsListTile( label: Text(context.localized.settingsClientTitle), subLabel: Text(context.localized.settingsClientDesc), @@ -138,7 +155,7 @@ class _SettingsScreenState extends ConsumerState { ), SettingsListTile( label: Text(context.localized.about), - subLabel: const Text("Fladder"), + subLabel: Text("Fladder, ${context.localized.latestReleases}"), selected: containsRoute(const AboutSettingsRoute()), leading: Opacity( opacity: 1, diff --git a/lib/screens/settings/widgets/settings_message_box.dart b/lib/screens/settings/widgets/settings_message_box.dart index 9298076..f304fb5 100644 --- a/lib/screens/settings/widgets/settings_message_box.dart +++ b/lib/screens/settings/widgets/settings_message_box.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/util/list_padding.dart'; diff --git a/lib/screens/settings/widgets/settings_update_information.dart b/lib/screens/settings/widgets/settings_update_information.dart new file mode 100644 index 0000000..e96be5a --- /dev/null +++ b/lib/screens/settings/widgets/settings_update_information.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:markdown_widget/widget/markdown.dart'; + +import 'package:fladder/providers/settings/client_settings_provider.dart'; +import 'package:fladder/providers/update_provider.dart'; +import 'package:fladder/screens/settings/settings_list_tile.dart'; +import 'package:fladder/screens/shared/media/external_urls.dart'; +import 'package:fladder/util/list_padding.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/util/theme_extensions.dart'; +import 'package:fladder/util/update_checker.dart'; + +class SettingsUpdateInformation extends ConsumerStatefulWidget { + const SettingsUpdateInformation({super.key}); + + @override + ConsumerState createState() => _SettingsUpdateInformationState(); +} + +class _SettingsUpdateInformationState extends ConsumerState { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((value) { + final latestRelease = ref.read(updateProvider.select((value) => value.latestRelease)); + if (latestRelease == null) return; + final lastViewedUpdate = ref.read(clientSettingsProvider.select((value) => value.lastViewedUpdate)); + if (lastViewedUpdate != latestRelease.version) { + ref + .read(clientSettingsProvider.notifier) + .update((value) => value.copyWith(lastViewedUpdate: latestRelease.version)); + } + }); + } + + @override + Widget build(BuildContext context) { + final updates = ref.watch(updateProvider); + final latestRelease = updates.latestRelease; + final otherReleases = updates.lastRelease; + final checkForUpdate = ref.watch(clientSettingsProvider.select((value) => value.checkForUpdates)); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + spacing: 8, + children: [ + const Divider(), + SettingsListTile( + label: Text(context.localized.latestReleases), + subLabel: Text(context.localized.autoCheckForUpdates), + onTap: () => ref + .read(clientSettingsProvider.notifier) + .update((value) => value.copyWith(checkForUpdates: !checkForUpdate)), + trailing: Switch( + value: checkForUpdate, + onChanged: (value) => ref + .read(clientSettingsProvider.notifier) + .update((value) => value.copyWith(checkForUpdates: !checkForUpdate)), + ), + ), + if (checkForUpdate) ...[ + if (latestRelease != null) + UpdateInformation( + releaseInfo: latestRelease, + expanded: true, + ), + ...otherReleases.where((element) => element != latestRelease).map( + (value) => UpdateInformation(releaseInfo: value), + ), + ] + ], + ), + ); + } +} + +class UpdateInformation extends StatelessWidget { + final ReleaseInfo releaseInfo; + final bool expanded; + const UpdateInformation({ + required this.releaseInfo, + this.expanded = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ExpansionTile( + backgroundColor: + releaseInfo.isNewerThanCurrent ? context.colors.primaryContainer : context.colors.surfaceContainer, + collapsedBackgroundColor: releaseInfo.isNewerThanCurrent ? context.colors.primaryContainer : null, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + collapsedShape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Text(releaseInfo.version), + initiallyExpanded: expanded, + childrenPadding: const EdgeInsets.all(16), + expandedCrossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: MarkdownWidget( + data: releaseInfo.changelog, + shrinkWrap: true, + ), + ), + ), + ...releaseInfo.preferredDownloads.entries.map( + (entry) { + return FilledButton( + onPressed: () => launchUrl(context, entry.value), + child: Text( + entry.key.prettifyKey(), + ), + ); + }, + ), + ...releaseInfo.otherDownloads.entries.map( + (entry) { + return ElevatedButton( + onPressed: () => launchUrl(context, entry.value), + child: Text( + entry.key.prettifyKey(), + ), + ); + }, + ) + ].addInBetween(const SizedBox(height: 12)), + ); + } +} diff --git a/lib/util/update_checker.dart b/lib/util/update_checker.dart new file mode 100644 index 0000000..ebcb3e9 --- /dev/null +++ b/lib/util/update_checker.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; + +class ReleaseInfo { + final String version; + final String changelog; + final String url; + final bool isNewerThanCurrent; + final Map downloads; + + ReleaseInfo({ + required this.version, + required this.changelog, + required this.url, + required this.isNewerThanCurrent, + required this.downloads, + }); + + String? downloadUrlFor(String platform) => downloads[platform]; + + Map get preferredDownloads { + final group = _platformGroup(); + final entries = downloads.entries.where((e) => e.key.contains(group)); + return Map.fromEntries(entries); + } + + Map get otherDownloads { + final group = _platformGroup(); + final entries = downloads.entries.where((e) => !e.key.contains(group)); + return Map.fromEntries(entries); + } + + String _platformGroup() { + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return 'android'; + case TargetPlatform.iOS: + return 'ios'; + case TargetPlatform.windows: + return 'windows'; + case TargetPlatform.macOS: + return 'macos'; + case TargetPlatform.linux: + return 'linux'; + default: + return ''; + } + } +} + +extension DownloadLabelFormatter on String { + String prettifyKey() { + final parts = split('_'); + if (parts.isEmpty) return this; + + final base = parts.first.capitalize(); + if (parts.length == 1) return base; + + final variant = parts.sublist(1).join(' ').capitalize(); + return '$base ($variant)'; + } + + String capitalize() => isEmpty ? this : '${this[0].toUpperCase()}${substring(1)}'; +} + +class UpdateChecker { + final String owner = 'DonutWare'; + final String repo = 'Fladder'; + + Future> fetchRecentReleases({int count = 5}) async { + final info = await PackageInfo.fromPlatform(); + final currentVersion = info.version; + + final url = Uri.parse('https://api.github.com/repos/$owner/$repo/releases?per_page=$count'); + final response = await http.get(url); + + if (response.statusCode != 200) { + print('Failed to fetch releases: ${response.statusCode}'); + return []; + } + + final List releases = jsonDecode(response.body); + return releases.map((json) { + final tag = (json['tag_name'] as String?)?.replaceFirst(RegExp(r'^v'), ''); + final changelog = json['body'] as String? ?? ''; + final htmlUrl = json['html_url'] as String? ?? ''; + final assets = json['assets'] as List? ?? []; + + final Map downloads = {}; + for (final asset in assets) { + final name = asset['name'] as String? ?? ''; + final downloadUrl = asset['browser_download_url'] as String? ?? ''; + + if (name.contains('Android') && name.endsWith('.apk')) { + downloads['android'] = downloadUrl; + } else if (name.contains('iOS') && name.endsWith('.ipa')) { + downloads['ios'] = downloadUrl; + } else if (name.contains('Windows') && name.endsWith('Setup.exe')) { + downloads['windows_installer'] = downloadUrl; + } else if (name.contains('Windows') && name.endsWith('.zip')) { + downloads['windows_portable'] = downloadUrl; + } else if (name.contains('macOS') && name.endsWith('.dmg')) { + downloads['macos'] = downloadUrl; + } else if (name.contains('Linux') && name.endsWith('.AppImage')) { + downloads['linux_appimage'] = downloadUrl; + } else if (name.contains('Linux') && name.endsWith('.flatpak')) { + downloads['linux_flatpak'] = downloadUrl; + } else if (name.contains('Linux') && name.endsWith('.zip')) { + downloads['linux_zip'] = downloadUrl; + } else if (name.contains('Linux') && name.endsWith('.zsync')) { + downloads['linux_zsync'] = downloadUrl; + } else if (name.contains('Web') && name.endsWith('.zip')) { + downloads['web'] = downloadUrl; + } + } + + bool isNewer = tag != null && _compareVersions(tag, currentVersion) > 0; + + return ReleaseInfo( + version: tag ?? 'unknown', + changelog: changelog.trim(), + url: htmlUrl, + isNewerThanCurrent: isNewer, + downloads: downloads, + ); + }).toList(); + } + + Future isUpToDate() async { + final releases = await fetchRecentReleases(count: 1); + if (releases.isEmpty) return true; + return !releases.first.isNewerThanCurrent; + } + + static int _compareVersions(String a, String b) { + final aParts = a.split('.').map(int.tryParse).toList(); + final bParts = b.split('.').map(int.tryParse).toList(); + + for (var i = 0; i < aParts.length || i < bParts.length; i++) { + final aVal = i < aParts.length ? (aParts[i] ?? 0) : 0; + final bVal = i < bParts.length ? (bParts[i] ?? 0) : 0; + if (aVal != bVal) return aVal.compareTo(bVal); + } + return 0; + } +} diff --git a/lib/widgets/navigation_scaffold/components/settings_user_icon.dart b/lib/widgets/navigation_scaffold/components/settings_user_icon.dart index b4fe5d3..055908a 100644 --- a/lib/widgets/navigation_scaffold/components/settings_user_icon.dart +++ b/lib/widgets/navigation_scaffold/components/settings_user_icon.dart @@ -2,20 +2,25 @@ import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; +import 'package:fladder/providers/update_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/routes/auto_router.gr.dart'; import 'package:fladder/screens/shared/flat_button.dart'; import 'package:fladder/screens/shared/user_icon.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/util/theme_extensions.dart'; class SettingsUserIcon extends ConsumerWidget { const SettingsUserIcon({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final size = 24.0; final user = ref.watch(userProvider); + final hasNewUpdate = ref.watch(hasNewUpdateProvider); return Tooltip( message: context.localized.settings, waitDuration: const Duration(seconds: 1), @@ -28,9 +33,35 @@ class SettingsUserIcon extends ConsumerWidget { context.router.push(const ClientSettingsRoute()); } }, - child: UserIcon( - user: user, - cornerRadius: 200, + child: Stack( + alignment: Alignment.bottomRight, + children: [ + UserIcon( + user: user, + cornerRadius: 200, + ), + if (hasNewUpdate) + Transform.translate( + offset: Offset(size / 4, size / 4), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: context.colors.surface, + shape: BoxShape.circle, + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: FittedBox( + child: Icon( + IconsaxPlusBold.information, + color: context.colors.primary, + ), + ), + ), + ), + ) + ], ), ), ); diff --git a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart index 965c3d2..c6e1ece 100644 --- a/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart +++ b/lib/widgets/navigation_scaffold/components/side_navigation_bar.dart @@ -221,7 +221,7 @@ class _SideNavigationBarState extends ConsumerState { selectedIcon: const Icon(IconsaxPlusBold.setting_3), horizontal: true, expanded: shouldExpand, - icon: const SizedBox(height: 32, child: SettingsUserIcon()), + icon: const SettingsUserIcon(), onPressed: () { if (AdaptiveLayout.layoutModeOf(context) == LayoutMode.single) { context.router.push(const SettingsRoute()); diff --git a/pubspec.lock b/pubspec.lock index 6ca594b..19337ec 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -595,6 +595,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + flutter_highlight: + dependency: transitive + description: + name: flutter_highlight + sha256: "7b96333867aa07e122e245c033b8ad622e4e3a42a1a2372cbb098a2541d8782c" + url: "https://pub.dev" + source: hosted + version: "0.7.0" flutter_keyboard_visibility: dependency: transitive description: @@ -842,6 +850,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + highlight: + dependency: transitive + description: + name: highlight + sha256: "5353a83ffe3e3eca7df0abfb72dcf3fa66cc56b953728e7113ad4ad88497cf21" + url: "https://pub.dev" + source: hosted + version: "0.7.0" html: dependency: transitive description: @@ -1082,6 +1098,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.3.0" + markdown_widget: + dependency: "direct main" + description: + name: markdown_widget + sha256: b52c13d3ee4d0e60c812e15b0593f142a3b8a2003cde1babb271d001a1dbdc1c + url: "https://pub.dev" + source: hosted + version: "2.3.2+8" matcher: dependency: transitive description: @@ -1570,6 +1594,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" + scroll_to_index: + dependency: transitive + description: + name: scroll_to_index + sha256: b707546e7500d9f070d63e5acf74fd437ec7eeeb68d3412ef7b0afada0b4f176 + url: "https://pub.dev" + source: hosted + version: "3.0.1" share_plus: dependency: "direct main" description: @@ -2055,6 +2087,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.4" + visibility_detector: + dependency: transitive + description: + name: visibility_detector + sha256: dd5cc11e13494f432d15939c3aa8ae76844c42b723398643ce9addb88a5ed420 + url: "https://pub.dev" + source: hosted + version: "0.4.0+2" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 51b8648..673aaee 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,6 +89,7 @@ dependencies: reorderable_grid: ^1.0.10 overflow_view: ^0.4.0 flutter_sticky_header: ^0.7.0 + markdown_widget: ^2.3.2+8 # Navigation auto_route: ^9.3.0+1