location: use LatLng instead of Tuple for coordinates, approximation when calling geocoder, locate without storing address line

This commit is contained in:
Thibault Deckers 2020-11-24 15:32:06 +09:00
parent 5898c9052a
commit 78f5abc39c
12 changed files with 132 additions and 63 deletions

View file

@ -8,13 +8,14 @@ import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/metadata_service.dart'; import 'package:aves/services/metadata_service.dart';
import 'package:aves/services/service_policy.dart'; import 'package:aves/services/service_policy.dart';
import 'package:aves/utils/change_notifier.dart'; import 'package:aves/utils/change_notifier.dart';
import 'package:aves/utils/math_utils.dart';
import 'package:aves/utils/time_utils.dart'; import 'package:aves/utils/time_utils.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:geocoder/geocoder.dart'; import 'package:geocoder/geocoder.dart';
import 'package:latlong/latlong.dart';
import 'package:path/path.dart' as ppath; import 'package:path/path.dart' as ppath;
import 'package:tuple/tuple.dart';
import 'mime_types.dart'; import 'mime_types.dart';
@ -295,9 +296,14 @@ class ImageEntry {
bool get isLocated => _addressDetails != null; bool get isLocated => _addressDetails != null;
Tuple2<double, double> get latLng => isCatalogued ? Tuple2(_catalogMetadata.latitude, _catalogMetadata.longitude) : null; LatLng get latLng => isCatalogued ? LatLng(_catalogMetadata.latitude, _catalogMetadata.longitude) : null;
String get geoUri => hasGps ? 'geo:${_catalogMetadata.latitude},${_catalogMetadata.longitude}?q=${_catalogMetadata.latitude},${_catalogMetadata.longitude}' : null; String get geoUri {
if (!hasGps) return null;
final latitude = roundToPrecision(_catalogMetadata.latitude, decimals: 6);
final longitude = roundToPrecision(_catalogMetadata.longitude, decimals: 6);
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
}
List<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? []; List<String> get xmpSubjects => _catalogMetadata?.xmpSubjects?.split(';')?.where((tag) => tag.isNotEmpty)?.toList() ?? [];
@ -366,7 +372,6 @@ class ImageEntry {
final address = addresses.first; final address = addresses.first;
addressDetails = AddressDetails( addressDetails = AddressDetails(
contentId: contentId, contentId: contentId,
addressLine: address.addressLine,
countryCode: address.countryCode, countryCode: address.countryCode,
countryName: address.countryName, countryName: address.countryName,
adminArea: address.adminArea, adminArea: address.adminArea,
@ -378,11 +383,29 @@ class ImageEntry {
} }
} }
Future<String> findAddressLine() async {
final latitude = _catalogMetadata?.latitude;
final longitude = _catalogMetadata?.longitude;
if (latitude == null || longitude == null || (latitude == 0 && longitude == 0)) return null;
final coordinates = Coordinates(latitude, longitude);
try {
final addresses = await Geocoder.local.findAddressesFromCoordinates(coordinates);
if (addresses != null && addresses.isNotEmpty) {
final address = addresses.first;
return address.addressLine;
}
} catch (error, stackTrace) {
debugPrint('$runtimeType findAddressLine failed with path=$path coordinates=$coordinates error=$error\n$stackTrace');
}
return null;
}
String get shortAddress { String get shortAddress {
if (!isLocated) return ''; if (!isLocated) return '';
// admin area examples: Seoul, Geneva, null // `admin area` examples: Seoul, Geneva, null
// locality examples: Mapo-gu, Geneva, Annecy // `locality` examples: Mapo-gu, Geneva, Annecy
return { return {
_addressDetails.countryName, _addressDetails.countryName,
_addressDetails.adminArea, _addressDetails.adminArea,
@ -390,12 +413,13 @@ class ImageEntry {
}.where((part) => part != null && part.isNotEmpty).join(', '); }.where((part) => part != null && part.isNotEmpty).join(', ');
} }
bool search(String query) { bool search(String query) => {
if (bestTitle?.toUpperCase()?.contains(query) ?? false) return true; bestTitle,
if (_catalogMetadata?.xmpSubjects?.toUpperCase()?.contains(query) ?? false) return true; _catalogMetadata?.xmpSubjects,
if (_addressDetails?.addressLine?.toUpperCase()?.contains(query) ?? false) return true; _addressDetails?.countryName,
return false; _addressDetails?.adminArea,
} _addressDetails?.locality,
}.any((s) => s != null && s.toUpperCase().contains(query));
Future<void> _applyNewFields(Map newFields) async { Future<void> _applyNewFields(Map newFields) async {
final uri = newFields['uri']; final uri = newFields['uri'];

View file

@ -142,13 +142,12 @@ class OverlayMetadata {
class AddressDetails { class AddressDetails {
final int contentId; final int contentId;
final String addressLine, countryCode, countryName, adminArea, locality; final String countryCode, countryName, adminArea, locality;
String get place => locality != null && locality.isNotEmpty ? locality : adminArea; String get place => locality != null && locality.isNotEmpty ? locality : adminArea;
AddressDetails({ AddressDetails({
this.contentId, this.contentId,
this.addressLine,
this.countryCode, this.countryCode,
this.countryName, this.countryName,
this.adminArea, this.adminArea,
@ -160,7 +159,6 @@ class AddressDetails {
}) { }) {
return AddressDetails( return AddressDetails(
contentId: contentId ?? this.contentId, contentId: contentId ?? this.contentId,
addressLine: addressLine,
countryCode: countryCode, countryCode: countryCode,
countryName: countryName, countryName: countryName,
adminArea: adminArea, adminArea: adminArea,
@ -171,7 +169,6 @@ class AddressDetails {
factory AddressDetails.fromMap(Map map) { factory AddressDetails.fromMap(Map map) {
return AddressDetails( return AddressDetails(
contentId: map['contentId'], contentId: map['contentId'],
addressLine: map['addressLine'] ?? '',
countryCode: map['countryCode'] ?? '', countryCode: map['countryCode'] ?? '',
countryName: map['countryName'] ?? '', countryName: map['countryName'] ?? '',
adminArea: map['adminArea'] ?? '', adminArea: map['adminArea'] ?? '',
@ -181,7 +178,6 @@ class AddressDetails {
Map<String, dynamic> toMap() => { Map<String, dynamic> toMap() => {
'contentId': contentId, 'contentId': contentId,
'addressLine': addressLine,
'countryCode': countryCode, 'countryCode': countryCode,
'countryName': countryName, 'countryName': countryName,
'adminArea': adminArea, 'adminArea': adminArea,
@ -190,7 +186,7 @@ class AddressDetails {
@override @override
String toString() { String toString() {
return 'AddressDetails{contentId=$contentId, addressLine=$addressLine, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}'; return 'AddressDetails{contentId=$contentId, countryCode=$countryCode, countryName=$countryName, adminArea=$adminArea, locality=$locality}';
} }
} }

View file

@ -1,4 +1,5 @@
import 'package:aves/utils/geo_utils.dart'; import 'package:aves/utils/geo_utils.dart';
import 'package:latlong/latlong.dart';
import 'package:tuple/tuple.dart'; import 'package:tuple/tuple.dart';
enum CoordinateFormat { dms, decimal } enum CoordinateFormat { dms, decimal }
@ -15,12 +16,12 @@ extension ExtraCoordinateFormat on CoordinateFormat {
} }
} }
String format(Tuple2<double, double> latLng) { String format(LatLng latLng) {
switch (this) { switch (this) {
case CoordinateFormat.dms: case CoordinateFormat.dms:
return toDMS(latLng).join(', '); return toDMS(latLng).join(', ');
case CoordinateFormat.decimal: case CoordinateFormat.decimal:
return [latLng.item1, latLng.item2].map((n) => n.toStringAsFixed(6)).join(', '); return [latLng.latitude, latLng.longitude].map((n) => n.toStringAsFixed(6)).join(', ');
default: default:
return toString(); return toString();
} }

View file

@ -1,3 +1,5 @@
import 'dart:math';
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/image_metadata.dart'; import 'package:aves/model/image_metadata.dart';
@ -30,15 +32,25 @@ mixin LocationMixin on SourceBase {
final todo = byLocated[false] ?? []; final todo = byLocated[false] ?? [];
if (todo.isEmpty) return; if (todo.isEmpty) return;
// cache known locations to avoid querying the geocoder unless necessary // geocoder calls take between 150ms and 250ms
// measuring the time it takes to process ~3000 coordinates (with ~10% of duplicates) // approximation and caching can reduce geocoder usage
// does not clearly show whether it is an actual optimization, // for example, for a set of 2932 entries:
// as results vary wildly (durations in "min:sec"): // - 2476 calls (84%) when approximating to 6 decimal places (~10cm - individual humans)
// - with no cache: 06:17, 08:36, 08:34 // - 2433 calls (83%) when approximating to 5 decimal places (~1m - individual trees, houses)
// - with cache: 08:28, 05:42, 08:03, 05:58 // - 2277 calls (78%) when approximating to 4 decimal places (~10m - individual street, large buildings)
// anyway, in theory it should help! // - 1521 calls (52%) when approximating to 3 decimal places (~100m - neighborhood, street)
final knownLocations = <Tuple2<double, double>, AddressDetails>{}; // - 652 calls (22%) when approximating to 2 decimal places (~1km - town or village)
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(entry.latLng, () => entry.addressDetails)); // cf https://en.wikipedia.org/wiki/Decimal_degrees#Precision
final latLngFactor = pow(10, 2);
Tuple2 approximateLatLng(ImageEntry entry) {
final lat = entry.catalogMetadata?.latitude;
final lng = entry.catalogMetadata?.longitude;
if (lat == null || lng == null) return null;
return Tuple2((lat * latLngFactor).round(), (lng * latLngFactor).round());
}
final knownLocations = <Tuple2, AddressDetails>{};
byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(approximateLatLng(entry), () => entry.addressDetails));
var progressDone = 0; var progressDone = 0;
final progressTotal = todo.length; final progressTotal = todo.length;
@ -46,13 +58,14 @@ mixin LocationMixin on SourceBase {
final newAddresses = <AddressDetails>[]; final newAddresses = <AddressDetails>[];
await Future.forEach<ImageEntry>(todo, (entry) async { await Future.forEach<ImageEntry>(todo, (entry) async {
if (knownLocations.containsKey(entry.latLng)) { final latLng = approximateLatLng(entry);
entry.addressDetails = knownLocations[entry.latLng]?.copyWith(contentId: entry.contentId); if (knownLocations.containsKey(latLng)) {
entry.addressDetails = knownLocations[latLng]?.copyWith(contentId: entry.contentId);
} else { } else {
await entry.locate(background: true); await entry.locate(background: true);
// it is intended to insert `null` if the geocoder failed, // it is intended to insert `null` if the geocoder failed,
// so that we skip geocoding of following entries with the same coordinates // so that we skip geocoding of following entries with the same coordinates
knownLocations[entry.latLng] = entry.addressDetails; knownLocations[latLng] = entry.addressDetails;
} }
if (entry.isLocated) { if (entry.isLocated) {
newAddresses.add(entry.addressDetails); newAddresses.add(entry.addressDetails);

View file

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:tuple/tuple.dart'; import 'package:latlong/latlong.dart';
class Constants { class Constants {
// as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped // as of Flutter v1.22.3, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
@ -21,7 +21,7 @@ class Constants {
static const String overlayUnknown = ''; // em dash static const String overlayUnknown = ''; // em dash
static const String infoUnknown = 'unknown'; static const String infoUnknown = 'unknown';
static const pointNemo = Tuple2(-48.876667, -123.393333); static final pointNemo = LatLng(-48.876667, -123.393333);
static const int infoGroupMaxValueLength = 140; static const int infoGroupMaxValueLength = 140;

View file

@ -1,15 +1,12 @@
import 'dart:math'; import 'package:aves/utils/math_utils.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:tuple/tuple.dart'; import 'package:latlong/latlong.dart';
String _decimal2sexagesimal(final double degDecimal) { String _decimal2sexagesimal(final double degDecimal) {
double _round(final double value, {final int decimals = 6}) => (value * pow(10, decimals)).round() / pow(10, decimals);
List<int> _split(final double value) { List<int> _split(final double value) {
// NumberFormat is necessary to create digit after comma if the value // NumberFormat is necessary to create digit after comma if the value
// has no decimal point (only necessary for browser) // has no decimal point (only necessary for browser)
final tmp = NumberFormat('0.0#####').format(_round(value, decimals: 10)).split('.'); final tmp = NumberFormat('0.0#####').format(roundToPrecision(value, decimals: 10)).split('.');
return <int>[ return <int>[
int.parse(tmp[0]).abs(), int.parse(tmp[0]).abs(),
int.parse(tmp[1]), int.parse(tmp[1]),
@ -21,14 +18,14 @@ String _decimal2sexagesimal(final double degDecimal) {
final min = _split(minDecimal)[0]; final min = _split(minDecimal)[0];
final sec = (minDecimal - min) * 60; final sec = (minDecimal - min) * 60;
return '$deg° $min ${_round(sec, decimals: 2).toStringAsFixed(2)}'; return '$deg° $min ${roundToPrecision(sec, decimals: 2).toStringAsFixed(2)}';
} }
// return coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E'] // return coordinates formatted as DMS, e.g. ['41° 24 12.2″ N', '2° 10 26.5″ E']
List<String> toDMS(Tuple2<double, double> latLng) { List<String> toDMS(LatLng latLng) {
if (latLng == null) return []; if (latLng == null) return [];
final lat = latLng.item1; final lat = latLng.latitude;
final lng = latLng.item2; final lng = latLng.longitude;
return [ return [
'${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}', '${_decimal2sexagesimal(lat)} ${lat < 0 ? 'S' : 'N'}',
'${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}', '${_decimal2sexagesimal(lng)} ${lng < 0 ? 'W' : 'E'}',

View file

@ -1,5 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/foundation.dart';
final double _log2 = log(2); final double _log2 = log(2);
const double _piOver180 = pi / 180.0; const double _piOver180 = pi / 180.0;
@ -9,6 +11,8 @@ double toRadians(num degrees) => degrees * _piOver180;
int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor()); int highestPowerOf2(num x) => x < 1 ? 0 : pow(2, (log(x) / _log2).floor());
double roundToPrecision(final double value, {@required final int decimals}) => (value * pow(10, decimals)).round() / pow(10, decimals);
// e.g. x=12345, precision=3 should return 13000 // e.g. x=12345, precision=3 should return 13000
int ceilBy(num x, int precision) { int ceilBy(num x, int precision) {
final factor = pow(10, precision); final factor = pow(10, precision);

View file

@ -128,7 +128,6 @@ class _DbTabState extends State<DbTab> {
Text('DB address:${data == null ? ' no row' : ''}'), Text('DB address:${data == null ? ' no row' : ''}'),
if (data != null) if (data != null)
InfoRowGroup({ InfoRowGroup({
'addressLine': '${data.addressLine}',
'countryCode': '${data.countryCode}', 'countryCode': '${data.countryCode}',
'countryName': '${data.countryName}', 'countryName': '${data.countryName}',
'adminArea': '${data.adminArea}', 'adminArea': '${data.adminArea}',

View file

@ -12,6 +12,7 @@ import 'package:aves/widgets/fullscreen/info/maps/google_map.dart';
import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart'; import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart';
import 'package:aves/widgets/fullscreen/info/maps/marker.dart'; import 'package:aves/widgets/fullscreen/info/maps/marker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:tuple/tuple.dart';
class LocationSection extends StatefulWidget { class LocationSection extends StatefulWidget {
final CollectionLens collection; final CollectionLens collection;
@ -79,11 +80,9 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
final showMap = (_loadedUri == entry.uri) || (entry.hasGps && widget.visibleNotifier.value); final showMap = (_loadedUri == entry.uri) || (entry.hasGps && widget.visibleNotifier.value);
if (showMap) { if (showMap) {
_loadedUri = entry.uri; _loadedUri = entry.uri;
var location = '';
final filters = <LocationFilter>[]; final filters = <LocationFilter>[];
if (entry.isLocated) { if (entry.isLocated) {
final address = entry.addressDetails; final address = entry.addressDetails;
location = address.addressLine;
final country = address.countryName; final country = address.countryName;
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}')); if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
final place = address.place; final place = address.place;
@ -114,7 +113,8 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
vsync: this, vsync: this,
child: settings.infoMapStyle.isGoogleMaps child: settings.infoMapStyle.isGoogleMaps
? EntryGoogleMap( ? EntryGoogleMap(
latLng: entry.latLng, // `LatLng` used by `google_maps_flutter` is not the one from `latlong` package
latLng: Tuple2<double, double>(entry.latLng.latitude, entry.latLng.longitude),
geoUri: entry.geoUri, geoUri: entry.geoUri,
initialZoom: settings.infoMapZoom, initialZoom: settings.infoMapZoom,
markerId: entry.uri ?? entry.path, markerId: entry.uri ?? entry.path,
@ -130,11 +130,7 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
), ),
), ),
), ),
if (entry.hasGps) if (entry.hasGps) _AddressInfoGroup(entry: entry),
InfoRowGroup(Map.fromEntries([
MapEntry('Coordinates', settings.coordinateFormat.format(entry.latLng)),
if (location.isNotEmpty) MapEntry('Address', location),
])),
if (filters.isNotEmpty) if (filters.isNotEmpty)
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8), padding: EdgeInsets.symmetric(horizontal: AvesFilterChip.outlineWidth / 2) + EdgeInsets.only(top: 8),
@ -160,6 +156,41 @@ class _LocationSectionState extends State<LocationSection> with TickerProviderSt
void _handleChange() => setState(() {}); void _handleChange() => setState(() {});
} }
class _AddressInfoGroup extends StatefulWidget {
final ImageEntry entry;
const _AddressInfoGroup({@required this.entry});
@override
_AddressInfoGroupState createState() => _AddressInfoGroupState();
}
class _AddressInfoGroupState extends State<_AddressInfoGroup> {
Future<String> _addressLineLoader;
ImageEntry get entry => widget.entry;
@override
void initState() {
super.initState();
_addressLineLoader = entry.findAddressLine();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _addressLineLoader,
builder: (context, snapshot) {
final address = !snapshot.hasError && snapshot.connectionState == ConnectionState.done ? snapshot.data : '';
return InfoRowGroup({
'Coordinates': settings.coordinateFormat.format(entry.latLng),
if (address.isNotEmpty) 'Address': address,
});
},
);
}
}
// browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/ // browse providers at https://leaflet-extras.github.io/leaflet-providers/preview/
enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor } enum EntryMapStyle { googleNormal, googleHybrid, googleTerrain, osmHot, stamenToner, stamenWatercolor }

View file

@ -5,7 +5,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:latlong/latlong.dart'; import 'package:latlong/latlong.dart';
import 'package:tuple/tuple.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../location_section.dart'; import '../location_section.dart';
@ -18,16 +17,15 @@ class EntryLeafletMap extends StatefulWidget {
final Size markerSize; final Size markerSize;
final WidgetBuilder markerBuilder; final WidgetBuilder markerBuilder;
EntryLeafletMap({ const EntryLeafletMap({
Key key, Key key,
Tuple2<double, double> latLng, this.latLng,
this.geoUri, this.geoUri,
this.initialZoom, this.initialZoom,
this.style, this.style,
this.markerBuilder, this.markerBuilder,
this.markerSize, this.markerSize,
}) : latLng = LatLng(latLng.item1, latLng.item2), }) : super(key: key);
super(key: key);
@override @override
State<StatefulWidget> createState() => EntryLeafletMapState(); State<StatefulWidget> createState() => EntryLeafletMapState();

View file

@ -1,12 +1,13 @@
import 'package:aves/utils/geo_utils.dart'; import 'package:aves/utils/geo_utils.dart';
import 'package:latlong/latlong.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:tuple/tuple.dart';
void main() { void main() {
test('Decimal degrees to DMS (sexagesimal)', () { test('Decimal degrees to DMS (sexagesimal)', () {
expect(toDMS(Tuple2(37.496667, 127.0275)), ['37° 29 48.00″ N', '127° 1 39.00″ E']); // Gangnam expect(toDMS(LatLng(37.496667, 127.0275)), ['37° 29 48.00″ N', '127° 1 39.00″ E']); // Gangnam
expect(toDMS(Tuple2(78.9243503, 11.9230465)), ['78° 55 27.66″ N', '11° 55 22.97″ E']); // Ny-Ålesund expect(toDMS(LatLng(78.9243503, 11.9230465)), ['78° 55 27.66″ N', '11° 55 22.97″ E']); // Ny-Ålesund
expect(toDMS(Tuple2(-38.6965891, 175.9830047)), ['38° 41 47.72″ S', '175° 58 58.82″ E']); // Taupo expect(toDMS(LatLng(-38.6965891, 175.9830047)), ['38° 41 47.72″ S', '175° 58 58.82″ E']); // Taupo
expect(toDMS(Tuple2(-64.249391, -56.6556145)), ['64° 14 57.81″ S', '56° 39 20.21″ W']); // Marambio expect(toDMS(LatLng(-64.249391, -56.6556145)), ['64° 14 57.81″ S', '56° 39 20.21″ W']); // Marambio
expect(toDMS(LatLng(0, 0)), ['0° 0 0.00″ N', '0° 0 0.00″ E']);
}); });
} }

View file

@ -21,6 +21,11 @@ void main() {
expect(highestPowerOf2(-42), 0); expect(highestPowerOf2(-42), 0);
}); });
test('rounding to a given precision after the decimal', () {
expect(roundToPrecision(1.2345678, decimals: 3), 1.235);
expect(roundToPrecision(0, decimals: 3), 0);
});
test('rounding up to a given precision before the decimal', () { test('rounding up to a given precision before the decimal', () {
expect(ceilBy(12345.678, 3), 13000); expect(ceilBy(12345.678, 3), 13000);
expect(ceilBy(42, 3), 1000); expect(ceilBy(42, 3), 1000);