diff --git a/lib/model/availability.dart b/lib/model/availability.dart index 735c802e3..704211ca3 100644 --- a/lib/model/availability.dart +++ b/lib/model/availability.dart @@ -1,11 +1,17 @@ +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/theme/durations.dart'; import 'package:connectivity/connectivity.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:github/github.dart'; import 'package:google_api_availability/google_api_availability.dart'; +import 'package:package_info/package_info.dart'; +import 'package:version/version.dart'; final AvesAvailability availability = AvesAvailability._private(); class AvesAvailability { - bool _isConnected, _hasPlayServices; + bool _isConnected, _hasPlayServices, _isNewVersionAvailable; AvesAvailability._private() { Connectivity().onConnectivityChanged.listen(_updateConnectivityFromResult); @@ -38,4 +44,29 @@ class AvesAvailability { // local geolocation with `geocoder` requires Play Services Future get canGeolocate => Future.wait([isConnected, hasPlayServices]).then((results) => results.every((result) => result)); + + Future get isNewVersionAvailable async { + if (_isNewVersionAvailable != null) return SynchronousFuture(_isNewVersionAvailable); + + final now = DateTime.now(); + final dueDate = settings.lastVersionCheckDate.add(Durations.lastVersionCheckInterval); + if (now.isBefore(dueDate)) { + _isNewVersionAvailable = false; + return SynchronousFuture(_isNewVersionAvailable); + } + + if (!(await isConnected)) return false; + + Version version(String s) => Version.parse(s.replaceFirst('v', '')); + final currentTag = (await PackageInfo.fromPlatform()).version; + final latestTag = (await GitHub().repositories.getLatestRelease(RepositorySlug('deckerst', 'aves'))).tagName; + _isNewVersionAvailable = version(latestTag) > version(currentTag); + if (_isNewVersionAvailable) { + debugPrint('Aves $latestTag is available on github'); + } else { + debugPrint('Aves $currentTag is the latest version'); + settings.lastVersionCheckDate = now; + } + return _isNewVersionAvailable; + } } diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index fcc10a444..1f06c24ea 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -64,6 +64,9 @@ class Settings extends ChangeNotifier { static const saveSearchHistoryKey = 'save_search_history'; static const searchHistoryKey = 'search_history'; + // version + static const lastVersionCheckDateKey = 'last_version_check_date'; + Future init() async { _prefs = await SharedPreferences.getInstance(); } @@ -214,6 +217,12 @@ class Settings extends ChangeNotifier { set searchHistory(List newValue) => setAndNotify(searchHistoryKey, newValue.map((filter) => filter.toJson()).toList()); + // version + + DateTime get lastVersionCheckDate => DateTime.fromMillisecondsSinceEpoch(_prefs.getInt(lastVersionCheckDateKey) ?? 0); + + set lastVersionCheckDate(DateTime newValue) => setAndNotify(lastVersionCheckDateKey, newValue.millisecondsSinceEpoch); + // convenience methods // ignore: avoid_positional_boolean_parameters diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 0c8827f16..2ac6a36c6 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -35,7 +35,7 @@ class Durations { static const viewerOverlayChangeAnimation = Duration(milliseconds: 150); static const viewerOverlayPageChooserAnimation = Duration(milliseconds: 200); - // info + // info animations static const mapStyleSwitchAnimation = Duration(milliseconds: 300); static const xmpStructArrayCardTransition = Duration(milliseconds: 300); @@ -49,4 +49,7 @@ class Durations { static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const searchDebounceDelay = Duration(milliseconds: 250); static const contentChangeDebounceDelay = Duration(milliseconds: 500); + + // app life + static const lastVersionCheckInterval = Duration(days: 7); } diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index 87453882c..b2284ba3b 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -167,6 +167,12 @@ class Constants { licenseUrl: 'https://github.com/aloisdeniel/flutter_geocoder/blob/master/LICENSE', sourceUrl: 'https://github.com/aloisdeniel/flutter_geocoder', ), + Dependency( + name: 'Github', + license: 'MIT', + licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE', + sourceUrl: 'https://github.com/SpinlockLabs/github.dart', + ), Dependency( name: 'Google Maps for Flutter', license: 'BSD 3-Clause', @@ -287,6 +293,12 @@ class Constants { licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE', sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher', ), + Dependency( + name: 'Version', + license: 'BSD 3-Clause', + licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE', + sourceUrl: 'https://github.com/dartninja/version', + ), Dependency( name: 'XML', license: 'MIT', diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 8abc4a8db..fa43d749e 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -1,5 +1,8 @@ import 'package:aves/flutter_version.dart'; +import 'package:aves/widgets/about/app_ref.dart'; +import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/licenses.dart'; +import 'package:aves/widgets/about/new_version.dart'; import 'package:aves/widgets/common/basic/link_chip.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; import 'package:flutter/material.dart'; @@ -23,43 +26,9 @@ class AboutPage extends StatelessWidget { delegate: SliverChildListDelegate( [ AppReference(), - SizedBox(height: 16), Divider(), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ConstrainedBox( - constraints: BoxConstraints(minHeight: 48), - child: Align( - alignment: AlignmentDirectional.centerStart, - child: Text( - 'Credits', - style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'), - ), - ), - ), - RichText( - text: TextSpan( - children: [ - TextSpan(text: 'This app uses the font '), - WidgetSpan( - child: LinkChip( - text: 'Concourse', - url: 'https://mbtype.com/fonts/concourse/', - textStyle: TextStyle(fontWeight: FontWeight.bold), - ), - alignment: PlaceholderAlignment.middle, - ), - TextSpan(text: ' for titles and the media information page.'), - ], - ), - ), - SizedBox(height: 16), - ], - ), - ), + AboutNewVersion(), + AboutCredits(), Divider(), ], ), @@ -72,71 +41,3 @@ class AboutPage extends StatelessWidget { ); } } - -class AppReference extends StatefulWidget { - @override - _AppReferenceState createState() => _AppReferenceState(); -} - -class _AppReferenceState extends State { - Future packageInfoLoader; - - @override - void initState() { - super.initState(); - packageInfoLoader = PackageInfo.fromPlatform(); - } - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - children: [ - _buildAvesLine(), - _buildFlutterLine(), - ], - ), - ); - } - - Widget _buildAvesLine() { - final textTheme = Theme.of(context).textTheme; - final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold); - - return FutureBuilder( - future: packageInfoLoader, - builder: (context, snapshot) { - return LinkChip( - leading: AvesLogo( - size: style.fontSize * 1.25, - ), - text: 'Aves ${snapshot.data?.version}', - url: 'https://github.com/deckerst/aves', - textStyle: style, - ); - }, - ); - } - - Widget _buildFlutterLine() { - final style = DefaultTextStyle.of(context).style; - final subColor = style.color.withOpacity(.6); - - return Text.rich( - TextSpan( - children: [ - WidgetSpan( - child: Padding( - padding: EdgeInsetsDirectional.only(end: 4), - child: FlutterLogo( - size: style.fontSize * 1.25, - ), - ), - ), - TextSpan(text: 'Flutter ${version['frameworkVersion']}'), - ], - ), - style: TextStyle(color: subColor), - ); - } -} diff --git a/lib/widgets/about/app_ref.dart b/lib/widgets/about/app_ref.dart new file mode 100644 index 000000000..22dfc740d --- /dev/null +++ b/lib/widgets/about/app_ref.dart @@ -0,0 +1,74 @@ +import 'package:aves/flutter_version.dart'; +import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:aves/widgets/common/identity/aves_logo.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info/package_info.dart'; + +class AppReference extends StatefulWidget { + @override + _AppReferenceState createState() => _AppReferenceState(); +} + +class _AppReferenceState extends State { + Future _packageInfoLoader; + + @override + void initState() { + super.initState(); + _packageInfoLoader = PackageInfo.fromPlatform(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + children: [ + _buildAvesLine(), + _buildFlutterLine(), + SizedBox(height: 16), + ], + ), + ); + } + + Widget _buildAvesLine() { + final textTheme = Theme.of(context).textTheme; + final style = textTheme.headline6.copyWith(fontWeight: FontWeight.bold); + + return FutureBuilder( + future: _packageInfoLoader, + builder: (context, snapshot) { + return LinkChip( + leading: AvesLogo( + size: style.fontSize * 1.25, + ), + text: 'Aves ${snapshot.data?.version}', + url: 'https://github.com/deckerst/aves', + textStyle: style, + ); + }, + ); + } + + Widget _buildFlutterLine() { + final style = DefaultTextStyle.of(context).style; + final subColor = style.color.withOpacity(.6); + + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: EdgeInsetsDirectional.only(end: 4), + child: FlutterLogo( + size: style.fontSize * 1.25, + ), + ), + ), + TextSpan(text: 'Flutter ${version['frameworkVersion']}'), + ], + ), + style: TextStyle(color: subColor), + ); + } +} diff --git a/lib/widgets/about/credits.dart b/lib/widgets/about/credits.dart new file mode 100644 index 000000000..027e79f29 --- /dev/null +++ b/lib/widgets/about/credits.dart @@ -0,0 +1,43 @@ +import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:flutter/material.dart'; + +class AboutCredits extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints(minHeight: 48), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + 'Credits', + style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'), + ), + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan(text: 'This app uses the font '), + WidgetSpan( + child: LinkChip( + text: 'Concourse', + url: 'https://mbtype.com/fonts/concourse/', + textStyle: TextStyle(fontWeight: FontWeight.bold), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: ' for titles and the media information page.'), + ], + ), + ), + SizedBox(height: 16), + ], + ), + ); + } +} diff --git a/lib/widgets/about/new_version.dart b/lib/widgets/about/new_version.dart new file mode 100644 index 000000000..4c77cdffb --- /dev/null +++ b/lib/widgets/about/new_version.dart @@ -0,0 +1,92 @@ +import 'package:aves/model/availability.dart'; +import 'package:aves/widgets/about/news_badge.dart'; +import 'package:aves/widgets/common/basic/link_chip.dart'; +import 'package:flutter/material.dart'; + +class AboutNewVersion extends StatefulWidget { + @override + _AboutNewVersionState createState() => _AboutNewVersionState(); +} + +class _AboutNewVersionState extends State { + Future _newVersionLoader; + + @override + void initState() { + super.initState(); + _newVersionLoader = availability.isNewVersionAvailable; + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _newVersionLoader, + builder: (context, snapshot) { + final newVersion = snapshot.data == true; + if (!newVersion) return SizedBox(); + return Column( + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: BoxConstraints(minHeight: 48), + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: EdgeInsetsDirectional.only(end: 8), + child: AboutNewsBadge(), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan( + text: 'New Version Available', + style: Theme.of(context).textTheme.headline6.copyWith(fontFamily: 'Concourse Caps'), + ), + ], + ), + ), + ), + ), + Text.rich( + TextSpan( + children: [ + TextSpan(text: 'A new version of Aves is available on '), + WidgetSpan( + child: LinkChip( + text: 'Github', + url: 'https://github.com/deckerst/aves/releases', + textStyle: TextStyle(fontWeight: FontWeight.bold), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: ' and '), + WidgetSpan( + child: LinkChip( + text: 'Google Play', + url: 'https://play.google.com/store/apps/details?id=deckers.thibault.aves', + textStyle: TextStyle(fontWeight: FontWeight.bold), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: '.'), + ], + ), + ), + SizedBox(height: 16), + ], + ), + ), + Divider(), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/about/news_badge.dart b/lib/widgets/about/news_badge.dart new file mode 100644 index 000000000..69a571dde --- /dev/null +++ b/lib/widgets/about/news_badge.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; + +class AboutNewsBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Icon( + Icons.circle, + size: 12, + color: Colors.red, + ); + } +} diff --git a/lib/widgets/debug/settings.dart b/lib/widgets/debug/settings.dart index d695c4126..9a1759feb 100644 --- a/lib/widgets/debug/settings.dart +++ b/lib/widgets/debug/settings.dart @@ -39,6 +39,7 @@ class DebugSettingsSection extends StatelessWidget { 'infoMapZoom': '${settings.infoMapZoom}', 'pinnedFilters': toMultiline(settings.pinnedFilters), 'searchHistory': toMultiline(settings.searchHistory), + 'lastVersionCheckDate': '${settings.lastVersionCheckDate}', }), ), ], diff --git a/lib/widgets/drawer/app_drawer.dart b/lib/widgets/drawer/app_drawer.dart index 112fc28ae..1604da167 100644 --- a/lib/widgets/drawer/app_drawer.dart +++ b/lib/widgets/drawer/app_drawer.dart @@ -1,5 +1,6 @@ import 'dart:ui'; +import 'package:aves/model/availability.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/mime.dart'; @@ -11,6 +12,7 @@ import 'package:aves/ref/mime_types.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/about/about_page.dart'; +import 'package:aves/widgets/about/news_badge.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/identity/aves_icons.dart'; import 'package:aves/widgets/common/identity/aves_logo.dart'; @@ -35,8 +37,16 @@ class AppDrawer extends StatefulWidget { } class _AppDrawerState extends State { + Future _newVersionLoader; + CollectionSource get source => widget.source; + @override + void initState() { + super.initState(); + _newVersionLoader = availability.isNewVersionAvailable; + } + @override Widget build(BuildContext context) { final drawerItems = [ @@ -211,12 +221,19 @@ class _AppDrawerState extends State { pageBuilder: (_) => SettingsPage(), ); - Widget get aboutTile => NavTile( - icon: AIcons.info, - title: 'About', - topLevel: false, - routeName: AboutPage.routeName, - pageBuilder: (_) => AboutPage(), + Widget get aboutTile => FutureBuilder( + future: _newVersionLoader, + builder: (context, snapshot) { + final newVersion = snapshot.data == true; + return NavTile( + icon: AIcons.info, + title: 'About', + trailing: newVersion ? AboutNewsBadge() : null, + topLevel: false, + routeName: AboutPage.routeName, + pageBuilder: (_) => AboutPage(), + ); + }, ); Widget get debugTile => NavTile( diff --git a/pubspec.lock b/pubspec.lock index 432448f83..324e8b2b9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -380,6 +380,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.2.1" + github: + dependency: "direct main" + description: + name: github + url: "https://pub.dartlang.org" + source: hosted + version: "7.0.4" glob: dependency: transitive description: @@ -464,6 +471,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.6.3-nullsafety.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.1" json_rpc_2: dependency: transitive description: @@ -1043,6 +1057,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.1.0-nullsafety.3" + version: + dependency: "direct main" + description: + name: version + url: "https://pub.dartlang.org" + source: hosted + version: "1.2.0" vm_service: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 888311dc2..6c7d6e862 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: flutter_staggered_animations: flutter_svg: geocoder: + github: google_api_availability: google_maps_flutter: intl: @@ -76,6 +77,7 @@ dependencies: streams_channel: tuple: url_launcher: + version: xml: dev_dependencies: