#549 #70 states: filters, info, search, stats (AU/GB/US)

This commit is contained in:
Thibault Deckers 2023-03-22 10:12:37 +01:00
parent d2a6e31a1a
commit cf47ebebed
25 changed files with 475 additions and 81 deletions

View file

@ -42,6 +42,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
"canPinShortcut" to ShortcutManagerCompat.isRequestPinShortcutSupported(context),
"canPrint" to (sdkInt >= Build.VERSION_CODES.KITKAT),
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
"canRenderSubdivisionFlagEmojis" to (sdkInt >= Build.VERSION_CODES.O),
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
"canUseCrypto" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),

87
lib/geo/states.dart Normal file
View file

@ -0,0 +1,87 @@
import 'package:aves/utils/emoji_utils.dart';
import 'package:country_code/country_code.dart';
class GeoStates {
static final Set<String> stateCountryCodes = {
CountryCode.AU,
CountryCode.GB,
CountryCode.US,
}.map((v) => v.alpha2).toSet();
static const stateCodeByName = {
..._australiaEnglish,
..._unitedKingdomEnglish,
..._unitedStatesEnglish,
};
static const _australiaEnglish = {
'Australian Capital Territory': StateCodes.auAustralianCapitalTerritory,
'New South Wales': StateCodes.auNewSouthWales,
'Northern Territory': StateCodes.auNorthernTerritory,
'Queensland': StateCodes.auQueensland,
'South Australia': StateCodes.auSouthAustralia,
'Tasmania': StateCodes.auTasmania,
'Victoria': StateCodes.auVictoria,
'Western Australia': StateCodes.auWesternAustralia,
};
static const _unitedKingdomEnglish = {
'England': StateCodes.gbEngland,
'Northern Ireland': StateCodes.gbNorthernIreland,
'Scotland': StateCodes.gbScotland,
'Wales': StateCodes.gbWales,
};
static const _unitedStatesEnglish = {
'Alabama': StateCodes.usAlabama,
'Alaska': StateCodes.usAlaska,
'Arizona': StateCodes.usArizona,
'Arkansas': StateCodes.usArkansas,
'California': StateCodes.usCalifornia,
'Colorado': StateCodes.usColorado,
'Connecticut': StateCodes.usConnecticut,
'Delaware': StateCodes.usDelaware,
'Florida': StateCodes.usFlorida,
'Georgia': StateCodes.usGeorgia,
'Hawaii': StateCodes.usHawaii,
'Idaho': StateCodes.usIdaho,
'Illinois': StateCodes.usIllinois,
'Indiana': StateCodes.usIndiana,
'Iowa': StateCodes.usIowa,
'Kansas': StateCodes.usKansas,
'Kentucky': StateCodes.usKentucky,
'Louisiana': StateCodes.usLouisiana,
'Maine': StateCodes.usMaine,
'Maryland': StateCodes.usMaryland,
'Massachusetts': StateCodes.usMassachusetts,
'Michigan': StateCodes.usMichigan,
'Minnesota': StateCodes.usMinnesota,
'Mississippi': StateCodes.usMississippi,
'Missouri': StateCodes.usMissouri,
'Montana': StateCodes.usMontana,
'Nebraska': StateCodes.usNebraska,
'Nevada': StateCodes.usNevada,
'New Hampshire': StateCodes.usNewHampshire,
'New Jersey': StateCodes.usNewJersey,
'New Mexico': StateCodes.usNewMexico,
'New York': StateCodes.usNewYork,
'North Carolina': StateCodes.usNorthCarolina,
'North Dakota': StateCodes.usNorthDakota,
'Ohio': StateCodes.usOhio,
'Oklahoma': StateCodes.usOklahoma,
'Oregon': StateCodes.usOregon,
'Pennsylvania': StateCodes.usPennsylvania,
'Rhode Island': StateCodes.usRhodeIsland,
'South Carolina': StateCodes.usSouthCarolina,
'South Dakota': StateCodes.usSouthDakota,
'Tennessee': StateCodes.usTennessee,
'Utah': StateCodes.usUtah,
'Vermont': StateCodes.usVermont,
'Virginia': StateCodes.usVirginia,
'Washington': StateCodes.usWashington,
'Washington DC': StateCodes.usWashingtonDC,
'West Virginia': StateCodes.usWestVirginia,
'Wisconsin': StateCodes.usWisconsin,
'Wyoming': StateCodes.usWyoming,
};
}

View file

