Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-09-04 22:14:06 +09:00
commit dc907a0d51
64 changed files with 1052 additions and 991 deletions

View file

@ -5,6 +5,8 @@ on:
branches: branches:
- develop - develop
# TODO TLAD run `flutter format -l 1000 .` and fail if any
jobs: jobs:
build: build:
name: Check code quality. name: Check code quality.

View file

@ -1,4 +1,4 @@
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/data_providers/settings_provider.dart'; import 'package:aves/widgets/common/data_providers/settings_provider.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/home_page.dart'; import 'package:aves/widgets/home_page.dart';
@ -6,6 +6,7 @@ import 'package:aves/widgets/welcome_page.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:overlay_support/overlay_support.dart';
void main() { void main() {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging // HttpClient.enableTimelineLogging = true; // enable network traffic logging
@ -41,34 +42,36 @@ class _AvesAppState extends State<AvesApp> {
// place the settings provider above `MaterialApp` // place the settings provider above `MaterialApp`
// so it can be used during navigation transitions // so it can be used during navigation transitions
return SettingsProvider( return SettingsProvider(
child: MaterialApp( child: OverlaySupport(
title: 'Aves', child: MaterialApp(
theme: ThemeData( title: 'Aves',
brightness: Brightness.dark, theme: ThemeData(
accentColor: accentColor, brightness: Brightness.dark,
scaffoldBackgroundColor: Colors.grey[900], accentColor: accentColor,
buttonColor: accentColor, scaffoldBackgroundColor: Colors.grey[900],
toggleableActiveColor: accentColor, buttonColor: accentColor,
tooltipTheme: TooltipThemeData( toggleableActiveColor: accentColor,
verticalOffset: 32, tooltipTheme: TooltipThemeData(
), verticalOffset: 32,
appBarTheme: AppBarTheme( ),
textTheme: TextTheme( appBarTheme: AppBarTheme(
headline6: TextStyle( textTheme: TextTheme(
fontSize: 20, headline6: TextStyle(
fontWeight: FontWeight.bold, fontSize: 20,
fontFamily: 'Concourse Caps', fontWeight: FontWeight.bold,
fontFamily: 'Concourse Caps',
),
), ),
), ),
), ),
), home: FutureBuilder<void>(
home: FutureBuilder<void>( future: _appSetup,
future: _appSetup, builder: (context, snapshot) {
builder: (context, snapshot) { if (snapshot.hasError) return Icon(AIcons.error);
if (snapshot.hasError) return Icon(AIcons.error); if (snapshot.connectionState != ConnectionState.done) return Scaffold();
if (snapshot.connectionState != ConnectionState.done) return Scaffold(); return settings.hasAcceptedTerms ? HomePage() : WelcomePage();
return settings.hasAcceptedTerms ? HomePage() : WelcomePage(); },
}, ),
), ),
), ),
); );

View file

@ -0,0 +1,28 @@
import 'package:aves/utils/geo_utils.dart';
import 'package:tuple/tuple.dart';
enum CoordinateFormat { dms, decimal }
extension ExtraCoordinateFormat on CoordinateFormat {
String get name {
switch (this) {
case CoordinateFormat.dms:
return 'DMS';
case CoordinateFormat.decimal:
return 'Decimal degrees';
default:
return toString();
}
}
String format(Tuple2<double, double> latLng) {
switch (this) {
case CoordinateFormat.dms:
return toDMS(latLng).join(', ');
case CoordinateFormat.decimal:
return [latLng.item1, latLng.item2].map((n) => n.toStringAsFixed(6)).join(', ');
default:
return toString();
}
}
}

View file

@ -0,0 +1,28 @@
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
enum HomePageSetting { collection, albums }
extension ExtraHomePageSetting on HomePageSetting {
String get name {
switch (this) {
case HomePageSetting.collection:
return 'Collection';
case HomePageSetting.albums:
return 'Albums';
default:
return toString();
}
}
String get routeName {
switch (this) {
case HomePageSetting.collection:
return CollectionPage.routeName;
case HomePageSetting.albums:
return AlbumListPage.routeName;
default:
return toString();
}
}
}

View file

@ -1,11 +1,11 @@
import 'package:aves/utils/geo_utils.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:tuple/tuple.dart';
import 'source/enums.dart'; import '../source/enums.dart';
final Settings settings = Settings._private(); final Settings settings = Settings._private();
@ -16,18 +16,27 @@ class Settings extends ChangeNotifier {
Settings._private(); Settings._private();
// preferences // app
static const hasAcceptedTermsKey = 'has_accepted_terms';
static const mustBackTwiceToExitKey = 'must_back_twice_to_exit';
static const homePageKey = 'home_page';
static const catalogTimeZoneKey = 'catalog_time_zone'; static const catalogTimeZoneKey = 'catalog_time_zone';
// collection
static const collectionGroupFactorKey = 'collection_group_factor'; static const collectionGroupFactorKey = 'collection_group_factor';
static const collectionSortFactorKey = 'collection_sort_factor'; static const collectionSortFactorKey = 'collection_sort_factor';
static const collectionTileExtentKey = 'collection_tile_extent'; static const collectionTileExtentKey = 'collection_tile_extent';
static const hasAcceptedTermsKey = 'has_accepted_terms';
// filter grids
static const albumSortFactorKey = 'album_sort_factor';
// info
static const infoMapStyleKey = 'info_map_style'; static const infoMapStyleKey = 'info_map_style';
static const infoMapZoomKey = 'info_map_zoom'; static const infoMapZoomKey = 'info_map_zoom';
static const launchPageKey = 'launch_page';
static const coordinateFormatKey = 'coordinates_format'; static const coordinateFormatKey = 'coordinates_format';
// rendering
static const svgBackgroundKey = 'svg_background'; static const svgBackgroundKey = 'svg_background';
static const albumSortFactorKey = 'album_sort_factor';
Future<void> init() async { Future<void> init() async {
_prefs = await SharedPreferences.getInstance(); _prefs = await SharedPreferences.getInstance();
@ -37,10 +46,26 @@ class Settings extends ChangeNotifier {
return _prefs.clear(); return _prefs.clear();
} }
// app
bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, false);
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
bool get mustBackTwiceToExit => getBoolOrDefault(mustBackTwiceToExitKey, true);
set mustBackTwiceToExit(bool newValue) => setAndNotify(mustBackTwiceToExitKey, newValue);
HomePageSetting get homePage => getEnumOrDefault(homePageKey, HomePageSetting.collection, HomePageSetting.values);
set homePage(HomePageSetting newValue) => setAndNotify(homePageKey, newValue.toString());
String get catalogTimeZone => _prefs.getString(catalogTimeZoneKey) ?? ''; String get catalogTimeZone => _prefs.getString(catalogTimeZoneKey) ?? '';
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue); set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
// collection
EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values); EntryGroupFactor get collectionGroupFactor => getEnumOrDefault(collectionGroupFactorKey, EntryGroupFactor.month, EntryGroupFactor.values);
set collectionGroupFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString()); set collectionGroupFactor(EntryGroupFactor newValue) => setAndNotify(collectionGroupFactorKey, newValue.toString());
@ -53,6 +78,14 @@ class Settings extends ChangeNotifier {
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue); set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue);
// filter grids
ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.name, ChipSortFactor.values);
set albumSortFactor(ChipSortFactor newValue) => setAndNotify(albumSortFactorKey, newValue.toString());
// info
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values); EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);
set infoMapStyle(EntryMapStyle newValue) => setAndNotify(infoMapStyleKey, newValue.toString()); set infoMapStyle(EntryMapStyle newValue) => setAndNotify(infoMapStyleKey, newValue.toString());
@ -61,25 +94,23 @@ class Settings extends ChangeNotifier {
set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue); set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue);
bool get hasAcceptedTerms => getBoolOrDefault(hasAcceptedTermsKey, false);
set hasAcceptedTerms(bool newValue) => setAndNotify(hasAcceptedTermsKey, newValue);
LaunchPage get launchPage => getEnumOrDefault(launchPageKey, LaunchPage.collection, LaunchPage.values);
set launchPage(LaunchPage newValue) => setAndNotify(launchPageKey, newValue.toString());
CoordinateFormat get coordinateFormat => getEnumOrDefault(coordinateFormatKey, CoordinateFormat.dms, CoordinateFormat.values); CoordinateFormat get coordinateFormat => getEnumOrDefault(coordinateFormatKey, CoordinateFormat.dms, CoordinateFormat.values);
set coordinateFormat(CoordinateFormat newValue) => setAndNotify(coordinateFormatKey, newValue.toString()); set coordinateFormat(CoordinateFormat newValue) => setAndNotify(coordinateFormatKey, newValue.toString());
// rendering
int get svgBackground => _prefs.getInt(svgBackgroundKey) ?? 0xFFFFFFFF; int get svgBackground => _prefs.getInt(svgBackgroundKey) ?? 0xFFFFFFFF;
set svgBackground(int newValue) => setAndNotify(svgBackgroundKey, newValue); set svgBackground(int newValue) => setAndNotify(svgBackgroundKey, newValue);
ChipSortFactor get albumSortFactor => getEnumOrDefault(albumSortFactorKey, ChipSortFactor.date, ChipSortFactor.values); // utils
set albumSortFactor(ChipSortFactor newValue) => setAndNotify(albumSortFactorKey, newValue.toString()); // `RoutePredicate`
RoutePredicate navRemoveRoutePredicate(String pushedRouteName) {
final home = homePage.routeName;
return (route) => pushedRouteName != home && route.settings?.name == home;
}
// convenience methods // convenience methods
@ -125,44 +156,3 @@ class Settings extends ChangeNotifier {
} }
} }
} }
enum LaunchPage { collection, albums }
extension ExtraLaunchPage on LaunchPage {
String get name {
switch (this) {
case LaunchPage.collection:
return 'All Media';
case LaunchPage.albums:
return 'Albums';
default:
return toString();
}
}
}
enum CoordinateFormat { dms, decimal }
extension ExtraCoordinateFormat on CoordinateFormat {
String get name {
switch (this) {
case CoordinateFormat.dms:
return 'DMS';
case CoordinateFormat.decimal:
return 'Decimal degrees';
default:
return toString();
}
}
String format(Tuple2<double, double> latLng) {
switch (this) {
case CoordinateFormat.dms:
return toDMS(latLng).join(', ');
case CoordinateFormat.decimal:
return [latLng.item1, latLng.item2].map((n) => n.toStringAsFixed(6)).join(', ');
default:
return toString();
}
}
}

