diff --git a/lib/apis/github_api.dart b/lib/apis/github_api.dart index 02d63bb..c96ea5f 100644 --- a/lib/apis/github_api.dart +++ b/lib/apis/github_api.dart @@ -2,42 +2,6 @@ import 'dart:convert'; import 'package:http/http.dart' as http; -class SemVer { - final int major; - final int minor; - final int patch; - - SemVer({required this.major, required this.minor, required this.patch}); - - factory SemVer.fromString(String str) { - final split = str.split("."); - if (split.length != 3) { - throw "Invalid version format"; - } - return SemVer(major: int.parse(split[0]), minor: int.parse(split[1]), patch: int.parse(split[2])); - } - - - @override - bool operator ==(Object other) => - identical(this, other) || - other is SemVer && - runtimeType == other.runtimeType && - major == other.major && - minor == other.minor && - patch == other.patch; - - @override - int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; - - bool operator >(SemVer other) { - if (major > other.major || (major == other.major && minor > other.minor) || (major == other.major && minor == other.minor && patch > other.patch)) { - return true; - } - return false; - } -} - class GithubApi { static const baseUrl = "https://api.github.com"; diff --git a/lib/clients/settings_client.dart b/lib/clients/settings_client.dart index 9b8a3d4..0f7666e 100644 --- a/lib/clients/settings_client.dart +++ b/lib/clients/settings_client.dart @@ -14,12 +14,8 @@ class SettingsClient { Future loadSettings() async { final data = await _storage.read(key: _settingsKey); if (data == null) return; - try { - _currentSettings = Settings.fromMap(jsonDecode(data)); - } catch (_) { - _storage.delete(key: _settingsKey); - rethrow; - } + _currentSettings = Settings.fromMap(jsonDecode(data)); + } Future changeSettings(Settings newSettings) async { diff --git a/lib/main.dart b/lib/main.dart index e58c746..bfcdf71 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,10 +5,10 @@ import 'package:contacts_plus_plus/apis/github_api.dart'; import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/settings_client.dart'; +import 'package:contacts_plus_plus/models/sem_ver.dart'; import 'package:contacts_plus_plus/widgets/friends/friends_list.dart'; import 'package:contacts_plus_plus/widgets/login_screen.dart'; import 'package:contacts_plus_plus/widgets/update_notifier.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:logging/logging.dart'; @@ -59,31 +59,49 @@ class _ContactsPlusPlusState extends State { void showUpdateDialogOnFirstBuild(BuildContext context) { final navigator = Navigator.of(context); - if (_checkedForUpdate || kDebugMode) return; + final settings = ClientHolder + .of(context) + .settingsClient; + if (_checkedForUpdate) return; _checkedForUpdate = true; - GithubApi.getLatestTagName().then((value) async { + GithubApi.getLatestTagName().then((remoteVer) async { final currentVer = (await PackageInfo.fromPlatform()).version; + SemVer currentSem; + SemVer remoteSem; + SemVer lastDismissedSem; try { - final currentSem = SemVer.fromString(currentVer); - final remoteSem = SemVer.fromString(value); - if (remoteSem > currentSem && navigator.overlay?.context != null) { - showDialog( - context: navigator.overlay!.context, - builder: (context) { - return const UpdateNotifier(); - }, - ); - } - } catch (e) { - if (currentVer != value && navigator.overlay?.context != null) { - showDialog( - context: navigator.overlay!.context, - builder: (context) { - return const UpdateNotifier(); - }, - ); - } + currentSem = SemVer.fromString(currentVer); + } catch (_) { + currentSem = SemVer.zero(); + } + + try { + lastDismissedSem = SemVer.fromString(settings.currentSettings.lastDismissedVersion.valueOrDefault); + } catch (_) { + lastDismissedSem = SemVer.zero(); + } + + try { + remoteSem = SemVer.fromString(remoteVer); + } catch (_) { + return; + } + + if (remoteSem <= lastDismissedSem && lastDismissedSem.isNotZero) { + return; + } + + if (remoteSem > currentSem && navigator.overlay?.context != null) { + showDialog( + context: navigator.overlay!.context, + builder: (context) { + return UpdateNotifier( + remoteVersion: remoteSem, + localVersion: currentSem, + ); + }, + ); } }); } @@ -101,7 +119,7 @@ class _ContactsPlusPlusState extends State { textTheme: _typography.white, colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) ), - home: Builder( + home: Builder( // Builder is necessary here since we need a context which has access to the ClientHolder builder: (context) { showUpdateDialogOnFirstBuild(context); final clientHolder = ClientHolder.of(context); diff --git a/lib/models/sem_ver.dart b/lib/models/sem_ver.dart new file mode 100644 index 0000000..995d67a --- /dev/null +++ b/lib/models/sem_ver.dart @@ -0,0 +1,76 @@ + +class SemVer { + static final RegExp _versionMatcher = RegExp(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"); + static final RegExp _characterFilter = RegExp(r"[a-z]"); + final int major; + final int minor; + final int patch; + + SemVer({required this.major, required this.minor, required this.patch}); + + factory SemVer.fromString(String str) { + str = str.replaceAll(_characterFilter, ""); + final match = _versionMatcher.firstMatch(str); + if (match == null || match.group(1) == null) { + throw "Invalid version format"; + } + + return SemVer( + major: int.parse(match.group(1)!), + minor: int.parse(match.group(2) ?? "0"), + patch: int.parse(match.group(3) ?? "0"), + ); + } + + factory SemVer.zero() { + return SemVer(major: 0, minor: 0, patch: 0); + } + + factory SemVer.max() { + //Chosen because it is larger than any version this app will ever see but small enough to not look bad when displayed as text + const max = 999; + return SemVer(major: max, minor: max, patch: max); + } + + bool get isZero => major == 0 && minor == 0 && patch == 0; + + bool get isNotZero => !isZero; + + @override + String toString() { + return "$major.$minor.$patch"; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is SemVer && + runtimeType == other.runtimeType && + major == other.major && + minor == other.minor && + patch == other.patch; + + @override + int get hashCode => major.hashCode ^ minor.hashCode ^ patch.hashCode; + + bool operator >(SemVer other) { + if (major > other.major || (major == other.major && minor > other.minor) || (major == other.major && minor == other.minor && patch > other.patch)) { + return true; + } + return false; + } + + bool operator >=(SemVer other) { + if (this == other) return true; + return this > other; + } + + bool operator <(SemVer other) { + return !(this > other); + } + + bool operator <=(SemVer other) { + if (this == other) return true; + return this < other; + } +} diff --git a/lib/models/settings.dart b/lib/models/settings.dart index b407b51..da55744 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:contacts_plus_plus/models/friend.dart'; +import 'package:contacts_plus_plus/models/sem_ver.dart'; class SettingsEntry { final T? value; @@ -17,7 +18,7 @@ class SettingsEntry { Map toMap() { return { - "value": value.toString(), + "value": jsonEncode(value), "default": deflt, }; } @@ -35,26 +36,36 @@ class Settings { final SettingsEntry notificationsDenied; final SettingsEntry unreadCheckIntervalMinutes; final SettingsEntry lastOnlineStatus; + final SettingsEntry lastDismissedVersion; Settings({ SettingsEntry? notificationsDenied, SettingsEntry? unreadCheckIntervalMinutes, SettingsEntry? lastOnlineStatus, - }) : notificationsDenied = notificationsDenied ?? const SettingsEntry(deflt: false), - unreadCheckIntervalMinutes = unreadCheckIntervalMinutes ?? const SettingsEntry(deflt: 60), - lastOnlineStatus = lastOnlineStatus ?? SettingsEntry(deflt: OnlineStatus.online.index); + SettingsEntry? lastDismissedVersion + }) + : notificationsDenied = notificationsDenied ?? const SettingsEntry(deflt: false), + unreadCheckIntervalMinutes = unreadCheckIntervalMinutes ?? const SettingsEntry(deflt: 60), + lastOnlineStatus = lastOnlineStatus ?? SettingsEntry(deflt: OnlineStatus.online.index), + lastDismissedVersion = lastDismissedVersion ?? SettingsEntry(deflt: SemVer.zero().toString()) + ; factory Settings.fromMap(Map map) { return Settings( notificationsDenied: retrieveEntryOrNull(map["notificationsDenied"]), unreadCheckIntervalMinutes: retrieveEntryOrNull(map["unreadCheckIntervalMinutes"]), lastOnlineStatus: retrieveEntryOrNull(map["lastOnlineStatus"]), + lastDismissedVersion: retrieveEntryOrNull(map["lastDismissedVersion"]) ); } static SettingsEntry? retrieveEntryOrNull(Map? map) { if (map == null) return null; - return SettingsEntry.fromMap(map); + try { + return SettingsEntry.fromMap(map); + } catch (_) { + return null; + } } Map toMap() { @@ -62,18 +73,23 @@ class Settings { "notificationsDenied": notificationsDenied.toMap(), "unreadCheckIntervalMinutes": unreadCheckIntervalMinutes.toMap(), "lastOnlineStatus": lastOnlineStatus.toMap(), + "lastDismissedVersion": lastDismissedVersion.toMap(), }; } Settings copy() => copyWith(); - Settings copyWith({bool? notificationsDenied, int? unreadCheckIntervalMinutes, int? lastOnlineStatus}) { + Settings copyWith({ + bool? notificationsDenied, + int? unreadCheckIntervalMinutes, + int? lastOnlineStatus, + String? lastDismissedVersion, + }) { return Settings( notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), unreadCheckIntervalMinutes: this.unreadCheckIntervalMinutes.passThrough(unreadCheckIntervalMinutes), lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus), + lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion), ); } - - } \ No newline at end of file diff --git a/lib/widgets/update_notifier.dart b/lib/widgets/update_notifier.dart index 91dcc2b..3409c0d 100644 --- a/lib/widgets/update_notifier.dart +++ b/lib/widgets/update_notifier.dart @@ -1,8 +1,13 @@ +import 'package:contacts_plus_plus/client_holder.dart'; +import 'package:contacts_plus_plus/models/sem_ver.dart'; import 'package:flutter/material.dart'; import 'package:url_launcher/url_launcher.dart'; class UpdateNotifier extends StatelessWidget { - const UpdateNotifier({super.key}); + const UpdateNotifier({required this.remoteVersion, required this.localVersion, super.key}); + + final SemVer remoteVersion; + final SemVer localVersion; @override Widget build(BuildContext context) { @@ -15,6 +20,17 @@ class UpdateNotifier extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const Text("There is a new version available for download!"), + const SizedBox(height: 8,), + Row( + children: [ + Text("Your version: ${localVersion.toString()}"), + ], + ), + Row( + children: [ + Text("New version: ${remoteVersion.toString()}"), + ], + ), const SizedBox(height: 24,), Row( mainAxisAlignment: MainAxisAlignment.center, @@ -27,9 +43,15 @@ class UpdateNotifier extends StatelessWidget { ); }, style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), - foregroundColor: Theme.of(context).colorScheme.onSecondary, - backgroundColor: Theme.of(context).colorScheme.secondary + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), + foregroundColor: Theme + .of(context) + .colorScheme + .onSecondary, + backgroundColor: Theme + .of(context) + .colorScheme + .secondary ), icon: const Icon(Icons.download), label: const Text("Get it on Github"), @@ -39,7 +61,16 @@ class UpdateNotifier extends StatelessWidget { ], ), actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text("I'll do it later.")) + TextButton( + onPressed: () { + final sClient = ClientHolder + .of(context) + .settingsClient; + sClient.changeSettings(sClient.currentSettings.copyWith(lastDismissedVersion: remoteVersion.toString())); + Navigator.of(context).pop(); + }, + child: const Text("I'll do it later."), + ), ], ); }