feat: implement web service

Signed-off-by: The one with the braid <info@braid.business>
This commit is contained in:
The one with the braid 2023-11-26 19:42:29 +01:00
parent 44494eaa90
commit 6e7f19a764
26 changed files with 331 additions and 512 deletions

30
lib/pkpass/error.dart Normal file
View file

@ -0,0 +1,30 @@
abstract class PKPassError extends Error {
final String message;
PKPassError({required this.message});
@override
String toString() => 'PKPassError: $message';
}
class InvalidEncodingError extends PKPassError {
InvalidEncodingError() : super(message: 'Input not in ZIP format.');
}
class ManifestNotFoundError extends PKPassError {
ManifestNotFoundError()
: super(message: 'No manifest.json found in PKPass archive.');
}
class ManifestChecksumError extends PKPassError {
final String expected;
final String actual;
ManifestChecksumError({
required this.expected,
required this.actual,
}) : super(
message:
'Manifest sha1 checksum missmatch, expected $expected, actual $actual',
);
}

View file

@ -0,0 +1,89 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:barcode/barcode.dart';
/// Information about a passs barcode.
///
/// Use with Flutter:
///
/// ```dart
/// BarcodeWidget.fromBytes(
/// barcode: Barcode.fromType(myPassBarcode.format),
/// data: myPassBarcode.barcodeData,
/// )
/// ```
///
/// Please not the spec requires you to display the [altText] next to the
/// barcode in case available.
class PassBarcode {
/// the [Encoding] supported for [messageEncoding]. Can be expanded at runtime.
///
/// Default values are
/// - iso-8859-1: [Latin1Codec]
/// - utf-8: [Utf8Codec]
static Map<String, Encoding> supportedCodecs = {
'iso-8859-1': Latin1Codec(),
'iso-8859': Latin1Codec(),
'iso8859': Latin1Codec(),
'utf-8': Utf8Codec(),
'utf8': Utf8Codec(),
};
static const _allowedFormats = {
'PKBarcodeFormatQR': BarcodeType.QrCode,
'PKBarcodeFormatPDF417': BarcodeType.PDF417,
'PKBarcodeFormatAztec': BarcodeType.Aztec,
'PKBarcodeFormatCode128': BarcodeType.Code128,
};
/// Barcode format. For the barcode dictionary, you can use only the
/// following values: PKBarcodeFormatQR, PKBarcodeFormatPDF417, or
/// PKBarcodeFormatAztec. For dictionaries in the barcodes array, you may
/// also use PKBarcodeFormatCode128.
///
/// The spec defined keys are converted into the corresponding [BarcodeType]
/// representations.
final BarcodeType format;
/// Message or payload to be displayed as a barcode.
///
/// Do not use directly, use the encoded [barcodeData] instead.
final String message;
/// Text encoding that is used to convert the message from the string
/// representation to a data representation to render the barcode.
///
/// The value is typically iso-8859-1, but you may use another encoding that
/// is supported by your barcode scanning infrastructure.
///
/// Only supported values by this packages are:
/// - iso-8859-1
/// - utf-8
///
/// Custom codecs can be provided as [Codec] in [PassBarcode.supportedCodecs].
final Encoding messageEncoding;
/// Text displayed near the barcode. For example, a human-readable version
/// of the barcode data in case the barcode doesnt scan.
final String? altText;
const PassBarcode({
required this.format,
required this.message,
required this.messageEncoding,
required this.altText,
});
factory PassBarcode.fromJson(Map<String, Object?> json) => PassBarcode(
format: _allowedFormats[json['format']]!,
message: json['message'] as String,
messageEncoding:
supportedCodecs[(json['messageEncoding'] as String).toLowerCase()]!,
altText: json['altText'] as String?,
);
/// Correctly encoded byte list to be displayed in the [barcode].
Uint8List get barcodeData =>
Uint8List.fromList(messageEncoding.encode(message));
}

View file

@ -0,0 +1,30 @@
/// Information about a location beacon.
class Beacon {
/// Unique identifier of a Bluetooth Low Energy location beacon.
final String proximityUUID;
/// Major identifier of a Bluetooth Low Energy location beacon.
final double? major;
/// Minor identifier of a Bluetooth Low Energy location beacon.
final double? minor;
/// Text displayed on the lock screen when the pass is currently relevant.
/// For example, a description of the nearby location such as
/// Store nearby on 1st and Main.
final String? relevantText;
const Beacon({
required this.proximityUUID,
this.major,
this.minor,
this.relevantText,
});
factory Beacon.fromJson(Map<String, Object?> json) => Beacon(
proximityUUID: json['proximityUUID'] as String,
major: json['major'] as double?,
minor: json['minor'] as double?,
relevantText: json['relevantText'] as String?,
);
}

View file

@ -0,0 +1,30 @@
/// Information about a location.
class Location {
/// Latitude, in degrees, of the location.
final double latitude;
/// Longitude, in degrees, of the location.
final double longitude;
/// Altitude, in meters, of the location.
final double? altitude;
/// Text displayed on the lock screen when the pass is currently relevant.
/// For example, a description of the nearby location such as
/// Store nearby on 1st and Main.
final String? relevantText;
const Location({
required this.latitude,
required this.longitude,
this.altitude,
this.relevantText,
});
factory Location.fromJson(Map<String, Object?> json) => Location(
latitude: json['latitude'] as double,
longitude: json['longitude'] as double,
altitude: json['altitude'] as double?,
relevantText: json['relevantText'] as String?,
);
}