View file

@ -4,7 +4,7 @@ import 'dart:collection';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/tag.dart'; import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';

View file

@ -10,13 +10,6 @@ class Constants {
color: Color(0xFFEEEEEE), color: Color(0xFFEEEEEE),
fontSize: 20, fontSize: 20,
fontFamily: 'Concourse Caps', fontFamily: 'Concourse Caps',
shadows: [
Shadow(
offset: Offset(0, 2),
blurRadius: 3,
color: Color(0xFF212121),
),
],
); );
static const List<Dependency> androidDependencies = [ static const List<Dependency> androidDependencies = [

View file

@ -31,4 +31,5 @@ class Durations {
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300);
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;
static const doubleBackTimerDelay = Duration(milliseconds: 1000);
} }

View file

@ -0,0 +1,5 @@
import 'package:flutter/widgets.dart';
extension ExtraContext on BuildContext {
String get currentRouteName => ModalRoute.of(this)?.settings?.name;
}

View file

@ -7,6 +7,8 @@ import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
import 'package:package_info/package_info.dart'; import 'package:package_info/package_info.dart';
class AboutPage extends StatelessWidget { class AboutPage extends StatelessWidget {
static const routeName = '/about';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(

View file

@ -1,16 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/main.dart'; import 'package:aves/main.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/filter_bar.dart'; import 'package:aves/widgets/album/filter_bar.dart';
import 'package:aves/widgets/album/search/search_delegate.dart'; import 'package:aves/widgets/album/search/search_delegate.dart';
import 'package:aves/widgets/common/action_delegates/collection_group_dialog.dart';
import 'package:aves/widgets/common/action_delegates/collection_sort_dialog.dart';
import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart'; import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart'; import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
@ -135,7 +134,10 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
Widget _buildAppBarTitle() { Widget _buildAppBarTitle() {
if (collection.isBrowsing) { if (collection.isBrowsing) {
Widget title = Text(AvesApp.mode == AppMode.pick ? 'Select' : 'Aves', key: Key('appbar-title')); Widget title = Text(
AvesApp.mode == AppMode.pick ? 'Select' : 'Collection',
key: Key('appbar-title'),
);
if (AvesApp.mode == AppMode.main) { if (AvesApp.mode == AppMode.main) {
title = SourceStateAwareAppBarTitle( title = SourceStateAwareAppBarTitle(
title: title, title: title,
@ -296,23 +298,40 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
unawaited(_goToStats()); unawaited(_goToStats());
break; break;
case CollectionAction.group: case CollectionAction.group:
final factor = await showDialog<EntryGroupFactor>( final value = await showDialog<EntryGroupFactor>(
context: context, context: context,
builder: (context) => CollectionGroupDialog(), builder: (context) => AvesSelectionDialog<EntryGroupFactor>(
initialValue: settings.collectionGroupFactor,
options: {
EntryGroupFactor.album: 'By album',
EntryGroupFactor.month: 'By month',
EntryGroupFactor.day: 'By day',
EntryGroupFactor.none: 'Do not group',
},
title: 'Group',
),
); );
if (factor != null) { if (value != null) {
settings.collectionGroupFactor = factor; settings.collectionGroupFactor = value;
collection.group(factor); collection.group(value);
} }
break; break;
case CollectionAction.sort: case CollectionAction.sort:
final factor = await showDialog<EntrySortFactor>( final value = await showDialog<EntrySortFactor>(
context: context, context: context,
builder: (context) => CollectionSortDialog(initialValue: settings.collectionSortFactor), builder: (context) => AvesSelectionDialog<EntrySortFactor>(
initialValue: settings.collectionSortFactor,
options: {
EntrySortFactor.date: 'By date',
EntrySortFactor.size: 'By size',
EntrySortFactor.name: 'By album & file name',
},
title: 'Sort',
),
); );
if (factor != null) { if (value != null) {
settings.collectionSortFactor = factor; settings.collectionSortFactor = value;
collection.sort(factor); collection.sort(value);
} }
break; break;
} }
@ -329,6 +348,7 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
return Navigator.push( return Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: StatsPage.routeName),
builder: (context) => StatsPage( builder: (context) => StatsPage(
collection: collection, collection: collection,
), ),

View file

@ -1,12 +1,15 @@
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/album/thumbnail_collection.dart'; import 'package:aves/widgets/album/thumbnail_collection.dart';
import 'package:aves/widgets/app_drawer.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/double_back_pop.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class CollectionPage extends StatelessWidget { class CollectionPage extends StatelessWidget {
static const routeName = '/collection';
final CollectionLens collection; final CollectionLens collection;
const CollectionPage(this.collection); const CollectionPage(this.collection);
@ -26,7 +29,9 @@ class CollectionPage extends StatelessWidget {
} }
return SynchronousFuture(true); return SynchronousFuture(true);
}, },
child: ThumbnailCollection(), child: DoubleBackPopScope(
child: ThumbnailCollection(),
),
), ),
drawer: AppDrawer( drawer: AppDrawer(
source: collection.source, source: collection.source,

View file

@ -5,7 +5,7 @@ import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/album/grid/list_known_extent.dart'; import 'package:aves/widgets/album/grid/list_known_extent.dart';
import 'package:aves/widgets/album/grid/list_section_layout.dart'; import 'package:aves/widgets/album/grid/list_section_layout.dart';
import 'package:aves/widgets/album/thumbnail/decorated.dart'; import 'package:aves/widgets/album/thumbnail/decorated.dart';
import 'package:aves/widgets/common/transparent_material_page_route.dart'; import 'package:aves/widgets/common/routes.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -85,6 +85,7 @@ class GridThumbnail extends StatelessWidget {
Navigator.push( Navigator.push(
context, context,
TransparentMaterialPageRoute( TransparentMaterialPageRoute(
settings: RouteSettings(name: MultiFullscreenPage.routeName),
pageBuilder: (c, a, sa) => MultiFullscreenPage( pageBuilder: (c, a, sa) => MultiFullscreenPage(
collection: collection, collection: collection,
initialEntry: entry, initialEntry: entry,

View file

@ -1,6 +1,6 @@
import 'dart:math'; import 'dart:math';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
class TileExtentManager { class TileExtentManager {

View file

@ -1,5 +1,5 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';

View file

@ -1,319 +0,0 @@
import 'dart:ui';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/debug_page.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:aves/widgets/settings/settings_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class AppDrawer extends StatefulWidget {
final CollectionSource source;
const AppDrawer({@required this.source});
@override
_AppDrawerState createState() => _AppDrawerState();
}
class _AppDrawerState extends State<AppDrawer> {
CollectionSource get source => widget.source;
@override
Widget build(BuildContext context) {
final header = Container(
decoration: BoxDecoration(
border: Border(
bottom: Divider.createBorderSide(context),
),
),
child: Container(
padding: EdgeInsets.all(16),
color: Theme.of(context).accentColor,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: Wrap(
spacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
AvesLogo(size: 64),
Text(
'Aves',
style: TextStyle(
fontSize: 44,
fontFamily: 'Concourse Caps',
),
),
],
),
),
],
),
),
),
);
final allMediaEntry = _FilteredCollectionNavTile(
source: source,
leading: Icon(AIcons.allMedia),
title: 'All media',
filter: null,
);
final videoEntry = _FilteredCollectionNavTile(
source: source,
leading: Icon(AIcons.video),
title: 'Videos',
filter: MimeFilter(MimeTypes.anyVideo),
);
final favouriteEntry = _FilteredCollectionNavTile(
source: source,
leading: Icon(AIcons.favourite),
title: 'Favourites',
filter: FavouriteFilter(),
);
final settingsEntry = SafeArea(
top: false,
bottom: false,
child: ListTile(
leading: Icon(AIcons.settings),
title: Text('Preferences'),
onTap: () => _goTo((_) => SettingsPage()),
),
);
final aboutEntry = SafeArea(
top: false,
bottom: false,
child: ListTile(
leading: Icon(AIcons.info),
title: Text('About'),
onTap: () => _goTo((_) => AboutPage()),
),
);
final drawerItems = <Widget>[
header,
allMediaEntry,
videoEntry,
favouriteEntry,
_buildSpecialAlbumSection(),
Divider(),
_buildRegularAlbumSection(),
_buildCountrySection(),
_buildTagSection(),
Divider(),
settingsEntry,
aboutEntry,
if (kDebugMode) ...[
Divider(),
SafeArea(
top: false,
bottom: false,
child: ListTile(
leading: Icon(AIcons.debug),
title: Text('Debug'),
onTap: () => _goTo((_) => DebugPage(source: source)),
),
),
],
];
return Drawer(
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.viewInsets.bottom,
builder: (c, mqViewInsetsBottom, child) {
return SingleChildScrollView(
padding: EdgeInsets.only(bottom: mqViewInsetsBottom),
child: Theme(
data: Theme.of(context).copyWith(
// color used by `ExpansionTile` for leading icon
unselectedWidgetColor: Colors.white,
),
child: Column(
children: drawerItems,
),
),
);
},
),
);
}
Widget _buildAlbumEntry(String album, {bool dense = true}) {
final uniqueName = source.getUniqueAlbumName(album);
return _FilteredCollectionNavTile(
source: source,
leading: IconUtils.getAlbumIcon(context: context, album: album),
title: uniqueName,
trailing: androidFileUtils.isOnRemovableStorage(album)
? Icon(
AIcons.removableStorage,
size: 16,
color: Colors.grey,
)
: null,
dense: dense,
filter: AlbumFilter(album, uniqueName),
);
}
Widget _buildSpecialAlbumSection() {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final specialAlbums = source.sortedAlbums.where((album) {
final type = androidFileUtils.getAlbumType(album);
return type != AlbumType.regular && type != AlbumType.app;
});
if (specialAlbums.isEmpty) return SizedBox.shrink();
return Column(
children: [
Divider(),
...specialAlbums.map((album) => _buildAlbumEntry(album, dense: false)),
],
);
});
}
Widget _buildRegularAlbumSection() {
return SafeArea(
top: false,
bottom: false,
child: ListTile(
key: Key('albums-tile'),
leading: Icon(AIcons.album),
title: Text('Albums'),
trailing: StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) {
return Text(
'${source.sortedAlbums.length}',
style: TextStyle(
color: Colors.white.withOpacity(.6),
),
);
}),
onTap: () => _goTo((_) => AlbumListPage(source: source)),
),
);
}
Widget _buildCountrySection() {
return SafeArea(
top: false,
bottom: false,
child: ListTile(
leading: Icon(AIcons.location),
title: Text('Countries'),
trailing: StreamBuilder(
stream: source.eventBus.on<LocationsChangedEvent>(),
builder: (context, snapshot) {
return Text(
'${source.sortedCountries.length}',
style: TextStyle(
color: Colors.white.withOpacity(.6),
),
);
}),
onTap: () => _goTo((_) => CountryListPage(source: source)),
),
);
}
Widget _buildTagSection() {
return SafeArea(
top: false,
bottom: false,
child: ListTile(
leading: Icon(AIcons.tag),
title: Text('Tags'),
trailing: StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, snapshot) {
return Text(
'${source.sortedTags.length}',
style: TextStyle(
color: Colors.white.withOpacity(.6),
),
);
}),
onTap: () => _goTo((_) => TagListPage(source: source)),
),
);
}
void _goTo(WidgetBuilder builder) {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: builder));
}
}
class _FilteredCollectionNavTile extends StatelessWidget {
final CollectionSource source;
final Widget leading;
final String title;
final Widget trailing;
final bool dense;
final CollectionFilter filter;
const _FilteredCollectionNavTile({
@required this.source,
@required this.leading,
@required this.title,
this.trailing,
bool dense,
@required this.filter,
}) : dense = dense ?? false;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
bottom: false,
child: ListTile(
leading: leading,
title: Text(title),
trailing: trailing,
dense: dense,
onTap: () => _goToCollection(context),
),
);
}
void _goToCollection(BuildContext context) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
(route) => false,
);
}
}

