diff --git a/.idea/libraries/Dart_Packages.xml b/.idea/libraries/Dart_Packages.xml index b019c35..23b18fc 100644 --- a/.idea/libraries/Dart_Packages.xml +++ b/.idea/libraries/Dart_Packages.xml @@ -37,6 +37,13 @@ + + + + + + @@ -54,7 +61,7 @@ - @@ -219,6 +226,13 @@ + + + + + + @@ -374,9 +388,10 @@ + - + @@ -400,6 +415,7 @@ + diff --git a/README.md b/README.md index 83a1218..f39884c 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,35 @@ A Dart native pkpass parsing library - no platform specific dependencies - pure Dart - parse any .pkpass file as blob +- checksum verification - extract metadata - high level lookup for assets by locale and scale, with proper fallbacks +- high level barcode API +- No dependency on `dart:io` or `dart:ui` runs everywhere, from CLI to Flutter + +## Not supported (yet) + +Some parts of the PkPass specification are either not yet implemented, or not planned, such as: + +- `signature`: The detached PKCS #7 signature using Apple certificates of the manifest. Note: Checksums _are_ checked. - + Not planned, feel free to contribute. +- `nfc`: Card payment information for Apple Pay. - Not planned, feel free to contribute. +- `webService`: Information used to update passes using the web service. - Planned, feel free to contribute. + +## Dependencies and compatibility + +Any package should keep its dependencies as minimal as possible. Sometimes, there are specifications making this +difficult. The PkPass spec unfortunately is a very complex one, requiring support of many standards and formats. + +The following dependencies are used to correctly parse the PkPass file into a relevant Dart representation. + +- [`pub:archive`](https://pub.dev/packages/archive): The PkPass file itself is a ZIP archive, used to parse the raw + bytes. +- [`pub:barcode`](https://pub.dev/packages/barcode): Used to provide high-level access to barcode generation with the + proper encoding supported. +- [`pub:crypto`](https://pub.dev/packages/crypto): Used for SHA1 signature verification as defined in the PkPass spec. +- [`pub:intl`](https://pub.dev/packages/intl): Used for localization lookup of localizable resources like Strings or + assets. ## Getting started diff --git a/example/pkpass_example.dart b/example/pkpass_example.dart index c528792..8596a93 100644 --- a/example/pkpass_example.dart +++ b/example/pkpass_example.dart @@ -22,6 +22,9 @@ Future main(List args) async { ); print('Logo image blob length: ${logo?.length}'); + print('First barcode: ${pass.metadata.barcodes.firstOrNull?.message}'); + print('Location: ${pass.metadata.locations.firstOrNull?.relevantText}'); + print('Date: ${pass.metadata.relevantDate}'); return 0; } diff --git a/lib/src/models/barcode.dart b/lib/src/models/barcode.dart new file mode 100644 index 0000000..37d3e19 --- /dev/null +++ b/lib/src/models/barcode.dart @@ -0,0 +1,89 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:barcode/barcode.dart'; + +/// Information about a pass’s 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 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 doesn’t scan. + final String? altText; + + const PassBarcode({ + required this.format, + required this.message, + required this.messageEncoding, + required this.altText, + }); + + factory PassBarcode.fromJson(Map 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)); +} diff --git a/lib/src/models/beacon.dart b/lib/src/models/beacon.dart new file mode 100644 index 0000000..18596b9 --- /dev/null +++ b/lib/src/models/beacon.dart @@ -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 json) => Beacon( + proximityUUID: json['proximityUUID'] as String, + major: json['major'] as double?, + minor: json['minor'] as double?, + relevantText: json['relevantText'] as String?, + ); +} diff --git a/lib/src/models/location.dart b/lib/src/models/location.dart new file mode 100644 index 0000000..2ea31a2 --- /dev/null +++ b/lib/src/models/location.dart @@ -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 json) => Location( + latitude: json['latitude'] as double, + longitude: json['longitude'] as double, + altitude: json['altitude'] as double?, + relevantText: json['relevantText'] as String?, + ); +} diff --git a/lib/src/models/pass.dart b/lib/src/models/pass.dart index 7f84551..3a85025 100644 --- a/lib/src/models/pass.dart +++ b/lib/src/models/pass.dart @@ -1,15 +1,106 @@ +import 'package:pkpass/src/models/barcode.dart'; +import 'package:pkpass/src/utils/mabe_decode.dart'; +import 'beacon.dart'; +import 'location.dart'; +import 'pass_structure_dictionary.dart'; +import 'pass_web_service.dart'; + +/// Information that is required for all passes. class PassMetadata { - final String? description; + /// Brief description of the pass, used by accessibility technologies. + /// + /// Don’t 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; - final String? organizationName; - final String? passTypeIdentifier; - final String? serialNumber; - final String? teamIdentifier; - final bool? suppressStripShine; - final EventTicket? eventTicket; - final Barcode? barcode; - final String? foregroundColor; - final List? locations; + + /// 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 void—for 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 beacons; + + /// Locations where the pass is relevant. For example, the location of your store. + final List locations; + + /// Maximum distance in meters from a relevant latitude and longitude that + /// the pass is relevant. This number is compared to the pass’s 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 pass’s barcode. + /// The system uses the first valid barcode dictionary in the array. + /// Additional dictionaries can be added as fallbacks. + final List 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, @@ -18,308 +109,86 @@ class PassMetadata { required this.passTypeIdentifier, required this.serialNumber, required this.teamIdentifier, - required this.suppressStripShine, - required this.eventTicket, - required this.barcode, - required this.foregroundColor, - required this.locations, + 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 json) => PassMetadata( - description: json['description'] as String?, + 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?, - suppressStripShine: json['suppressStripShine'] as bool?, - eventTicket: json['ventTicket'] == null + 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 - : EventTicket.fromJson( - json['eventTicket'] as Map? ?? {}, + : PassStructureDictionary.fromJson( + (json['boardingPass'] as Map).cast(), ), - barcode: json['barcode'] == null + coupon: json['coupon'] == null ? null - : Barcode.fromJson(json['barcode'] as Map? ?? {}), - foregroundColor: json['foregroundColor'] as String?, - locations: (json['locations'] as List) + : PassStructureDictionary.fromJson( + (json['coupon'] as Map).cast(), + ), + eventTicket: json['eventTicket'] == null + ? null + : PassStructureDictionary.fromJson( + (json['coupon'] as Map).cast(), + ), + generic: json['generic'] == null + ? null + : PassStructureDictionary.fromJson( + (json['generic'] as Map).cast(), + ), + storeCard: json['storeCard'] == null + ? null + : PassStructureDictionary.fromJson( + (json['storeCard'] as Map).cast(), + ), + 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(), - ); - - Map toJson() => { - 'description': description, - 'formatVersion': formatVersion, - 'organizationName': organizationName, - 'passTypeIdentifier': passTypeIdentifier, - 'serialNumber': serialNumber, - 'teamIdentifier': teamIdentifier, - 'suppressStripShine': suppressStripShine, - 'eventTicket': eventTicket?.toJson(), - 'barcode': barcode?.toJson(), - 'foregroundColor': foregroundColor, - 'locations': locations?.map((i) => i.toJson()).toList(), - }; -} - -class EventTicket { - final List headerFields; - final List primaryFields; - final List secondaryFields; - final List backFields; - final List auxiliaryFields; - - const EventTicket({ - required this.headerFields, - required this.primaryFields, - required this.secondaryFields, - required this.backFields, - required this.auxiliaryFields, - }); - - factory EventTicket.fromJson(Map json) => EventTicket( - headerFields: (json['headerFields'] as List) - .map((i) => HeaderField.fromJson(i)) - .toList(), - primaryFields: (json['primaryFields'] as List) - .map((i) => PrimaryField.fromJson(i)) - .toList(), - secondaryFields: (json['secondaryFields'] as List) - .map((i) => SecondaryField.fromJson(i)) - .toList(), - backFields: (json['backFields'] as List) - .map((i) => BackField.fromJson(i)) - .toList(), - auxiliaryFields: (json['auxiliaryFields'] as List) - .map((i) => AuxiliaryField.fromJson(i)) + 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?, + ), ); - - Map toJson() => { - 'headerFields': headerFields.map((i) => i.toJson()).toList(), - 'primaryFields': primaryFields.map((i) => i.toJson()).toList(), - 'secondaryFields': secondaryFields.map((i) => i.toJson()).toList(), - 'backFields': backFields.map((i) => i.toJson()).toList(), - 'auxiliaryFields': auxiliaryFields.map((i) => i.toJson()).toList(), - }; -} - -class HeaderField { - final String? key; - final String? value; - final String? label; - final String? changeMessage; - final String? textAlignment; - - const HeaderField({ - required this.key, - required this.value, - required this.label, - required this.changeMessage, - required this.textAlignment, - }); - - factory HeaderField.fromJson(Map json) => HeaderField( - key: json['key'] as String?, - value: json['value'] as String?, - label: json['label'] as String?, - changeMessage: json['changeMessage'] as String?, - textAlignment: json['textAlignment'] as String?, - ); - - Map toJson() => { - 'key': key, - 'value': value, - 'label': label, - 'changeMessage': changeMessage, - 'textAlignment': textAlignment, - }; -} - -class PrimaryField { - final String? key; - final String? value; - final String? label; - final String? changeMessage; - final String? textAlignment; - - const PrimaryField({ - required this.key, - required this.value, - required this.label, - required this.changeMessage, - required this.textAlignment, - }); - - factory PrimaryField.fromJson(Map json) => PrimaryField( - key: json['key'] as String?, - value: json['value'] as String?, - label: json['label'] as String?, - changeMessage: json['changeMessage'] as String?, - textAlignment: json['textAlignment'] as String?, - ); - - Map toJson() => { - 'key': key, - 'value': value, - 'label': label, - 'changeMessage': changeMessage, - 'textAlignment': textAlignment, - }; -} - -class SecondaryField { - final String? key; - final String? value; - final String? label; - final String? changeMessage; - final String? textAlignment; - - const SecondaryField({ - required this.key, - required this.value, - required this.label, - required this.changeMessage, - required this.textAlignment, - }); - - factory SecondaryField.fromJson(Map json) => SecondaryField( - key: json['key'] as String?, - value: json['value'] as String?, - label: json['label'] as String?, - changeMessage: json['changeMessage'] as String?, - textAlignment: json['textAlignment'] as String?, - ); - - Map toJson() => { - 'key': key, - 'value': value, - 'label': label, - 'changeMessage': changeMessage, - 'textAlignment': textAlignment, - }; -} - -class BackField { - final String? key; - final String? value; - final String? label; - final String? changeMessage; - final String? textAlignment; - - const BackField({ - required this.key, - required this.value, - required this.label, - required this.changeMessage, - required this.textAlignment, - }); - - factory BackField.fromJson(Map json) => BackField( - key: json['key'] as String?, - value: json['value'] as String?, - label: json['label'] as String?, - changeMessage: json['changeMessage'] as String?, - textAlignment: json['textAlignment'] as String?, - ); - - Map toJson() => { - 'key': key, - 'value': value, - 'label': label, - 'changeMessage': changeMessage, - 'textAlignment': textAlignment, - }; -} - -class AuxiliaryField { - final String? key; - final String? value; - final String? label; - final String? changeMessage; - final String? textAlignment; - - const AuxiliaryField({ - required this.key, - required this.value, - required this.label, - required this.changeMessage, - required this.textAlignment, - }); - - factory AuxiliaryField.fromJson(Map json) => AuxiliaryField( - key: json['key'] as String?, - value: json['value'] as String?, - label: json['label'] as String?, - changeMessage: json['changeMessage'] as String?, - textAlignment: json['textAlignment'] as String?, - ); - - Map toJson() => { - 'key': key, - 'value': value, - 'label': label, - 'changeMessage': changeMessage, - 'textAlignment': textAlignment, - }; -} - -class Barcode { - final String? format; - final String? message; - final String? messageEncoding; - final String? altText; - - const Barcode({ - required this.format, - required this.message, - required this.messageEncoding, - required this.altText, - }); - - factory Barcode.fromJson(Map json) => Barcode( - format: json['format'] as String?, - message: json['message'] as String?, - messageEncoding: json['messageEncoding'] as String?, - altText: json['altText'] as String?, - ); - - Map toJson() => { - 'format': format, - 'message': message, - 'messageEncoding': messageEncoding, - 'altText': altText, - }; -} - -class Location { - final double? latitude; - final double? longitude; - final double? altitude; - final double? distance; - final String? relevantText; - - const Location({ - required this.latitude, - required this.longitude, - required this.altitude, - required this.distance, - required this.relevantText, - }); - - factory Location.fromJson(Map json) => Location( - latitude: json['latitude'] as double?, - longitude: json['longitude'] as double?, - altitude: json['altitude'] as double?, - distance: json['distance'] as double?, - relevantText: json['relevantText'] as String?, - ); - - Map toJson() => { - 'latitude': latitude, - 'longitude': longitude, - 'altitude': altitude, - 'distance': distance, - 'relevantText': relevantText, - }; } diff --git a/lib/src/models/pass_structure_dictionary.dart b/lib/src/models/pass_structure_dictionary.dart new file mode 100644 index 0000000..fc9e8fd --- /dev/null +++ b/lib/src/models/pass_structure_dictionary.dart @@ -0,0 +1,171 @@ +import 'package:pkpass/src/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 headerFields; + + /// Fields to be displayed prominently on the front of the pass. + final List primaryFields; + + /// Fields to be displayed on the front of the pass. + final List secondaryFields; + + /// Fields to be on the back of the pass. + final List backFields; + + /// Additional fields to be displayed on the front of the pass. + final List 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 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 field’s new value. For example, “Gate changed to %@.” + /// + /// If you don’t specify a change message, the user isn’t notified when the field changes. + final String? changeMessage; + + /// Alignment for the field’s contents. + final PassTextAlign? textAlignment; + + /// Attributed value of the field. + /// + /// The value may contain HTML markup for links. Only the 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": "Edit my profile" + /// + /// This key’s 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 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), + ); +} + +abstract class DictionaryValue { + const DictionaryValue(); + + 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); + } +} + +class StringDictionaryValue extends DictionaryValue { + final String string; + + const StringDictionaryValue(this.string); +} + +class DateTimeDictionaryValue extends DictionaryValue { + final DateTime dateTime; + + const DateTimeDictionaryValue(this.dateTime); +} + +class NumberDictionaryValue extends DictionaryValue { + final int number; + + const NumberDictionaryValue(this.number); +} + +enum TransitType { + air, + boat, + bus, + generic, + train, +} + +enum PassTextAlign { left, center, right, 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; + } + } +} diff --git a/lib/src/models/pass_web_service.dart b/lib/src/models/pass_web_service.dart new file mode 100644 index 0000000..41d4140 --- /dev/null +++ b/lib/src/models/pass_web_service.dart @@ -0,0 +1,31 @@ +/// TODO: implement PassKit Web Service Reference +/// +/// 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, + }); + + 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, + ); + } +} diff --git a/lib/src/utils/color_helper.dart b/lib/src/utils/color_helper.dart new file mode 100644 index 0000000..eefa3af --- /dev/null +++ b/lib/src/utils/color_helper.dart @@ -0,0 +1,71 @@ +/// Creates in [int] with a parsed [Color] value from RGB(A) color value. +/// +/// Credits : https://pub.dev/packages/from_css_color - ported since dependency +/// on `dart:ui`, here reimplemented without but with raw int. +int parseRgbToInt(String color) { + List channels = _parseChannels(color)!; + int result = 0xFF000000; + int 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; +} + +/// Parses channels from RGBA/HSLA string representation. +List? _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 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)); +} diff --git a/lib/src/utils/mabe_decode.dart b/lib/src/utils/mabe_decode.dart new file mode 100644 index 0000000..99f0146 --- /dev/null +++ b/lib/src/utils/mabe_decode.dart @@ -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 parseRgbToInt(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; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 337d65d..cb3bfd8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,6 +10,7 @@ environment: dependencies: archive: ^3.3.7 + barcode: ^2.2.4 crypto: ^3.0.3 intl: ^0.18.1 diff --git a/test/pkpass_test.dart b/test/pkpass_test.dart index b22f0d4..0394b90 100644 --- a/test/pkpass_test.dart +++ b/test/pkpass_test.dart @@ -23,17 +23,12 @@ void main() { // ignore: unused_local_variable final file = PassFile( PassMetadata( - description: null, - formatVersion: 0, - organizationName: null, - passTypeIdentifier: null, - serialNumber: null, - teamIdentifier: null, - suppressStripShine: null, - eventTicket: null, - barcode: null, - foregroundColor: null, - locations: [], + description: '', + formatVersion: 1, + organizationName: '', + passTypeIdentifier: '', + serialNumber: '', + teamIdentifier: '', ), archive, );