feature: Added update notification and download links per platform (#362)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-06-01 21:36:50 +02:00 committed by GitHub
parent 5ef7936c33
commit 2c71dde228
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 712 additions and 8 deletions

View file

@ -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"
}

View file

@ -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;

View file

@ -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

View file

@ -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<String, dynamic> _$$ClientSettingsModelImplToJson(
'showAllCollectionTypes': instance.showAllCollectionTypes,
'maxConcurrentDownloads': instance.maxConcurrentDownloads,
'schemeVariant': _$DynamicSchemeVariantEnumMap[instance.schemeVariant]!,
'checkForUpdates': instance.checkForUpdates,
'lastViewedUpdate': instance.lastViewedUpdate,
'libraryPageSize': instance.libraryPageSize,
};

View file

@ -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<TargetPlatform> get _directUpdatePlatforms => {
TargetPlatform.linux,
TargetPlatform.macOS,
TargetPlatform.windows,
};
final hasNewUpdateProvider = Provider<bool>((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<List<ReleaseInfo>> _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<ReleaseInfo> lastRelease,
}) = _UpdatesModel;
ReleaseInfo? get latestRelease => lastRelease.firstWhereOrNull((value) => value.isNewerThanCurrent);
}

View file

@ -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>(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<ReleaseInfo> 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<UpdatesModel> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $UpdatesModelCopyWith<$Res> {
factory $UpdatesModelCopyWith(
UpdatesModel value, $Res Function(UpdatesModel) then) =
_$UpdatesModelCopyWithImpl<$Res, UpdatesModel>;
@useResult
$Res call({List<ReleaseInfo> 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<ReleaseInfo>,
) 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<ReleaseInfo> 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<ReleaseInfo>,
));
}
}
/// @nodoc
class _$UpdatesModelImpl extends _UpdatesModel with DiagnosticableTreeMixin {
_$UpdatesModelImpl({final List<ReleaseInfo> lastRelease = const []})
: _lastRelease = lastRelease,
super._();
final List<ReleaseInfo> _lastRelease;
@override
@JsonKey()
List<ReleaseInfo> 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<ReleaseInfo> lastRelease}) =
_$UpdatesModelImpl;
_UpdatesModel._() : super._();
@override
List<ReleaseInfo> 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;
}

View file

@ -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<Update, UpdatesModel>.internal(
Update.new,
name: r'updateProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$updateHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$Update = Notifier<UpdatesModel>;
// 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

View file

@ -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)),
);
}

View file

@ -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<SettingsScreen> {
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<SettingsScreen> {
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<SettingsScreen> {
),
SettingsListTile(
label: Text(context.localized.about),
subLabel: const Text("Fladder"),
subLabel: Text("Fladder, ${context.localized.latestReleases}"),
selected: containsRoute(const AboutSettingsRoute()),
leading: Opacity(
opacity: 1,

View file

@ -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';

View file

@ -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<ConsumerStatefulWidget> createState() => _SettingsUpdateInformationState();
}
class _SettingsUpdateInformationState extends ConsumerState<SettingsUpdateInformation> {
@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)),
);
}
}

View file

@ -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<String, String> downloads;
ReleaseInfo({
required this.version,
required this.changelog,
required this.url,
required this.isNewerThanCurrent,
required this.downloads,
});
String? downloadUrlFor(String platform) => downloads[platform];
Map<String, String> get preferredDownloads {
final group = _platformGroup();
final entries = downloads.entries.where((e) => e.key.contains(group));
return Map.fromEntries(entries);
}
Map<String, String> 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<List<ReleaseInfo>> 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<dynamic> 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<dynamic>? ?? [];
final Map<String, String> 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<bool> 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;
}
}

View file

@ -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,10 +33,36 @@ class SettingsUserIcon extends ConsumerWidget {
context.router.push(const ClientSettingsRoute());
}
},
child: UserIcon(
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,
),
),
),
),
)
],
),
),
);
}

View file

@ -221,7 +221,7 @@ class _SideNavigationBarState extends ConsumerState<SideNavigationBar> {
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());

View file

@ -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:

View file

@ -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