View file

@ -1,59 +0,0 @@
import 'package:aves/model/source/enums.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../dialog.dart';
class ChipSortDialog extends StatefulWidget {
final ChipSortFactor initialValue;
const ChipSortDialog({@required this.initialValue});
@override
_ChipSortDialogState createState() => _ChipSortDialogState();
}
class _ChipSortDialogState extends State<ChipSortDialog> {
ChipSortFactor _selectedSort;
@override
void initState() {
super.initState();
_selectedSort = widget.initialValue;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
title: 'Sort',
scrollableContent: [
_buildRadioListTile(ChipSortFactor.date, 'By date'),
_buildRadioListTile(ChipSortFactor.name, 'By name'),
],
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
key: Key('apply-button'),
onPressed: () => Navigator.pop(context, _selectedSort),
child: Text('Apply'.toUpperCase()),
),
],
);
}
Widget _buildRadioListTile(ChipSortFactor value, String title) => RadioListTile<ChipSortFactor>(
key: Key(value.toString()),
value: value,
groupValue: _selectedSort,
onChanged: (sort) => setState(() => _selectedSort = sort),
title: Text(
title,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
}

View file

@ -1,58 +0,0 @@
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/enums.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../dialog.dart';
class CollectionGroupDialog extends StatefulWidget {
@override
_CollectionGroupDialogState createState() => _CollectionGroupDialogState();
}
class _CollectionGroupDialogState extends State<CollectionGroupDialog> {
EntryGroupFactor _selectedGroup;
@override
void initState() {
super.initState();
_selectedGroup = settings.collectionGroupFactor;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
title: 'Group',
scrollableContent: [
_buildRadioListTile(EntryGroupFactor.album, 'By album'),
_buildRadioListTile(EntryGroupFactor.month, 'By month'),
_buildRadioListTile(EntryGroupFactor.day, 'By day'),
_buildRadioListTile(EntryGroupFactor.none, 'Do not group'),
],
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
key: Key('apply-button'),
onPressed: () => Navigator.pop(context, _selectedGroup),
child: Text('Apply'.toUpperCase()),
),
],
);
}
Widget _buildRadioListTile(EntryGroupFactor value, String title) => RadioListTile<EntryGroupFactor>(
key: Key(value.toString()),
value: value,
groupValue: _selectedGroup,
onChanged: (group) => setState(() => _selectedGroup = group),
title: Text(
title,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
}

View file

@ -1,60 +0,0 @@
import 'package:aves/model/source/enums.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../dialog.dart';
class CollectionSortDialog extends StatefulWidget {
final EntrySortFactor initialValue;
const CollectionSortDialog({@required this.initialValue});
@override
_CollectionSortDialogState createState() => _CollectionSortDialogState();
}
class _CollectionSortDialogState extends State<CollectionSortDialog> {
EntrySortFactor _selectedSort;
@override
void initState() {
super.initState();
_selectedSort = widget.initialValue;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
title: 'Sort',
scrollableContent: [
_buildRadioListTile(EntrySortFactor.date, 'By date'),
_buildRadioListTile(EntrySortFactor.size, 'By size'),
_buildRadioListTile(EntrySortFactor.name, 'By album & file name'),
],
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
key: Key('apply-button'),
onPressed: () => Navigator.pop(context, _selectedSort),
child: Text('Apply'.toUpperCase()),
),
],
);
}
Widget _buildRadioListTile(EntrySortFactor value, String title) => RadioListTile<EntrySortFactor>(
key: Key(value.toString()),
value: value,
groupValue: _selectedSort,
onChanged: (sort) => setState(() => _selectedSort = sort),
title: Text(
title,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
}

View file

@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import '../dialog.dart'; import '../aves_dialog.dart';
class CreateAlbumDialog extends StatefulWidget { class CreateAlbumDialog extends StatefulWidget {
@override @override

View file

@ -7,7 +7,7 @@ import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/common/action_delegates/feedback.dart'; import 'package:aves/widgets/common/action_delegates/feedback.dart';
import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
import 'package:aves/widgets/common/action_delegates/rename_entry_dialog.dart'; import 'package:aves/widgets/common/action_delegates/rename_entry_dialog.dart';
import 'package:aves/widgets/common/dialog.dart'; import 'package:aves/widgets/common/aves_dialog.dart';
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/image_providers/uri_image_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/debug.dart'; import 'package:aves/widgets/fullscreen/debug.dart';
@ -174,6 +174,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: FullscreenDebugPage.routeName),
builder: (context) => FullscreenDebugPage(entry: entry), builder: (context) => FullscreenDebugPage(entry: entry),
), ),
); );

