Merge branch 'develop'
This commit is contained in:
commit
8b4ca6fe83
21 changed files with 1158 additions and 234 deletions
|
@ -1,4 +1,6 @@
|
|||
// run `scripts/update_flutter_version.sh` to update with the content of `flutter --version --machine`
|
||||
// note on static analysis: the output of the script above yields double quotes, just like the example below
|
||||
// ignore_for_file: prefer_single_quotes
|
||||
const Map<String, String> version = {
|
||||
"channel": "unknown",
|
||||
"dartSdkVersion": "unknown",
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
@ -15,12 +16,14 @@ class Settings {
|
|||
Settings._private();
|
||||
|
||||
// preferences
|
||||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
static const collectionGroupFactorKey = 'collection_group_factor';
|
||||
static const collectionSortFactorKey = 'collection_sort_factor';
|
||||
static const collectionTileExtentKey = 'collection_tile_extent';
|
||||
static const infoMapZoomKey = 'info_map_zoom';
|
||||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
static const hasAcceptedTermsKey = 'has_accepted_terms';
|
||||
static const infoMapStyleKey = 'info_map_style';
|
||||
static const infoMapZoomKey = 'info_map_zoom';
|
||||
static const launchPageKey = 'launch_page';
|
||||
|
||||
Future<void> init() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
|
@ -50,10 +53,6 @@ class Settings {
|
|||
}
|
||||
}
|
||||
|
||||
double get infoMapZoom => _prefs.getDouble(infoMapZoomKey) ?? 12;
|
||||
|
||||
set infoMapZoom(double newValue) => setAndNotify(infoMapZoomKey, newValue);
|
||||
|
||||
String get catalogTimeZone => _prefs.getString(catalogTimeZoneKey) ?? '';
|
||||
|
||||
set catalogTimeZone(String newValue) => setAndNotify(catalogTimeZoneKey, newValue);
|
||||
|
@ -70,10 +69,22 @@ class Settings {
|
|||
|
||||
set collectionTileExtent(double newValue) => setAndNotify(collectionTileExtentKey, newValue);
|
||||
|
||||
EntryMapStyle get infoMapStyle => getEnumOrDefault(infoMapStyleKey, EntryMapStyle.stamenWatercolor, EntryMapStyle.values);
|
||||
|
||||
set infoMapStyle(EntryMapStyle newValue) => setAndNotify(infoMapStyleKey, newValue.toString());
|
||||
|
||||
double get infoMapZoom => _prefs.getDouble(infoMapZoomKey) ?? 12;
|
||||
|
||||
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());
|
||||
|
||||
// convenience methods
|
||||
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
|
@ -118,3 +129,18 @@ class Settings {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -65,7 +65,7 @@ class GridThumbnail extends StatelessWidget {
|
|||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
if (AvesApp.mode == AppMode.main && collection.isBrowsing) {
|
||||
if (AvesApp.mode == AppMode.main) {
|
||||
collection.toggleSelection(entry);
|
||||
}
|
||||
},
|
||||
|
|
|
@ -3,9 +3,7 @@ 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/location.dart';
|
||||
import 'package:aves/model/filters/mime.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/mime_types.dart';
|
||||
import 'package:aves/model/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
|
@ -16,11 +14,11 @@ 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/album/empty.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_grid_page.dart';
|
||||
import 'package:aves/widgets/settings_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -93,13 +91,22 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
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: () => _goToAbout(context),
|
||||
onTap: () => _goTo((_) => AboutPage()),
|
||||
),
|
||||
);
|
||||
|
||||
|
@ -114,6 +121,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
_buildCountrySection(),
|
||||
_buildTagSection(),
|
||||
Divider(),
|
||||
settingsEntry,
|
||||
aboutEntry,
|
||||
if (kDebugMode) ...[
|
||||
Divider(),
|
||||
|
@ -123,7 +131,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
child: ListTile(
|
||||
leading: Icon(AIcons.debug),
|
||||
title: Text('Debug'),
|
||||
onTap: () => _goToDebug(context),
|
||||
onTap: () => _goTo((_) => DebugPage(source: source)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -204,7 +212,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
),
|
||||
);
|
||||
}),
|
||||
onTap: () => _goToAlbums(context),
|
||||
onTap: () => _goTo((_) => AlbumListPage(source: source)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -226,7 +234,7 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
),
|
||||
);
|
||||
}),
|
||||
onTap: () => _goToCountries(context),
|
||||
onTap: () => _goTo((_) => CountryListPage(source: source)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -248,88 +256,14 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
),
|
||||
);
|
||||
}),
|
||||
onTap: () => _goToTags(context),
|
||||
onTap: () => _goTo((_) => TagListPage(source: source)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToAlbums(BuildContext context) {
|
||||
void _goTo(WidgetBuilder builder) {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Albums',
|
||||
filterEntries: source.getAlbumEntries(),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToCountries(BuildContext context) {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Countries',
|
||||
filterEntries: source.getCountryEntries(),
|
||||
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
text: 'No countries',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToTags(BuildContext context) {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Tags',
|
||||
filterEntries: source.getTagEntries(),
|
||||
filterBuilder: (s) => TagFilter(s),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
text: 'No tags',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToAbout(BuildContext context) {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => AboutPage(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _goToDebug(BuildContext context) {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => DebugPage(
|
||||
source: source,
|
||||
),
|
||||
),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: builder));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
51
lib/widgets/common/action_delegates/map_style_dialog.dart
Normal file
51
lib/widgets/common/action_delegates/map_style_dialog.dart
Normal file
|
@ -0,0 +1,51 @@
|
|||
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,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -35,9 +35,7 @@ class AvesDialog extends AlertDialog {
|
|||
actions: actions,
|
||||
actionsPadding: EdgeInsets.symmetric(horizontal: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(24),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ class BlurredRRect extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.all(Radius.circular(borderRadius)),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 4, sigmaY: 4),
|
||||
child: child,
|
||||
|
|
|
@ -19,6 +19,7 @@ class AIcons {
|
|||
static const IconData location = OMIcons.place;
|
||||
static const IconData shooting = OMIcons.camera;
|
||||
static const IconData removableStorage = OMIcons.sdStorage;
|
||||
static const IconData settings = OMIcons.settings;
|
||||
static const IconData text = OMIcons.formatQuote;
|
||||
static const IconData tag = OMIcons.localOffer;
|
||||
|
||||
|
@ -45,6 +46,7 @@ class AIcons {
|
|||
static const IconData share = OMIcons.share;
|
||||
static const IconData sort = OMIcons.sort;
|
||||
static const IconData stats = OMIcons.pieChart;
|
||||
static const IconData style = OMIcons.palette;
|
||||
static const IconData zoomIn = OMIcons.add;
|
||||
static const IconData zoomOut = OMIcons.remove;
|
||||
|
||||
|
@ -136,9 +138,7 @@ class OverlayIcon extends StatelessWidget {
|
|||
padding: text != null ? EdgeInsets.only(right: size / 4) : null,
|
||||
decoration: BoxDecoration(
|
||||
color: Color(0xBB000000),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(size),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(size),
|
||||
),
|
||||
child: text == null
|
||||
? iconChild
|
||||
|
|
|
@ -12,9 +12,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
|
|||
final scrollThumb = Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12.0),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
height: height,
|
||||
margin: EdgeInsets.only(right: .5),
|
||||
|
@ -24,9 +22,7 @@ ScrollThumbBuilder avesScrollThumbBuilder({
|
|||
width: 20.0,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12.0),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12.0),
|
||||
),
|
||||
),
|
||||
clipper: ArrowClipper(),
|
||||
|
|
|
@ -2,13 +2,19 @@ import 'dart:ui';
|
|||
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/filters/location.dart';
|
||||
import 'package:aves/model/filters/tag.dart';
|
||||
import 'package:aves/model/image_entry.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/utils/durations.dart';
|
||||
import 'package:aves/widgets/album/collection_page.dart';
|
||||
import 'package:aves/widgets/album/empty.dart';
|
||||
import 'package:aves/widgets/album/thumbnail/raster.dart';
|
||||
import 'package:aves/widgets/album/thumbnail/vector.dart';
|
||||
import 'package:aves/widgets/app_drawer.dart';
|
||||
|
@ -20,6 +26,75 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class AlbumListPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
|
||||
const AlbumListPage({@required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Albums',
|
||||
filterEntries: source.getAlbumEntries(),
|
||||
filterBuilder: (s) => AlbumFilter(s, source.getUniqueAlbumName(s)),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.album,
|
||||
text: 'No albums',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CountryListPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
|
||||
const CountryListPage({@required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<LocationsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Countries',
|
||||
filterEntries: source.getCountryEntries(),
|
||||
filterBuilder: (s) => LocationFilter(LocationLevel.country, s),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.location,
|
||||
text: 'No countries',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TagListPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
|
||||
const TagListPage({@required this.source});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: source.eventBus.on<TagsChangedEvent>(),
|
||||
builder: (context, snapshot) => FilterNavigationPage(
|
||||
source: source,
|
||||
title: 'Tags',
|
||||
filterEntries: source.getTagEntries(),
|
||||
filterBuilder: (s) => TagFilter(s),
|
||||
emptyBuilder: () => EmptyContent(
|
||||
icon: AIcons.tag,
|
||||
text: 'No tags',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FilterNavigationPage extends StatelessWidget {
|
||||
final CollectionSource source;
|
||||
final String title;
|
||||
|
@ -48,7 +123,12 @@ class FilterNavigationPage extends StatelessWidget {
|
|||
),
|
||||
filterEntries: filterEntries,
|
||||
filterBuilder: filterBuilder,
|
||||
emptyBuilder: emptyBuilder,
|
||||
emptyBuilder: () => ValueListenableBuilder<SourceState>(
|
||||
valueListenable: source.stateNotifier,
|
||||
builder: (context, sourceState, child) {
|
||||
return sourceState != SourceState.loading && emptyBuilder != null ? emptyBuilder() : SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
onPressed: (filter) => Navigator.pushAndRemoveUntil(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
|
|
|
@ -2,13 +2,14 @@ import 'package:aves/model/filters/location.dart';
|
|||
import 'package:aves/model/image_entry.dart';
|
||||
import 'package:aves/model/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/utils/geo_utils.dart';
|
||||
import 'package:aves/widgets/common/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/info_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/maps/buttons.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/maps/google_map.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
|
||||
class LocationSection extends StatefulWidget {
|
||||
final CollectionLens collection;
|
||||
|
@ -94,14 +95,24 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
padding: EdgeInsets.only(bottom: 8),
|
||||
child: SectionRow(AIcons.location),
|
||||
),
|
||||
ImageMap(
|
||||
markerId: entry.uri ?? entry.path,
|
||||
latLng: LatLng(
|
||||
entry.latLng.item1,
|
||||
entry.latLng.item2,
|
||||
),
|
||||
geoUri: entry.geoUri,
|
||||
initialZoom: settings.infoMapZoom,
|
||||
NotificationListener(
|
||||
onNotification: (notification) {
|
||||
if (notification is MapStyleChangedNotification) setState(() {});
|
||||
return false;
|
||||
},
|
||||
child: settings.infoMapStyle == EntryMapStyle.google
|
||||
? EntryGoogleMap(
|
||||
markerId: entry.uri ?? entry.path,
|
||||
latLng: entry.latLng,
|
||||
geoUri: entry.geoUri,
|
||||
initialZoom: settings.infoMapZoom,
|
||||
)
|
||||
: EntryLeafletMap(
|
||||
latLng: entry.latLng,
|
||||
geoUri: entry.geoUri,
|
||||
initialZoom: settings.infoMapZoom,
|
||||
style: settings.infoMapStyle,
|
||||
),
|
||||
),
|
||||
if (location.isNotEmpty)
|
||||
Padding(
|
||||
|
@ -133,113 +144,22 @@ class _LocationSectionState extends State<LocationSection> {
|
|||
void _handleChange() => setState(() {});
|
||||
}
|
||||
|
||||
class ImageMap extends StatefulWidget {
|
||||
final String markerId;
|
||||
final LatLng latLng;
|
||||
final String geoUri;
|
||||
final double initialZoom;
|
||||
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
|
||||
enum EntryMapStyle { google, osmHot, stamenToner, stamenWatercolor }
|
||||
|
||||
const ImageMap({
|
||||
Key key,
|
||||
this.markerId,
|
||||
this.latLng,
|
||||
this.geoUri,
|
||||
this.initialZoom,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => ImageMapState();
|
||||
}
|
||||
|
||||
class ImageMapState extends State<ImageMap> with AutomaticKeepAliveClientMixin {
|
||||
GoogleMapController _controller;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ImageMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.latLng != oldWidget.latLng && _controller != null) {
|
||||
_controller.moveCamera(CameraUpdate.newLatLng(widget.latLng));
|
||||
extension ExtraEntryMapStyle on EntryMapStyle {
|
||||
String get name {
|
||||
switch (this) {
|
||||
case EntryMapStyle.google:
|
||||
return 'Google Maps';
|
||||
case EntryMapStyle.osmHot:
|
||||
return 'Humanitarian OpenStreetMap';
|
||||
case EntryMapStyle.stamenToner:
|
||||
return 'Stamen Toner';
|
||||
case EntryMapStyle.stamenWatercolor:
|
||||
return 'Stamen Watercolor';
|
||||
default:
|
||||
return toString();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
final accentHue = HSVColor.fromColor(Theme.of(context).accentColor).hue;
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onScaleStart: (details) {
|
||||
// absorb scale gesture here to prevent scrolling
|
||||
// and triggering by mistake a move to the image page above
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
height: 200,
|
||||
child: GoogleMap(
|
||||
// GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: widget.latLng,
|
||||
zoom: widget.initialZoom,
|
||||
),
|
||||
onMapCreated: (controller) => setState(() => _controller = controller),
|
||||
rotateGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
liteModeEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
markers: {
|
||||
Marker(
|
||||
markerId: MarkerId(widget.markerId),
|
||||
icon: BitmapDescriptor.defaultMarkerWithHue(accentHue),
|
||||
position: widget.latLng,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Column(children: [
|
||||
IconButton(
|
||||
icon: Icon(AIcons.zoomIn),
|
||||
onPressed: _controller == null ? null : () => _zoomBy(1),
|
||||
tooltip: 'Zoom in',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(AIcons.zoomOut),
|
||||
onPressed: _controller == null ? null : () => _zoomBy(-1),
|
||||
tooltip: 'Zoom out',
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(AIcons.openInNew),
|
||||
onPressed: () => AndroidAppService.openMap(widget.geoUri),
|
||||
tooltip: 'Show on map...',
|
||||
),
|
||||
]),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomBy(double amount) {
|
||||
settings.infoMapZoom += amount;
|
||||
_controller.animateCamera(CameraUpdate.zoomBy(amount));
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
|
112
lib/widgets/fullscreen/info/maps/buttons.dart
Normal file
112
lib/widgets/fullscreen/info/maps/buttons.dart
Normal file
|
@ -0,0 +1,112 @@
|
|||
import 'package:aves/model/settings.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/fx/blurred.dart';
|
||||
import 'package:aves/widgets/common/icons.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/location_section.dart';
|
||||
import 'package:aves/widgets/fullscreen/overlay/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MapButtonPanel extends StatelessWidget {
|
||||
final String geoUri;
|
||||
final void Function(double amount) zoomBy;
|
||||
|
||||
static const BorderRadius mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
|
||||
static const double padding = 4;
|
||||
|
||||
const MapButtonPanel({
|
||||
@required this.geoUri,
|
||||
@required this.zoomBy,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned.fill(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(padding),
|
||||
child: TooltipTheme(
|
||||
data: TooltipTheme.of(context).copyWith(
|
||||
preferBelow: false,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MapOverlayButton(
|
||||
icon: AIcons.style,
|
||||
onPressed: () async {
|
||||
final style = await showDialog<EntryMapStyle>(
|
||||
context: context,
|
||||
builder: (context) => MapStyleDialog(),
|
||||
);
|
||||
if (style != null) {
|
||||
settings.infoMapStyle = style;
|
||||
MapStyleChangedNotification().dispatch(context);
|
||||
}
|
||||
},
|
||||
tooltip: 'Style map...',
|
||||
),
|
||||
SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.openInNew,
|
||||
onPressed: () => AndroidAppService.openMap(geoUri),
|
||||
tooltip: 'Show on map...',
|
||||
),
|
||||
Spacer(),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.zoomIn,
|
||||
onPressed: () => zoomBy(1),
|
||||
tooltip: 'Zoom in',
|
||||
),
|
||||
SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.zoomOut,
|
||||
onPressed: () => zoomBy(-1),
|
||||
tooltip: 'Zoom out',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MapOverlayButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const MapOverlayButton({
|
||||
@required this.icon,
|
||||
@required this.tooltip,
|
||||
@required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlurredOval(
|
||||
child: Material(
|
||||
type: MaterialType.circle,
|
||||
color: FullscreenOverlay.backgroundColor,
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
border: FullscreenOverlay.buildBorder(context),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: IconButton(
|
||||
iconSize: 20,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(icon),
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MapStyleChangedNotification extends Notification {}
|
109
lib/widgets/fullscreen/info/maps/google_map.dart
Normal file
109
lib/widgets/fullscreen/info/maps/google_map.dart
Normal file
|
@ -0,0 +1,109 @@
|
|||
import 'package:aves/model/settings.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/maps/buttons.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class EntryGoogleMap extends StatefulWidget {
|
||||
final String markerId;
|
||||
final LatLng latLng;
|
||||
final String geoUri;
|
||||
final double initialZoom;
|
||||
|
||||
EntryGoogleMap({
|
||||
Key key,
|
||||
this.markerId,
|
||||
Tuple2<double, double> latLng,
|
||||
this.geoUri,
|
||||
this.initialZoom,
|
||||
}) : latLng = LatLng(latLng.item1, latLng.item2),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => EntryGoogleMapState();
|
||||
}
|
||||
|
||||
class EntryGoogleMapState extends State<EntryGoogleMap> with AutomaticKeepAliveClientMixin {
|
||||
GoogleMapController _controller;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(EntryGoogleMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.latLng != oldWidget.latLng && _controller != null) {
|
||||
_controller.moveCamera(CameraUpdate.newLatLng(widget.latLng));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_controller?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Stack(
|
||||
children: [
|
||||
_buildMap(),
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
child: MapButtonPanel(
|
||||
geoUri: widget.geoUri,
|
||||
zoomBy: _zoomBy,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMap() {
|
||||
final accentHue = HSVColor.fromColor(Theme.of(context).accentColor).hue;
|
||||
return GestureDetector(
|
||||
onScaleStart: (details) {
|
||||
// absorb scale gesture here to prevent scrolling
|
||||
// and triggering by mistake a move to the image page above
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: MapButtonPanel.mapBorderRadius,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
height: 200,
|
||||
child: GoogleMap(
|
||||
// GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493
|
||||
initialCameraPosition: CameraPosition(
|
||||
target: widget.latLng,
|
||||
zoom: widget.initialZoom,
|
||||
),
|
||||
onMapCreated: (controller) => setState(() => _controller = controller),
|
||||
rotateGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
zoomControlsEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
liteModeEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
myLocationButtonEnabled: false,
|
||||
markers: {
|
||||
Marker(
|
||||
markerId: MarkerId(widget.markerId),
|
||||
icon: BitmapDescriptor.defaultMarkerWithHue(accentHue),
|
||||
position: widget.latLng,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomBy(double amount) {
|
||||
settings.infoMapZoom += amount;
|
||||
_controller.animateCamera(CameraUpdate.zoomBy(amount));
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
218
lib/widgets/fullscreen/info/maps/leaflet_map.dart
Normal file
218
lib/widgets/fullscreen/info/maps/leaflet_map.dart
Normal file
|
@ -0,0 +1,218 @@
|
|||
import 'package:aves/model/settings.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/maps/buttons.dart';
|
||||
import 'package:aves/widgets/fullscreen/info/maps/scale_layer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:latlong/latlong.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../location_section.dart';
|
||||
|
||||
class EntryLeafletMap extends StatefulWidget {
|
||||
final LatLng latLng;
|
||||
final String geoUri;
|
||||
final double initialZoom;
|
||||
final EntryMapStyle style;
|
||||
|
||||
EntryLeafletMap({
|
||||
Key key,
|
||||
Tuple2<double, double> latLng,
|
||||
this.geoUri,
|
||||
this.initialZoom,
|
||||
this.style,
|
||||
}) : latLng = LatLng(latLng.item1, latLng.item2),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => EntryLeafletMapState();
|
||||
}
|
||||
|
||||
class EntryLeafletMapState extends State<EntryLeafletMap> with AutomaticKeepAliveClientMixin, TickerProviderStateMixin {
|
||||
final MapController _mapController = MapController();
|
||||
|
||||
static const markerSize = 40.0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(EntryLeafletMap oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.latLng != oldWidget.latLng && _mapController != null) {
|
||||
_mapController.move(widget.latLng, settings.infoMapZoom);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
_buildMap(),
|
||||
MapButtonPanel(
|
||||
geoUri: widget.geoUri,
|
||||
zoomBy: _zoomBy,
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildAttribution(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMap() {
|
||||
return GestureDetector(
|
||||
onScaleStart: (details) {
|
||||
// absorb scale gesture here to prevent scrolling
|
||||
// and triggering by mistake a move to the image page above
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: MapButtonPanel.mapBorderRadius,
|
||||
child: Container(
|
||||
color: Colors.white70,
|
||||
height: 200,
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
center: widget.latLng,
|
||||
zoom: widget.initialZoom,
|
||||
interactive: false,
|
||||
),
|
||||
children: [
|
||||
_buildMapLayer(),
|
||||
ScaleLayerWidget(
|
||||
options: ScaleLayerOptions(),
|
||||
),
|
||||
MarkerLayerWidget(
|
||||
options: MarkerLayerOptions(
|
||||
markers: [
|
||||
Marker(
|
||||
width: markerSize,
|
||||
height: markerSize,
|
||||
point: widget.latLng,
|
||||
builder: (ctx) {
|
||||
return Icon(
|
||||
Icons.place,
|
||||
size: markerSize,
|
||||
color: Theme.of(context).accentColor,
|
||||
);
|
||||
},
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
mapController: _mapController,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapLayer() {
|
||||
switch (widget.style) {
|
||||
case EntryMapStyle.osmHot:
|
||||
return OSMHotLayer();
|
||||
case EntryMapStyle.stamenToner:
|
||||
return StamenTonerLayer();
|
||||
case EntryMapStyle.stamenWatercolor:
|
||||
return StamenWatercolorLayer();
|
||||
default:
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAttribution() {
|
||||
switch (widget.style) {
|
||||
case EntryMapStyle.osmHot:
|
||||
return _buildAttributionMarkdown('© [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors, Tiles style by [Humanitarian OpenStreetMap Team](https://www.hotosm.org/) hosted by [OpenStreetMap France](https://openstreetmap.fr/)');
|
||||
case EntryMapStyle.stamenToner:
|
||||
case EntryMapStyle.stamenWatercolor:
|
||||
return _buildAttributionMarkdown('Map tiles by [Stamen Design](http://stamen.com), [CC BY 3.0](http://creativecommons.org/licenses/by/3.0) — Map data © [OpenStreetMap](https://www.openstreetmap.org/copyright) contributors');
|
||||
default:
|
||||
return SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAttributionMarkdown(String data) {
|
||||
return Markdown(
|
||||
data: data,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
a: TextStyle(color: Theme.of(context).accentColor),
|
||||
p: TextStyle(color: Colors.white70, fontSize: 13, fontFamily: 'Concourse'),
|
||||
),
|
||||
onTapLink: (url) async {
|
||||
if (await canLaunch(url)) {
|
||||
await launch(url);
|
||||
}
|
||||
},
|
||||
padding: EdgeInsets.symmetric(vertical: 4, horizontal: 0),
|
||||
shrinkWrap: true,
|
||||
);
|
||||
}
|
||||
|
||||
void _zoomBy(double amount) {
|
||||
if (_mapController == null) return;
|
||||
|
||||
final endZoom = (settings.infoMapZoom + amount).clamp(1.0, 16.0);
|
||||
settings.infoMapZoom = endZoom;
|
||||
|
||||
final zoomTween = Tween<double>(begin: _mapController.zoom, end: endZoom);
|
||||
final controller = AnimationController(duration: const Duration(milliseconds: 200), vsync: this);
|
||||
final animation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn);
|
||||
controller.addListener(() => _mapController.move(widget.latLng, zoomTween.evaluate(animation)));
|
||||
animation.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
controller.dispose();
|
||||
} else if (status == AnimationStatus.dismissed) {
|
||||
controller.dispose();
|
||||
}
|
||||
});
|
||||
controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class OSMHotLayer extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TileLayerWidget(
|
||||
options: TileLayerOptions(
|
||||
urlTemplate: 'https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png',
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
retinaMode: MediaQuery.of(context).devicePixelRatio > 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StamenTonerLayer extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TileLayerWidget(
|
||||
options: TileLayerOptions(
|
||||
urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/toner-lite/{z}/{x}/{y}{r}.png',
|
||||
subdomains: ['a', 'b', 'c', 'd'],
|
||||
retinaMode: MediaQuery.of(context).devicePixelRatio > 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StamenWatercolorLayer extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TileLayerWidget(
|
||||
options: TileLayerOptions(
|
||||
urlTemplate: 'https://stamen-tiles-{s}.a.ssl.fastly.net/watercolor/{z}/{x}/{y}.jpg',
|
||||
subdomains: ['a', 'b', 'c', 'd'],
|
||||
retinaMode: MediaQuery.of(context).devicePixelRatio > 1,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
140
lib/widgets/fullscreen/info/maps/scale_layer.dart
Normal file
140
lib/widgets/fullscreen/info/maps/scale_layer.dart
Normal file
|
@ -0,0 +1,140 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/labs/outlined_text.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
|
||||
import 'scalebar_utils.dart' as util;
|
||||
|
||||
class ScaleLayerOptions extends LayerOptions {
|
||||
final Widget Function(double width, String distance) builder;
|
||||
|
||||
ScaleLayerOptions({
|
||||
Key key,
|
||||
this.builder = defaultBuilder,
|
||||
rebuild,
|
||||
}) : super(key: key, rebuild: rebuild);
|
||||
|
||||
static Widget defaultBuilder(double width, String distance) {
|
||||
return ScaleBar(
|
||||
distance: distance,
|
||||
width: width,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScaleLayerWidget extends StatelessWidget {
|
||||
final ScaleLayerOptions options;
|
||||
|
||||
ScaleLayerWidget({@required this.options}) : super(key: options.key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mapState = MapState.of(context);
|
||||
return ScaleLayer(options, mapState, mapState.onMoved);
|
||||
}
|
||||
}
|
||||
|
||||
class ScaleLayer extends StatelessWidget {
|
||||
final ScaleLayerOptions scaleLayerOpts;
|
||||
final MapState map;
|
||||
final Stream<Null> stream;
|
||||
final scale = [
|
||||
25000000,
|
||||
15000000,
|
||||
8000000,
|
||||
4000000,
|
||||
2000000,
|
||||
1000000,
|
||||
500000,
|
||||
250000,
|
||||
100000,
|
||||
50000,
|
||||
25000,
|
||||
15000,
|
||||
8000,
|
||||
4000,
|
||||
2000,
|
||||
1000,
|
||||
500,
|
||||
250,
|
||||
100,
|
||||
50,
|
||||
25,
|
||||
10,
|
||||
5,
|
||||
];
|
||||
|
||||
ScaleLayer(this.scaleLayerOpts, this.map, this.stream) : super(key: scaleLayerOpts.key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<Null>(
|
||||
stream: stream,
|
||||
builder: (context, snapshot) {
|
||||
final center = map.center;
|
||||
final latitude = center.latitude.abs();
|
||||
final level = map.zoom.round() + (latitude > 80 ? 4 : latitude > 60 ? 3 : 2);
|
||||
final distance = scale[max(0, min(20, level))].toDouble();
|
||||
final start = map.project(center);
|
||||
final targetPoint = util.calculateEndingGlobalCoordinates(center, 90, distance);
|
||||
final end = map.project(targetPoint);
|
||||
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
|
||||
final double width = (end.x - start.x);
|
||||
|
||||
return scaleLayerOpts.builder(width, displayDistance);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ScaleBar extends StatelessWidget {
|
||||
final String distance;
|
||||
final double width;
|
||||
|
||||
static const Color fillColor = Colors.black;
|
||||
static const Color outlineColor = Colors.white;
|
||||
static const double outlineWidth = .5;
|
||||
static const double barThickness = 1;
|
||||
|
||||
const ScaleBar({
|
||||
@required this.distance,
|
||||
@required this.width,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: AlignmentDirectional.bottomStart,
|
||||
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
OutlinedText(
|
||||
text: distance,
|
||||
style: TextStyle(
|
||||
color: fillColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
outlineWidth: outlineWidth * 2,
|
||||
outlineColor: outlineColor,
|
||||
),
|
||||
Container(
|
||||
height: barThickness + outlineWidth * 2,
|
||||
width: width,
|
||||
decoration: BoxDecoration(
|
||||
color: fillColor,
|
||||
border: Border.all(
|
||||
color: outlineColor,
|
||||
width: outlineWidth,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
126
lib/widgets/fullscreen/info/maps/scalebar_utils.dart
Normal file
126
lib/widgets/fullscreen/info/maps/scalebar_utils.dart
Normal file
|
@ -0,0 +1,126 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:latlong/latlong.dart';
|
||||
|
||||
const double piOver180 = PI / 180.0;
|
||||
|
||||
double toDegrees(double radians) {
|
||||
return radians / piOver180;
|
||||
}
|
||||
|
||||
double toRadians(double degrees) {
|
||||
return degrees * piOver180;
|
||||
}
|
||||
|
||||
LatLng calculateEndingGlobalCoordinates(LatLng start, double startBearing, double distance) {
|
||||
var mSemiMajorAxis = 6378137.0; //WGS84 major axis
|
||||
var mSemiMinorAxis = (1.0 - 1.0 / 298.257223563) * 6378137.0;
|
||||
var mFlattening = 1.0 / 298.257223563;
|
||||
// double mInverseFlattening = 298.257223563;
|
||||
|
||||
var a = mSemiMajorAxis;
|
||||
var b = mSemiMinorAxis;
|
||||
var aSquared = a * a;
|
||||
var bSquared = b * b;
|
||||
var f = mFlattening;
|
||||
var phi1 = toRadians(start.latitude);
|
||||
var alpha1 = toRadians(startBearing);
|
||||
var cosAlpha1 = cos(alpha1);
|
||||
var sinAlpha1 = sin(alpha1);
|
||||
var s = distance;
|
||||
var tanU1 = (1.0 - f) * tan(phi1);
|
||||
var cosU1 = 1.0 / sqrt(1.0 + tanU1 * tanU1);
|
||||
var sinU1 = tanU1 * cosU1;
|
||||
|
||||
// eq. 1
|
||||
var sigma1 = atan2(tanU1, cosAlpha1);
|
||||
|
||||
// eq. 2
|
||||
var sinAlpha = cosU1 * sinAlpha1;
|
||||
|
||||
var sin2Alpha = sinAlpha * sinAlpha;
|
||||
var cos2Alpha = 1 - sin2Alpha;
|
||||
var uSquared = cos2Alpha * (aSquared - bSquared) / bSquared;
|
||||
|
||||
// eq. 3
|
||||
var A = 1 + (uSquared / 16384) * (4096 + uSquared * (-768 + uSquared * (320 - 175 * uSquared)));
|
||||
|
||||
// eq. 4
|
||||
var B = (uSquared / 1024) * (256 + uSquared * (-128 + uSquared * (74 - 47 * uSquared)));
|
||||
|
||||
// iterate until there is a negligible change in sigma
|
||||
double deltaSigma;
|
||||
var sOverbA = s / (b * A);
|
||||
var sigma = sOverbA;
|
||||
double sinSigma;
|
||||
var prevSigma = sOverbA;
|
||||
double sigmaM2;
|
||||
double cosSigmaM2;
|
||||
double cos2SigmaM2;
|
||||
|
||||
for (;;) {
|
||||
// eq. 5
|
||||
sigmaM2 = 2.0 * sigma1 + sigma;
|
||||
cosSigmaM2 = cos(sigmaM2);
|
||||
cos2SigmaM2 = cosSigmaM2 * cosSigmaM2;
|
||||
sinSigma = sin(sigma);
|
||||
var cosSignma = cos(sigma);
|
||||
|
||||
// eq. 6
|
||||
deltaSigma = B * sinSigma * (cosSigmaM2 + (B / 4.0) * (cosSignma * (-1 + 2 * cos2SigmaM2) - (B / 6.0) * cosSigmaM2 * (-3 + 4 * sinSigma * sinSigma) * (-3 + 4 * cos2SigmaM2)));
|
||||
|
||||
// eq. 7
|
||||
sigma = sOverbA + deltaSigma;
|
||||
|
||||
// break after converging to tolerance
|
||||
if ((sigma - prevSigma).abs() < 0.0000000000001) break;
|
||||
|
||||
prevSigma = sigma;
|
||||
}
|
||||
|
||||
sigmaM2 = 2.0 * sigma1 + sigma;
|
||||
cosSigmaM2 = cos(sigmaM2);
|
||||
cos2SigmaM2 = cosSigmaM2 * cosSigmaM2;
|
||||
|
||||
var cosSigma = cos(sigma);
|
||||
sinSigma = sin(sigma);
|
||||
|
||||
// eq. 8
|
||||
var phi2 = atan2(sinU1 * cosSigma + cosU1 * sinSigma * cosAlpha1, (1.0 - f) * sqrt(sin2Alpha + pow(sinU1 * sinSigma - cosU1 * cosSigma * cosAlpha1, 2.0)));
|
||||
|
||||
// eq. 9
|
||||
// This fixes the pole crossing defect spotted by Matt Feemster. When a
|
||||
// path passes a pole and essentially crosses a line of latitude twice -
|
||||
// once in each direction - the longitude calculation got messed up.
|
||||
// Using
|
||||
// atan2 instead of atan fixes the defect. The change is in the next 3
|
||||
// lines.
|
||||
// double tanLambda = sinSigma * sinAlpha1 / (cosU1 * cosSigma - sinU1 *
|
||||
// sinSigma * cosAlpha1);
|
||||
// double lambda = Math.atan(tanLambda);
|
||||
var lambda = atan2(sinSigma * sinAlpha1, (cosU1 * cosSigma - sinU1 * sinSigma * cosAlpha1));
|
||||
|
||||
// eq. 10
|
||||
var C = (f / 16) * cos2Alpha * (4 + f * (4 - 3 * cos2Alpha));
|
||||
|
||||
// eq. 11
|
||||
var L = lambda - (1 - C) * f * sinAlpha * (sigma + C * sinSigma * (cosSigmaM2 + C * cosSigma * (-1 + 2 * cos2SigmaM2)));
|
||||
|
||||
// eq. 12
|
||||
// double alpha2 = Math.atan2(sinAlpha, -sinU1 * sinSigma + cosU1 *
|
||||
// cosSigma * cosAlpha1);
|
||||
|
||||
// build result
|
||||
var latitude = toDegrees(phi2);
|
||||
var longitude = start.longitude + toDegrees(L);
|
||||
|
||||
// if ((endBearing != null) && (endBearing.length > 0)) {
|
||||
// endBearing[0] = toDegrees(alpha2);
|
||||
// }
|
||||
|
||||
latitude = latitude < -90 ? -90 : latitude;
|
||||
latitude = latitude > 90 ? 90 : latitude;
|
||||
longitude = longitude < -180 ? -180 : longitude;
|
||||
longitude = longitude > 180 ? 180 : longitude;
|
||||
return LatLng(latitude, longitude);
|
||||
}
|
|
@ -182,9 +182,7 @@ class VideoControlOverlayState extends State<VideoControlOverlay> with SingleTic
|
|||
decoration: BoxDecoration(
|
||||
color: FullscreenOverlay.backgroundColor,
|
||||
border: FullscreenOverlay.buildBorder(context),
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(progressBarBorderRadius),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(progressBarBorderRadius),
|
||||
),
|
||||
child: Column(
|
||||
key: _progressBarKey,
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:aves/utils/android_file_utils.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/icons.dart';
|
||||
import 'package:aves/widgets/filter_grid_page.dart';
|
||||
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -101,11 +102,17 @@ class _HomePageState extends State<HomePage> {
|
|||
return SingleFullscreenPage(entry: _viewerEntry);
|
||||
}
|
||||
if (_mediaStore != null) {
|
||||
return CollectionPage(CollectionLens(
|
||||
source: _mediaStore,
|
||||
groupFactor: settings.collectionGroupFactor,
|
||||
sortFactor: settings.collectionSortFactor,
|
||||
));
|
||||
switch (settings.launchPage) {
|
||||
case LaunchPage.albums:
|
||||
return AlbumListPage(source: _mediaStore);
|
||||
break;
|
||||
case LaunchPage.collection:
|
||||
return CollectionPage(CollectionLens(
|
||||
source: _mediaStore,
|
||||
groupFactor: settings.collectionGroupFactor,
|
||||
sortFactor: settings.collectionSortFactor,
|
||||
));
|
||||
}
|
||||
}
|
||||
return SizedBox.shrink();
|
||||
});
|
||||
|
|
65
lib/widgets/settings_page.dart
Normal file
65
lib/widgets/settings_page.dart
Normal file
|
@ -0,0 +1,65 @@
|
|||
import 'package:aves/model/settings.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQueryDataProvider(
|
||||
child: DefaultTabController(
|
||||
length: 4,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('Preferences'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.all(16),
|
||||
children: [
|
||||
Text('General', style: Constants.titleTextStyle),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Launch Page:'),
|
||||
SizedBox(width: 8),
|
||||
Flexible(child: LaunchPageSelector()),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(() {});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
150
pubspec.lock
150
pubspec.lock
|
@ -1,6 +1,13 @@
|
|||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ansicolor
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -36,6 +43,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
cached_network_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cached_network_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.0+1"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -78,6 +92,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.14.13"
|
||||
console_log_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: console_log_handler
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.6"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -150,6 +171,13 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_cache_manager:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
flutter_ijkplayer:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -159,6 +187,20 @@ packages:
|
|||
url: "git://github.com/deckerst/flutter_ijkplayer.git"
|
||||
source: git
|
||||
version: "0.3.7"
|
||||
flutter_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
flutter_map:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.10.1+1"
|
||||
flutter_markdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -217,14 +259,28 @@ packages:
|
|||
name: google_maps_flutter
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.29"
|
||||
version: "0.5.30"
|
||||
google_maps_flutter_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_maps_flutter_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
version: "1.0.4"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.12.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -246,6 +302,20 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.6.2"
|
||||
latlong:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: latlong
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.6.1"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lists
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.6"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -274,6 +344,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.8"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mgrs_dart
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -323,6 +400,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
path_provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.6.11"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -330,6 +414,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+2"
|
||||
path_provider_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4+3"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -343,7 +434,7 @@ packages:
|
|||
name: pdf
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.10.0"
|
||||
version: "1.10.1"
|
||||
pedantic:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -409,13 +500,20 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
positioned_tap_detector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: positioned_tap_detector
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
printing:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: printing
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.5.0"
|
||||
version: "3.6.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -423,6 +521,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.13"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: proj4dart
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -451,6 +556,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.24.1"
|
||||
screen:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -575,6 +687,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.17"
|
||||
transparent_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: transparent_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
tuple:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -589,6 +708,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.4"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -623,7 +749,7 @@ packages:
|
|||
name: url_launcher_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
version: "0.1.2+1"
|
||||
utf:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -638,6 +764,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
validate:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: validate
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.7.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -645,6 +778,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wkt_parser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.7"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -49,6 +49,7 @@ dependencies:
|
|||
# path: ../flutter_ijkplayer
|
||||
git:
|
||||
url: git://github.com/deckerst/flutter_ijkplayer.git
|
||||
flutter_map:
|
||||
flutter_markdown:
|
||||
flutter_native_timezone:
|
||||
flutter_staggered_animations:
|
||||
|
@ -56,6 +57,7 @@ dependencies:
|
|||
geocoder:
|
||||
google_maps_flutter:
|
||||
intl:
|
||||
latlong: # for flutter_map
|
||||
outline_material_icons:
|
||||
package_info:
|
||||
palette_generator:
|
||||
|
|
Loading…
Reference in a new issue