@ -10,7 +10,7 @@ final Device device = Device._private();
class Device {
late final String _userAgent;
late final bool _canAuthenticateUser, _canGrantDirectoryAccess, _canPinShortcut, _canPrint;
late final bool _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper, _canUseCrypto;
late final bool _canRenderFlagEmojis, _canRenderSubdivisionFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper, _canUseCrypto;
late final bool _hasGeocoder, _isDynamicColorAvailable, _isTelevision, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode, _supportPictureInPicture;
String get userAgent => _userAgent;
@ -25,6 +25,8 @@ class Device {
bool get canRenderFlagEmojis => _canRenderFlagEmojis;
bool get canRenderSubdivisionFlagEmojis => _canRenderSubdivisionFlagEmojis;
bool get canRequestManageMedia => _canRequestManageMedia;
bool get canSetLockScreenWallpaper => _canSetLockScreenWallpaper;
@ -71,6 +73,7 @@ class Device {
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
_canPrint = capabilities['canPrint'] ?? false;
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
_canRenderSubdivisionFlagEmojis = capabilities['canRenderSubdivisionFlagEmojis'] ?? false;
_canRequestManageMedia = capabilities['canRequestManageMedia'] ?? false;
_canSetLockScreenWallpaper = capabilities['canSetLockScreenWallpaper'] ?? false;
_canUseCrypto = capabilities['canUseCrypto'] ?? false;

View file

@ -57,6 +57,9 @@ extension ExtraAvesEntryLocation on AvesEntry {
final cc = address.countryCode?.toUpperCase();
final cn = address.countryName;
final aa = address.adminArea;
final l = address.locality;
final sl = address.subLocality;
final saa = address.subAdminArea;
addressDetails = AddressDetails(
id: id,
countryCode: cc,
@ -64,7 +67,7 @@ extension ExtraAvesEntryLocation on AvesEntry {
adminArea: aa,
// if country & admin fields are null, it is likely the ocean,
// which is identified by `featureName` but we default to the address line anyway
locality: address.locality ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
locality: l ?? sl ?? saa ?? (cc == null && cn == null && aa == null ? address.addressLine : null),
);
}
} catch (error, stack) {

View file

@ -1,6 +1,7 @@
import 'package:aves/model/device.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/theme/icons.dart';
import 'package:aves/utils/emoji_utils.dart';
import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
@ -11,23 +12,31 @@ class LocationFilter extends CoveredCollectionFilter {
final LocationLevel level;
late final String _location;
late final String? _countryCode;
late final String? _code;
late final EntryFilter _test;
@override
List<Object?> get props => [level, _location, _countryCode, reversed];
List<Object?> get props => [level, _location, _code, reversed];
LocationFilter(this.level, String location, {super.reversed = false}) {
final split = location.split(locationSeparator);
_location = split.isNotEmpty ? split[0] : location;
_countryCode = split.length > 1 ? split[1] : null;
_code = split.length > 1 ? split[1] : null;
if (_location.isEmpty) {
_test = (entry) => !entry.hasGps;
} else if (level == LocationLevel.country) {
_test = (entry) => entry.addressDetails?.countryCode == _countryCode;
} else if (level == LocationLevel.place) {
} else {
switch (level) {
case LocationLevel.country:
_test = (entry) => entry.addressDetails?.countryCode == _code;
break;
case LocationLevel.state:
_test = (entry) => entry.addressDetails?.stateCode == _code;
break;
case LocationLevel.place:
_test = (entry) => entry.addressDetails?.place == _location;
break;
}
}
}
@ -40,16 +49,29 @@ class LocationFilter extends CoveredCollectionFilter {
}
@override
Map<String, dynamic> toMap() => {
Map<String, dynamic> toMap() {
String location = _location;
switch (level) {
case LocationLevel.country:
case LocationLevel.state:
if (_code != null) {
location = _nameAndCode;
}
break;
case LocationLevel.place:
break;
}
return {
'type': type,
'level': level.toString(),
'location': _countryCode != null ? countryNameAndCode : _location,
'location': location,
'reversed': reversed,
};
}
String get countryNameAndCode => '$_location$locationSeparator$_countryCode';
String get _nameAndCode => '$_location$locationSeparator$_code';
String? get countryCode => _countryCode;
String? get code => _code;
String get place => _location;
@ -71,11 +93,9 @@ class LocationFilter extends CoveredCollectionFilter {
return Icon(AIcons.locationUnlocated, size: size);
}
switch (level) {
case LocationLevel.place:
return Icon(AIcons.place, size: size);
case LocationLevel.country:
if (_countryCode != null && device.canRenderFlagEmojis) {
final flag = countryCodeToFlag(_countryCode);
if (_code != null && device.canRenderFlagEmojis) {
final flag = EmojiUtils.countryCodeToFlag(_code);
if (flag != null) {
return Text(
flag,
@ -85,6 +105,20 @@ class LocationFilter extends CoveredCollectionFilter {
}
}
return Icon(AIcons.country, size: size);
case LocationLevel.state:
if (_code != null && device.canRenderSubdivisionFlagEmojis) {
final flag = EmojiUtils.stateCodeToFlag(_code);
if (flag != null) {
return Text(
flag,
style: TextStyle(fontSize: size),
textScaleFactor: 1.0,
);
}
}
return Icon(AIcons.state, size: size);
case LocationLevel.place:
return Icon(AIcons.place, size: size);
}
}
@ -92,16 +126,7 @@ class LocationFilter extends CoveredCollectionFilter {
String get category => type;
@override
String get key => '$type-$reversed-$level-$_location';
// U+0041 Latin Capital letter A
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041;
static String? countryCodeToFlag(String? code) {
if (code == null || code.length != 2) return null;
return String.fromCharCodes(code.toUpperCase().codeUnits.map((letter) => letter += _countryCodeToFlagDiff));
}
String get key => '$type-$reversed-$level-$code-$place';
}
enum LocationLevel { place, country }
enum LocationLevel { place, state, country }

View file

@ -1,3 +1,4 @@
import 'package:aves/geo/states.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
@ -7,11 +8,15 @@ class AddressDetails extends Equatable {
final int id;
final String? countryCode, countryName, adminArea, locality;
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
@override
List<Object?> get props => [id, countryCode, countryName, adminArea, locality];
String? get place => locality != null && locality!.isNotEmpty ? locality : adminArea;
String? get stateCode => GeoStates.stateCodeByName[stateName];
String? get stateName => GeoStates.stateCountryCodes.contains(countryCode) ? adminArea : null;
const AddressDetails({
required this.id,
this.countryCode,

View file

@ -21,6 +21,7 @@ import 'package:aves/model/source/events.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/location/location.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/location/state.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/model/source/trash.dart';
import 'package:aves/model/vaults/vaults.dart';
@ -59,7 +60,7 @@ mixin SourceBase {
void invalidateEntries();
}
abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, PlaceMixin, LocationMixin, TagMixin, TrashMixin {
abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, PlaceMixin, StateMixin, LocationMixin, TagMixin, TrashMixin {
CollectionSource() {
settings.updateStream.where((event) => event.key == Settings.localeKey).listen((_) => invalidateAlbumDisplayNames());
settings.updateStream.where((event) => event.key == Settings.hiddenFiltersKey).listen((event) {
@ -142,6 +143,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
invalidateAlbumFilterSummary(entries: entries, notify: notify);
invalidateCountryFilterSummary(entries: entries, notify: notify);
invalidatePlaceFilterSummary(entries: entries, notify: notify);
invalidateStateFilterSummary(entries: entries, notify: notify);
invalidateTagFilterSummary(entries: entries, notify: notify);
}
@ -511,6 +513,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
switch (filter.level) {
case LocationLevel.country:
return countryEntryCount(filter);
case LocationLevel.state:
return stateEntryCount(filter);
case LocationLevel.place:
return placeEntryCount(filter);
}
@ -525,6 +529,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
switch (filter.level) {
case LocationLevel.country:
return countrySize(filter);
case LocationLevel.state:
return stateSize(filter);
case LocationLevel.place:
return placeSize(filter);
}
@ -539,6 +545,8 @@ abstract class CollectionSource with SourceBase, AlbumMixin, CountryMixin, Place
switch (filter.level) {
case LocationLevel.country:
return countryRecentEntry(filter);
case LocationLevel.state:
return stateRecentEntry(filter);
case LocationLevel.place:
return placeRecentEntry(filter);
}

View file

@ -5,8 +5,6 @@ import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart';
mixin CountryMixin on SourceBase {
// filter summary
// by country code
final Map<String, int> _filterEntryCountMap = {}, _filterSizeMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
@ -39,19 +37,19 @@ mixin CountryMixin on SourceBase {
}
int countryEntryCount(LocationFilter filter) {
final countryCode = filter.countryCode;
final countryCode = filter.code;
if (countryCode == null) return 0;
return _filterEntryCountMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).length);
}
int countrySize(LocationFilter filter) {
final countryCode = filter.countryCode;
final countryCode = filter.code;
if (countryCode == null) return 0;
return _filterSizeMap.putIfAbsent(countryCode, () => visibleEntries.where(filter.test).map((v) => v.sizeBytes).sum);
}
AvesEntry? countryRecentEntry(LocationFilter filter) {
final countryCode = filter.countryCode;
final countryCode = filter.code;
if (countryCode == null) return null;
return _filterRecentEntryMap.putIfAbsent(countryCode, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
}

View file

@ -10,16 +10,18 @@ import 'package:aves/model/source/analysis_controller.dart';
import 'package:aves/model/source/enums/enums.dart';
import 'package:aves/model/source/location/country.dart';
import 'package:aves/model/source/location/place.dart';
import 'package:aves/model/source/location/state.dart';
import 'package:aves/services/common/services.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:tuple/tuple.dart';
mixin LocationMixin on CountryMixin, PlaceMixin {
mixin LocationMixin on CountryMixin, StateMixin {
static const commitCountThreshold = 200;
static const _stopCheckCountThreshold = 50;
List<String> sortedCountries = List.unmodifiable([]);
List<String> sortedStates = List.unmodifiable([]);
List<String> sortedPlaces = List.unmodifiable([]);
Future<void> loadAddresses({Set<int>? ids}) async {
@ -152,32 +154,56 @@ mixin LocationMixin on CountryMixin, PlaceMixin {
void updateLocations() {
final locations = visibleEntries.map((entry) => entry.addressDetails).whereNotNull().toList();
final updatedPlaces = locations.map((address) => address.place).whereNotNull().where((v) => v.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase);
if (!listEquals(updatedPlaces, sortedPlaces)) {
sortedPlaces = List.unmodifiable(updatedPlaces);
eventBus.fire(PlacesChangedEvent());
}
// the same country code could be found with different country names
// e.g. if the locale changed between geocoding calls
// so we merge countries by code, keeping only one name for each code
final countriesByCode = Map.fromEntries(locations.map((address) {
final code = address.countryCode;
if (code == null || code.isEmpty) return null;
return MapEntry(code, address.countryName);
}).whereNotNull());
final updatedCountries = countriesByCode.entries.map((kv) {
final code = kv.key;
final name = kv.value;
return '${name != null && name.isNotEmpty ? name : code}${LocationFilter.locationSeparator}$code';
}).toList()
..sort(compareAsciiUpperCase);
final updatedStates = _getAreaByCode(
locations: locations,
getCode: (v) => v.stateCode,
getName: (v) => v.stateName,
);
if (!listEquals(updatedStates, sortedStates)) {
sortedStates = List.unmodifiable(updatedStates);
invalidateStateFilterSummary();
eventBus.fire(StatesChangedEvent());
}
final updatedCountries = _getAreaByCode(
locations: locations,
getCode: (v) => v.countryCode,
getName: (v) => v.countryName,
);
if (!listEquals(updatedCountries, sortedCountries)) {
sortedCountries = List.unmodifiable(updatedCountries);
invalidateCountryFilterSummary();
eventBus.fire(CountriesChangedEvent());
}
}
// the same country/state code could be found with different country/state names
// e.g. if the locale changed between geocoding calls
// so we merge countries/states by code, keeping only one name for each code
List<String> _getAreaByCode({
required List<AddressDetails> locations,
required String? Function(AddressDetails address) getCode,
required String? Function(AddressDetails address) getName,
}) {
final namesByCode = Map.fromEntries(locations.map((address) {
final code = getCode(address);
if (code == null || code.isEmpty) return null;
return MapEntry(code, getName(address));
}).whereNotNull());
return namesByCode.entries.map((kv) {
final code = kv.key;
final name = kv.value;
return '${name != null && name.isNotEmpty ? name : code}${LocationFilter.locationSeparator}$code';
}).toList()
..sort(compareAsciiUpperCase);
}
}
class AddressMetadataChangedEvent {}

View file

@ -5,8 +5,6 @@ import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart';
mixin PlaceMixin on SourceBase {
// filter summary
// by place
final Map<String, int> _filterEntryCountMap = {}, _filterSizeMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};

View file

@ -0,0 +1,64 @@
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/collection_utils.dart';
import 'package:collection/collection.dart';
mixin StateMixin on SourceBase {
// by state code
final Map<String, int> _filterEntryCountMap = {}, _filterSizeMap = {};
final Map<String, AvesEntry?> _filterRecentEntryMap = {};
void invalidateStateFilterSummary({
Set<AvesEntry>? entries,
Set<String>? stateCodes,
bool notify = true,
}) {
if (_filterEntryCountMap.isEmpty && _filterSizeMap.isEmpty && _filterRecentEntryMap.isEmpty) return;
if (entries == null && stateCodes == null) {
_filterEntryCountMap.clear();
_filterSizeMap.clear();
_filterRecentEntryMap.clear();
} else {
stateCodes ??= {};
if (entries != null) {
stateCodes.addAll(entries.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.stateCode).whereNotNull());
}
stateCodes.forEach((stateCode) {
_filterEntryCountMap.remove(stateCode);
_filterSizeMap.remove(stateCode);
_filterRecentEntryMap.remove(stateCode);
});
}
if (notify) {
eventBus.fire(StateSummaryInvalidatedEvent(stateCodes));
}
}
int stateEntryCount(LocationFilter filter) {
final stateCode = filter.code;
if (stateCode == null) return 0;
return _filterEntryCountMap.putIfAbsent(stateCode, () => visibleEntries.where(filter.test).length);
}
int stateSize(LocationFilter filter) {
final stateCode = filter.code;
if (stateCode == null) return 0;
return _filterSizeMap.putIfAbsent(stateCode, () => visibleEntries.where(filter.test).map((v) => v.sizeBytes).sum);
}
AvesEntry? stateRecentEntry(LocationFilter filter) {
final stateCode = filter.code;
if (stateCode == null) return null;
return _filterRecentEntryMap.putIfAbsent(stateCode, () => sortedEntriesByDate.firstWhereOrNull(filter.test));
}
}
class StatesChangedEvent {}
class StateSummaryInvalidatedEvent {
final Set<String>? stateCodes;
const StateSummaryInvalidatedEvent(this.stateCodes);
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:ui';
import 'package:aves/services/common/services.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
@ -35,9 +36,12 @@ class GeocodingService {
}
@immutable
class Address {
class Address extends Equatable {
final String? addressLine, adminArea, countryCode, countryName, featureName, locality, postalCode, subAdminArea, subLocality, subThoroughfare, thoroughfare;
@override
List<Object?> get props => [addressLine, adminArea, countryCode, countryName, featureName, locality, postalCode, subAdminArea, subLocality, subThoroughfare, thoroughfare];
const Address({
this.addressLine,
this.adminArea,

View file

@ -37,6 +37,7 @@ class AIcons {
static const IconData location = Icons.place_outlined;
static const IconData locationUnlocated = Icons.location_off_outlined;
static const IconData country = Icons.flag_outlined;
static const IconData state = Icons.flag_outlined;
static const IconData place = Icons.place_outlined;
static const IconData mainStorage = Icons.smartphone_outlined;
static const IconData mimeType = Icons.code_outlined;

View file

@ -0,0 +1,93 @@
class EmojiUtils {
// U+0041 Latin Capital letter A
static const _capitalLetterA = 0x0041;
// U+1F1E6 Regional Indicator Symbol Letter A
static const _countryCodeToFlagDiff = 0x1F1E6 - _capitalLetterA;
// U+E0061 Tag Latin Small Letter a
static const _stateCodeToFlagDiff = 0xE0061 - _capitalLetterA;
static const _blackFlag = 0x1F3F4;
static const _cancel = 0xE007F;
static String? countryCodeToFlag(String? code) {
if (code == null || code.length != 2) return null;
return String.fromCharCodes(code.toUpperCase().codeUnits.map((letter) => letter += _countryCodeToFlagDiff));
}
static String? stateCodeToFlag(String? code) {
if (code == null) return null;
return String.fromCharCodes([_blackFlag, ...code.toUpperCase().codeUnits.map((letter) => letter += _stateCodeToFlagDiff), _cancel]);
}
}
class StateCodes {
// AU
static const auAustralianCapitalTerritory = 'auact';
static const auNewSouthWales = 'aunsw';
static const auNorthernTerritory = 'aunt';
static const auQueensland = 'auqld';
static const auSouthAustralia = 'ausa';
static const auTasmania = 'autas';
static const auVictoria = 'auvic';
static const auWesternAustralia = 'auwa';
// GB
static const gbEngland = 'gbeng';
static const gbNorthernIreland = 'gbnir';
static const gbScotland = 'gbsct';
static const gbWales = 'gbwls';
// US
static const usAlabama = 'usal';
static const usAlaska = 'usak';
static const usArizona = 'usaz';
static const usArkansas = 'usar';
static const usCalifornia = 'usca';
static const usColorado = 'usco';
static const usConnecticut = 'usct';
static const usDelaware = 'usde';
static const usFlorida = 'usfl';
static const usGeorgia = 'usga';
static const usHawaii = 'ushi';
static const usIdaho = 'usid';
static const usIllinois = 'usil';
static const usIndiana = 'usin';
static const usIowa = 'usia';
static const usKansas = 'usks';
static const usKentucky = 'usky';
static const usLouisiana = 'usla';
static const usMaine = 'usme';
static const usMaryland = 'usmd';
static const usMassachusetts = 'usma';
static const usMichigan = 'usmi';
static const usMinnesota = 'usmn';
static const usMississippi = 'usms';
static const usMissouri = 'usmo';
static const usMontana = 'usmt';
static const usNebraska = 'usne';
static const usNevada = 'usnv';
static const usNewHampshire = 'usnh';
static const usNewJersey = 'usnj';
static const usNewMexico = 'usnm';
static const usNewYork = 'usny';
static const usNorthCarolina = 'usnc';
static const usNorthDakota = 'usnd';
static const usOhio = 'usoh';
static const usOklahoma = 'usok';
static const usOregon = 'usor';
static const usPennsylvania = 'uspa';
static const usRhodeIsland = 'usri';
static const usSouthCarolina = 'ussc';
static const usSouthDakota = 'ussd';
static const usTennessee = 'ustn';
static const usUtah = 'usut';
static const usVermont = 'usvt';
static const usVirginia = 'usva';
static const usWashington = 'uswa';
static const usWashingtonDC = 'usdc';
static const usWestVirginia = 'uswv';
static const usWisconsin = 'uswi';
static const usWyoming = 'uswy';
}

View file

@ -18,7 +18,6 @@ import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/media_store_source.dart';
import 'package:aves/services/accessibility_service.dart';
import 'package:aves_utils/aves_utils.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/colors.dart';
import 'package:aves/theme/durations.dart';
@ -40,6 +39,7 @@ import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/navigation/tv_page_transitions.dart';
import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:aves/widgets/welcome_page.dart';
import 'package:aves_utils/aves_utils.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:equatable/equatable.dart';
import 'package:fijkplayer/fijkplayer.dart';
@ -74,8 +74,7 @@ class AvesApp extends StatefulWidget {
@override
State<AvesApp> createState() => _AvesAppState();
static void setSystemUIStyle(BuildContext context) {
final theme = Theme.of(context);
static void setSystemUIStyle(ThemeData theme) {
final style = systemUIStyleForBrightness(theme.brightness, theme.scaffoldBackgroundColor);
SystemChrome.setSystemUIOverlayStyle(style);
}
@ -300,7 +299,7 @@ class _AvesAppState extends State<AvesApp> with WidgetsBindingObserver {
required Widget? child,
}) {
if (initialized) {
WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(context));
WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(Theme.of(context)));
}
return Selector<Settings, bool>(
selector: (context, s) => s.initialized ? s.accessibilityAnimations.animate : true,

View file

@ -417,6 +417,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
Set<String> obsoleteTags = todoItems.expand((entry) => entry.tags).toSet();
Set<String> obsoleteCountryCodes = todoItems.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.countryCode).whereNotNull().toSet();
Set<String> obsoleteStateCodes = todoItems.where((entry) => entry.hasAddress).map((entry) => entry.addressDetails?.stateCode).whereNotNull().toSet();
final dataTypes = <EntryDataType>{};
final source = context.read<CollectionSource>();
@ -447,6 +448,9 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
if (obsoleteCountryCodes.isNotEmpty) {
source.invalidateCountryFilterSummary(countryCodes: obsoleteCountryCodes);
}
if (obsoleteStateCodes.isNotEmpty) {
source.invalidateStateFilterSummary(stateCodes: obsoleteStateCodes);
}
if (obsoleteTags.isNotEmpty) {
source.invalidateTagFilterSummary(tags: obsoleteTags);
}

View file

@ -78,7 +78,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
}
case LocationFilter:
{
final countryCode = (filter as LocationFilter).countryCode;
final countryCode = (filter as LocationFilter).code;
return StreamBuilder<CountrySummaryInvalidatedEvent>(
stream: source.eventBus.on<CountrySummaryInvalidatedEvent>().where((event) => event.countryCodes == null || event.countryCodes!.contains(countryCode)),
builder: (context, snapshot) => _buildChip(context, source),

View file

@ -230,7 +230,10 @@ class CollectionSearchDelegate extends AvesSearchDelegate with FeedbackMixin, Va
return _buildFilterRow(
context: context,
title: context.l10n.searchPlacesSectionTitle,
filters: source.sortedPlaces.where(containQuery).map((s) => LocationFilter(LocationLevel.place, s)).toList(),
filters: [
...source.sortedStates.where(containQuery).map((s) => LocationFilter(LocationLevel.state, s)),
...source.sortedPlaces.where(containQuery).map((s) => LocationFilter(LocationLevel.place, s)),
].toList(),
);
},
);

View file

@ -29,6 +29,7 @@ import 'package:aves/widgets/stats/filter_table.dart';
import 'package:aves/widgets/stats/mime_donut.dart';
import 'package:aves/widgets/stats/percent_text.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';
@ -54,7 +55,8 @@ class StatsPage extends StatefulWidget {
}
class _StatsPageState extends State<StatsPage> with FeedbackMixin, VaultAwareMixin {
final Map<String, int> _entryCountPerCountry = {}, _entryCountPerPlace = {}, _entryCountPerTag = {}, _entryCountPerAlbum = {};
final Map<String, int> _entryCountPerCountry = {}, _entryCountPerTag = {}, _entryCountPerAlbum = {};
final Map<_PlaceFilterKey, int> _entryCountPerPlace = {};
final Map<int, int> _entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0)));
late final ValueNotifier<bool> _isPageAnimatingNotifier;
@ -78,9 +80,16 @@ class _StatsPageState extends State<StatsPage> with FeedbackMixin, VaultAwareMix
country += '${LocationFilter.locationSeparator}${address.countryCode}';
_entryCountPerCountry[country] = (_entryCountPerCountry[country] ?? 0) + 1;
}
var state = address.stateName;
if (state != null && state.isNotEmpty) {
state += '${LocationFilter.locationSeparator}${address.stateCode}';
final key = _PlaceFilterKey(LocationLevel.state, state);
_entryCountPerPlace[key] = (_entryCountPerPlace[key] ?? 0) + 1;
}
final place = address.place;
if (place != null && place.isNotEmpty) {
_entryCountPerPlace[place] = (_entryCountPerPlace[place] ?? 0) + 1;
final key = _PlaceFilterKey(LocationLevel.place, place);
_entryCountPerPlace[key] = (_entryCountPerPlace[key] ?? 0) + 1;
}
}
@ -210,7 +219,7 @@ class _StatsPageState extends State<StatsPage> with FeedbackMixin, VaultAwareMix
),
locationIndicator,
..._buildFilterSection<String>(context, l10n.statsTopCountriesSectionTitle, _entryCountPerCountry, (v) => LocationFilter(LocationLevel.country, v)),
..._buildFilterSection<String>(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(LocationLevel.place, v)),
..._buildFilterSection<_PlaceFilterKey>(context, l10n.statsTopPlacesSectionTitle, _entryCountPerPlace, (v) => LocationFilter(v.level, v.location)),
..._buildFilterSection<String>(context, l10n.statsTopTagsSectionTitle, _entryCountPerTag, TagFilter.new),
..._buildFilterSection<String>(context, l10n.statsTopAlbumsSectionTitle, _entryCountPerAlbum, (v) => AlbumFilter(v, source.getAlbumDisplayName(context, v))),
if (showRatings) ..._buildFilterSection<int>(context, l10n.searchRatingSectionTitle, _entryCountPerRating, RatingFilter.new, sortByCount: false, maxRowCount: null),
@ -399,3 +408,27 @@ class StatsTopPage extends StatelessWidget {
);
}
}
@immutable
class _PlaceFilterKey extends Comparable<_PlaceFilterKey> with EquatableMixin {
final LocationLevel level;
final String location;
@override
List<Object?> get props => [level, location];
_PlaceFilterKey(this.level, this.location);
static const _levelOrder = [
LocationLevel.country,
LocationLevel.state,
LocationLevel.place,
];
@override
int compareTo(_PlaceFilterKey other) {
final c = _levelOrder.indexOf(level).compareTo(_levelOrder.indexOf(other.level));
if (c != 0) return c;
return location.compareTo(other.location);
}
}

View file

@ -34,6 +34,7 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
if (isMainMode && source != null) {
Set<String> obsoleteTags = targetEntry.tags;
String? obsoleteCountryCode = targetEntry.addressDetails?.countryCode;
String? obsoleteStateCode = targetEntry.addressDetails?.stateCode;
await source.refreshEntries({targetEntry}, dataTypes);
@ -43,6 +44,9 @@ mixin SingleEntryEditorMixin on FeedbackMixin, PermissionAwareMixin {
if (obsoleteCountryCode != null) {
source.invalidateCountryFilterSummary(countryCodes: {obsoleteCountryCode});
}
if (obsoleteStateCode != null) {
source.invalidateStateFilterSummary(stateCodes: {obsoleteStateCode});
}
if (obsoleteTags.isNotEmpty) {
source.invalidateTagFilterSummary(tags: obsoleteTags);
}

View file

@ -2,8 +2,11 @@ import 'dart:collection';
import 'dart:typed_data';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/model/entry/extensions/location.dart';
import 'package:aves/model/settings/settings.dart';
import 'package:aves/ref/mime_types.dart';
import 'package:aves/services/android_debug_service.dart';
import 'package:aves/services/geocoding_service.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/viewer/info/common.dart';
@ -23,7 +26,7 @@ class MetadataTab extends StatefulWidget {
class _MetadataTabState extends State<MetadataTab> {
late Future<Map> _bitmapFactoryLoader, _contentResolverMetadataLoader, _exifInterfaceMetadataLoader;
late Future<Map> _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader;
late Future<Map> _mediaMetadataLoader, _metadataExtractorLoader, _pixyMetaLoader, _tiffStructureLoader, _addressLoader;
late Future<String?> _mp4ParserDumpLoader;
// MediaStore timestamp keys
@ -47,6 +50,27 @@ class _MetadataTabState extends State<MetadataTab> {
_mp4ParserDumpLoader = AndroidDebugService.getMp4ParserDump(entry);
_pixyMetaLoader = AndroidDebugService.getPixyMetadata(entry);
_tiffStructureLoader = AndroidDebugService.getTiffStructure(entry);
_addressLoader = entry.hasGps
? GeocodingService.getAddress(entry.latLng!, settings.appliedLocale).then((addresses) {
if (addresses.isNotEmpty) {
final address = addresses.first;
return {
'addressLine': address.addressLine,
'adminArea': address.adminArea,
'countryCode': address.countryCode,
'countryName': address.countryName,
'featureName': address.featureName,
'locality': address.locality,
'postalCode': address.postalCode,
'subAdminArea': address.subAdminArea,
'subLocality': address.subLocality,
'subThoroughfare': address.subThoroughfare,
'thoroughfare': address.thoroughfare,
};
}
return {};
})
: Future.value({});
setState(() {});
}
@ -152,6 +176,10 @@ class _MetadataTabState extends State<MetadataTab> {
);
},
),
FutureBuilder<Map>(
future: _addressLoader,
builder: (context, snapshot) => builderFromSnapshot(context, snapshot, 'Address'),
),
],
);
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math';
import 'package:aves/app_mode.dart';
@ -15,7 +16,6 @@ import 'package:aves/model/settings/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/common/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves_utils/aves_utils.dart';
import 'package:aves/widgets/aves_app.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart';
@ -33,9 +33,10 @@ import 'package:aves/widgets/viewer/overlay/top.dart';
import 'package:aves/widgets/viewer/overlay/video/video.dart';
import 'package:aves/widgets/viewer/page_entry_builder.dart';
import 'package:aves/widgets/viewer/video/conductor.dart';
import 'package:aves_video/aves_video.dart';
import 'package:aves/widgets/viewer/visual/conductor.dart';
import 'package:aves/widgets/viewer/visual/controller_mixin.dart';
import 'package:aves_utils/aves_utils.dart';
import 'package:aves_video/aves_video.dart';
import 'package:collection/collection.dart';
import 'package:floating/floating.dart';
import 'package:flutter/foundation.dart';
@ -544,16 +545,16 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
_verticalScrollNotifier.notify();
}
void _goToCollection(CollectionFilter filter) {
Future<void> _goToCollection(CollectionFilter filter) async {
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
if (!isMainMode) return;
final baseCollection = collection;
if (baseCollection == null) return;
_onLeave();
unawaited(_onLeave());
final uri = entryNotifier.value?.uri;
Navigator.maybeOf(context)?.pushAndRemoveUntil(
unawaited(Navigator.maybeOf(context)?.pushAndRemoveUntil(
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage(
@ -563,7 +564,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
),
),
(route) => false,
);
));
}
Future<void> _goToVerticalPage(int page) async {
@ -704,8 +705,8 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
void _popVisual() {
if (Navigator.canPop(context)) {
void pop() {
_onLeave();
Future<void> pop() async {
unawaited(_onLeave());
Navigator.maybeOf(context)?.pop();
}
@ -747,13 +748,17 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
}
Future<void> _onLeave() async {
// get the theme first, as the context is likely
// to be unmounted after the other async steps
final theme = Theme.of(context);
await ScreenBrightness().resetScreenBrightness();
if (settings.keepScreenOn == KeepScreenOn.viewerOnly) {
await windowService.keepScreenOn(false);
}
await mediaSessionService.release();
await AvesApp.showSystemUI();
AvesApp.setSystemUIStyle(context);
AvesApp.setSystemUIStyle(theme);
if (!settings.useTvLayout) {
await windowService.requestOrientation();
}
@ -805,7 +810,7 @@ class _EntryViewerStackState extends State<EntryViewerStack> with EntryViewContr
if (!mounted) return;
if (_overlayVisible.value) {
await AvesApp.showSystemUI();
AvesApp.setSystemUIStyle(context);
AvesApp.setSystemUIStyle(Theme.of(context));
if (animate) {
await _overlayAnimationController.forward();
} else {

View file

@ -103,6 +103,8 @@ class _LocationSectionState extends State<LocationSection> {
final address = entry.addressDetails!;
final country = address.countryName;
if (country != null && country.isNotEmpty) filters.add(LocationFilter(LocationLevel.country, '$country${LocationFilter.locationSeparator}${address.countryCode}'));
final state = address.stateName;
if (state != null && state.isNotEmpty) filters.add(LocationFilter(LocationLevel.state, '$state${LocationFilter.locationSeparator}${address.stateCode}'));
final place = address.place;
if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place));
}

View file

@ -166,7 +166,7 @@ class _PanoramaPageState extends State<PanoramaPage> {
Future<void> _onLeave() async {
await AvesApp.showSystemUI();
AvesApp.setSystemUIStyle(context);
AvesApp.setSystemUIStyle(Theme.of(context));
}
// system UI
@ -183,7 +183,7 @@ class _PanoramaPageState extends State<PanoramaPage> {
Future<void> _onOverlayVisibleChanged() async {
if (_overlayVisible.value) {
await AvesApp.showSystemUI();
AvesApp.setSystemUIStyle(context);
AvesApp.setSystemUIStyle(Theme.of(context));
} else {
await AvesApp.hideSystemUI();
}

View file

@ -262,7 +262,7 @@ class _EntryEditorState extends State<EntryEditor> with EntryViewControllerMixin
Future<void> _onOverlayVisibleChanged({bool animate = true}) async {
if (_overlayVisible.value) {
await AvesApp.showSystemUI();
AvesApp.setSystemUIStyle(context);
AvesApp.setSystemUIStyle(Theme.of(context));
if (animate) {
await _overlayAnimationController.forward();
} else {