210
lib/pkpass/models/pass.dart Normal file
View file

@ -0,0 +1,210 @@
import 'package:intl/locale.dart';
import 'package:pkpass/pkpass.dart';
import 'package:pkpass/pkpass/utils/mabe_decode.dart';
/// Information that is required for all passes.
class PassMetadata {
/// Brief description of the pass, used by accessibility technologies.
///
/// Dont try to include all of the data on the pass in its description,
/// just include enough detail to distinguish passes of the same type.
final String description;
/// Version of the file format. The value must be 1.
final int formatVersion;
/// Display name of the organization that originated and signed the pass.
final String organizationName;
/// Pass type identifier, as issued by Apple. The value must correspond with
/// your signing certificate.
final String passTypeIdentifier;
/// Serial number that uniquely identifies the pass. No two passes with the
/// same pass type identifier may have the same serial number.
final String serialNumber;
/// Team identifier of the organization that originated and signed the pass,
/// as issued by Apple.
final String teamIdentifier;
/// A URL to be passed to the associated app when launching it.
final String? appLaunchURL;
/// Date and time when the pass expires.
final DateTime? expirationDate;
/// Indicates that the pass is voidfor example, a one time use coupon that
/// has been redeemed. The default value is false.
final bool voided;
/// Beacons marking locations where the pass is relevant.
final List<Beacon> beacons;
/// Locations where the pass is relevant. For example, the location of your store.
final List<Location> locations;
/// Maximum distance in meters from a relevant latitude and longitude that
/// the pass is relevant. This number is compared to the passs default
/// distance and the smaller value is used.
final int? maxDistance;
/// Recommended for event tickets and boarding passes; otherwise optional.
/// Date and time when the pass becomes relevant. For example, the start
/// time of a movie.
final DateTime? relevantDate;
/// Information specific to a boarding pass.
final PassStructureDictionary? boardingPass;
/// Information specific to a coupon.
final PassStructureDictionary? coupon;
/// Information specific to an event ticket.
final PassStructureDictionary? eventTicket;
/// Information specific to a generic pass.
final PassStructureDictionary? generic;
/// Information specific to a store card.
final PassStructureDictionary? storeCard;
/// Information specific to the passs barcode.
/// The system uses the first valid barcode dictionary in the array.
/// Additional dictionaries can be added as fallbacks.
final List<PassBarcode> barcodes;
/// Background color of the pass.
final int? backgroundColor;
/// Foreground color of the pass.
final int? foregroundColor;
/// Optional for event tickets and boarding passes; otherwise not allowed.
/// Identifier used to group related passes. If a grouping identifier is
/// specified, passes with the same style, pass type identifier, and grouping
/// identifier are displayed as a group. Otherwise, passes are grouped
/// automatically.
///
/// Use this to group passes that are tightly related, such as the boarding
/// passes for different connections of the same trip.
final String? groupingIdentifier;
/// Color of the label text.
final int? labelColor;
/// Text displayed next to the logo on the pass.
final String? logoText;
/// Information used to update passes using the web service.
final PassWebService? webService;
const PassMetadata({
required this.description,
required this.formatVersion,
required this.organizationName,
required this.passTypeIdentifier,
required this.serialNumber,
required this.teamIdentifier,
this.appLaunchURL,
this.expirationDate,
this.voided = false,
this.beacons = const [],
this.locations = const [],
this.maxDistance,
this.relevantDate,
this.boardingPass,
this.coupon,
this.eventTicket,
this.generic,
this.storeCard,
this.barcodes = const [],
this.backgroundColor,
this.foregroundColor,
this.groupingIdentifier,
this.labelColor,
this.logoText,
this.webService,
});
factory PassMetadata.fromJson(Map<String, Object?> json) => PassMetadata(
description: json['description'] as String,
formatVersion: json['formatVersion'] as int,
organizationName: json['organizationName'] as String,
passTypeIdentifier: json['passTypeIdentifier'] as String,
serialNumber: json['serialNumber'] as String,
teamIdentifier: json['teamIdentifier'] as String,
boardingPass: json['boardingPass'] == null
? null
: PassStructureDictionary.fromJson(
(json['boardingPass'] as Map).cast<String, Object?>(),
),
coupon: json['coupon'] == null
? null
: PassStructureDictionary.fromJson(
(json['coupon'] as Map).cast<String, Object?>(),
),
eventTicket: json['eventTicket'] == null
? null
: PassStructureDictionary.fromJson(
(json['eventTicket'] as Map).cast<String, Object?>(),
),
generic: json['generic'] == null
? null
: PassStructureDictionary.fromJson(
(json['generic'] as Map).cast<String, Object?>(),
),
storeCard: json['storeCard'] == null
? null
: PassStructureDictionary.fromJson(
(json['storeCard'] as Map).cast<String, Object?>(),
),
barcodes: (json['barcodes'] as List? ??
[if (json['barcode'] != null) json['barcode']])
.map((i) => PassBarcode.fromJson(i))
.toList(),
locations: (json['locations'] as List? ?? [])
.map((i) => Location.fromJson(i))
.toList(),
appLaunchURL: json['appLaunchURL'] as String?,
expirationDate:
MaybeDecode.maybeDateTime(json['expirationDate'] as String?),
voided: json['voided'] as bool? ?? false,
beacons: (json['beacons'] as List? ?? [])
.map((i) => Beacon.fromJson(i))
.toList(),
maxDistance: json['maxDistance'] as int?,
relevantDate:
MaybeDecode.maybeDateTime(json['relevantDate'] as String?),
backgroundColor:
MaybeDecode.maybeColor(json['backgroundColor'] as String?),
foregroundColor:
MaybeDecode.maybeColor(json['foregroundColor'] as String?),
groupingIdentifier: json['locoText'] as String?,
labelColor: MaybeDecode.maybeColor(json['labelColor'] as String?),
logoText: json['locoText'] as String?,
webService: PassWebService.maybe(
authenticationToken: json['authenticationToken'] as String?,
webServiceURL: json['webServiceURL'] as String?,
),
);
/// Localized version of [description] based on given [locale] and [pass].
String getLocalizedDescription(PassFile pass, Locale? locale) {
final localizations = pass.getLocalizations(locale);
return localizations?[description] ?? description;
}
/// Localized version of [organizationName] based on given [locale] and [pass].
String getLocalizedOrganizationName(PassFile pass, Locale? locale) {
final localizations = pass.getLocalizations(locale);
return localizations?[organizationName] ?? organizationName;
}
/// Localized version of [logoText] based on given [locale] and [pass].
String? getLocalizedLogoText(PassFile pass, Locale? locale) {
final localizations = pass.getLocalizations(locale);
return localizations?[logoText] ?? logoText;
}
}

