Make version check more reliable and make check only appear once per version

This commit is contained in:
Nutcake 2023-05-07 15:01:01 +02:00
parent 0d04bcdd1c
commit 639cdebf4e
6 changed files with 179 additions and 78 deletions

View file

@ -2,42 +2,6 @@ import 'dart:convert';
import 'package:http/http.dart' as http; 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 { class GithubApi {
static const baseUrl = "https://api.github.com"; static const baseUrl = "https://api.github.com";

View file

@ -14,12 +14,8 @@ class SettingsClient {
Future<void> loadSettings() async { Future<void> loadSettings() async {
final data = await _storage.read(key: _settingsKey); final data = await _storage.read(key: _settingsKey);
if (data == null) return; if (data == null) return;
try { _currentSettings = Settings.fromMap(jsonDecode(data));
_currentSettings = Settings.fromMap(jsonDecode(data));
} catch (_) {
_storage.delete(key: _settingsKey);
rethrow;
}
} }
Future<void> changeSettings(Settings newSettings) async { Future<void> changeSettings(Settings newSettings) async {

View file

@ -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/client_holder.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart'; import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/clients/settings_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/friends/friends_list.dart';
import 'package:contacts_plus_plus/widgets/login_screen.dart'; import 'package:contacts_plus_plus/widgets/login_screen.dart';
import 'package:contacts_plus_plus/widgets/update_notifier.dart'; import 'package:contacts_plus_plus/widgets/update_notifier.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -59,31 +59,49 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
void showUpdateDialogOnFirstBuild(BuildContext context) { void showUpdateDialogOnFirstBuild(BuildContext context) {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (_checkedForUpdate || kDebugMode) return; final settings = ClientHolder
.of(context)
.settingsClient;
if (_checkedForUpdate) return;
_checkedForUpdate = true; _checkedForUpdate = true;
GithubApi.getLatestTagName().then((value) async { GithubApi.getLatestTagName().then((remoteVer) async {
final currentVer = (await PackageInfo.fromPlatform()).version; final currentVer = (await PackageInfo.fromPlatform()).version;
SemVer currentSem;
SemVer remoteSem;
SemVer lastDismissedSem;
try { try {
final currentSem = SemVer.fromString(currentVer); currentSem = SemVer.fromString(currentVer);
final remoteSem = SemVer.fromString(value); } catch (_) {
if (remoteSem > currentSem && navigator.overlay?.context != null) { currentSem = SemVer.zero();
showDialog( }
context: navigator.overlay!.context,
builder: (context) { try {
return const UpdateNotifier(); lastDismissedSem = SemVer.fromString(settings.currentSettings.lastDismissedVersion.valueOrDefault);
}, } catch (_) {
); lastDismissedSem = SemVer.zero();
} }
} catch (e) {
if (currentVer != value && navigator.overlay?.context != null) { try {
showDialog( remoteSem = SemVer.fromString(remoteVer);
context: navigator.overlay!.context, } catch (_) {
builder: (context) { return;
return const UpdateNotifier(); }
},
); 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<ContactsPlusPlus> {
textTheme: _typography.white, textTheme: _typography.white,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) 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) { builder: (context) {
showUpdateDialogOnFirstBuild(context); showUpdateDialogOnFirstBuild(context);
final clientHolder = ClientHolder.of(context); final clientHolder = ClientHolder.of(context);

76
lib/models/sem_ver.dart Normal file
View file

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

View file

@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/sem_ver.dart';
class SettingsEntry<T> { class SettingsEntry<T> {
final T? value; final T? value;
@ -17,7 +18,7 @@ class SettingsEntry<T> {
Map toMap() { Map toMap() {
return { return {
"value": value.toString(), "value": jsonEncode(value),
"default": deflt, "default": deflt,
}; };
} }
@ -35,26 +36,36 @@ class Settings {
final SettingsEntry<bool> notificationsDenied; final SettingsEntry<bool> notificationsDenied;
final SettingsEntry<int> unreadCheckIntervalMinutes; final SettingsEntry<int> unreadCheckIntervalMinutes;
final SettingsEntry<int> lastOnlineStatus; final SettingsEntry<int> lastOnlineStatus;
final SettingsEntry<String> lastDismissedVersion;
Settings({ Settings({
SettingsEntry<bool>? notificationsDenied, SettingsEntry<bool>? notificationsDenied,
SettingsEntry<int>? unreadCheckIntervalMinutes, SettingsEntry<int>? unreadCheckIntervalMinutes,
SettingsEntry<int>? lastOnlineStatus, SettingsEntry<int>? lastOnlineStatus,
}) : notificationsDenied = notificationsDenied ?? const SettingsEntry(deflt: false), SettingsEntry<String>? lastDismissedVersion
unreadCheckIntervalMinutes = unreadCheckIntervalMinutes ?? const SettingsEntry(deflt: 60), })
lastOnlineStatus = lastOnlineStatus ?? SettingsEntry(deflt: OnlineStatus.online.index); : notificationsDenied = notificationsDenied ?? const SettingsEntry<bool>(deflt: false),
unreadCheckIntervalMinutes = unreadCheckIntervalMinutes ?? const SettingsEntry<int>(deflt: 60),
lastOnlineStatus = lastOnlineStatus ?? SettingsEntry<int>(deflt: OnlineStatus.online.index),
lastDismissedVersion = lastDismissedVersion ?? SettingsEntry<String>(deflt: SemVer.zero().toString())
;
factory Settings.fromMap(Map map) { factory Settings.fromMap(Map map) {
return Settings( return Settings(
notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]), notificationsDenied: retrieveEntryOrNull<bool>(map["notificationsDenied"]),
unreadCheckIntervalMinutes: retrieveEntryOrNull<int>(map["unreadCheckIntervalMinutes"]), unreadCheckIntervalMinutes: retrieveEntryOrNull<int>(map["unreadCheckIntervalMinutes"]),
lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]), lastOnlineStatus: retrieveEntryOrNull<int>(map["lastOnlineStatus"]),
lastDismissedVersion: retrieveEntryOrNull<String>(map["lastDismissedVersion"])
); );
} }
static SettingsEntry<T>? retrieveEntryOrNull<T>(Map? map) { static SettingsEntry<T>? retrieveEntryOrNull<T>(Map? map) {
if (map == null) return null; if (map == null) return null;
return SettingsEntry<T>.fromMap(map); try {
return SettingsEntry<T>.fromMap(map);
} catch (_) {
return null;
}
} }
Map toMap() { Map toMap() {
@ -62,18 +73,23 @@ class Settings {
"notificationsDenied": notificationsDenied.toMap(), "notificationsDenied": notificationsDenied.toMap(),
"unreadCheckIntervalMinutes": unreadCheckIntervalMinutes.toMap(), "unreadCheckIntervalMinutes": unreadCheckIntervalMinutes.toMap(),
"lastOnlineStatus": lastOnlineStatus.toMap(), "lastOnlineStatus": lastOnlineStatus.toMap(),
"lastDismissedVersion": lastDismissedVersion.toMap(),
}; };
} }
Settings copy() => copyWith(); Settings copy() => copyWith();
Settings copyWith({bool? notificationsDenied, int? unreadCheckIntervalMinutes, int? lastOnlineStatus}) { Settings copyWith({
bool? notificationsDenied,
int? unreadCheckIntervalMinutes,
int? lastOnlineStatus,
String? lastDismissedVersion,
}) {
return Settings( return Settings(
notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied),
unreadCheckIntervalMinutes: this.unreadCheckIntervalMinutes.passThrough(unreadCheckIntervalMinutes), unreadCheckIntervalMinutes: this.unreadCheckIntervalMinutes.passThrough(unreadCheckIntervalMinutes),
lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus), lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus),
lastDismissedVersion: this.lastDismissedVersion.passThrough(lastDismissedVersion),
); );
} }
} }

View file

@ -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:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
class UpdateNotifier extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -15,6 +20,17 @@ class UpdateNotifier extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Text("There is a new version available for download!"), 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,), const SizedBox(height: 24,),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -27,9 +43,15 @@ class UpdateNotifier extends StatelessWidget {
); );
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24),
foregroundColor: Theme.of(context).colorScheme.onSecondary, foregroundColor: Theme
backgroundColor: Theme.of(context).colorScheme.secondary .of(context)
.colorScheme
.onSecondary,
backgroundColor: Theme
.of(context)
.colorScheme
.secondary
), ),
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
label: const Text("Get it on Github"), label: const Text("Get it on Github"),
@ -39,7 +61,16 @@ class UpdateNotifier extends StatelessWidget {
], ],
), ),
actions: [ 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."),
),
], ],
); );
} }