View file

@ -1,51 +0,0 @@
import 'package:aves/model/settings.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../dialog.dart';
class MapStyleDialog extends StatefulWidget {
@override
_MapStyleDialogState createState() => _MapStyleDialogState();
}
class _MapStyleDialogState extends State<MapStyleDialog> {
EntryMapStyle _selectedStyle;
@override
void initState() {
super.initState();
_selectedStyle = settings.infoMapStyle;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
title: 'Map Style',
scrollableContent: EntryMapStyle.values.map((style) => _buildRadioListTile(style, style.name)).toList(),
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
FlatButton(
onPressed: () => Navigator.pop(context, _selectedStyle),
child: Text('Apply'.toUpperCase()),
),
],
);
}
Widget _buildRadioListTile(EntryMapStyle style, String title) => RadioListTile<EntryMapStyle>(
value: style,
groupValue: _selectedStyle,
onChanged: (style) => setState(() => _selectedStyle = style),
title: Text(
title,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
}

View file

@ -2,7 +2,7 @@ import 'package:aves/model/image_entry.dart';
import 'package:aves/services/android_file_service.dart'; import 'package:aves/services/android_file_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../dialog.dart'; import '../aves_dialog.dart';
mixin PermissionAwareMixin { mixin PermissionAwareMixin {
Future<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> entries) { Future<bool> checkStoragePermission(BuildContext context, Iterable<ImageEntry> entries) {

View file

@ -1,7 +1,8 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/common/dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../aves_dialog.dart';
class RenameEntryDialog extends StatefulWidget { class RenameEntryDialog extends StatefulWidget {
final ImageEntry entry; final ImageEntry entry;

View file

@ -14,9 +14,10 @@ import 'package:aves/widgets/album/empty.dart';
import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart'; import 'package:aves/widgets/common/action_delegates/create_album_dialog.dart';
import 'package:aves/widgets/common/action_delegates/feedback.dart'; import 'package:aves/widgets/common/action_delegates/feedback.dart';
import 'package:aves/widgets/common/action_delegates/permission_aware.dart'; import 'package:aves/widgets/common/action_delegates/permission_aware.dart';
import 'package:aves/widgets/common/dialog.dart'; import 'package:aves/widgets/common/aves_dialog.dart';
import 'package:aves/widgets/common/entry_actions.dart'; import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -89,7 +90,7 @@ class SelectionActionDelegate with FeedbackMixin, PermissionAwareMixin {
], ],
floating: true, floating: true,
), ),
filterEntries: source.getAlbumEntries(), filterEntries: AlbumListPage.getAlbumEntries(source),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,

View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'aves_dialog.dart';
class AvesSelectionDialog<T> extends StatefulWidget {
final T initialValue;
final Map<T, String> options;
final String title;
const AvesSelectionDialog({
@required this.initialValue,
@required this.options,
@required this.title,
});
@override
_AvesSelectionDialogState<T> createState() => _AvesSelectionDialogState<T>();
}
class _AvesSelectionDialogState<T> extends State<AvesSelectionDialog> {
T _selectedValue;
@override
void initState() {
super.initState();
_selectedValue = widget.initialValue;
}
@override
Widget build(BuildContext context) {
return AvesDialog(
title: widget.title,
scrollableContent: widget.options.entries.map((kv) => _buildRadioListTile(kv.key, kv.value)).toList(),
actions: [
FlatButton(
onPressed: () => Navigator.pop(context),
child: Text('Cancel'.toUpperCase()),
),
],
);
}
Widget _buildRadioListTile(T value, String title) => RadioListTile<T>(
key: Key(value.toString()),
value: value,
groupValue: _selectedValue,
onChanged: (v) {
_selectedValue = v;
Navigator.pop(context, _selectedValue);
setState(() {});
},
title: Text(
title,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
);
}

View file

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:aves/model/favourite_repo.dart'; import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';

View file

@ -0,0 +1,54 @@
import 'dart:async';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/action_delegates/feedback.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:overlay_support/overlay_support.dart';
class DoubleBackPopScope extends StatefulWidget {
final Widget child;
const DoubleBackPopScope({
@required this.child,
});
@override
_DoubleBackPopScopeState createState() => _DoubleBackPopScopeState();
}
class _DoubleBackPopScopeState extends State<DoubleBackPopScope> with FeedbackMixin {
bool _backOnce = false;
Timer _backTimer;
@override
void dispose() {
_stopBackTimer();
super.dispose();
}
@override
Widget build(BuildContext context) {
return WillPopScope(
onWillPop: () {
if (!Navigator.canPop(context) && settings.mustBackTwiceToExit && !_backOnce) {
_backOnce = true;
_stopBackTimer();
_backTimer = Timer(Durations.doubleBackTimerDelay, () => _backOnce = false);
toast(
'Tap “back” again to exit.',
duration: Durations.doubleBackTimerDelay,
);
return SynchronousFuture(false);
}
return SynchronousFuture(true);
},
child: widget.child,
);
}
void _stopBackTimer() {
_backTimer?.cancel();
}
}

View file

@ -0,0 +1,43 @@
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/common/fx/highlight_decoration.dart';
import 'package:flutter/material.dart';
class HighlightTitle extends StatelessWidget {
final String name;
final double fontSize;
const HighlightTitle(
this.name, {
this.fontSize = 20,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: AlignmentDirectional.centerStart,
child: Container(
decoration: HighlightDecoration(
color: stringToColor(name),
),
margin: EdgeInsets.symmetric(vertical: 4.0),
child: Text(
name,
style: TextStyle(
shadows: [
Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2,
)
],
fontSize: fontSize,
fontFamily: 'Concourse Caps',
),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
),
);
}
}

View file

@ -7,7 +7,7 @@ import 'package:flutter/material.dart';
import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:outline_material_icons/outline_material_icons.dart';
class AIcons { class AIcons {
static const IconData allMedia = OMIcons.collections; static const IconData allCollection = OMIcons.collections;
static const IconData image = OMIcons.photo; static const IconData image = OMIcons.photo;
static const IconData video = OMIcons.movie; static const IconData video = OMIcons.movie;
static const IconData vector = OMIcons.code; static const IconData vector = OMIcons.code;

View file

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
class DirectMaterialPageRoute<T> extends PageRouteBuilder<T> {
DirectMaterialPageRoute({
RouteSettings settings,
@required WidgetBuilder builder,
}) : super(
settings: settings,
transitionDuration: Duration.zero,
pageBuilder: (c, a, sa) => builder(c),
);
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return child;
}
}
class TransparentMaterialPageRoute<T> extends PageRouteBuilder<T> {
TransparentMaterialPageRoute({
RouteSettings settings,
@required RoutePageBuilder pageBuilder,
}) : super(settings: settings, pageBuilder: pageBuilder);
@override
bool get opaque => false;
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
final theme = Theme.of(context).pageTransitionsTheme;
return theme.buildTransitions<T>(this, context, animation, secondaryAnimation, child);
}
}

View file

@ -1,16 +0,0 @@
import 'package:flutter/material.dart';
class TransparentMaterialPageRoute<T> extends PageRouteBuilder<T> {
TransparentMaterialPageRoute({
@required RoutePageBuilder pageBuilder,
}) : super(pageBuilder: pageBuilder);
@override
bool get opaque => false;
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
final theme = Theme.of(context).pageTransitionsTheme;
return theme.buildTransitions<T>(this, context, animation, secondaryAnimation, child);
}
}

View file

@ -4,7 +4,7 @@ import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
@ -18,6 +18,8 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:outline_material_icons/outline_material_icons.dart'; import 'package:outline_material_icons/outline_material_icons.dart';
class DebugPage extends StatefulWidget { class DebugPage extends StatefulWidget {
static const routeName = '/debug';
final CollectionSource source; final CollectionSource source;
const DebugPage({this.source}); const DebugPage({this.source});

View file

@ -0,0 +1,228 @@
import 'dart:ui';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/debug_page.dart';
import 'package:aves/widgets/drawer/collection_tile.dart';
import 'package:aves/widgets/drawer/tile.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/filter_grids/countries_page.dart';
import 'package:aves/widgets/filter_grids/tags_page.dart';
import 'package:aves/widgets/settings/settings_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class AppDrawer extends StatefulWidget {
final CollectionSource source;
const AppDrawer({@required this.source});
@override
_AppDrawerState createState() => _AppDrawerState();
}
class _AppDrawerState extends State<AppDrawer> {
CollectionSource get source => widget.source;
@override
Widget build(BuildContext context) {
final header = Container(
decoration: BoxDecoration(
border: Border(
bottom: Divider.createBorderSide(context),
),
),
child: Container(
padding: EdgeInsets.all(16),
color: Theme.of(context).accentColor,
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Align(
alignment: AlignmentDirectional.centerStart,
child: Wrap(
spacing: 16,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
AvesLogo(size: 64),
Text(
'Aves',
style: TextStyle(
fontSize: 44,
fontFamily: 'Concourse Caps',
),
),
],
),
),
],
),
),
),
);
final drawerItems = <Widget>[
header,
allCollectionTile,
videoTile,
favouriteTile,
_buildSpecialAlbumSection(),
Divider(),
albumListTile,
countryListTile,
tagListTile,
Divider(),
settingsTile,
aboutTile,
if (kDebugMode) ...[
Divider(),
debugTile,
],
];
return Drawer(
child: Selector<MediaQueryData, double>(
selector: (c, mq) => mq.viewInsets.bottom,
builder: (c, mqViewInsetsBottom, child) {
return SingleChildScrollView(
padding: EdgeInsets.only(bottom: mqViewInsetsBottom),
child: Theme(
data: Theme.of(context).copyWith(
// color used by `ExpansionTile` for leading icon
unselectedWidgetColor: Colors.white,
),
child: Column(
children: drawerItems,
),
),
);
},
),
);
}
Widget _buildAlbumTile(String album) {
final uniqueName = source.getUniqueAlbumName(album);
return CollectionNavTile(
source: source,
leading: IconUtils.getAlbumIcon(context: context, album: album),
title: uniqueName,
trailing: androidFileUtils.isOnRemovableStorage(album)
? Icon(
AIcons.removableStorage,
size: 16,
color: Colors.grey,
)
: null,
filter: AlbumFilter(album, uniqueName),
);
}
Widget _buildSpecialAlbumSection() {
return StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, snapshot) {
final specialAlbums = source.sortedAlbums.where((album) {
final type = androidFileUtils.getAlbumType(album);
return [AlbumType.camera, AlbumType.screenshots].contains(type);
});
if (specialAlbums.isEmpty) return SizedBox.shrink();
return Column(
children: [
Divider(),
...specialAlbums.map(_buildAlbumTile),
],
);
});
}
// tiles
Widget get allCollectionTile => CollectionNavTile(
source: source,
leading: Icon(AIcons.allCollection),
title: 'All collection',
filter: null,
);
Widget get videoTile => CollectionNavTile(
source: source,
leading: Icon(AIcons.video),
title: 'Videos',
filter: MimeFilter(MimeTypes.anyVideo),
);
Widget get favouriteTile => CollectionNavTile(
source: source,
leading: Icon(AIcons.favourite),
title: 'Favourites',
filter: FavouriteFilter(),
);
Widget get albumListTile => NavTile(
icon: AIcons.album,
title: 'Albums',
trailing: StreamBuilder(
stream: source.eventBus.on<AlbumsChangedEvent>(),
builder: (context, _) => Text('${source.sortedAlbums.length}'),
),
routeName: AlbumListPage.routeName,
pageBuilder: (_) => AlbumListPage(source: source),
);
Widget get countryListTile => NavTile(
icon: AIcons.location,
title: 'Countries',
trailing: StreamBuilder(
stream: source.eventBus.on<LocationsChangedEvent>(),
builder: (context, _) => Text('${source.sortedCountries.length}'),
),
routeName: CountryListPage.routeName,
pageBuilder: (_) => CountryListPage(source: source),
);
Widget get tagListTile => NavTile(
icon: AIcons.tag,
title: 'Tags',
trailing: StreamBuilder(
stream: source.eventBus.on<TagsChangedEvent>(),
builder: (context, _) => Text('${source.sortedTags.length}'),
),
routeName: TagListPage.routeName,
pageBuilder: (_) => TagListPage(source: source),
);
Widget get settingsTile => NavTile(
icon: AIcons.settings,
title: 'Settings',
routeName: SettingsPage.routeName,
pageBuilder: (_) => SettingsPage(),
);
Widget get aboutTile => NavTile(
icon: AIcons.info,
title: 'About',
routeName: AboutPage.routeName,
pageBuilder: (_) => AboutPage(),
);
Widget get debugTile => NavTile(
icon: AIcons.debug,
title: 'Debug',
routeName: DebugPage.routeName,
pageBuilder: (_) => DebugPage(source: source),
);
}