View file

@ -0,0 +1,235 @@
import 'package:intl/locale.dart';
import 'package:pkpass/pkpass.dart';
import 'package:pkpass/pkpass/utils/mabe_decode.dart';
/// Keys that define the structure of the pass.
///
/// These keys are used for all pass styles and partition the fields into the various parts of the pass.
class PassStructureDictionary {
/// Fields to be displayed in the header on the front of the pass.
///
/// Use header fields sparingly; unlike all other fields, they remain visible when a stack of passes are displayed.
final List<DictionaryField> headerFields;
/// Fields to be displayed prominently on the front of the pass.
final List<DictionaryField> primaryFields;
/// Fields to be displayed on the front of the pass.
final List<DictionaryField> secondaryFields;
/// Fields to be on the back of the pass.
final List<DictionaryField> backFields;
/// Additional fields to be displayed on the front of the pass.
final List<DictionaryField> auxiliaryFields;
/// Required for boarding passes; otherwise not allowed. Type of transit.
final TransitType? transitType;
const PassStructureDictionary({
this.headerFields = const [],
this.primaryFields = const [],
this.secondaryFields = const [],
this.backFields = const [],
this.auxiliaryFields = const [],
this.transitType,
});
factory PassStructureDictionary.fromJson(Map<String, Object?> json) =>
PassStructureDictionary(
headerFields: (json['headerFields'] as List?)
?.map((i) => DictionaryField.fromJson(i))
.toList() ??
[],
primaryFields: (json['primaryFields'] as List?)
?.map((i) => DictionaryField.fromJson(i))
.toList() ??
[],
secondaryFields: (json['secondaryFields'] as List?)
?.map((i) => DictionaryField.fromJson(i))
.toList() ??
[],
backFields: (json['backFields'] as List?)
?.map((i) => DictionaryField.fromJson(i))
.toList() ??
[],
auxiliaryFields: (json['auxiliaryFields'] as List?)
?.map((i) => DictionaryField.fromJson(i))
.toList() ??
[],
transitType: _TarnsitType.parse(json['transitType'] as String?),
);
}
/// Information about a field.
class DictionaryField {
/// The key must be unique within the scope of the entire pass. For example, departure-gate.
final String key;
/// Value of the field, for example, 42.
final DictionaryValue value;
/// Label text for the field.
final String? label;
/// Format string for the alert text that is displayed when the pass is updated. The format string must contain the escape %@, which is replaced with the fields new value. For example, Gate changed to %@.
///
/// If you dont specify a change message, the user isnt notified when the field changes.
final String? changeMessage;
/// Alignment for the fields contents.
final PassTextAlign? textAlignment;
/// Attributed value of the field.
///
/// The value may contain HTML markup for links. Only the <a> tag and its href attribute are supported. For example, the following is key-value pair specifies a link with the text Edit my profile:
///
/// "attributedValue": "<a href='http://example.com/customers/123'>Edit my profile</a>"
///
/// This keys value overrides the text specified by the value key.
final DictionaryValue? attributedValue;
const DictionaryField({
required this.key,
required this.value,
this.label,
this.changeMessage,
this.textAlignment,
this.attributedValue,
});
factory DictionaryField.fromJson(Map<String, Object?> json) =>
DictionaryField(
key: json['key'] as String,
value: DictionaryValue.parse(json['value'] as String),
label: json['label'] as String?,
changeMessage: json['changeMessage'] as String?,
textAlignment:
MaybeDecode.maybeTextAlign(json['textAlignment'] as String?),
attributedValue: json['attributedValue'] == null
? null
: DictionaryValue.parse(json['attributedValue'] as String),
);
/// Localized version of [label] based on given [locale] and [pass].
String? getLocalizedLabel(PassFile pass, Locale? locale) {
final localizations = pass.getLocalizations(locale);
return localizations?[label] ?? label;
}
/// Localized version of [changeMessage] based on given [locale] and [pass].
String? getLocalizedChangeMessage(PassFile pass, Locale? locale) {
final localizations = pass.getLocalizations(locale);
return localizations?[changeMessage] ?? changeMessage;
}
}
/// represents the possible values of a [DictionaryField].
abstract class DictionaryValue {
const DictionaryValue();
/// parses the correct [DictionaryValue] implementor based on a given [value].
factory DictionaryValue.parse(String value) {
final number = int.tryParse(value);
if (number != null) return NumberDictionaryValue(number);
final dateTime = DateTime.tryParse(value);
if (dateTime != null) return DateTimeDictionaryValue(dateTime);
return StringDictionaryValue(value);
}
/// Localized value based on given [locale] and [pass].
DictionaryValue getLocalizedValue(PassFile pass, Locale? locale);
}
/// [String] content of a [DictionaryField].
class StringDictionaryValue extends DictionaryValue {
final String string;
const StringDictionaryValue(this.string);
@override
/// Localized value based on given [locale] and [pass].
DictionaryValue getLocalizedValue(PassFile pass, Locale? locale) {
final localizations = pass.getLocalizations(locale);
return StringDictionaryValue(localizations?[string] ?? string);
}
}
/// [DateTime] content of a [DictionaryField].
class DateTimeDictionaryValue extends DictionaryValue {
final DateTime dateTime;
const DateTimeDictionaryValue(this.dateTime);
@override
/// Localized value based on given [locale] and [pass]. Same as [dateTime].
DictionaryValue getLocalizedValue(PassFile pass, Locale? locale) => this;
}
/// [int] content of a [DictionaryField].
class NumberDictionaryValue extends DictionaryValue {
final int number;
const NumberDictionaryValue(this.number);
@override
/// Localized value based on given [locale] and [pass]. Same as [number].
DictionaryValue getLocalizedValue(PassFile pass, Locale? locale) => this;
}
/// Possible types of [PassStructureDictionary.transitType].
enum TransitType {
/// PKTransitTypeAir
air,
/// PKTransitTypeBoat
boat,
/// PKTransitTypeBus
bus,
/// PKTransitTypeGeneric
generic,
/// PKTransitTypeTrain
train,
}
/// Possible types of [DictionaryField.textAlignment].
enum PassTextAlign {
/// PKTextAlignmentLeft, corresponds `dart:ui` [TextAlign.left]
left,
/// PKTextAlignmentCenter, corresponds `dart:ui` [TextAlign.center]
center,
/// PKTextAlignmentRight, corresponds `dart:ui` [TextAlign.left]
right,
/// PKTextAlignmentNatural, corresponds `dart:ui` [TextAlign.start]
natural,
}
extension _TarnsitType on TransitType {
static TransitType? parse(String? type) {
if (type == null) return null;
switch (type) {
case 'PKTransitTypeAir':
return TransitType.air;
case 'PKTransitTypeBoat':
return TransitType.boat;
case 'PKTransitTypeBus':
return TransitType.bus;
case 'PKTransitTypeTrain':
return TransitType.train;
default:
return TransitType.generic;
}
}
}

View file

@ -0,0 +1,31 @@
/// Metadata required for Pass Web Service
///
/// https://developer.apple.com/library/archive/documentation/PassKit/Reference/PassKit_WebService/WebService.html#//apple_ref/doc/uid/TP40011988
class PassWebService {
/// The authentication token to use with the web service.
/// The token must be 16 characters or longer.
final String authenticationToken;
/// The URL of a web service that conforms to the API described in PassKit Web Service Reference.
final Uri webServiceURL;
const PassWebService({
required this.authenticationToken,
required this.webServiceURL,
});
/// returns a [PassWebService] in case [authenticationToken] and
/// [webServiceURL] are both valid values.
static PassWebService? maybe({
String? authenticationToken,
String? webServiceURL,
}) {
if (authenticationToken == null || webServiceURL == null) return null;
final uri = Uri.tryParse(webServiceURL);
if (uri == null || uri.scheme != 'https') return null;
return PassWebService(
authenticationToken: authenticationToken,
webServiceURL: uri,
);
}
}

134
lib/pkpass/pass_file.dart Normal file
View file

@ -0,0 +1,134 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:intl/locale.dart';
import 'package:pkpass/pkpass.dart';
import 'package:pkpass/pkpass/utils/file_matcher.dart';
import 'package:pkpass/pkpass/utils/lproj_parser.dart';
final _utf8codec = Utf8Codec();
final _jsonCodec = JsonCodec();
class PassFile {
const PassFile(this.metadata, this._folder);
static Future<PassFile> parse(Uint8List pass) async {
final codec = ZipDecoder();
Archive archive;
try {
archive = codec.decodeBytes(pass);
} catch (e) {
throw InvalidEncodingError();
}
Map<String, String> manifest;
try {
final file = archive.files
.singleWhere((element) => element.name == 'manifest.json');
manifest = (_jsonCodec.decode(
_utf8codec.decode(
file.rawContent?.toUint8List() ?? (file.content as Uint8List),
),
) as Map)
.cast<String, String>();
} catch (e) {
throw ManifestNotFoundError();
}
final folder = <ArchiveFile>[];
await Future.wait(
manifest.entries.map(
(manifestEntry) async {
final file = archive.files
.singleWhere((element) => element.name == manifestEntry.key);
final content =
file.rawContent?.toUint8List() ?? file.content as Uint8List;
String hash = sha1.convert(content).toString();
final checksum = manifestEntry.value;
if (hash != checksum) {
throw ManifestChecksumError(expected: checksum, actual: hash);
}
folder.add(file);
},
),
);
final passFile =
archive.singleWhere((element) => element.name == 'pass.json');
final PassMetadata metadata = PassMetadata.fromJson(
_jsonCodec.decode(passFile.stringContent) as Map<String, Object?>,
);
return PassFile(metadata, folder);
}
final PassMetadata metadata;
final List<ArchiveFile> _folder;
Uint8List? _matchUtf8List({
required String name,
required Locale? locale,
required int scale,
}) {
final files = _folder.map((e) => e.name).toList();
final path = FileMatcher.matchFile(
files: files,
name: name,
scale: scale,
locale: locale,
extension: 'png',
);
if (path == null) return null;
final file = _folder.singleWhere((element) => element.name == path);
final content = file.rawContent?.toUint8List() ?? file.content as Uint8List;
return content;
}
Uint8List? getBackground({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'background', locale: locale, scale: scale);
Uint8List? getFooter({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'footer', locale: locale, scale: scale);
Uint8List? getIcon({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'icon', locale: locale, scale: scale);
Uint8List? getLogo({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'logo', locale: locale, scale: scale);
Uint8List? getStrip({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'strip', locale: locale, scale: scale);
Uint8List? getThumbnail({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'thumbnail', locale: locale, scale: scale);
Map<String, String>? getLocalizations(Locale? locale) {
final files = _folder.map((e) => e.name).toList();
final paths = FileMatcher.matchLocale(
files: files,
name: 'pass',
extension: 'strings',
locale: locale,
);
if (paths.isEmpty) return null;
final file = _folder.singleWhere((element) => element.name == paths.first);
return LProjParser.parse(file.stringContent);
}
}
extension on ArchiveFile {
String get stringContent =>
_utf8codec.decode(rawContent?.toUint8List() ?? (content as Uint8List));
}

View file

@ -0,0 +1,408 @@
/// Creates in [int] with a parsed [Color] value from a CSS color String.
///
/// Credits : https://pub.dev/packages/from_css_color - ported since dependency
/// on `dart:ui`, here reimplemented without but with raw int.
int fromCssColor(String color) {
color = color.trim();
switch (_recognizeCssColorFormat(color)) {
case ColorFormat.hex:
return _hexToColor(color);
case ColorFormat.rgb:
case ColorFormat.rgba:
return _rgbToColor(color);
case ColorFormat.keyword:
return colorKeywords[color]!;
default:
return _hslToColor(color);
}
}
/// Translation options from [Color] to a string format recognizable according to https://drafts.csswg.org/css-color-3 and forthcoming drafts.
enum CssColorString {
/// Hex format that truncates to a short form (3-4 digits) if possible and contains alpha digits if color is not fully opaque.
hex,
/// RGB/RGBA format that contains alpha value if color is not fully opaque.
rgb,
}
/// Color formats available to construct [Color] instance from.
enum ColorFormat {
hex,
rgb,
rgba,
hsl,
hsla,
keyword,
}
ColorFormat _recognizeCssColorFormat(String color) {
if (color.startsWith('#')) {
return ColorFormat.hex;
} else if (color.startsWith('rgba')) {
return ColorFormat.rgba;
} else if (color.startsWith('rgb')) {
return ColorFormat.rgb;
} else if (color.startsWith('hsla')) {
return ColorFormat.hsla;
} else if (color.startsWith('hsl')) {
return ColorFormat.hsl;
} else if (colorKeywords.containsKey(color)) {
return ColorFormat.keyword;
}
throw FormatException('Unable to recognize this CSS color format.', color);
}
/// Check correctness of color string according to https://drafts.csswg.org/css-color-3
bool isCssColor(String color) {
color = color.trim();
final chNumExpr = '-?[0-9]{1,3}(\\.[0-9]+)?';
final opNumExpr = '-?([01]+(\\.[0-9]+)?|\\.[0-9]+)';
if (RegExp(
r'^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$',
).hasMatch(color)) {
return true;
} else if (RegExp(
'^rgba?\\($chNumExpr%,\\s?$chNumExpr%,\\s?$chNumExpr%(,\\s?$opNumExpr)?\\)\$',
).hasMatch(color)) {
return true;
} else if (RegExp(
'^rgba?\\($chNumExpr,\\s?$chNumExpr,\\s?$chNumExpr(,\\s?$opNumExpr)?\\)\$',
).hasMatch(color)) {
return true;
} else if (RegExp(
'^hsla?\\($chNumExpr,\\s?$chNumExpr%,\\s?$chNumExpr%(,\\s?$opNumExpr)?\\)\$',
).hasMatch(color)) {
return true;
} else if (colorKeywords.containsKey(color)) {
return true;
} else {
return false;
}
}
@Deprecated('Call isCssColor() instead.'
'This feature was deprecated since v1.2.0 to meet the https://dart.dev/guides/language/effective-dart/style#do-capitalize-acronyms-and-abbreviations-longer-than-two-letters-like-words')
/// Check correctness of color string according to https://drafts.csswg.org/css-color-3
bool isCSSColor(String color) {
return isCssColor(color);
}
/// Creates [Color] instance from hexadecimal color value.
int _hexToColor(String color) {
color = color.substring(1);
String alpha = 'FF';
if (color.length == 4) {
alpha = color[3] * 2;
color = color.substring(0, 3);
} else if (color.length == 8) {
alpha = color.substring(6);
color = color.substring(0, 6);
}
if (color.length == 3) {
color = color.splitMapJoin('', onNonMatch: (m) => m * 2);
} else if (color.length != 6) {
throw FormatException(
'Hex color string has incorrect length, only strings of 3 or 6 characters are allowed.',
'#$color',
);
}
return 0x1000000 *
int.parse(
alpha,
radix: 16,
) +
int.parse(
color,
radix: 16,
);
}
/// Parses channels from RGBA/HSLA string representation.
List<String>? _parseChannels(color) {
return color.substring(color.indexOf('(') + 1, color.length - 1).split(',');
}
/// Converts RGB channel numeric [value] to hexadecimal integer form.
int _rgbChannelNumToHex(String value) {
return double.parse(value).clamp(0, 255).floor();
}
/// Returns `true` if all [rgb] channels are in percent format, `false` in non of them, throws otherwise.
bool _isPercentFormat(List<String> rgb) {
if (rgb.every((ch) => ch.endsWith('%'))) {
return true;
} else if (rgb.every((ch) => !ch.endsWith('%'))) {
return false;
} else {
throw FormatException(
'Mixing integer and percentage values in the same RGB color definition is forbidden.',
rgb.toString(),
);
}
}
/// Converts RGBA/HSLA opacity channel [value] to hexadecimal integer form.
int _opacityChannelToHex(String value) {
return (double.parse(value).clamp(0, 1) * 255).floor();
}
/// Converts RGB channel percentage [value] to hexadecimal integer form.
int _rgbChannelPercentToHex(String value) {
return (_parsePercent(value) * 255 / 100).floor();
}
/// Parses numeric value from string [percent] representation.
num _parsePercent(String percent) {
return (double.parse(percent.substring(0, percent.length - 1)).clamp(0, 100));
}
/// Creates [Color] instance from RGB(A) color value.
int _rgbToColor(String color) {
var channels = _parseChannels(color)!;
var result = 0xFF000000;
var shift = 16;
if (channels.length == 4) {
result = (_opacityChannelToHex(channels.removeLast()) << 24) & result;
} else if (channels.length != 3) {
throw FormatException(
'Incorrect number of values in RGB color string, there must be 3 or 4 of them.',
color,
);
}
if (_isPercentFormat(channels)) {
for (var ch in channels) {
result = (_rgbChannelPercentToHex(ch) << shift) | result;
shift -= 8;
}
} else {
for (var ch in channels) {
result = (_rgbChannelNumToHex(ch) << shift) | result;
shift -= 8;
}
}
return result;
}
/// Creates [Color] instance from HSL(A) color value.
int _hslToColor(String color) {
var channels = _parseChannels(color)!;
var result = 0xFF000000;
var shift = 16;
if (channels.length == 4) {
result = (_opacityChannelToHex(channels.removeLast()) << 24) & result;
} else if (channels.length != 3) {
throw FormatException(
'Incorrect number of values in HSL color string, there must be 3 or 4 of them.',
color,
);
}
try {
// Translate HSL to RGB according to CSS3 draft
final h = double.parse(channels[0]) % 360 / 360;
final s = _parsePercent(channels[1]) / 100;
final l = _parsePercent(channels[2]) / 100;
final m2 = l < 0.5 ? l * (s + 1) : l + s - l * s;
final m1 = l * 2 - m2;
final hexChannels = [
_hueToRGB(m1, m2, h + 1 / 3),
_hueToRGB(m1, m2, h),
_hueToRGB(m1, m2, h - 1 / 3),
];
for (var ch in hexChannels) {
result = (ch << shift) | result;
shift -= 8;
}
return result;
} on FormatException catch (e) {
throw FormatException(
'Incorrect format of HSL color string.',
'${e.message} ${e.source}',
);
}
}
/// Converts hue parameters of HSL to RGB channel hexadecimal integer form.
int _hueToRGB(num m1, num m2, num h) {
int result;
if (h < 0) {
h = h + 1;
} else if (h > 1) {
h = h - 1;
}
if (h * 6 < 1) {
result = ((m1 + (m2 - m1) * h * 6) * 255).floor();
} else if (h * 2 < 1) {
result = (m2 * 255).floor();
} else if (h * 3 < 2) {
result = ((m1 + (m2 - m1) * (2 / 3 - h) * 6) * 255).floor();
} else {
result = (m1 * 255).floor();
}
return result;
}
// A map of X11 color keywords and their 8-digit hexadecimal forms.
Map<String, int> colorKeywords = {
"transparent": 0x00000000,
"aliceblue": 0xFFF0F8FF,
"antiquewhite": 0xFFFAEBD7,
"aqua": 0xFF00FFFF,
"aquamarine": 0xFF7FFFD4,
"azure": 0xFFF0FFFF,
"beige": 0xFFF5F5DC,
"bisque": 0xFFFFE4C4,
"black": 0xFF000000,
"blanchedalmond": 0xFFFFEBCD,
"blue": 0xFF0000FF,
"blueviolet": 0xFF8A2BE2,
"brown": 0xFFA52A2A,
"burlywood": 0xFFDEB887,
"cadetblue": 0xFF5F9EA0,
"chartreuse": 0xFF7FFF00,
"chocolate": 0xFFD2691E,
"coral": 0xFFFF7F50,
"cornflowerblue": 0xFF6495ED,
"cornsilk": 0xFFFFF8DC,
"crimson": 0xFFDC143C,
"cyan": 0xFF00FFFF,
"darkblue": 0xFF00008B,
"darkcyan": 0xFF008B8B,
"darkgoldenrod": 0xFFB8860B,
"darkgray": 0xFFA9A9A9,
"darkgreen": 0xFF006400,
"darkgrey": 0xFFA9A9A9,
"darkkhaki": 0xFFBDB76B,
"darkmagenta": 0xFF8B008B,
"darkolivegreen": 0xFF556B2F,
"darkorange": 0xFFFF8C00,
"darkorchid": 0xFF9932CC,
"darkred": 0xFF8B0000,
"darksalmon": 0xFFE9967A,
"darkseagreen": 0xFF8FBC8F,
"darkslateblue": 0xFF483D8B,
"darkslategray": 0xFF2F4F4F,
"darkslategrey": 0xFF2F4F4F,
"darkturquoise": 0xFF00CED1,
"darkviolet": 0xFF9400D3,
"deeppink": 0xFFFF1493,
"deepskyblue": 0xFF00BFFF,
"dimgray": 0xFF696969,
"dimgrey": 0xFF696969,
"dodgerblue": 0xFF1E90FF,
"firebrick": 0xFFB22222,
"floralwhite": 0xFFFFFAF0,
"forestgreen": 0xFF228B22,
"fuchsia": 0xFFFF00FF,
"gainsboro": 0xFFDCDCDC,
"ghostwhite": 0xFFF8F8FF,
"gold": 0xFFFFD700,
"goldenrod": 0xFFDAA520,
"gray": 0xFF808080,
"green": 0xFF008000,
"greenyellow": 0xFFADFF2F,
"grey": 0xFF808080,
"honeydew": 0xFFF0FFF0,
"hotpink": 0xFFFF69B4,
"indianred": 0xFFCD5C5C,
"indigo": 0xFF4B0082,
"ivory": 0xFFFFFFF0,
"khaki": 0xFFF0E68C,
"lavender": 0xFFE6E6FA,
"lavenderblush": 0xFFFFF0F5,
"lawngreen": 0xFF7CFC00,
"lemonchiffon": 0xFFFFFACD,
"lightblue": 0xFFADD8E6,
"lightcoral": 0xFFF08080,
"lightcyan": 0xFFE0FFFF,
"lightgoldenrodyellow": 0xFFFAFAD2,
"lightgray": 0xFFD3D3D3,
"lightgreen": 0xFF90EE90,
"lightgrey": 0xFFD3D3D3,
"lightpink": 0xFFFFB6C1,
"lightsalmon": 0xFFFFA07A,
"lightseagreen": 0xFF20B2AA,
"lightskyblue": 0xFF87CEFA,
"lightslategray": 0xFF778899,
"lightslategrey": 0xFF778899,
"lightsteelblue": 0xFFB0C4DE,
"lightyellow": 0xFFFFFFE0,
"lime": 0xFF00FF00,
"limegreen": 0xFF32CD32,
"linen": 0xFFFAF0E6,
"magenta": 0xFFFF00FF,
"maroon": 0xFF800000,
"mediumaquamarine": 0xFF66CDAA,
"mediumblue": 0xFF0000CD,
"mediumorchid": 0xFFBA55D3,
"mediumpurple": 0xFF9370DB,
"mediumseagreen": 0xFF3CB371,
"mediumslateblue": 0xFF7B68EE,
"mediumspringgreen": 0xFF00FA9A,
"mediumturquoise": 0xFF48D1CC,
"mediumvioletred": 0xFFC71585,
"midnightblue": 0xFF191970,
"mintcream": 0xFFF5FFFA,
"mistyrose": 0xFFFFE4E1,
"moccasin": 0xFFFFE4B5,
"navajowhite": 0xFFFFDEAD,
"navy": 0xFF000080,
"oldlace": 0xFFFDF5E6,
"olive": 0xFF808000,
"olivedrab": 0xFF6B8E23,
"orange": 0xFFFFA500,
"orangered": 0xFFFF4500,
"orchid": 0xFFDA70D6,
"palegoldenrod": 0xFFEEE8AA,
"palegreen": 0xFF98FB98,
"paleturquoise": 0xFFAFEEEE,
"palevioletred": 0xFFDB7093,
"papayawhip": 0xFFFFEFD5,
"peachpuff": 0xFFFFDAB9,
"peru": 0xFFCD853F,
"pink": 0xFFFFC0CB,
"plum": 0xFFDDA0DD,
"powderblue": 0xFFB0E0E6,
"purple": 0xFF800080,
"red": 0xFFFF0000,
"rosybrown": 0xFFBC8F8F,
"royalblue": 0xFF4169E1,
"saddlebrown": 0xFF8B4513,
"salmon": 0xFFFA8072,
"sandybrown": 0xFFF4A460,
"seagreen": 0xFF2E8B57,
"seashell": 0xFFFFF5EE,
"sienna": 0xFFA0522D,
"silver": 0xFFC0C0C0,
"skyblue": 0xFF87CEEB,
"slateblue": 0xFF6A5ACD,
"slategray": 0xFF708090,
"slategrey": 0xFF708090,
"snow": 0xFFFFFAFA,
"springgreen": 0xFF00FF7F,
"steelblue": 0xFF4682B4,
"tan": 0xFFD2B48C,
"teal": 0xFF008080,
"thistle": 0xFFD8BFD8,
"tomato": 0xFFFF6347,
"turquoise": 0xFF40E0D0,
"violet": 0xFFEE82EE,
"wheat": 0xFFF5DEB3,
"white": 0xFFFFFFFF,
"whitesmoke": 0xFFF5F5F5,
"yellow": 0xFFFFFF00,
"yellowgreen": 0xFF9ACD32,
};

View file

@ -0,0 +1,133 @@
import 'package:intl/locale.dart';
abstract class FileMatcher {
const FileMatcher._();
static String? matchFile({
required List<String> files,
required String name,
String extension = 'png',
int scale = 1,
Locale? locale,
}) {
final localized = matchLocale(files: files, name: 'logo', extension: 'png');
final scaled = matchScale(files: localized, name: 'logo', extension: 'png');
final file = files.singleWhere((element) => element == scaled);
return file;
}
static List<String> matchLocale({
required List<String> files,
Locale? locale,
required String name,
required String extension,
}) {
files.sort();
files = files.reversed.toList();
List<RegExp> expressions = <RegExp>[];
// adding the fallbacks
// - match just *any* language
// - match the five mostly spoken languages of the world, copied from Wikipedia
expressions.addAll(
[
RegExp(
'^([a-z]+(-[a-z]+)?\\.lproj\\/)?$name(@\\d+x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
),
...['en', 'zh', 'hi', 'es', 'fr'].reversed.map(
(language) => RegExp(
'^$language(-[a-z]+)?\\.lproj\\/$name(@\\d+x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
),
),
],
);
if (locale != null) {
final language = locale.languageCode;
expressions.add(
RegExp(
'^$language(-[a-z]+)?\\.lproj\\/$name(@\\d+x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
),
);
final region = locale.countryCode;
if (region != null) {
expressions.add(
RegExp(
'^$language-$region\\.lproj\\/$name(@\\d+x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
),
);
}
}
for (final regex in expressions.reversed) {
final matches = <String>[];
for (final file in files) {
final match = regex.stringMatch(file);
if (match != null) matches.add(match);
}
if (matches.isNotEmpty) return matches;
}
return [];
}
static String matchScale({
required List<String> files,
int scale = 1,
required String name,
required String extension,
}) {
files.sort();
files = files.reversed.toList();
final regex = RegExp(
'$name(@(\\d+)x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
);
int closestScale = 0;
final matches = <String>[];
for (final file in files) {
final match = regex.firstMatch(file);
if (match != null) {
String? group = match[2];
if (group == null) {
if (scale == 1) return match.input;
group = '1';
}
final matchedScale = int.parse(group);
if (matchedScale == scale) return match.input;
if (matchedScale < scale) {
if (closestScale < matchedScale) closestScale = matchedScale;
} else if (closestScale < matchedScale) {
closestScale = matchedScale;
}
matches.add(match.input);
}
}
final scaledMatches = matches
.where(
(element) => RegExp(
'$name(@${closestScale}x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
).hasMatch(element),
)
.toList();
scaledMatches.sort();
return scaledMatches.last;
}
}

View file

@ -0,0 +1,10 @@
abstract class LProjParser {
const LProjParser._();
static Map<String, String> parse(String stringsFile) => Map.fromEntries(
RegExp(r'"((?:\\"|[^"])*)"\s?=\s?"((?:\\"|[^"])*)"\s?;')
.allMatches(stringsFile)
.map(
(match) => MapEntry(match.group(1)!, match.group(2)!),
),
);
}

View file

@ -0,0 +1,29 @@
import '../models/pass_structure_dictionary.dart';
import 'color_helper.dart';
abstract class MaybeDecode {
const MaybeDecode._();
static int? maybeColor(String? colorCode) {
if (colorCode == null) return null;
return fromCssColor(colorCode);
}
static DateTime? maybeDateTime(String? timeStamp) {
if (timeStamp == null) return null;
return DateTime.tryParse(timeStamp);
}
static PassTextAlign? maybeTextAlign(String? align) {
switch (align) {
case 'PKTextAlignmentLeft':
return PassTextAlign.left;
case 'PKTextAlignmentCenter':
return PassTextAlign.center;
case 'PKTextAlignmentRight':
return PassTextAlign.right;
default:
return PassTextAlign.natural;
}
}
}