View file

@ -0,0 +1,57 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class CollectionNavTile extends StatelessWidget {
final CollectionSource source;
final Widget leading;
final String title;
final Widget trailing;
final bool dense;
final CollectionFilter filter;
const CollectionNavTile({
@required this.source,
@required this.leading,
@required this.title,
this.trailing,
bool dense,
@required this.filter,
}) : dense = dense ?? false;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
bottom: false,
child: ListTile(
leading: leading,
title: Text(title),
trailing: trailing,
dense: dense,
onTap: () => _goToCollection(context),
),
);
}
void _goToCollection(BuildContext context) {
Navigator.pop(context);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(CollectionLens(
source: source,
filters: [filter],
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
)),
),
settings.navRemoveRoutePredicate(CollectionPage.routeName),
);
}
}

View file

@ -0,0 +1,59 @@
import 'dart:ui';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/flutter_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class NavTile extends StatelessWidget {
final IconData icon;
final String title;
final Widget trailing;
final String routeName;
final WidgetBuilder pageBuilder;
const NavTile({
@required this.icon,
@required this.title,
this.trailing,
@required this.routeName,
@required this.pageBuilder,
});
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
bottom: false,
child: ListTile(
key: Key('$title-tile'),
leading: Icon(icon),
title: Text(title),
trailing: trailing != null
? Builder(
builder: (context) => DefaultTextStyle.merge(
style: TextStyle(
color: IconTheme.of(context).color.withOpacity(.6),
),
child: trailing,
),
)
: null,
onTap: () {
Navigator.pop(context);
if (routeName != context.currentRouteName) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(
settings: RouteSettings(name: routeName),
builder: pageBuilder,
),
settings.navRemoveRoutePredicate(routeName),
);
}
},
selected: context.currentRouteName == routeName,
),
);
}
}

View file

@ -1,13 +1,13 @@
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/album.dart'; import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/album/empty.dart';
import 'package:aves/widgets/common/action_delegates/chip_sort_dialog.dart'; import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart'; import 'package:aves/widgets/common/menu_row.dart';
import 'package:aves/widgets/filter_grids/filter_grid_page.dart'; import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
@ -17,6 +17,8 @@ import 'package:flutter/scheduler.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class AlbumListPage extends StatelessWidget { class AlbumListPage extends StatelessWidget {
static const routeName = '/albums';
final CollectionSource source; final CollectionSource source;
const AlbumListPage({@required this.source}); const AlbumListPage({@required this.source});
@ -35,7 +37,7 @@ class AlbumListPage extends StatelessWidget {
source: source, source: source,
title: 'Albums', title: 'Albums',
actions: _buildActions(), actions: _buildActions(),
filterEntries: _getAlbumEntries(), filterEntries: getAlbumEntries(source),
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)), filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
emptyBuilder: () => EmptyContent( emptyBuilder: () => EmptyContent(
icon: AIcons.album, icon: AIcons.album,
@ -49,7 +51,52 @@ class AlbumListPage extends StatelessWidget {
); );
} }
Map<String, ImageEntry> _getAlbumEntries() { List<Widget> _buildActions() {
return [
Builder(
builder: (context) => PopupMenuButton<ChipAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
return [
PopupMenuItem(
key: Key('menu-sort'),
value: ChipAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
),
];
},
onSelected: (action) => _onChipActionSelected(context, action),
),
),
];
}
void _onChipActionSelected(BuildContext context, ChipAction action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
switch (action) {
case ChipAction.sort:
final factor = await showDialog<ChipSortFactor>(
context: context,
builder: (context) => AvesSelectionDialog<ChipSortFactor>(
initialValue: settings.albumSortFactor,
options: {
ChipSortFactor.date: 'By date',
ChipSortFactor.name: 'By name',
},
title: 'Sort',
),
);
if (factor != null) {
settings.albumSortFactor = factor;
}
break;
}
}
// common with album selection page to move/copy entries
static Map<String, ImageEntry> getAlbumEntries(CollectionSource source) {
final entriesByDate = source.sortedEntriesForFilterList; final entriesByDate = source.sortedEntriesForFilterList;
final albumEntries = source.sortedAlbums.map((album) { final albumEntries = source.sortedAlbums.map((album) {
return MapEntry( return MapEntry(
@ -89,40 +136,4 @@ class AlbumListPage extends StatelessWidget {
})); }));
} }
} }
List<Widget> _buildActions() {
return [
Builder(
builder: (context) => PopupMenuButton<ChipAction>(
key: Key('appbar-menu-button'),
itemBuilder: (context) {
return [
PopupMenuItem(
key: Key('menu-sort'),
value: ChipAction.sort,
child: MenuRow(text: 'Sort...', icon: AIcons.sort),
),
];
},
onSelected: (action) => _onChipActionSelected(context, action),
),
),
];
}
void _onChipActionSelected(BuildContext context, ChipAction action) async {
// wait for the popup menu to hide before proceeding with the action
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
switch (action) {
case ChipAction.sort:
final factor = await showDialog<ChipSortFactor>(
context: context,
builder: (context) => ChipSortDialog(initialValue: settings.albumSortFactor),
);
if (factor != null) {
settings.albumSortFactor = factor;
}
break;
}
}
} }

View file

@ -7,6 +7,8 @@ import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class CountryListPage extends StatelessWidget { class CountryListPage extends StatelessWidget {
static const routeName = '/countries';
final CollectionSource source; final CollectionSource source;
const CountryListPage({@required this.source}); const CountryListPage({@required this.source});

View file

@ -2,15 +2,16 @@ import 'dart:ui';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/app_drawer.dart';
import 'package:aves/widgets/common/app_bar_subtitle.dart'; import 'package:aves/widgets/common/app_bar_subtitle.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/double_back_pop.dart';
import 'package:aves/widgets/drawer/app_drawer.dart';
import 'package:aves/widgets/filter_grids/decorated_filter_chip.dart'; import 'package:aves/widgets/filter_grids/decorated_filter_chip.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
@ -56,6 +57,7 @@ class FilterNavigationPage extends StatelessWidget {
onPressed: (filter) => Navigator.pushAndRemoveUntil( onPressed: (filter) => Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(CollectionLens( builder: (context) => CollectionPage(CollectionLens(
source: source, source: source,
filters: [filter], filters: [filter],
@ -63,7 +65,7 @@ class FilterNavigationPage extends StatelessWidget {
sortFactor: settings.collectionSortFactor, sortFactor: settings.collectionSortFactor,
)), )),
), ),
(route) => false, settings.navRemoveRoutePredicate(CollectionPage.routeName),
), ),
); );
} }
@ -95,66 +97,68 @@ class FilterGridPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
child: Scaffold( child: Scaffold(
body: SafeArea( body: DoubleBackPopScope(
child: Selector<MediaQueryData, double>( child: SafeArea(
selector: (c, mq) => mq.size.width, child: Selector<MediaQueryData, double>(
builder: (c, mqWidth, child) { selector: (c, mq) => mq.size.width,
final columnCount = (mqWidth / maxCrossAxisExtent).ceil(); builder: (c, mqWidth, child) {
return AnimationLimiter( final columnCount = (mqWidth / maxCrossAxisExtent).ceil();
child: CustomScrollView( return AnimationLimiter(
slivers: [ child: CustomScrollView(
appBar, slivers: [
filterKeys.isEmpty appBar,
? SliverFillRemaining( filterKeys.isEmpty
child: emptyBuilder(), ? SliverFillRemaining(
hasScrollBody: false, child: emptyBuilder(),
) hasScrollBody: false,
: SliverPadding( )
padding: EdgeInsets.all(AvesFilterChip.outlineWidth), : SliverPadding(
sliver: SliverGrid( padding: EdgeInsets.all(AvesFilterChip.outlineWidth),
delegate: SliverChildBuilderDelegate( sliver: SliverGrid(
(context, i) { delegate: SliverChildBuilderDelegate(
final key = filterKeys[i]; (context, i) {
final child = DecoratedFilterChip( final key = filterKeys[i];
source: source, final child = DecoratedFilterChip(
filter: filterBuilder(key), source: source,
entry: filterEntries[key], filter: filterBuilder(key),
onPressed: onPressed, entry: filterEntries[key],
); onPressed: onPressed,
return AnimationConfiguration.staggeredGrid( );
position: i, return AnimationConfiguration.staggeredGrid(
columnCount: columnCount, position: i,
duration: Durations.staggeredAnimation, columnCount: columnCount,
delay: Durations.staggeredAnimationDelay, duration: Durations.staggeredAnimation,
child: SlideAnimation( delay: Durations.staggeredAnimationDelay,
verticalOffset: 50.0, child: SlideAnimation(
child: FadeInAnimation( verticalOffset: 50.0,
child: child, child: FadeInAnimation(
child: child,
),
), ),
), );
); },
}, childCount: filterKeys.length,
childCount: filterKeys.length, ),
), gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent,
maxCrossAxisExtent: maxCrossAxisExtent, mainAxisSpacing: 8,
mainAxisSpacing: 8, crossAxisSpacing: 8,
crossAxisSpacing: 8, ),
), ),
), ),
), SliverToBoxAdapter(
SliverToBoxAdapter( child: Selector<MediaQueryData, double>(
child: Selector<MediaQueryData, double>( selector: (context, mq) => mq.viewInsets.bottom,
selector: (context, mq) => mq.viewInsets.bottom, builder: (context, mqViewInsetsBottom, child) {
builder: (context, mqViewInsetsBottom, child) { return SizedBox(height: mqViewInsetsBottom);
return SizedBox(height: mqViewInsetsBottom); },
}, ),
), ),
), ],
], ),
), );
); },
}, ),
), ),
), ),
drawer: AppDrawer( drawer: AppDrawer(

View file

@ -7,6 +7,8 @@ import 'package:aves/widgets/filter_grids/filter_grid_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class TagListPage extends StatelessWidget { class TagListPage extends StatelessWidget {
static const routeName = '/tags';
final CollectionSource source; final CollectionSource source;
const TagListPage({@required this.source}); const TagListPage({@required this.source});

View file

@ -10,6 +10,8 @@ import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
class FullscreenDebugPage extends StatefulWidget { class FullscreenDebugPage extends StatefulWidget {
static const routeName = '/fullscreen/debug';
final ImageEntry entry; final ImageEntry entry;
const FullscreenDebugPage({@required this.entry}); const FullscreenDebugPage({@required this.entry});

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
@ -275,9 +276,10 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(collection.derive(filter)), builder: (context) => CollectionPage(collection.derive(filter)),
), ),
(route) => false, settings.navRemoveRoutePredicate(CollectionPage.routeName),
); );
} }
@ -323,7 +325,7 @@ class FullscreenBodyState extends State<FullscreenBody> with SingleTickerProvide
} }
void _onLeave() { void _onLeave() {
if (!ModalRoute.of(context).canPop) { if (!Navigator.canPop(context)) {
// exit app when trying to pop a fullscreen page that is a viewer for a single entry // exit app when trying to pop a fullscreen page that is a viewer for a single entry
exit(0); exit(0);
} }

View file

@ -5,6 +5,8 @@ import 'package:aves/widgets/fullscreen/fullscreen_body.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class MultiFullscreenPage extends AnimatedWidget { class MultiFullscreenPage extends AnimatedWidget {
static const routeName = '/fullscreen';
final CollectionLens collection; final CollectionLens collection;
final ImageEntry initialEntry; final ImageEntry initialEntry;
@ -30,6 +32,8 @@ class MultiFullscreenPage extends AnimatedWidget {
} }
class SingleFullscreenPage extends StatelessWidget { class SingleFullscreenPage extends StatelessWidget {
static const routeName = '/fullscreen';
final ImageEntry entry; final ImageEntry entry;
const SingleFullscreenPage({ const SingleFullscreenPage({

View file

@ -1,5 +1,5 @@
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/album/empty.dart'; import 'package:aves/widgets/album/empty.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart';

View file

@ -1,6 +1,7 @@
import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/location.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart'; import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';

View file

@ -1,6 +1,6 @@
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/widgets/common/action_delegates/map_style_dialog.dart'; import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/borders.dart'; import 'package:aves/widgets/common/borders.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
@ -70,7 +70,11 @@ class MapButtonPanel extends StatelessWidget {
onPressed: () async { onPressed: () async {
final style = await showDialog<EntryMapStyle>( final style = await showDialog<EntryMapStyle>(
context: context, context: context,
builder: (context) => MapStyleDialog(), builder: (context) => AvesSelectionDialog<EntryMapStyle>(
initialValue: settings.infoMapStyle,
options: Map.fromEntries(EntryMapStyle.values.map((v) => MapEntry(v, v.name))),
title: 'Map Style',
),
); );
if (style != null) { if (style != null) {
settings.infoMapStyle = style; settings.infoMapStyle = style;

View file

@ -1,4 +1,4 @@
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart';
import 'package:aves/widgets/fullscreen/info/maps/common.dart'; import 'package:aves/widgets/fullscreen/info/maps/common.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -1,4 +1,4 @@
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/fullscreen/info/maps/common.dart'; import 'package:aves/widgets/fullscreen/info/maps/common.dart';
import 'package:aves/widgets/fullscreen/info/maps/scale_layer.dart'; import 'package:aves/widgets/fullscreen/info/maps/scale_layer.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';

View file

@ -3,8 +3,7 @@ import 'dart:collection';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/common/fx/highlight_decoration.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:expansion_tile_card/expansion_tile_card.dart'; import 'package:expansion_tile_card/expansion_tile_card.dart';
@ -90,7 +89,10 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
key: Key('tilecard-${dir.name}'), key: Key('tilecard-${dir.name}'),
value: dir.name, value: dir.name,
expandedNotifier: _expandedDirectoryNotifier, expandedNotifier: _expandedDirectoryNotifier,
title: _DirectoryTitle(dir.name), title: HighlightTitle(
dir.name,
fontSize: 18,
),
children: [ children: [
Divider(thickness: 1.0, height: 1.0), Divider(thickness: 1.0, height: 1.0),
Container( Container(
@ -138,42 +140,6 @@ class _MetadataSectionSliverState extends State<MetadataSectionSliver> with Auto
bool get wantKeepAlive => true; bool get wantKeepAlive => true;
} }
class _DirectoryTitle extends StatelessWidget {
final String name;
const _DirectoryTitle(this.name);
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
decoration: HighlightDecoration(
color: stringToColor(name),
),
margin: EdgeInsets.symmetric(vertical: 4.0),
child: Text(
name,
style: TextStyle(
shadows: [
Shadow(
color: Colors.black,
offset: Offset(1, 1),
blurRadius: 2,
)
],
fontSize: 18,
fontFamily: 'Concourse Caps',
),
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
),
);
}
}
class _MetadataDirectory { class _MetadataDirectory {
final String name; final String name;
final SplayTreeMap<String, String> tags; final SplayTreeMap<String, String> tags;

View file

@ -3,7 +3,8 @@ import 'dart:ui';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/fx/blurred.dart'; import 'package:aves/widgets/common/fx/blurred.dart';

View file

@ -126,7 +126,7 @@ class _TopOverlayRow extends StatelessWidget {
children: [ children: [
OverlayButton( OverlayButton(
scale: scale, scale: scale,
child: ModalRoute.of(context)?.canPop ?? true ? BackButton() : CloseButton(), child: Navigator.canPop(context) ? BackButton() : CloseButton(),
), ),
Spacer(), Spacer(),
...quickActions.map(_buildOverlayButton), ...quickActions.map(_buildOverlayButton),

View file

@ -1,13 +1,14 @@
import 'package:aves/main.dart'; import 'package:aves/main.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/image_file_service.dart'; import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/viewer_service.dart'; import 'package:aves/services/viewer_service.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart'; import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/routes.dart';
import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart'; import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -17,6 +18,8 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:screen/screen.dart'; import 'package:screen/screen.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
static const routeName = '/';
const HomePage(); const HomePage();
@override @override
@ -26,16 +29,18 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
MediaStoreSource _mediaStore; MediaStoreSource _mediaStore;
ImageEntry _viewerEntry; ImageEntry _viewerEntry;
Future<void> _appSetup;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_appSetup = _setup(); _setup();
imageCache.maximumSizeBytes = 512 * (1 << 20); imageCache.maximumSizeBytes = 512 * (1 << 20);
Screen.keepOn(true); Screen.keepOn(true);
} }
@override
Widget build(BuildContext context) => Scaffold();
Future<void> _setup() async { Future<void> _setup() async {
final permissions = await [ final permissions = await [
Permission.storage, Permission.storage,
@ -80,6 +85,8 @@ class _HomePageState extends State<HomePage> {
await _mediaStore.init(); await _mediaStore.init();
unawaited(_mediaStore.refresh()); unawaited(_mediaStore.refresh());
} }
unawaited(Navigator.pushReplacement(context, _getRedirectRoute()));
} }
Future<ImageEntry> _initViewerEntry({@required String uri, @required String mimeType}) async { Future<ImageEntry> _initViewerEntry({@required String uri, @required String mimeType}) async {
@ -92,30 +99,36 @@ class _HomePageState extends State<HomePage> {
return entry; return entry;
} }
@override Route _getRedirectRoute() {
Widget build(BuildContext context) { switch (AvesApp.mode) {
return FutureBuilder<void>( case AppMode.view:
future: _appSetup, return DirectMaterialPageRoute(
builder: (context, snapshot) { settings: RouteSettings(name: SingleFullscreenPage.routeName),
if (snapshot.hasError) return Icon(AIcons.error); builder: (_) => SingleFullscreenPage(entry: _viewerEntry),
if (snapshot.connectionState != ConnectionState.done) return Scaffold(); );
if (AvesApp.mode == AppMode.view) { case AppMode.main:
return SingleFullscreenPage(entry: _viewerEntry); case AppMode.pick:
if (_mediaStore != null) {
switch (settings.homePage) {
case HomePageSetting.albums:
return DirectMaterialPageRoute(
settings: RouteSettings(name: AlbumListPage.routeName),
builder: (_) => AlbumListPage(source: _mediaStore),
);
case HomePageSetting.collection:
return DirectMaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
builder: (_) => CollectionPage(
CollectionLens(
source: _mediaStore,
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
),
),
);
} }
if (_mediaStore != null) { }
switch (settings.launchPage) { }
case LaunchPage.albums: return null;
return AlbumListPage(source: _mediaStore);
break;
case LaunchPage.collection:
return CollectionPage(CollectionLens(
source: _mediaStore,
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
));
}
}
return SizedBox.shrink();
});
} }
} }

View file

@ -1,31 +0,0 @@
import 'package:aves/model/settings.dart';
import 'package:flutter/material.dart';
class CoordinateFormatSelector extends StatefulWidget {
@override
_CoordinateFormatSelectorState createState() => _CoordinateFormatSelectorState();
}
class _CoordinateFormatSelectorState extends State<CoordinateFormatSelector> {
@override
Widget build(BuildContext context) {
return DropdownButton<CoordinateFormat>(
items: CoordinateFormat.values
.map((selected) => DropdownMenuItem(
value: selected,
child: Text(
selected.name,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
))
.toList(),
value: settings.coordinateFormat,
onChanged: (selected) {
settings.coordinateFormat = selected;
setState(() {});
},
);
}
}

View file

@ -1,31 +0,0 @@
import 'package:aves/model/settings.dart';
import 'package:flutter/material.dart';
class LaunchPageSelector extends StatefulWidget {
@override
_LaunchPageSelectorState createState() => _LaunchPageSelectorState();
}
class _LaunchPageSelectorState extends State<LaunchPageSelector> {
@override
Widget build(BuildContext context) {
return DropdownButton<LaunchPage>(
items: LaunchPage.values
.map((selected) => DropdownMenuItem(
value: selected,
child: Text(
selected.name,
softWrap: false,
overflow: TextOverflow.fade,
maxLines: 1,
),
))
.toList(),
value: settings.launchPage,
onChanged: (selected) {
settings.launchPage = selected;
setState(() {});
},
);
}
}

View file

@ -1,11 +1,16 @@
import 'package:aves/utils/constants.dart'; import 'package:aves/model/settings/coordinate_format.dart';
import 'package:aves/model/settings/home_page.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/aves_selection_dialog.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/settings/coordinate_format.dart'; import 'package:aves/widgets/common/highlight_title.dart';
import 'package:aves/widgets/settings/launch_page.dart';
import 'package:aves/widgets/settings/svg_background.dart'; import 'package:aves/widgets/settings/svg_background.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SettingsPage extends StatelessWidget { class SettingsPage extends StatelessWidget {
static const routeName = '/settings';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MediaQueryDataProvider( return MediaQueryDataProvider(
@ -13,38 +18,60 @@ class SettingsPage extends StatelessWidget {
length: 4, length: 4,
child: Scaffold( child: Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text('Preferences'), title: Text('Settings'),
), ),
body: SafeArea( body: SafeArea(
child: ListView( child: Consumer<Settings>(
padding: EdgeInsets.all(16), builder: (context, settings, child) => ListView(
children: [ padding: EdgeInsets.symmetric(vertical: 16),
Text('General', style: Constants.titleTextStyle), children: [
Row( SectionTitle('Navigation'),
mainAxisSize: MainAxisSize.min, ListTile(
children: [ title: Text('Home'),
Text('Launch page:'), subtitle: Text(settings.homePage.name),
SizedBox(width: 8), onTap: () async {
Flexible(child: LaunchPageSelector()), final value = await showDialog<HomePageSetting>(
], context: context,
), builder: (context) => AvesSelectionDialog<HomePageSetting>(
Row( initialValue: settings.homePage,
mainAxisSize: MainAxisSize.min, options: Map.fromEntries(HomePageSetting.values.map((v) => MapEntry(v, v.name))),
children: [ title: 'Home',
Text('SVG background:'), ),
SizedBox(width: 8), );
Flexible(child: SvgBackgroundSelector()), if (value != null) {
], settings.homePage = value;
), }
Row( },
mainAxisSize: MainAxisSize.min, ),
children: [ SwitchListTile(
Text('Coordinate format:'), value: settings.mustBackTwiceToExit,
SizedBox(width: 8), onChanged: (v) => settings.mustBackTwiceToExit = v,
Flexible(child: CoordinateFormatSelector()), title: Text('Tap “back” twice to exit'),
], ),
), SectionTitle('Display'),
], ListTile(
title: Text('SVG background'),
trailing: SvgBackgroundSelector(),
),
ListTile(
title: Text('Coordinate format'),
subtitle: Text(settings.coordinateFormat.name),
onTap: () async {
final value = await showDialog<CoordinateFormat>(
context: context,
builder: (context) => AvesSelectionDialog<CoordinateFormat>(
initialValue: settings.coordinateFormat,
options: Map.fromEntries(CoordinateFormat.values.map((v) => MapEntry(v, v.name))),
title: 'Coordinate Format',
),
);
if (value != null) {
settings.coordinateFormat = value;
}
},
),
],
),
), ),
), ),
), ),
@ -52,3 +79,17 @@ class SettingsPage extends StatelessWidget {
); );
} }
} }
class SectionTitle extends StatelessWidget {
final String text;
const SectionTitle(this.text);
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(left: 16, top: 6, right: 16, bottom: 12),
child: HighlightTitle(text),
);
}
}

View file

@ -1,4 +1,4 @@
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/widgets/common/borders.dart'; import 'package:aves/widgets/common/borders.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -11,33 +11,35 @@ class _SvgBackgroundSelectorState extends State<SvgBackgroundSelector> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const radius = 24.0; const radius = 24.0;
return DropdownButton<int>( return DropdownButtonHideUnderline(
items: [0xFFFFFFFF, 0xFF000000, 0x00000000].map((selected) { child: DropdownButton<int>(
return DropdownMenuItem( items: [0xFFFFFFFF, 0xFF000000, 0x00000000].map((selected) {
value: selected, return DropdownMenuItem<int>(
child: Container( value: selected,
height: radius, child: Container(
width: radius, height: radius,
decoration: BoxDecoration( width: radius,
color: Color(selected), decoration: BoxDecoration(
border: AvesCircleBorder.build(context), color: Color(selected),
shape: BoxShape.circle, border: AvesCircleBorder.build(context),
shape: BoxShape.circle,
),
child: selected == 0
? Icon(
Icons.clear,
size: 20,
color: Colors.white30,
)
: null,
), ),
child: selected == 0 );
? Icon( }).toList(),
Icons.clear, value: settings.svgBackground,
size: 20, onChanged: (selected) {
color: Colors.white30, settings.svgBackground = selected;
) setState(() {});
: null, },
), ),
);
}).toList(),
value: settings.svgBackground,
onChanged: (selected) {
settings.svgBackground = selected;
setState(() {});
},
); );
} }
} }

View file

@ -1,4 +1,5 @@
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/album/collection_page.dart'; import 'package:aves/widgets/album/collection_page.dart';
@ -90,9 +91,10 @@ class FilterTable extends StatelessWidget {
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(collection.derive(filter)), builder: (context) => CollectionPage(collection.derive(filter)),
), ),
(route) => false, settings.navRemoveRoutePredicate(CollectionPage.routeName),
); );
} }
} }

View file

@ -5,6 +5,7 @@ import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart'; import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart'; import 'package:aves/utils/color_utils.dart';
import 'package:aves/utils/constants.dart'; import 'package:aves/utils/constants.dart';
@ -20,6 +21,8 @@ import 'package:intl/intl.dart';
import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:percent_indicator/linear_percent_indicator.dart';
class StatsPage extends StatelessWidget { class StatsPage extends StatelessWidget {
static const routeName = '/collection/stats';
final CollectionLens collection; final CollectionLens collection;
final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {}; final Map<String, int> entryCountPerCountry = {}, entryCountPerPlace = {}, entryCountPerTag = {};
@ -236,9 +239,10 @@ class StatsPage extends StatelessWidget {
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(collection.derive(filter)), builder: (context) => CollectionPage(collection.derive(filter)),
), ),
(route) => false, settings.navRemoveRoutePredicate(CollectionPage.routeName),
); );
} }
} }

View file

@ -1,4 +1,4 @@
import 'package:aves/model/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/utils/durations.dart'; import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_logo.dart'; import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/labeled_checkbox.dart'; import 'package:aves/widgets/common/labeled_checkbox.dart';
@ -104,12 +104,12 @@ class _WelcomePageState extends State<WelcomePage> {
onPressed: _hasAcceptedTerms onPressed: _hasAcceptedTerms
? () { ? () {
settings.hasAcceptedTerms = true; settings.hasAcceptedTerms = true;
Navigator.pushAndRemoveUntil( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: RouteSettings(name: HomePage.routeName),
builder: (context) => HomePage(), builder: (context) => HomePage(),
), ),
(route) => false,
); );
} }
: null, : null,

View file

@ -466,6 +466,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.1.1" version: "0.1.1"
overlay_support:
dependency: "direct main"
description:
name: overlay_support
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.5"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:

View file

@ -15,7 +15,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
# Read more about iOS versioning at # Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
version: 1.1.8+20 version: 1.1.9+21
# video_player (as of v0.10.8+2, backed by ExoPlayer): # video_player (as of v0.10.8+2, backed by ExoPlayer):
# - does not support content URIs (by default, but trivial by fork) # - does not support content URIs (by default, but trivial by fork)
@ -63,6 +63,7 @@ dependencies:
intl: intl:
latlong: # for flutter_map latlong: # for flutter_map
outline_material_icons: outline_material_icons:
overlay_support:
package_info: package_info:
palette_generator: palette_generator:
pdf: pdf:

View file

@ -56,7 +56,7 @@ void agreeToTerms() {
await driver.tap(find.byValueKey('continue-button')); await driver.tap(find.byValueKey('continue-button'));
await driver.waitUntilNoTransientCallbacks(); await driver.waitUntilNoTransientCallbacks();
expect(await driver.getText(find.byValueKey('appbar-title')), 'Aves'); expect(await driver.getText(find.byValueKey('appbar-title')), 'Collection');
}); });
} }
@ -91,7 +91,7 @@ void selectFirstAlbum() {
await driver.tap(find.byValueKey('appbar-leading-button')); await driver.tap(find.byValueKey('appbar-leading-button'));
await driver.waitUntilNoTransientCallbacks(); await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.byValueKey('albums-tile')); await driver.tap(find.byValueKey('Albums-tile'));
await driver.waitUntilNoTransientCallbacks(); await driver.waitUntilNoTransientCallbacks();
await driver.tap(find.descendant( await driver.tap(find.descendant(