diff --git a/app/lib/example/example_passes.dart b/app/lib/example/example_passes.dart index 4b01d22..ff69411 100644 --- a/app/lib/example/example_passes.dart +++ b/app/lib/example/example_passes.dart @@ -44,9 +44,9 @@ class _ExamplePassesState extends State { child: InkWell( child: PkPassWidget(pass: pass), onTap: () { - router.push( + navigator.pushNamed( '/backside', - extra: PassBackSidePageArgs(pass, false), + arguments: PassBackSidePageArgs(pass, false), ); }, ), diff --git a/app/lib/home/home_page.dart b/app/lib/home/home_page.dart index 31b7972..3398cd5 100644 --- a/app/lib/home/home_page.dart +++ b/app/lib/home/home_page.dart @@ -1,4 +1,5 @@ import 'package:app/home/pass_list_notifier.dart'; +import 'package:app/import_order/import_order_page.dart'; import 'package:app/import_pass/import_page.dart'; import 'package:app/import_pass/pick_pass.dart'; import 'package:app/pass_backside/pass_backside_page.dart'; @@ -38,12 +39,22 @@ class _HomePageState extends State { } // TODO(ueman): Add more validation - await router.push( - '/import', - extra: PkPassImportSource( - bytes: await detail.files.first.readAsBytes(), - ), - ); + if (firstFile.name.endsWith('pkpass')) { + await navigator.pushNamed( + '/import', + arguments: PkPassImportSource( + bytes: await detail.files.first.readAsBytes(), + ), + ); + } + if (firstFile.name.endsWith('order')) { + await navigator.pushNamed( + '/importOrder', + arguments: PkOrderImportSource( + bytes: await detail.files.first.readAsBytes(), + ), + ); + } }, child: Scaffold( appBar: AppBar( @@ -51,7 +62,7 @@ class _HomePageState extends State { centerTitle: true, leading: kDebugMode ? IconButton( - onPressed: () => router.push('/examples'), + onPressed: () => navigator.pushNamed('/examples'), icon: const Icon(Icons.card_giftcard), ) : null, @@ -61,7 +72,7 @@ class _HomePageState extends State { icon: const Icon(Icons.file_open), ), IconButton( - onPressed: () => router.push('/settings'), + onPressed: () => navigator.pushNamed('/settings'), icon: const Icon(Icons.settings), tooltip: AppLocalizations.of(context).settings, ), @@ -96,9 +107,10 @@ class _HomePageState extends State { return PassListTile( pass: pass, onTap: () { - router.push( + navigator.pushNamed( '/backside', - extra: PassBackSidePageArgs(pass, true), + arguments: + PassBackSidePageArgs(pass, true), ); }, ); @@ -108,9 +120,10 @@ class _HomePageState extends State { child: InkWell( child: PkPassWidget(pass: pass), onTap: () { - router.push( + navigator.pushNamed( '/backside', - extra: PassBackSidePageArgs(pass, true), + arguments: + PassBackSidePageArgs(pass, true), ); }, ), diff --git a/app/lib/import_order/import_order_page.dart b/app/lib/import_order/import_order_page.dart new file mode 100644 index 0000000..81b35e0 --- /dev/null +++ b/app/lib/import_order/import_order_page.dart @@ -0,0 +1,103 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:content_resolver/content_resolver.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:passkit/passkit.dart'; +// ignore: implementation_imports +import 'package:passkit_ui/src/order/order_widget.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PkOrderImportSource { + PkOrderImportSource({this.contentResolverPath, this.bytes, this.filePath}) + : assert( + contentResolverPath != null || bytes != null || filePath != null, + ); + + final String? contentResolverPath; + final Uint8List? bytes; + final String? filePath; + + Future getOrder() async { + if (contentResolverPath != null) { + final Content content = + await ContentResolver.resolveContent(contentResolverPath!); + return PkOrder.fromBytes( + content.data, + skipChecksumVerification: true, + skipSignatureVerification: true, + ); + } else if (bytes != null) { + return PkOrder.fromBytes( + bytes!, + skipChecksumVerification: true, + skipSignatureVerification: true, + ); + } else if (filePath != null) { + return PkOrder.fromBytes( + await File(filePath!).readAsBytes(), + skipChecksumVerification: true, + skipSignatureVerification: true, + ); + } + throw Exception('No data'); + } +} + +class ImportOrderPage extends StatefulWidget { + const ImportOrderPage({super.key, required this.source}); + + final PkOrderImportSource source; + + @override + State createState() => _ImportOrderPageState(); +} + +class _ImportOrderPageState extends State { + PkOrder? order; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + final pkOrder = await widget.source.getOrder(); + setState(() { + order = pkOrder; + }); + } + + @override + Widget build(BuildContext context) { + if (order == null) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context).import), + actions: [ + IconButton( + // TODO(ueman): Maybe show confirmation dialog here + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.delete), + ), + ], + ), + body: const Center(child: CircularProgressIndicator()), + ); + } else { + return OrderWidget( + order: order!, + isOrderImport: true, + onDeleteOrderClicked: (order) {}, + onManageOrderClicked: launchUrl, + onVisitMerchantWebsiteClicked: launchUrl, + onShareClicked: (order) {}, + onMarkOrderCompletedClicked: (order) {}, + onTrackingLinkClicked: launchUrl, + onImportOrderClicked: (order) {}, + ); + } + } +} diff --git a/app/lib/import_order/pick_order.dart b/app/lib/import_order/pick_order.dart new file mode 100644 index 0000000..dd34fc8 --- /dev/null +++ b/app/lib/import_order/pick_order.dart @@ -0,0 +1,37 @@ +import 'package:app/import_order/import_order_page.dart'; +import 'package:app/router.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:path/path.dart'; + +Future pickOrder(BuildContext context) async { + final localizations = AppLocalizations.of(context); + final result = await FilePicker.platform.pickFiles( + dialogTitle: localizations.pickPasses, + allowMultiple: false, + type: FileType.any, + //allowedExtensions: ['pkpass', 'pass'], // This seems to not work even when combined with "type: FileType.custom" + ); + + if (result == null) { + return; + } + + final firstPath = result.files.firstOrNull?.path; + + if (firstPath == null) { + return; + } + + if ({'.order'}.contains(extension(firstPath))) { + // This is probably not a valid order + // TOOD show a hint to the user, that the user picked an ivalid file + return; + } + + await navigator.pushNamed( + '/importOrder', + arguments: PkOrderImportSource(contentResolverPath: firstPath), + ); +} diff --git a/app/lib/import_order/receive_pass.dart b/app/lib/import_order/receive_pass.dart new file mode 100644 index 0000000..21e2e95 --- /dev/null +++ b/app/lib/import_order/receive_pass.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:app/import_pass/import_page.dart'; +import 'package:app/router.dart'; +import 'package:flutter/services.dart'; +import 'package:receive_intent/receive_intent.dart'; + +Future initReceiveIntent() async { + try { + final receivedIntent = await ReceiveIntent.getInitialIntent(); + await _onIntent(receivedIntent); + } on PlatformException { + // Handle exception + } +} + +// Does not need to be cancelled, since it's running for the lifetime of the app +// ignore: unused_element +StreamSubscription? _sub; + +Future initReceiveIntentWhileRunning() async { + _sub = ReceiveIntent.receivedIntentStream.listen( + (Intent? intent) { + // Validate receivedIntent and warn the user, if it is not correct, + _onIntent(intent); + }, + onError: (err) { + // TODO(ueman): Handle exception + }, + ); +} + +Future _onIntent(Intent? receivedIntent) async { + if (receivedIntent == null || receivedIntent.isNull) { + // Validate receivedIntent and warn the user, if it is not correct, + // but keep in mind it could be `null` or "empty"(`receivedIntent.isNull`). + // + // TODO(ueman): show error popup? + return; + } + if (receivedIntent.action == 'android.intent.action.MAIN') { + return; + } + final path = receivedIntent.data; + + if (path == null) { + // TODO(ueman): show error popup? + return; + } + unawaited( + navigator.pushNamed( + '/import', + arguments: PkPassImportSource(contentResolverPath: path), + ), + ); +} diff --git a/app/lib/import_pass/import_page.dart b/app/lib/import_pass/import_page.dart index b996bd8..31c0d5d 100644 --- a/app/lib/import_pass/import_page.dart +++ b/app/lib/import_pass/import_page.dart @@ -10,7 +10,6 @@ import 'package:app/router.dart'; import 'package:content_resolver/content_resolver.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:go_router/go_router.dart'; import 'package:passkit/passkit.dart'; import 'package:passkit_ui/passkit_ui.dart'; @@ -88,9 +87,9 @@ class _ImportPassPageState extends State { child: InkWell( child: Center(child: PkPassWidget(pass: pass!)), onTap: () { - router.push( + navigator.pushNamed( '/backside', - extra: PassBackSidePageArgs(pass!, false), + arguments: PassBackSidePageArgs(pass!, false), ); }, ), @@ -128,7 +127,7 @@ class _ImportPassPageState extends State { actions: [ IconButton( // TODO(ueman): Maybe show confirmation dialog here - onPressed: () => context.pop(), + onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.delete), ), ], diff --git a/app/lib/import_pass/pick_pass.dart b/app/lib/import_pass/pick_pass.dart index e685132..1d6cd44 100644 --- a/app/lib/import_pass/pick_pass.dart +++ b/app/lib/import_pass/pick_pass.dart @@ -1,3 +1,4 @@ +import 'package:app/import_order/import_order_page.dart'; import 'package:app/import_pass/import_page.dart'; import 'package:app/router.dart'; import 'package:file_picker/file_picker.dart'; @@ -24,11 +25,19 @@ Future pickPass(BuildContext context) async { return; } - if (!{'.pkpass', '.pass'}.contains(extension(firstPath))) { - // This is probably not a valid PkPass - // TOOD show a hint to the user, that the user picked an ivalid file + if ({'.pkpass', '.pass'}.contains(extension(firstPath))) { + await navigator.pushNamed( + '/import', + arguments: PkPassImportSource(filePath: firstPath), + ); return; } - await router.push('/import', extra: PkPassImportSource(filePath: firstPath)); + if ('.order' == extension(firstPath)) { + await navigator.pushNamed( + '/importOrder', + arguments: PkOrderImportSource(filePath: firstPath), + ); + return; + } } diff --git a/app/lib/import_pass/receive_pass.dart b/app/lib/import_pass/receive_pass.dart index 66dd07b..21e2e95 100644 --- a/app/lib/import_pass/receive_pass.dart +++ b/app/lib/import_pass/receive_pass.dart @@ -48,9 +48,9 @@ Future _onIntent(Intent? receivedIntent) async { return; } unawaited( - router.push( + navigator.pushNamed( '/import', - extra: PkPassImportSource(contentResolverPath: path), + arguments: PkPassImportSource(contentResolverPath: path), ), ); } diff --git a/app/lib/main_app.dart b/app/lib/main_app.dart index c0af653..58c1dd5 100644 --- a/app/lib/main_app.dart +++ b/app/lib/main_app.dart @@ -9,9 +9,9 @@ class MainApp extends StatelessWidget { @override Widget build(BuildContext context) { - return MaterialApp.router( + return MaterialApp( debugShowCheckedModeBanner: false, - routerConfig: router, + navigatorKey: navigatorKey, scaffoldMessengerKey: scaffoldMessenger, theme: _lightTheme(), darkTheme: _darkTheme(), @@ -19,6 +19,7 @@ class MainApp extends StatelessWidget { onGenerateTitle: (context) => AppLocalizations.of(context).appName, supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, + onGenerateRoute: routeGenerator, ); } } diff --git a/app/lib/router.dart b/app/lib/router.dart index 8053c24..4a459c2 100644 --- a/app/lib/router.dart +++ b/app/lib/router.dart @@ -1,38 +1,59 @@ import 'package:app/example/example_passes.dart'; import 'package:app/home/home_page.dart'; +import 'package:app/import_order/import_order_page.dart'; import 'package:app/import_pass/import_page.dart'; import 'package:app/pass_backside/pass_backside_page.dart'; import 'package:app/settings/settings_page.dart'; -import 'package:go_router/go_router.dart'; +import 'package:flutter/material.dart'; -final router = GoRouter( - routes: [ - GoRoute( - path: '/', - builder: (context, state) => const HomePage(), - ), - GoRoute( - path: '/import', - builder: (context, state) => - ImportPassPage(source: state.extra as PkPassImportSource), - ), - GoRoute( - path: '/examples', - builder: (context, state) => const ExamplePasses(), - ), - GoRoute( - path: '/settings', - builder: (context, state) => const SettingsPage(), - ), - GoRoute( - path: '/backside', - builder: (context, state) { - final args = state.extra as PassBackSidePageArgs; - return PassBacksidePage( - pass: args.pass, - showDelete: args.showDelete, - ); - }, - ), - ], -); +final navigatorKey = GlobalKey(); + +NavigatorState get navigator => navigatorKey.currentState!; + +Route? routeGenerator(RouteSettings settings) { + return switch (settings.name) { + '/' => MaterialPageRoute( + builder: (context) { + return const HomePage(); + }, + ), + '/import' => MaterialPageRoute( + builder: (context) { + return ImportPassPage( + source: settings.arguments as PkPassImportSource, + ); + }, + ), + '/importOrder' => MaterialPageRoute( + builder: (context) { + return ImportOrderPage( + source: settings.arguments as PkOrderImportSource, + ); + }, + ), + '/examples' => MaterialPageRoute( + builder: (context) { + return const ExamplePasses(); + }, + ), + '/settings' => MaterialPageRoute( + builder: (context) { + return const SettingsPage(); + }, + ), + '/backside' => MaterialPageRoute( + builder: (context) { + final args = settings.arguments as PassBackSidePageArgs; + return PassBacksidePage( + pass: args.pass, + showDelete: args.showDelete, + ); + }, + ), + _ => MaterialPageRoute( + builder: (context) { + return const HomePage(); + }, + ), + }; +} diff --git a/app/macos/Podfile.lock b/app/macos/Podfile.lock index 382c2cd..3eb011d 100644 --- a/app/macos/Podfile.lock +++ b/app/macos/Podfile.lock @@ -18,6 +18,9 @@ PODS: - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS + - sqflite (0.0.3): + - Flutter + - FlutterMacOS - "sqlite3 (3.46.0+1)": - "sqlite3/common (= 3.46.0+1)" - "sqlite3/common (3.46.0+1)" @@ -51,6 +54,7 @@ DEPENDENCIES: - screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`) - sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`) @@ -78,6 +82,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin sqlite3_flutter_libs: :path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos url_launcher_macos: @@ -95,6 +101,7 @@ SPEC CHECKSUMS: screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda share_plus: 36537c04ce0c3e3f5bd297ce4318b6d5ee5fd6cf shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 sqlite3_flutter_libs: 5ca46c1a04eddfbeeb5b16566164aa7ad1616e7b url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift index d53ef64..8e02df2 100644 --- a/app/macos/Runner/AppDelegate.swift +++ b/app/macos/Runner/AppDelegate.swift @@ -1,7 +1,7 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true diff --git a/app/pubspec.lock b/app/pubspec.lock index 73a483b..992dc4d 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -230,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" dart_style: dependency: transitive description: @@ -506,14 +514,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.2" - go_router: - dependency: "direct main" - description: - name: go_router - sha256: "48d03a1e4887b00fe622695139246e3c778ac814eeb32421467b56d23fa64034" - url: "https://pub.dev" - source: hosted - version: "14.2.6" google_fonts: dependency: "direct main" description: @@ -554,6 +554,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + url: "https://pub.dev" + source: hosted + version: "4.2.0" intl: dependency: "direct main" description: @@ -712,7 +720,7 @@ packages: path: "../passkit" relative: true source: path - version: "0.0.7" + version: "0.0.10" passkit_ui: dependency: "direct main" description: @@ -1359,4 +1367,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.5.0 <4.0.0" - flutter: ">=3.24.1" + flutter: ">=3.24.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 21a451c..10ad20c 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -4,12 +4,13 @@ publish_to: "none" version: 0.1.0 environment: - sdk: ">=3.5.0 <4.0.0" - flutter: 3.24.1 + sdk: ">=3.4.0 <4.0.0" + flutter: 3.22.3 dependencies: android_intent_plus: ^5.0.2 content_resolver: ^0.3.1 + cupertino_icons: ^1.0.8 desktop_drop: ^0.4.4 file_picker: ^8.1.2 floor: ^1.5.0 @@ -22,7 +23,6 @@ dependencies: sdk: flutter geocoding: ^3.0.0 geolocator: ^13.0.0 - go_router: ^14.2.0 google_fonts: ^6.2.1 http: ^1.2.1 intl: any diff --git a/passkit/CHANGELOG.md b/passkit/CHANGELOG.md index 41b1329..4982da2 100644 --- a/passkit/CHANGELOG.md +++ b/passkit/CHANGELOG.md @@ -1,6 +1,9 @@ ## Unreleased - No longer mark `PkPass.write()` as experimental +- Add webservice support for orders +- Add support for readong images of orders +- Add support for creating order files ## 0.0.10 diff --git a/passkit/lib/passkit.dart b/passkit/lib/passkit.dart index b9bacda..fbf5cd6 100644 --- a/passkit/lib/passkit.dart +++ b/passkit/lib/passkit.dart @@ -16,6 +16,9 @@ export 'src/order/order_return.dart'; export 'src/order/order_return_info.dart'; export 'src/order/order_shipping_fulfillment.dart'; export 'src/order/pk_order.dart'; +export 'src/order_webservice/order_identifiers.dart'; +export 'src/order_webservice/order_web_client.dart'; +export 'src/order_webservice/order_web_client_exceptions.dart'; export 'src/pk_image.dart'; export 'src/pkpass/barcode.dart'; export 'src/pkpass/beacon.dart'; diff --git a/passkit/lib/src/order/order_address.dart b/passkit/lib/src/order/order_address.dart index a6e32ae..8fac2f2 100644 --- a/passkit/lib/src/order/order_address.dart +++ b/passkit/lib/src/order/order_address.dart @@ -3,7 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_address.g.dart'; /// The physical address for an order. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderAddress { OrderAddress({ required this.addressLines, @@ -51,4 +51,25 @@ class OrderAddress { /// Additional information associated with the location, such as a district or neighborhood. @JsonKey(name: 'subLocality') final String? subLocality; + + OrderAddress copyWith({ + List? addressLines, + String? administrativeArea, + String? countryCode, + String? locality, + String? postalCode, + String? subAdministrativeArea, + String? subLocality, + }) { + return OrderAddress( + addressLines: addressLines ?? this.addressLines, + administrativeArea: administrativeArea ?? this.administrativeArea, + countryCode: countryCode ?? this.countryCode, + locality: locality ?? this.locality, + postalCode: postalCode ?? this.postalCode, + subAdministrativeArea: + subAdministrativeArea ?? this.subAdministrativeArea, + subLocality: subLocality ?? this.subLocality, + ); + } } diff --git a/passkit/lib/src/order/order_address.g.dart b/passkit/lib/src/order/order_address.g.dart index dfce0f3..6de111d 100644 --- a/passkit/lib/src/order/order_address.g.dart +++ b/passkit/lib/src/order/order_address.g.dart @@ -18,13 +18,21 @@ OrderAddress _$OrderAddressFromJson(Map json) => OrderAddress( subLocality: json['subLocality'] as String?, ); -Map _$OrderAddressToJson(OrderAddress instance) => - { - 'addressLines': instance.addressLines, - 'administrativeArea': instance.administrativeArea, - 'countryCode': instance.countryCode, - 'locality': instance.locality, - 'postalCode': instance.postalCode, - 'subAdministrativeArea': instance.subAdministrativeArea, - 'subLocality': instance.subLocality, - }; +Map _$OrderAddressToJson(OrderAddress instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('addressLines', instance.addressLines); + writeNotNull('administrativeArea', instance.administrativeArea); + writeNotNull('countryCode', instance.countryCode); + writeNotNull('locality', instance.locality); + writeNotNull('postalCode', instance.postalCode); + writeNotNull('subAdministrativeArea', instance.subAdministrativeArea); + writeNotNull('subLocality', instance.subLocality); + return val; +} diff --git a/passkit/lib/src/order/order_application.dart b/passkit/lib/src/order/order_application.dart index 7f30438..f5901ab 100644 --- a/passkit/lib/src/order/order_application.dart +++ b/passkit/lib/src/order/order_application.dart @@ -3,7 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_application.g.dart'; /// The details of an app in the App Store. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderApplication { OrderApplication({ required this.customProductPageIdentifier, @@ -29,4 +29,17 @@ class OrderApplication { /// (Required) The ADAM ID (store identifier) of the application. @JsonKey(name: 'storeIdentifier') final num storeIdentifier; + + OrderApplication copyWith({ + String? customProductPageIdentifier, + String? launchURL, + num? storeIdentifier, + }) { + return OrderApplication( + customProductPageIdentifier: + customProductPageIdentifier ?? this.customProductPageIdentifier, + launchURL: launchURL ?? this.launchURL, + storeIdentifier: storeIdentifier ?? this.storeIdentifier, + ); + } } diff --git a/passkit/lib/src/order/order_application.g.dart b/passkit/lib/src/order/order_application.g.dart index 0d91476..079ea0a 100644 --- a/passkit/lib/src/order/order_application.g.dart +++ b/passkit/lib/src/order/order_application.g.dart @@ -14,9 +14,18 @@ OrderApplication _$OrderApplicationFromJson(Map json) => storeIdentifier: json['storeIdentifier'] as num, ); -Map _$OrderApplicationToJson(OrderApplication instance) => - { - 'customProductPageIdentifier': instance.customProductPageIdentifier, - 'launchURL': instance.launchURL, - 'storeIdentifier': instance.storeIdentifier, - }; +Map _$OrderApplicationToJson(OrderApplication instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull( + 'customProductPageIdentifier', instance.customProductPageIdentifier); + writeNotNull('launchURL', instance.launchURL); + val['storeIdentifier'] = instance.storeIdentifier; + return val; +} diff --git a/passkit/lib/src/order/order_barcode.dart b/passkit/lib/src/order/order_barcode.dart index 3c619f7..95d8d92 100644 --- a/passkit/lib/src/order/order_barcode.dart +++ b/passkit/lib/src/order/order_barcode.dart @@ -3,7 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_barcode.g.dart'; /// Information about a pass’s barcode. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderBarcode { OrderBarcode({ this.altText, @@ -37,6 +37,20 @@ class OrderBarcode { /// iso-8859-1, but you may specify an alternative encoding if required. @JsonKey(name: 'messageEncoding') final String messageEncoding; + + OrderBarcode copyWith({ + String? altText, + OrderBarcodeType? format, + String? message, + String? messageEncoding, + }) { + return OrderBarcode( + altText: altText ?? this.altText, + format: format ?? this.format, + message: message ?? this.message, + messageEncoding: messageEncoding ?? this.messageEncoding, + ); + } } enum OrderBarcodeType { diff --git a/passkit/lib/src/order/order_barcode.g.dart b/passkit/lib/src/order/order_barcode.g.dart index 9a63d21..714adc2 100644 --- a/passkit/lib/src/order/order_barcode.g.dart +++ b/passkit/lib/src/order/order_barcode.g.dart @@ -13,13 +13,21 @@ OrderBarcode _$OrderBarcodeFromJson(Map json) => OrderBarcode( messageEncoding: json['messageEncoding'] as String, ); -Map _$OrderBarcodeToJson(OrderBarcode instance) => - { - 'altText': instance.altText, - 'format': _$OrderBarcodeTypeEnumMap[instance.format]!, - 'message': instance.message, - 'messageEncoding': instance.messageEncoding, - }; +Map _$OrderBarcodeToJson(OrderBarcode instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('altText', instance.altText); + val['format'] = _$OrderBarcodeTypeEnumMap[instance.format]!; + val['message'] = instance.message; + val['messageEncoding'] = instance.messageEncoding; + return val; +} const _$OrderBarcodeTypeEnumMap = { OrderBarcodeType.qr: 'qr', diff --git a/passkit/lib/src/order/order_customer.dart b/passkit/lib/src/order/order_customer.dart index 107785e..20a93e4 100644 --- a/passkit/lib/src/order/order_customer.dart +++ b/passkit/lib/src/order/order_customer.dart @@ -3,7 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_customer.g.dart'; /// The details of the order’s customer. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderCustomer { OrderCustomer({ required this.emailAddress, @@ -39,4 +39,20 @@ class OrderCustomer { /// The customer’s phone number. @JsonKey(name: 'phoneNumber') final String phoneNumber; + + OrderCustomer copyWith({ + String? emailAddress, + String? familyName, + String? givenName, + String? organizationName, + String? phoneNumber, + }) { + return OrderCustomer( + emailAddress: emailAddress ?? this.emailAddress, + familyName: familyName ?? this.familyName, + givenName: givenName ?? this.givenName, + organizationName: organizationName ?? this.organizationName, + phoneNumber: phoneNumber ?? this.phoneNumber, + ); + } } diff --git a/passkit/lib/src/order/order_data.dart b/passkit/lib/src/order/order_data.dart index 2ccbd7d..707063f 100644 --- a/passkit/lib/src/order/order_data.dart +++ b/passkit/lib/src/order/order_data.dart @@ -23,13 +23,13 @@ class OrderData { required this.orderType, required this.orderTypeIdentifier, required this.status, - required this.schemaVersion, + this.schemaVersion = 1, required this.updatedAt, required this.associatedApplications, required this.associatedApplicationIdentifiers, required this.authenticationToken, required this.barcode, - required this.changeNotifications, + this.changeNotifications = OrderChangeNotification.enabled, required this.customer, required this.fulfillments, required this.lineItems, @@ -109,7 +109,7 @@ class OrderData { /// A property that describes whether the device notifies the user about /// relevant changes to the order. The default is enabled. @JsonKey(name: 'changeNotifications') - final OrderChangeNotification? changeNotifications; + final OrderChangeNotification changeNotifications; /// The customer for this order. @JsonKey(name: 'customer') @@ -154,6 +154,62 @@ class OrderData { /// The URL of your web service. This must begin with `HTTPS://`. @JsonKey(name: 'webServiceURL') final Uri? webServiceURL; + + OrderData copyWith({ + DateTime? createdAt, + OrderMerchant? merchant, + String? orderIdentifier, + Uri? orderManagementURL, + String? orderType, + String? orderTypeIdentifier, + OrderStatus? status, + num? schemaVersion, + DateTime? updatedAt, + List? associatedApplications, + List? associatedApplicationIdentifiers, + String? authenticationToken, + OrderBarcode? barcode, + OrderChangeNotification? changeNotifications, + OrderCustomer? customer, + List? fulfillments, + List? lineItems, + String? orderNumber, + OrderProvider? orderProvider, + OrderPayment? payment, + OrderReturnInfo? returnInfo, + List? returns, + String? statusDescription, + Uri? webServiceURL, + }) { + return OrderData( + createdAt: createdAt ?? this.createdAt, + merchant: merchant ?? this.merchant, + orderIdentifier: orderIdentifier ?? this.orderIdentifier, + orderManagementURL: orderManagementURL ?? this.orderManagementURL, + orderType: orderType ?? this.orderType, + orderTypeIdentifier: orderTypeIdentifier ?? this.orderTypeIdentifier, + status: status ?? this.status, + schemaVersion: schemaVersion ?? this.schemaVersion, + updatedAt: updatedAt ?? this.updatedAt, + associatedApplications: + associatedApplications ?? this.associatedApplications, + associatedApplicationIdentifiers: associatedApplicationIdentifiers ?? + this.associatedApplicationIdentifiers, + authenticationToken: authenticationToken ?? this.authenticationToken, + barcode: barcode ?? this.barcode, + changeNotifications: changeNotifications ?? this.changeNotifications, + customer: customer ?? this.customer, + fulfillments: fulfillments ?? this.fulfillments, + lineItems: lineItems ?? this.lineItems, + orderNumber: orderNumber ?? this.orderNumber, + orderProvider: orderProvider ?? this.orderProvider, + payment: payment ?? this.payment, + returnInfo: returnInfo ?? this.returnInfo, + returns: returns ?? this.returns, + statusDescription: statusDescription ?? this.statusDescription, + webServiceURL: webServiceURL ?? this.webServiceURL, + ); + } } List? _fulfillmentsFromJson(List? fulfillments) { diff --git a/passkit/lib/src/order/order_data.g.dart b/passkit/lib/src/order/order_data.g.dart index e100afb..5a23726 100644 --- a/passkit/lib/src/order/order_data.g.dart +++ b/passkit/lib/src/order/order_data.g.dart @@ -15,7 +15,7 @@ OrderData _$OrderDataFromJson(Map json) => OrderData( orderType: json['orderType'] as String, orderTypeIdentifier: json['orderTypeIdentifier'] as String, status: $enumDecode(_$OrderStatusEnumMap, json['status']), - schemaVersion: json['schemaVersion'] as num, + schemaVersion: json['schemaVersion'] as num? ?? 1, updatedAt: DateTime.parse(json['updatedAt'] as String), associatedApplications: (json['associatedApplications'] as List?) ?.map((e) => OrderApplication.fromJson(e as Map)) @@ -29,7 +29,8 @@ OrderData _$OrderDataFromJson(Map json) => OrderData( ? null : OrderBarcode.fromJson(json['barcode'] as Map), changeNotifications: $enumDecodeNullable( - _$OrderChangeNotificationEnumMap, json['changeNotifications']), + _$OrderChangeNotificationEnumMap, json['changeNotifications']) ?? + OrderChangeNotification.enabled, customer: json['customer'] == null ? null : OrderCustomer.fromJson(json['customer'] as Map), @@ -74,7 +75,7 @@ Map _$OrderDataToJson(OrderData instance) => { 'authenticationToken': instance.authenticationToken, 'barcode': instance.barcode, 'changeNotifications': - _$OrderChangeNotificationEnumMap[instance.changeNotifications], + _$OrderChangeNotificationEnumMap[instance.changeNotifications]!, 'customer': instance.customer, 'fulfillments': instance.fulfillments, 'lineItems': instance.lineItems, diff --git a/passkit/lib/src/order/order_line_item.dart b/passkit/lib/src/order/order_line_item.dart index e9f9cb5..db4d74f 100644 --- a/passkit/lib/src/order/order_line_item.dart +++ b/passkit/lib/src/order/order_line_item.dart @@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_line_item.g.dart'; -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderLineItem { OrderLineItem({ required this.image, @@ -49,9 +49,29 @@ class OrderLineItem { /// A merchant-specific unique product identifier. @JsonKey(name: 'sku') final String? sku; + + OrderLineItem copyWith({ + String? image, + OrderCurrencyAmount? price, + num? quantity, + String? subtitle, + String? title, + String? gtin, + String? sku, + }) { + return OrderLineItem( + image: image ?? this.image, + price: price ?? this.price, + quantity: quantity ?? this.quantity, + subtitle: subtitle ?? this.subtitle, + title: title ?? this.title, + gtin: gtin ?? this.gtin, + sku: sku ?? this.sku, + ); + } } -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderCurrencyAmount { OrderCurrencyAmount({required this.amount, required this.currency}); @@ -71,4 +91,14 @@ class OrderCurrencyAmount { /// Maximum Length: 3 @JsonKey(name: 'currency') final String currency; + + OrderCurrencyAmount copyWith({ + double? amount, + String? currency, + }) { + return OrderCurrencyAmount( + amount: amount ?? this.amount, + currency: currency ?? this.currency, + ); + } } diff --git a/passkit/lib/src/order/order_line_item.g.dart b/passkit/lib/src/order/order_line_item.g.dart index 33d4267..c1d4bde 100644 --- a/passkit/lib/src/order/order_line_item.g.dart +++ b/passkit/lib/src/order/order_line_item.g.dart @@ -19,16 +19,24 @@ OrderLineItem _$OrderLineItemFromJson(Map json) => sku: json['sku'] as String?, ); -Map _$OrderLineItemToJson(OrderLineItem instance) => - { - 'image': instance.image, - 'price': instance.price, - 'quantity': instance.quantity, - 'subtitle': instance.subtitle, - 'title': instance.title, - 'gtin': instance.gtin, - 'sku': instance.sku, - }; +Map _$OrderLineItemToJson(OrderLineItem instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('image', instance.image); + writeNotNull('price', instance.price); + val['quantity'] = instance.quantity; + writeNotNull('subtitle', instance.subtitle); + val['title'] = instance.title; + writeNotNull('gtin', instance.gtin); + writeNotNull('sku', instance.sku); + return val; +} OrderCurrencyAmount _$OrderCurrencyAmountFromJson(Map json) => OrderCurrencyAmount( diff --git a/passkit/lib/src/order/order_location.dart b/passkit/lib/src/order/order_location.dart index 0b4d86f..23fdc5e 100644 --- a/passkit/lib/src/order/order_location.dart +++ b/passkit/lib/src/order/order_location.dart @@ -3,7 +3,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_location.g.dart'; /// The details of a pickup order. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderLocation { OrderLocation({ this.altitude, @@ -30,4 +30,16 @@ class OrderLocation { /// Minimum Value: -180 /// Maximum Value: 180 final num longitude; + + OrderLocation copyWith({ + num? altitude, + num? latitude, + num? longitude, + }) { + return OrderLocation( + altitude: altitude ?? this.altitude, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } } diff --git a/passkit/lib/src/order/order_location.g.dart b/passkit/lib/src/order/order_location.g.dart index 63e78eb..8d3eb41 100644 --- a/passkit/lib/src/order/order_location.g.dart +++ b/passkit/lib/src/order/order_location.g.dart @@ -13,9 +13,17 @@ OrderLocation _$OrderLocationFromJson(Map json) => longitude: json['longitude'] as num, ); -Map _$OrderLocationToJson(OrderLocation instance) => - { - 'altitude': instance.altitude, - 'latitude': instance.latitude, - 'longitude': instance.longitude, - }; +Map _$OrderLocationToJson(OrderLocation instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('altitude', instance.altitude); + val['latitude'] = instance.latitude; + val['longitude'] = instance.longitude; + return val; +} diff --git a/passkit/lib/src/order/order_merchant.dart b/passkit/lib/src/order/order_merchant.dart index 1b2176e..237d7c7 100644 --- a/passkit/lib/src/order/order_merchant.dart +++ b/passkit/lib/src/order/order_merchant.dart @@ -62,4 +62,28 @@ class OrderMerchant { /// The URL for the merchant’s website or landing page. @JsonKey(name: 'url') final Uri url; + + OrderMerchant copyWith({ + OrderAddress? address, + Uri? businessChatURL, + Uri? contactURL, + String? displayName, + String? emailAddress, + String? logo, + String? merchantIdentifier, + String? phoneNumber, + Uri? url, + }) { + return OrderMerchant( + address: address ?? this.address, + businessChatURL: businessChatURL ?? this.businessChatURL, + contactURL: contactURL ?? this.contactURL, + displayName: displayName ?? this.displayName, + emailAddress: emailAddress ?? this.emailAddress, + logo: logo ?? this.logo, + merchantIdentifier: merchantIdentifier ?? this.merchantIdentifier, + phoneNumber: phoneNumber ?? this.phoneNumber, + url: url ?? this.url, + ); + } } diff --git a/passkit/lib/src/order/order_payment.dart b/passkit/lib/src/order/order_payment.dart index 361003f..4be3ca3 100644 --- a/passkit/lib/src/order/order_payment.dart +++ b/passkit/lib/src/order/order_payment.dart @@ -3,7 +3,7 @@ import 'package:passkit/src/order/order_line_item.dart'; part 'order_payment.g.dart'; -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderPayment { OrderPayment({ required this.total, @@ -58,10 +58,29 @@ class OrderPayment { /// transaction identifiers here to enable additional linking. @JsonKey(name: 'applePayTransactionIdentifiers') final List? applePayTransactionIdentifiers; + + OrderPayment copyWith({ + OrderCurrencyAmount? total, + List? summaryItems, + List? transactions, + List? paymentMethods, + String? status, + List? applePayTransactionIdentifiers, + }) { + return OrderPayment( + total: total ?? this.total, + summaryItems: summaryItems ?? this.summaryItems, + transactions: transactions ?? this.transactions, + paymentMethods: paymentMethods ?? this.paymentMethods, + status: status ?? this.status, + applePayTransactionIdentifiers: + applePayTransactionIdentifiers ?? this.applePayTransactionIdentifiers, + ); + } } /// The details about a payment transaction. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class PaymentTransaction { PaymentTransaction({ required this.amount, @@ -111,6 +130,27 @@ class PaymentTransaction { /// The filename of a receipt within the bundle that’s associated with the transaction. @JsonKey(name: 'receipt') final String? receipt; + + PaymentTransaction copyWith({ + OrderCurrencyAmount? amount, + DateTime? createdAt, + OrderPaymentMethod? paymentMethod, + PaymentTransactionStatus? status, + String? applePayTransactionIdentifier, + PaymentTransactionType? transactionType, + String? receipt, + }) { + return PaymentTransaction( + amount: amount ?? this.amount, + createdAt: createdAt ?? this.createdAt, + paymentMethod: paymentMethod ?? this.paymentMethod, + status: status ?? this.status, + applePayTransactionIdentifier: + applePayTransactionIdentifier ?? this.applePayTransactionIdentifier, + transactionType: transactionType ?? this.transactionType, + receipt: receipt ?? this.receipt, + ); + } } enum PaymentTransactionStatus { @@ -138,7 +178,7 @@ enum PaymentTransactionType { refund } -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderPaymentMethod { OrderPaymentMethod({required this.displayName}); @@ -152,10 +192,18 @@ class OrderPaymentMethod { /// (Required) The name of the payment method, such as the name of a specific payment pass or card. @JsonKey(name: 'displayName') final String displayName; + + OrderPaymentMethod copyWith({ + String? displayName, + }) { + return OrderPaymentMethod( + displayName: displayName ?? this.displayName, + ); + } } /// A breakdown of the total payment. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class PaymentSummaryItems { PaymentSummaryItems({required this.label, required this.value}); @@ -173,4 +221,14 @@ class PaymentSummaryItems { /// (Required) The monetary value. @JsonKey(name: 'value') final OrderCurrencyAmount value; + + PaymentSummaryItems copyWith({ + String? label, + OrderCurrencyAmount? value, + }) { + return PaymentSummaryItems( + label: label ?? this.label, + value: value ?? this.value, + ); + } } diff --git a/passkit/lib/src/order/order_payment.g.dart b/passkit/lib/src/order/order_payment.g.dart index 229e63f..faad82b 100644 --- a/passkit/lib/src/order/order_payment.g.dart +++ b/passkit/lib/src/order/order_payment.g.dart @@ -25,15 +25,25 @@ OrderPayment _$OrderPaymentFromJson(Map json) => OrderPayment( .toList(), ); -Map _$OrderPaymentToJson(OrderPayment instance) => - { - 'total': instance.total, - 'summaryItems': instance.summaryItems, - 'transactions': instance.transactions, - 'paymentMethods': instance.paymentMethods, - 'status': instance.status, - 'applePayTransactionIdentifiers': instance.applePayTransactionIdentifiers, - }; +Map _$OrderPaymentToJson(OrderPayment instance) { + final val = { + 'total': instance.total, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('summaryItems', instance.summaryItems); + writeNotNull('transactions', instance.transactions); + writeNotNull('paymentMethods', instance.paymentMethods); + writeNotNull('status', instance.status); + writeNotNull('applePayTransactionIdentifiers', + instance.applePayTransactionIdentifiers); + return val; +} PaymentTransaction _$PaymentTransactionFromJson(Map json) => PaymentTransaction( @@ -50,17 +60,27 @@ PaymentTransaction _$PaymentTransactionFromJson(Map json) => receipt: json['receipt'] as String?, ); -Map _$PaymentTransactionToJson(PaymentTransaction instance) => - { - 'amount': instance.amount, - 'createdAt': instance.createdAt.toIso8601String(), - 'paymentMethod': instance.paymentMethod, - 'status': _$PaymentTransactionStatusEnumMap[instance.status]!, - 'applePayTransactionIdentifier': instance.applePayTransactionIdentifier, - 'transactionType': - _$PaymentTransactionTypeEnumMap[instance.transactionType]!, - 'receipt': instance.receipt, - }; +Map _$PaymentTransactionToJson(PaymentTransaction instance) { + final val = { + 'amount': instance.amount, + 'createdAt': instance.createdAt.toIso8601String(), + 'paymentMethod': instance.paymentMethod, + 'status': _$PaymentTransactionStatusEnumMap[instance.status]!, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull( + 'applePayTransactionIdentifier', instance.applePayTransactionIdentifier); + val['transactionType'] = + _$PaymentTransactionTypeEnumMap[instance.transactionType]!; + writeNotNull('receipt', instance.receipt); + return val; +} const _$PaymentTransactionStatusEnumMap = { PaymentTransactionStatus.pending: 'pending', diff --git a/passkit/lib/src/order/order_pickup_fulfillment.dart b/passkit/lib/src/order/order_pickup_fulfillment.dart index 6b5ebb2..c1551e0 100644 --- a/passkit/lib/src/order/order_pickup_fulfillment.dart +++ b/passkit/lib/src/order/order_pickup_fulfillment.dart @@ -7,7 +7,7 @@ import 'package:passkit/src/order/order_location.dart'; part 'order_pickup_fulfillment.g.dart'; /// The details of a pickup order. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderPickupFulfillment { OrderPickupFulfillment({ required this.address, @@ -76,6 +76,39 @@ class OrderPickupFulfillment { /// A localized message describing the fulfillment status. final String? statusDescription; + + OrderPickupFulfillment copyWith({ + OrderAddress? address, + OrderBarcode? barcode, + String? displayName, + String? fulfillmentIdentifier, + String? fulfillmentType, + List? lineItems, + OrderLocation? location, + String? notes, + DateTime? pickedUpAt, + DateTime? pickupAt, + String? pickupWindowDuration, + PickupFulfillmentStatus? status, + String? statusDescription, + }) { + return OrderPickupFulfillment( + address: address ?? this.address, + barcode: barcode ?? this.barcode, + displayName: displayName ?? this.displayName, + fulfillmentIdentifier: + fulfillmentIdentifier ?? this.fulfillmentIdentifier, + fulfillmentType: fulfillmentType ?? this.fulfillmentType, + lineItems: lineItems ?? this.lineItems, + location: location ?? this.location, + notes: notes ?? this.notes, + pickedUpAt: pickedUpAt ?? this.pickedUpAt, + pickupAt: pickupAt ?? this.pickupAt, + pickupWindowDuration: pickupWindowDuration ?? this.pickupWindowDuration, + status: status ?? this.status, + statusDescription: statusDescription ?? this.statusDescription, + ); + } } enum PickupFulfillmentStatus { diff --git a/passkit/lib/src/order/order_pickup_fulfillment.g.dart b/passkit/lib/src/order/order_pickup_fulfillment.g.dart index 2f8c29f..5864502 100644 --- a/passkit/lib/src/order/order_pickup_fulfillment.g.dart +++ b/passkit/lib/src/order/order_pickup_fulfillment.g.dart @@ -37,22 +37,30 @@ OrderPickupFulfillment _$OrderPickupFulfillmentFromJson( ); Map _$OrderPickupFulfillmentToJson( - OrderPickupFulfillment instance) => - { - 'address': instance.address, - 'barcode': instance.barcode, - 'displayName': instance.displayName, - 'fulfillmentIdentifier': instance.fulfillmentIdentifier, - 'fulfillmentType': instance.fulfillmentType, - 'lineItems': instance.lineItems, - 'location': instance.location, - 'notes': instance.notes, - 'pickedUpAt': instance.pickedUpAt?.toIso8601String(), - 'pickupAt': instance.pickupAt?.toIso8601String(), - 'pickupWindowDuration': instance.pickupWindowDuration, - 'status': _$PickupFulfillmentStatusEnumMap[instance.status]!, - 'statusDescription': instance.statusDescription, - }; + OrderPickupFulfillment instance) { + final val = {}; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('address', instance.address); + writeNotNull('barcode', instance.barcode); + val['displayName'] = instance.displayName; + val['fulfillmentIdentifier'] = instance.fulfillmentIdentifier; + val['fulfillmentType'] = instance.fulfillmentType; + writeNotNull('lineItems', instance.lineItems); + writeNotNull('location', instance.location); + writeNotNull('notes', instance.notes); + writeNotNull('pickedUpAt', instance.pickedUpAt?.toIso8601String()); + writeNotNull('pickupAt', instance.pickupAt?.toIso8601String()); + writeNotNull('pickupWindowDuration', instance.pickupWindowDuration); + val['status'] = _$PickupFulfillmentStatusEnumMap[instance.status]!; + writeNotNull('statusDescription', instance.statusDescription); + return val; +} const _$PickupFulfillmentStatusEnumMap = { PickupFulfillmentStatus.open: 'open', diff --git a/passkit/lib/src/order/order_provider.dart b/passkit/lib/src/order/order_provider.dart index aec443e..4fc3b18 100644 --- a/passkit/lib/src/order/order_provider.dart +++ b/passkit/lib/src/order/order_provider.dart @@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_provider.g.dart'; -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderProvider { OrderProvider({ required this.displayName, @@ -37,4 +37,20 @@ class OrderProvider { /// (Required) The URL of the order provder platform. @JsonKey(name: 'url') final Uri url; + + OrderProvider copyWith({ + String? displayName, + String? trackingLogoNameDarkColorScheme, + String? trackingLogoNameLightColorScheme, + Uri? url, + }) { + return OrderProvider( + displayName: displayName ?? this.displayName, + trackingLogoNameDarkColorScheme: trackingLogoNameDarkColorScheme ?? + this.trackingLogoNameDarkColorScheme, + trackingLogoNameLightColorScheme: trackingLogoNameLightColorScheme ?? + this.trackingLogoNameLightColorScheme, + url: url ?? this.url, + ); + } } diff --git a/passkit/lib/src/order/order_return.dart b/passkit/lib/src/order/order_return.dart index 7d51dab..447ac5d 100644 --- a/passkit/lib/src/order/order_return.dart +++ b/passkit/lib/src/order/order_return.dart @@ -4,7 +4,7 @@ import 'package:passkit/src/order/order_line_item.dart'; part 'order_return.g.dart'; /// The details of a return order. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderReturn { OrderReturn({ required this.returnIdentifier, @@ -84,4 +84,34 @@ class OrderReturn { /// A localized message describing the return status. @JsonKey(name: 'statusDescription') final String? statusDescription; + + OrderReturn copyWith({ + String? returnIdentifier, + String? status, + String? carrier, + DateTime? dropOffBy, + DateTime? initiatedAt, + List? lineItems, + String? notes, + DateTime? returnedAt, + String? returnLabel, + Uri? returnManagementURL, + String? returnNumber, + String? statusDescription, + }) { + return OrderReturn( + returnIdentifier: returnIdentifier ?? this.returnIdentifier, + status: status ?? this.status, + carrier: carrier ?? this.carrier, + dropOffBy: dropOffBy ?? this.dropOffBy, + initiatedAt: initiatedAt ?? this.initiatedAt, + lineItems: lineItems ?? this.lineItems, + notes: notes ?? this.notes, + returnedAt: returnedAt ?? this.returnedAt, + returnLabel: returnLabel ?? this.returnLabel, + returnManagementURL: returnManagementURL ?? this.returnManagementURL, + returnNumber: returnNumber ?? this.returnNumber, + statusDescription: statusDescription ?? this.statusDescription, + ); + } } diff --git a/passkit/lib/src/order/order_return.g.dart b/passkit/lib/src/order/order_return.g.dart index e9248c5..fa72031 100644 --- a/passkit/lib/src/order/order_return.g.dart +++ b/passkit/lib/src/order/order_return.g.dart @@ -31,18 +31,27 @@ OrderReturn _$OrderReturnFromJson(Map json) => OrderReturn( statusDescription: json['statusDescription'] as String?, ); -Map _$OrderReturnToJson(OrderReturn instance) => - { - 'returnIdentifier': instance.returnIdentifier, - 'status': instance.status, - 'carrier': instance.carrier, - 'dropOffBy': instance.dropOffBy?.toIso8601String(), - 'initiatedAt': instance.initiatedAt?.toIso8601String(), - 'lineItems': instance.lineItems, - 'notes': instance.notes, - 'returnedAt': instance.returnedAt?.toIso8601String(), - 'returnLabel': instance.returnLabel, - 'returnManagementURL': instance.returnManagementURL?.toString(), - 'returnNumber': instance.returnNumber, - 'statusDescription': instance.statusDescription, - }; +Map _$OrderReturnToJson(OrderReturn instance) { + final val = { + 'returnIdentifier': instance.returnIdentifier, + 'status': instance.status, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('carrier', instance.carrier); + writeNotNull('dropOffBy', instance.dropOffBy?.toIso8601String()); + writeNotNull('initiatedAt', instance.initiatedAt?.toIso8601String()); + writeNotNull('lineItems', instance.lineItems); + writeNotNull('notes', instance.notes); + writeNotNull('returnedAt', instance.returnedAt?.toIso8601String()); + writeNotNull('returnLabel', instance.returnLabel); + writeNotNull('returnManagementURL', instance.returnManagementURL?.toString()); + writeNotNull('returnNumber', instance.returnNumber); + writeNotNull('statusDescription', instance.statusDescription); + return val; +} diff --git a/passkit/lib/src/order/order_return_info.dart b/passkit/lib/src/order/order_return_info.dart index f2d9b78..c763cdc 100644 --- a/passkit/lib/src/order/order_return_info.dart +++ b/passkit/lib/src/order/order_return_info.dart @@ -2,7 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; part 'order_return_info.g.dart'; -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderReturnInfo { OrderReturnInfo({ required this.returnPolicyURL, @@ -47,4 +47,21 @@ class OrderReturnInfo { /// common return window duration here. @JsonKey(name: 'returnPolicyDescription') final String? returnPolicyDescription; + + OrderReturnInfo copyWith({ + Uri? returnPolicyURL, + bool? displayCountdown, + DateTime? returnDeadline, + Uri? returnManagementURL, + String? returnPolicyDescription, + }) { + return OrderReturnInfo( + returnPolicyURL: returnPolicyURL ?? this.returnPolicyURL, + displayCountdown: displayCountdown ?? this.displayCountdown, + returnDeadline: returnDeadline ?? this.returnDeadline, + returnManagementURL: returnManagementURL ?? this.returnManagementURL, + returnPolicyDescription: + returnPolicyDescription ?? this.returnPolicyDescription, + ); + } } diff --git a/passkit/lib/src/order/order_return_info.g.dart b/passkit/lib/src/order/order_return_info.g.dart index ae1ed3c..59cc48b 100644 --- a/passkit/lib/src/order/order_return_info.g.dart +++ b/passkit/lib/src/order/order_return_info.g.dart @@ -19,11 +19,20 @@ OrderReturnInfo _$OrderReturnInfoFromJson(Map json) => returnPolicyDescription: json['returnPolicyDescription'] as String?, ); -Map _$OrderReturnInfoToJson(OrderReturnInfo instance) => - { - 'returnPolicyURL': instance.returnPolicyURL.toString(), - 'displayCountdown': instance.displayCountdown, - 'returnDeadline': instance.returnDeadline?.toIso8601String(), - 'returnManagementURL': instance.returnManagementURL?.toString(), - 'returnPolicyDescription': instance.returnPolicyDescription, - }; +Map _$OrderReturnInfoToJson(OrderReturnInfo instance) { + final val = { + 'returnPolicyURL': instance.returnPolicyURL.toString(), + 'displayCountdown': instance.displayCountdown, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('returnDeadline', instance.returnDeadline?.toIso8601String()); + writeNotNull('returnManagementURL', instance.returnManagementURL?.toString()); + writeNotNull('returnPolicyDescription', instance.returnPolicyDescription); + return val; +} diff --git a/passkit/lib/src/order/order_shipping_fulfillment.dart b/passkit/lib/src/order/order_shipping_fulfillment.dart index 97140a7..93c56b6 100644 --- a/passkit/lib/src/order/order_shipping_fulfillment.dart +++ b/passkit/lib/src/order/order_shipping_fulfillment.dart @@ -5,7 +5,7 @@ import 'package:passkit/src/order/order_line_item.dart'; part 'order_shipping_fulfillment.g.dart'; // The details of a shipped order. -@JsonSerializable() +@JsonSerializable(includeIfNull: false) class OrderShippingFulfillment { OrderShippingFulfillment({ required this.fulfillmentIdentifier, @@ -88,7 +88,10 @@ class OrderShippingFulfillment { /// Default: shipping /// /// Possible Values: shipping, delivery - @JsonKey(name: 'shippingType') + @JsonKey( + name: 'shippingType', + defaultValue: OrderShippingFulfillmentType.shipping, + ) final OrderShippingFulfillmentType shippingType; /// A localized message describing the fulfillment status. @@ -102,6 +105,44 @@ class OrderShippingFulfillment { /// A URL where the customer can track the shipment. @JsonKey(name: 'trackingURL') final Uri? trackingURL; + + OrderShippingFulfillment copyWith({ + String? fulfillmentIdentifier, + String? fulfillmentType, + OrderShippingFulfillmentStatus? status, + String? carrier, + DateTime? deliveredAt, + DateTime? estimatedDeliveryAt, + Duration? estimatedDeliveryWindowDuration, + List? lineItems, + String? notes, + ShippingFulfillmentRecipient? recipient, + DateTime? shippedAt, + OrderShippingFulfillmentType? shippingType, + String? statusDescription, + String? trackingNumber, + Uri? trackingURL, + }) { + return OrderShippingFulfillment( + fulfillmentIdentifier: + fulfillmentIdentifier ?? this.fulfillmentIdentifier, + fulfillmentType: fulfillmentType ?? this.fulfillmentType, + status: status ?? this.status, + carrier: carrier ?? this.carrier, + deliveredAt: deliveredAt ?? this.deliveredAt, + estimatedDeliveryAt: estimatedDeliveryAt ?? this.estimatedDeliveryAt, + estimatedDeliveryWindowDuration: estimatedDeliveryWindowDuration ?? + this.estimatedDeliveryWindowDuration, + lineItems: lineItems ?? this.lineItems, + notes: notes ?? this.notes, + recipient: recipient ?? this.recipient, + shippedAt: shippedAt ?? this.shippedAt, + shippingType: shippingType ?? this.shippingType, + statusDescription: statusDescription ?? this.statusDescription, + trackingNumber: trackingNumber ?? this.trackingNumber, + trackingURL: trackingURL ?? this.trackingURL, + ); + } } enum OrderShippingFulfillmentType { @@ -135,7 +176,20 @@ enum OrderShippingFulfillmentStatus { issue, @JsonValue('cancelled') - cancelled + cancelled; + + double toProgress() { + return switch (this) { + OrderShippingFulfillmentStatus.open => 0, + OrderShippingFulfillmentStatus.processing => 0.25, + OrderShippingFulfillmentStatus.shipped => 0.375, + OrderShippingFulfillmentStatus.onTheWay => 0.5, + OrderShippingFulfillmentStatus.outForDelivery => 0.75, + OrderShippingFulfillmentStatus.delivered => 1, + OrderShippingFulfillmentStatus.issue => 0, + OrderShippingFulfillmentStatus.cancelled => 0, + }; + } } @JsonSerializable() diff --git a/passkit/lib/src/order/order_shipping_fulfillment.g.dart b/passkit/lib/src/order/order_shipping_fulfillment.g.dart index 3b4608f..4a59abc 100644 --- a/passkit/lib/src/order/order_shipping_fulfillment.g.dart +++ b/passkit/lib/src/order/order_shipping_fulfillment.g.dart @@ -48,26 +48,36 @@ OrderShippingFulfillment _$OrderShippingFulfillmentFromJson( ); Map _$OrderShippingFulfillmentToJson( - OrderShippingFulfillment instance) => - { - 'fulfillmentIdentifier': instance.fulfillmentIdentifier, - 'fulfillmentType': instance.fulfillmentType, - 'status': _$OrderShippingFulfillmentStatusEnumMap[instance.status]!, - 'carrier': instance.carrier, - 'deliveredAt': instance.deliveredAt?.toIso8601String(), - 'estimatedDeliveryAt': instance.estimatedDeliveryAt?.toIso8601String(), - 'estimatedDeliveryWindowDuration': - instance.estimatedDeliveryWindowDuration?.inMicroseconds, - 'lineItems': instance.lineItems, - 'notes': instance.notes, - 'recipient': instance.recipient, - 'shippedAt': instance.shippedAt?.toIso8601String(), - 'shippingType': - _$OrderShippingFulfillmentTypeEnumMap[instance.shippingType]!, - 'statusDescription': instance.statusDescription, - 'trackingNumber': instance.trackingNumber, - 'trackingURL': instance.trackingURL?.toString(), - }; + OrderShippingFulfillment instance) { + final val = { + 'fulfillmentIdentifier': instance.fulfillmentIdentifier, + 'fulfillmentType': instance.fulfillmentType, + 'status': _$OrderShippingFulfillmentStatusEnumMap[instance.status]!, + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('carrier', instance.carrier); + writeNotNull('deliveredAt', instance.deliveredAt?.toIso8601String()); + writeNotNull( + 'estimatedDeliveryAt', instance.estimatedDeliveryAt?.toIso8601String()); + writeNotNull('estimatedDeliveryWindowDuration', + instance.estimatedDeliveryWindowDuration?.inMicroseconds); + writeNotNull('lineItems', instance.lineItems); + writeNotNull('notes', instance.notes); + writeNotNull('recipient', instance.recipient); + writeNotNull('shippedAt', instance.shippedAt?.toIso8601String()); + val['shippingType'] = + _$OrderShippingFulfillmentTypeEnumMap[instance.shippingType]!; + writeNotNull('statusDescription', instance.statusDescription); + writeNotNull('trackingNumber', instance.trackingNumber); + writeNotNull('trackingURL', instance.trackingURL?.toString()); + return val; +} const _$OrderShippingFulfillmentStatusEnumMap = { OrderShippingFulfillmentStatus.open: 'open', diff --git a/passkit/lib/src/order/pk_order.dart b/passkit/lib/src/order/pk_order.dart index 515c4a0..2c6b97c 100644 --- a/passkit/lib/src/order/pk_order.dart +++ b/passkit/lib/src/order/pk_order.dart @@ -4,7 +4,12 @@ import 'package:archive/archive.dart'; import 'package:passkit/src/archive_extensions.dart'; import 'package:passkit/src/archive_file_extension.dart'; import 'package:passkit/src/crypto/signature_verification.dart'; +import 'package:passkit/src/crypto/write_signature.dart'; +import 'package:passkit/src/pk_image.dart'; +import 'package:passkit/src/pk_image_extension.dart'; import 'package:passkit/src/pkpass/exceptions.dart'; +import 'package:passkit/src/strings/strings_writer.dart'; +import 'package:passkit/src/utils.dart'; import 'package:pkcs7/pkcs7.dart'; import 'order_data.dart'; @@ -12,8 +17,8 @@ import 'order_data.dart'; class PkOrder { PkOrder({ required this.order, - required this.manifest, - required this.sourceData, + this.manifest, + this.sourceData, this.languageData, }); @@ -63,14 +68,14 @@ class PkOrder { languageData: archive.getTranslations(), // source sourceData: bytes, - ); + ).._archive = archive; } final OrderData order; /// Mapping of files to their respective checksums. Typically not relevant for /// users of this package. - final Map manifest; + final Map? manifest; /// List of available languages. Each value is a language identifier Iterable get availableLanguages => languageData?.keys ?? []; @@ -78,13 +83,146 @@ class PkOrder { /// Translations for this PkPass. /// Consists of a mapping of language identifier to translation key-value /// pairs. - final Map>? languageData; + final Map>? languageData; + + late Archive _archive; + + // TODO(any): Do proper image loading for orders + // Do a sanity check for paths that already contains @2x/@3x modifier + // What about localized images? + // + // The Apple docs state that + // > Adding an image of the same name to the top-level folder of the order + // > file overrides any localized versions. + // So non-localized images are actually the fallback. + PkImage loadImage(String path, {String? locale}) { + assert(path.isNotEmpty); + + final fileExtension = path.split('.').last; + final twoXResPath = + path.replaceAll('.$fileExtension', '@2x.$fileExtension'); + final threeXResPath = + path.replaceAll('.$fileExtension', '@3x.$fileExtension'); + + return PkImage( + image1: _archive.findFile(path)?.content as Uint8List?, + image2: _archive.findFile(twoXResPath)?.content as Uint8List?, + image3: _archive.findFile(threeXResPath)?.content as Uint8List?, + ); + } /// The bytes of this PkPass - final Uint8List sourceData; + /// + /// `null` if this object was created with the default constructor. + final Uint8List? sourceData; /// Indicates whether a webservices is available. bool get isWebServiceAvailable => order.webServiceURL != null; + + /// Creates a PkOrder file. If this instance was created via [PkOrder.fromBytes] + /// it overwrites the signature if possible. + /// + /// When written to disk, the file should have an ending of `.order`. + /// + /// [certificatePem] is the certificate to be used to sign the PkPass file. + /// + /// [privateKeyPem] is the private key PEM file. Right now, + /// it's only supported if it's not password protected. + /// + /// Read more about signing [here](https://github.com/ueman/passkit/blob/master/passkit/SIGNING.md). + /// + /// If either [certificatePem] or [privateKeyPem] is null, the resulting PkPass + /// will not be properly signed, but still generated. + /// + /// Setting [overrideWwdrCert] overrides the Apple WWDR certificate, that's + /// shipped with this library. + /// + /// Apple's documentation [here](https://developer.apple.com/documentation/walletorders) + /// explains which fields to set. + /// + /// ## Images + /// + /// Images in order files work quite differently from passes, since they have + /// no predefined name. + /// You have to add the name, for example `something.png`, to the various properties, + /// and then pass the the image with the name for the [images] argument here. + /// Make sure the names always match, otherwise the order file might not work! + /// + /// Remarks: + /// - Image sizes aren't checked, which means it's possible to create orders + /// that look odd and wrong in the Apple Wallet app or in + /// [passkit_ui](https://pub.dev/packages/passkit_ui) + Uint8List? write({ + required String? certificatePem, + required String? privateKeyPem, + X509? overrideWwdrCert, + required List<(String name, PkImage image)> images, + }) { + final archive = Archive(); + + final orderContent = utf8JsonEncode(order.toJson()); + final orderFile = ArchiveFile( + 'order.json', + orderContent.length, + orderContent, + ); + archive.addFile(orderFile); + + // TODO(any): Write validation + for (final image in images) { + image.$2.writeToArchive(archive, image.$1.split('.').first); + } + + final translationEntries = languageData?.entries; + if (translationEntries != null && translationEntries.isNotEmpty) { + // TODO(any): Ensure every translation file has the same amount of key value pairs. + + for (final entry in translationEntries) { + final name = '${entry.key}.lproj/pass.strings'; + final localizationFile = + ArchiveFile.string(name, toStringsFile(entry.value)); + archive.addFile(localizationFile); + } + } + + final manifestFile = archive.createManifest(); + + if (certificatePem != null && privateKeyPem != null) { + final signature = writeSignature( + certificatePem, + privateKeyPem, + manifestFile, + order.orderTypeIdentifier, + order.merchant.merchantIdentifier, + true, + overrideWwdrCert, + ); + + final signatureFile = ArchiveFile( + 'signature', + signature.length, + signature, + ); + archive.addFile(signatureFile); + } + + final pkOrder = ZipEncoder().encode(archive); + return pkOrder == null ? null : Uint8List.fromList(pkOrder); + } + + PkOrder copyWith({ + OrderData? order, + Map? manifest, + Map>? languageData, + Uint8List? sourceData, + }) { + return PkOrder( + order: order ?? this.order, + manifest: manifest ?? this.manifest, + languageData: languageData ?? this.languageData, + sourceData: sourceData ?? this.sourceData, + ); + } } extension on Archive { diff --git a/passkit/lib/src/order_webservice/order_identifiers.dart b/passkit/lib/src/order_webservice/order_identifiers.dart new file mode 100644 index 0000000..46cb5ce --- /dev/null +++ b/passkit/lib/src/order_webservice/order_identifiers.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'order_identifiers.g.dart'; + +@JsonSerializable() +class OrderIdentifiers { + OrderIdentifiers({ + required this.orderIdentifiers, + required this.lastUpdated, + }); + + factory OrderIdentifiers.fromJson(Map json) => + _$OrderIdentifiersFromJson(json); + + Map toJson() => _$OrderIdentifiersToJson(this); + + /// An array of order identifer strings. + @JsonKey(name: 'orderIdentifiers') + final List orderIdentifiers; + + /// The date and time of when an order was last changed. + final String lastUpdated; +} diff --git a/passkit/lib/src/order_webservice/order_identifiers.g.dart b/passkit/lib/src/order_webservice/order_identifiers.g.dart new file mode 100644 index 0000000..48efea3 --- /dev/null +++ b/passkit/lib/src/order_webservice/order_identifiers.g.dart @@ -0,0 +1,21 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'order_identifiers.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +OrderIdentifiers _$OrderIdentifiersFromJson(Map json) => + OrderIdentifiers( + orderIdentifiers: (json['orderIdentifiers'] as List) + .map((e) => e as String) + .toList(), + lastUpdated: json['lastUpdated'] as String, + ); + +Map _$OrderIdentifiersToJson(OrderIdentifiers instance) => + { + 'orderIdentifiers': instance.orderIdentifiers, + 'lastUpdated': instance.lastUpdated, + }; diff --git a/passkit/lib/src/order_webservice/order_web_client.dart b/passkit/lib/src/order_webservice/order_web_client.dart new file mode 100644 index 0000000..fbcad3c --- /dev/null +++ b/passkit/lib/src/order_webservice/order_web_client.dart @@ -0,0 +1,246 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:passkit/src/order/pk_order.dart'; +import 'package:passkit/src/order_webservice/order_identifiers.dart'; +import 'package:passkit/src/order_webservice/order_web_client_exceptions.dart'; +import 'package:passkit/src/utils.dart'; + +/// This class allows you to update a [PkOrder] the latest version, if the order +/// allows it. +/// +/// Docs: +/// [https://developer.apple.com/documentation/walletorders] +class OrderWebClient { + /// If a [client] is passed to the constructor it will be used, otherwise a + /// default instance will be created. This is useful for testing, or for using + /// other implementations like [https://pub.dev/packages/cupertino_http] or + /// [https://pub.dev/packages/cronet_http]. + OrderWebClient({Client? client}) : _client = client ?? Client(); + + final Client _client; + + /// Loads the latest version for the given order. Throws, if the order doesn't + /// support being updated. To check whether an order supports being updated, + /// check whether [PkOrder.isWebServiceAvailable] returns true. + /// + /// Returns null if no new order is available. + /// + /// If [modifiedSince] is present, only updates after [modifiedSince] will be + /// considered. + Future getLatestVersion( + PkOrder order, { + DateTime? modifiedSince, + }) async { + if (!order.isWebServiceAvailable) { + throw OrderWebServiceUnsupported(); + } + + final webServiceUrl = order.order.webServiceURL!; + final authenticationToken = order.order.authenticationToken!; + + final identifier = order.order.orderTypeIdentifier; + final serial = order.order.orderIdentifier; + final endpoint = + webServiceUrl.appendPathSegments(['v1', 'orders', identifier, serial]); + + final response = await _client.get( + endpoint, + headers: { + if (modifiedSince != null) + 'If-Modified-Since': formatHttpDate(modifiedSince), + 'Authorization': 'AppleOrder $authenticationToken', + }, + ); + + final bytes = switch (response.statusCode) { + 200 => response.bodyBytes, + 304 => null, + 401 => throw OrderWebServiceAuthenticationError(), + _ => throw OrderWebServiceUnrecognizedStatusCode(response.statusCode), + }; + + if (bytes != null) { + return PkOrder.fromBytes(bytes); + } + return null; + } + + /// Record a message on the server. + /// + /// Docs: + /// [https://developer.apple.com/documentation/walletorders/receive-log-messages] + Future logMessages(PkOrder order, List messages) async { + final webServiceUrl = order.order.webServiceURL; + if (webServiceUrl == null) { + throw OrderWebServiceUnsupported(); + } + + final endpoint = webServiceUrl.appendPathSegments(['v1', 'log']); + + await _client.post(endpoint, body: jsonEncode(messages)); + } + + /// Set up change notifications for an order on a device. + /// + /// [deviceIdentifier] : A unique identifier you use to identify and + /// authenticate the device. + /// + /// [pushToken] : A push token the server uses to send update notifications + /// for a registered pass to a device. + /// + /// Docs: + /// https://developer.apple.com/documentation/walletorders/register-a-device-for-update-notifications + Future setupNotifications( + PkOrder order, { + required String deviceIdentifier, + required String pushToken, + }) async { + // https://your-web-service.com/v1/devices/{deviceIdentifier}/registrations/{orderTypeIdentifier}/{orderIdentifier} + + final webServiceUrl = order.order.webServiceURL; + if (webServiceUrl == null) { + throw OrderWebServiceUnsupported(); + } + final authenticationToken = order.order.authenticationToken!; + + final endpoint = webServiceUrl.appendPathSegments( + [ + 'v1', + 'devices', + deviceIdentifier, + 'registrations', + order.order.orderTypeIdentifier, + order.order.orderIdentifier, + ], + ); + + final response = await _client.post( + endpoint, + headers: { + 'Authorization': 'AppleOrder $authenticationToken', + }, + body: jsonEncode({ + 'pushToken': pushToken, + }), + ); + + switch (response.statusCode) { + case 200: + // The serial number is already registered for the device. + // We consider this success too, so no throwing + null; + case 201: + // Success. The registration is successful. + null; + case 401: + throw OrderWebServiceAuthenticationError(); + default: + throw OrderWebServiceUnrecognizedStatusCode(response.statusCode); + } + } + + /// Unregister an order for update notifications + /// Stop sending update notifications for an order on a device. + /// + /// [deviceIdentifier] : A unique identifier you use to identify and + /// authenticate the device. + /// + /// Docs: + /// https://developer.apple.com/documentation/walletorders/unregister-a-device-from-update-notifications + Future stopNotifications( + PkOrder order, { + required String deviceIdentifier, + }) async { + // https://your-web-service.com/v1/devices/{deviceIdentifier}/registrations/{orderTypeIdentifier}/{orderIdentifier} + + final webServiceUrl = order.order.webServiceURL; + if (webServiceUrl == null) { + throw OrderWebServiceUnsupported(); + } + final authenticationToken = order.order.authenticationToken!; + + final endpoint = webServiceUrl.appendPathSegments( + [ + 'v1', + 'devices', + deviceIdentifier, + 'registrations', + order.order.orderTypeIdentifier, + order.order.orderIdentifier, + ], + ); + + final response = await _client.delete( + endpoint, + headers: { + 'Authorization': 'AppleOrder $authenticationToken', + }, + ); + + switch (response.statusCode) { + case 200: + // The serial number is already registered for the device. + // We consider this success too, so no throwing + null; + case 401: + throw OrderWebServiceAuthenticationError(); + default: + throw OrderWebServiceUnrecognizedStatusCode(response.statusCode); + } + } + + /// Send the serial numbers for updated passes to a device. + /// + /// [deviceIdentifier] : A unique identifier you use to identify and + /// authenticate the device. + /// + /// [lastModified] : The value of the lastUpdated key from the + /// SerialNumbers object returned in a previous request. This value limits the + /// results of the current request to the passes updated since that previous + /// request. + /// + /// Docs: + /// https://developer.apple.com/documentation/walletorders/retrieve-the-registrations-for-a-device + Future getListOfRegisteredOrders( + PkOrder order, { + required String deviceIdentifier, + required String? lastModified, + }) async { + // https://your-web-service.com/v1/devices/{deviceIdentifier}/registrations/{orderTypeIdentifier}?ordersModifiedSince={lastModified} + final webServiceUrl = order.order.webServiceURL; + if (webServiceUrl == null) { + throw OrderWebServiceUnsupported(); + } + + final endpoint = webServiceUrl.appendPathSegments([ + 'v1', + 'devices', + deviceIdentifier, + 'registrations', + order.order.orderTypeIdentifier, + ]).replace( + queryParameters: { + if (lastModified != null) 'ordersModifiedSince': lastModified, + }, + ); + + final response = await _client.get(endpoint); + + return switch (response.statusCode) { + 200 => () { + final responseJson = utf8JsonDecode(response.bodyBytes)!; + return OrderIdentifiers.fromJson(responseJson); + }(), + 204 => null, + 401 => throw OrderWebServiceAuthenticationError(), + _ => throw OrderWebServiceUnrecognizedStatusCode(response.statusCode), + }; + } +} + +extension on Uri { + Uri appendPathSegments(List additionalSegments) => + replace(pathSegments: [...pathSegments, ...additionalSegments]); +} diff --git a/passkit/lib/src/order_webservice/order_web_client_exceptions.dart b/passkit/lib/src/order_webservice/order_web_client_exceptions.dart new file mode 100644 index 0000000..1410c9f --- /dev/null +++ b/passkit/lib/src/order_webservice/order_web_client_exceptions.dart @@ -0,0 +1,17 @@ +class OrderWebServiceUnsupported implements Exception { + @override + String toString() => "The Order file doesn't specify a 'webServiceURL' and " + "thus there's no possibility to make a HTTP request."; +} + +class OrderWebServiceAuthenticationError implements Exception {} + +class OrderWebServiceUnrecognizedStatusCode implements Exception { + OrderWebServiceUnrecognizedStatusCode(this.statusCode); + + final int statusCode; + + @override + String toString() => + 'OrderWebServiceUnrecognizedStatusCode(statusCode: $statusCode)'; +} diff --git a/passkit/lib/src/pk_image_extension.dart b/passkit/lib/src/pk_image_extension.dart new file mode 100644 index 0000000..915ac52 --- /dev/null +++ b/passkit/lib/src/pk_image_extension.dart @@ -0,0 +1,36 @@ +import 'package:archive/archive.dart'; +import 'package:passkit/src/pk_image.dart'; + +extension PkImageX on PkImage { + void writeToArchive(Archive archive, String name) { + if (image1 != null) { + archive.addFile(ArchiveFile('$name.png', image1!.lengthInBytes, image1)); + } + if (image2 != null) { + archive + .addFile(ArchiveFile('$name@2x.png', image2!.lengthInBytes, image2)); + } + if (image3 != null) { + archive + .addFile(ArchiveFile('$name@3x.png', image3!.lengthInBytes, image3)); + } + + if (localizedImages != null) { + for (final entry in localizedImages!.entries) { + final lang = entry.key; + for (final image in entry.value.entries) { + final fileName = switch (image.key) { + 1 => '$lang.lproj/$name.png', + 2 => '$lang.lproj/$name@2x.png', + 3 => '$lang.lproj/$name@3x.png', + _ => throw Exception('This case should never happen'), + }; + + archive.addFile( + ArchiveFile(fileName, image.value.lengthInBytes, image.value), + ); + } + } + } + } +} diff --git a/passkit/lib/src/pkpass/pass_data.g.dart b/passkit/lib/src/pkpass/pass_data.g.dart index 6b7f0b3..fe22fc2 100644 --- a/passkit/lib/src/pkpass/pass_data.g.dart +++ b/passkit/lib/src/pkpass/pass_data.g.dart @@ -8,11 +8,11 @@ part of 'pass_data.dart'; PassData _$PassDataFromJson(Map json) => PassData( description: json['description'] as String, - formatVersion: (json['formatVersion'] as num).toInt(), organizationName: json['organizationName'] as String, passTypeIdentifier: json['passTypeIdentifier'] as String, serialNumber: json['serialNumber'] as String, teamIdentifier: json['teamIdentifier'] as String, + formatVersion: (json['formatVersion'] as num?)?.toInt() ?? 1, appLaunchURL: json['appLaunchURL'] as String?, associatedStoreIdentifiers: (json['associatedStoreIdentifiers'] as List?) diff --git a/passkit/lib/src/pkpass/pkpass.dart b/passkit/lib/src/pkpass/pkpass.dart index 090db2b..dfbbcda 100644 --- a/passkit/lib/src/pkpass/pkpass.dart +++ b/passkit/lib/src/pkpass/pkpass.dart @@ -6,6 +6,7 @@ import 'package:passkit/src/archive_file_extension.dart'; import 'package:passkit/src/crypto/signature_verification.dart'; import 'package:passkit/src/crypto/write_signature.dart'; import 'package:passkit/src/pk_image.dart'; +import 'package:passkit/src/pk_image_extension.dart'; import 'package:passkit/src/pkpass/exceptions.dart'; import 'package:passkit/src/pkpass/pass_data.dart'; import 'package:passkit/src/pkpass/pass_type.dart'; @@ -259,7 +260,8 @@ class PkPass { /// /// Remarks: /// - Image sizes aren't checked, which means it's possible to create passes - /// that look odd and wrong in Apple wallet or [passkit_ui](https://pub.dev/packages/passkit_ui) + /// that look odd and wrong in the Apple Wallet app or in + /// [passkit_ui](https://pub.dev/packages/passkit_ui) Uint8List? write({ required String? certificatePem, required String? privateKeyPem, @@ -443,37 +445,3 @@ extension on Archive { return null; } } - -extension on PkImage { - void writeToArchive(Archive archive, String name) { - if (image1 != null) { - archive.addFile(ArchiveFile('$name.png', image1!.lengthInBytes, image1)); - } - if (image2 != null) { - archive - .addFile(ArchiveFile('$name@2x.png', image2!.lengthInBytes, image2)); - } - if (image3 != null) { - archive - .addFile(ArchiveFile('$name@3x.png', image3!.lengthInBytes, image3)); - } - - if (localizedImages != null) { - for (final entry in localizedImages!.entries) { - final lang = entry.key; - for (final image in entry.value.entries) { - final fileName = switch (image.key) { - 1 => '$lang.lproj/$name.png', - 2 => '$lang.lproj/$name@2x.png', - 3 => '$lang.lproj/$name@3x.png', - _ => throw Exception('This case should never happen'), - }; - - archive.addFile( - ArchiveFile(fileName, image.value.lengthInBytes, image.value), - ); - } - } - } - } -} diff --git a/passkit_ui/example/pubspec.lock b/passkit_ui/example/pubspec.lock index b4ec224..c97951c 100644 --- a/passkit_ui/example/pubspec.lock +++ b/passkit_ui/example/pubspec.lock @@ -9,22 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.6.1" - args: - dependency: transitive - description: - name: args - sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" - url: "https://pub.dev" - source: hosted - version: "2.5.0" - asn1lib: - dependency: transitive - description: - name: asn1lib - sha256: "2ca377ad4d677bbadca278e0ba4da4e057b80a10b927bfc8f7d8bda8fe2ceb75" - url: "https://pub.dev" - source: hosted - version: "1.5.4" async: dependency: transitive description: @@ -105,14 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" - encrypt: + cupertino_icons: dependency: transitive description: - name: encrypt - sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "1.0.8" fake_async: dependency: transitive description: @@ -155,6 +139,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.2" + image: + dependency: transitive + description: + name: image + sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8" + url: "https://pub.dev" + source: hosted + version: "4.2.0" intl: dependency: transitive description: @@ -241,7 +233,7 @@ packages: path: "../../passkit" relative: true source: path - version: "0.0.7" + version: "0.0.10" passkit_ui: dependency: "direct main" description: @@ -382,6 +374,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" sdks: dart: ">=3.4.0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/passkit_ui/lib/passkit_ui.dart b/passkit_ui/lib/passkit_ui.dart index 6bb0d30..fc6837b 100644 --- a/passkit_ui/lib/passkit_ui.dart +++ b/passkit_ui/lib/passkit_ui.dart @@ -1,4 +1,5 @@ -export 'src/widgets/widgets.dart'; +export 'src/export_pass_image.dart'; export 'src/extensions/extensions.dart'; +// export 'src/order/order_widget.dart'; # Not ready yet export 'src/pk_pass_widget.dart'; -export 'src/export_pass_image.dart'; +export 'src/widgets/widgets.dart'; diff --git a/passkit_ui/lib/src/order/fulfillment.dart b/passkit_ui/lib/src/order/fulfillment.dart new file mode 100644 index 0000000..eecadf8 --- /dev/null +++ b/passkit_ui/lib/src/order/fulfillment.dart @@ -0,0 +1,41 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:passkit/passkit.dart'; +import 'package:passkit_ui/src/order/pickup_fulfillment.dart'; +import 'package:passkit_ui/src/order/shipping_fulfillment.dart'; + +class FulfillmentSection extends StatelessWidget { + const FulfillmentSection({ + super.key, + required this.fulfillment, + required this.order, + required this.onTrackingLinkClicked, + required this.index, + required this.totalOrders, + }); + + final Object fulfillment; + final PkOrder order; + final ValueChanged onTrackingLinkClicked; + final int index; + final int totalOrders; + + @override + Widget build(BuildContext context) { + return switch (fulfillment) { + OrderShippingFulfillment shipping => OrderShippingFulfillmentWidget( + fulfillment: shipping, + order: order, + onTrackingLinkClicked: onTrackingLinkClicked, + index: index, + totalOrders: totalOrders, + ), + OrderPickupFulfillment orderPickup => OrderPickupFulfillmentWidget( + fulfillment: orderPickup, + index: index, + totalOrders: totalOrders, + ), + _ => const SizedBox.shrink() + }; + } +} diff --git a/passkit_ui/lib/src/order/l10n.dart b/passkit_ui/lib/src/order/l10n.dart new file mode 100644 index 0000000..ac3d5e5 --- /dev/null +++ b/passkit_ui/lib/src/order/l10n.dart @@ -0,0 +1,217 @@ +import 'package:flutter/widgets.dart'; +import 'package:intl/intl.dart'; +import 'package:passkit/passkit.dart'; + +abstract class OrderLocalizations { + /// Creates a [OrderLocalizations]. + const OrderLocalizations(); + + String orderedAt(DateTime date); + String deliveredAt(DateTime dateTime); + String estimatedDeliveryAt(DateTime dateTime); + String get courier; + String get trackingId; + String get trackShipment; + String from(String merchant); + String get deliveredStatus; + String get outForDeliveryStatus; + String get orderPlacedStatus; + String get details; + String get orderId; + String get orderTotal; + String get manageOrder; + String get visitMerchantWebsite; + String get subtotal; + String get coupon; + String get tax; + String get total; + String get transactions; + String status(String status); + String get pendingStatus; + String get amount; + String get shareOrder; + String get markAsComplete; + String get deleteOrder; + String get readyForPickup; + String get barcode; + String pickupTime(DateTime from, DateTime to); + String get pickup; + String get pickupInstructions; + String get pickupWindow; + String get cancelledStatus; + String formatCurrency(double amount, String currency); + String get markOrderCompleted; + String get track; + String get cancel; + + /// Reads something like "Order {[index]} from {[total]}". + /// [index] is 1-indexed + String shipmentXFromY(int index, int total); + + /// Merchant is responsible for the order, order details and receipt details. + String get merchantIsResponsibleNote; + + String shippingStatus(OrderShippingFulfillmentStatus status); + + /// This method is used to obtain a localized instance of + /// [OrderLocalizations]. + static OrderLocalizations of(BuildContext context) { + return Localizations.of( + context, + OrderLocalizations, + )!; + } +} + +class EnOrderLocalizations extends OrderLocalizations { + @override + String orderedAt(DateTime date) { + final dateFormat = DateFormat.yMd('en_EN'); + return 'Ordered ${dateFormat.format(date)}'; + } + + @override + final String amount = 'Amount'; + + @override + final String coupon = 'Coupon'; + + @override + final String courier = 'Courier'; + + @override + final String deleteOrder = 'Delete Order'; + + @override + final String deliveredStatus = 'Delivered'; + + @override + final String details = 'Details'; + + @override + String from(String merchant) { + return 'From $merchant'; + } + + @override + final String manageOrder = 'Manage Order'; + + @override + final String markAsComplete = 'Mark as Complete'; + + @override + final String merchantIsResponsibleNote = + 'Merchant is responsible for the order, order details and receipt details.'; + + @override + final String orderId = 'Order ID'; + + @override + final String orderPlacedStatus = 'Order placed'; + + @override + final String orderTotal = 'Order total'; + + @override + final String outForDeliveryStatus = 'Out for delivery'; + + @override + final String pendingStatus = 'Pending'; + + @override + final String shareOrder = 'Share order'; + + @override + String status(String status) { + return 'Status $status'; + } + + @override + final String subtotal = 'Subtotal'; + + @override + final String tax = 'Tax'; + + @override + final String total = 'Total'; + + @override + final String trackShipment = 'Track Shipment'; + + @override + final String trackingId = 'Tracking ID'; + + @override + final String transactions = 'Transactions'; + + @override + final String visitMerchantWebsite = 'Visit merchant website'; + + @override + final String barcode = 'Barcode'; + + @override + final String pickup = 'Pickup'; + + @override + final String pickupInstructions = 'Pickup Instructions'; + + @override + String pickupTime(DateTime from, DateTime to) { + // Date, time from - to + return 'Pickup from $from '; + } + + @override + final String readyForPickup = 'Ready for Pickup'; + + @override + final String pickupWindow = 'Pickup Window'; + + @override + final String cancelledStatus = 'Cancelled'; + + @override + String formatCurrency(double amount, String currency) { + final numberFormat = NumberFormat.currency(name: currency, locale: 'en_EN'); + return numberFormat.format(amount); + } + + @override + final String markOrderCompleted = 'Mark as completed'; + + @override + final String track = 'Track'; + + @override + final String cancel = 'Cancel'; + + @override + String shipmentXFromY(int index, int total) => 'Shipment $index from $total'; + + @override + String shippingStatus(OrderShippingFulfillmentStatus status) { + return switch (status) { + OrderShippingFulfillmentStatus.open => 'Open', + OrderShippingFulfillmentStatus.processing => 'Processing', + OrderShippingFulfillmentStatus.onTheWay => 'On the way', + OrderShippingFulfillmentStatus.outForDelivery => 'Out for delivery', + OrderShippingFulfillmentStatus.delivered => 'Delivered', + OrderShippingFulfillmentStatus.shipped => 'Shipped', + OrderShippingFulfillmentStatus.issue => 'Issue', + OrderShippingFulfillmentStatus.cancelled => 'Cancelled', + }; + } + + @override + String deliveredAt(DateTime dateTime) { + final dateFormat = DateFormat.yMd('en_EN'); + return 'Delivered at ${dateFormat.format(dateTime)}'; + } + + @override + String estimatedDeliveryAt(DateTime dateTime) { + final dateFormat = DateFormat.yMd('en_EN'); + return 'Estimated: ${dateFormat.format(dateTime)}'; + } +} diff --git a/passkit_ui/lib/src/order/order_details_model_sheet.dart b/passkit_ui/lib/src/order/order_details_model_sheet.dart new file mode 100644 index 0000000..e619563 --- /dev/null +++ b/passkit_ui/lib/src/order/order_details_model_sheet.dart @@ -0,0 +1,117 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:passkit/passkit.dart'; +import 'package:passkit_ui/src/order/l10n.dart'; + +Future showOrderDetailSheet(BuildContext context, PkOrder order) { + return showModalBottomSheet( + context: context, + builder: (context) => OrderDetailsModelSheet(order: order), + ); +} + +class OrderDetailsModelSheet extends StatelessWidget { + const OrderDetailsModelSheet({super.key, required this.order}); + + final PkOrder order; + + @override + Widget build(BuildContext context) { + final payment = order.order.payment; + final lineItems = order.order.lineItems; + + final l10n = EnOrderLocalizations(); + + return ListView( + children: [ + if (payment != null) + Text( + l10n.formatCurrency(payment.total.amount, payment.total.currency), + textAlign: TextAlign.center, + style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, + ), + Text( + order.order.merchant.displayName, + textAlign: TextAlign.center, + style: CupertinoTheme.of(context).textTheme.textStyle, + ), + Text( + l10n.orderedAt(order.order.createdAt), + textAlign: TextAlign.center, + style: CupertinoTheme.of(context).textTheme.textStyle, + ), + if (lineItems != null) + CupertinoListSection.insetGrouped( + children: [ + for (final lineItem in lineItems) + CupertinoListTile.notched( + title: Text(lineItem.title), + subtitle: lineItem.subtitle != null + ? Text(lineItem.subtitle!) + : null, + trailing: const Column( + children: [ + Text(''), + ], + ), + ), + _Summary(summaryItems: payment?.summaryItems), + if (payment?.total != null) + CupertinoListTile.notched( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(l10n.total), + Text( + l10n.formatCurrency( + payment!.total.amount, + payment.total.currency, + ), + ), + ], + ), + ), + // Transaction is not added since the properties are marked as + // deprecated. + ], + ), + ], + ); + } +} + +class _Summary extends StatelessWidget { + const _Summary({this.summaryItems}); + + final List? summaryItems; + + @override + Widget build(BuildContext context) { + if (summaryItems == null) { + return const SizedBox.shrink(); + } + if (summaryItems!.isEmpty) { + return const SizedBox.shrink(); + } + + final l10n = EnOrderLocalizations(); + + return CupertinoListTile.notched( + title: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final item in summaryItems!) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(item.label), + Text( + l10n.formatCurrency(item.value.amount, item.value.currency), + ), + ], + ), + ], + ), + ); + } +} diff --git a/passkit_ui/lib/src/order/order_widget.dart b/passkit_ui/lib/src/order/order_widget.dart new file mode 100644 index 0000000..bdc98a4 --- /dev/null +++ b/passkit_ui/lib/src/order/order_widget.dart @@ -0,0 +1,241 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:passkit/passkit.dart'; +import 'package:passkit_ui/src/extensions/pk_pass_image_extension.dart'; +import 'package:passkit_ui/src/order/fulfillment.dart'; +import 'package:passkit_ui/src/order/l10n.dart'; +import 'package:passkit_ui/src/order/order_details_model_sheet.dart'; +import 'package:passkit_ui/src/widgets/squircle.dart'; + +class OrderWidget extends StatelessWidget { + const OrderWidget({ + super.key, + required this.order, + required this.isOrderImport, + required this.onManageOrderClicked, + required this.onVisitMerchantWebsiteClicked, + required this.onShareClicked, + required this.onDeleteOrderClicked, + required this.onMarkOrderCompletedClicked, + required this.onTrackingLinkClicked, + required this.onImportOrderClicked, + }); + + final PkOrder order; + + /// When this is an import, the navigation bar shows a cancle and track button. + /// When this is not an import, the navigation bar shows, depending on the status, + /// a mark as completed button, a share button, and a delete order button. + final bool isOrderImport; + final ValueChanged onManageOrderClicked; + final ValueChanged onVisitMerchantWebsiteClicked; + final ValueChanged onTrackingLinkClicked; + final ValueChanged onShareClicked; + final ValueChanged onDeleteOrderClicked; + final ValueChanged onMarkOrderCompletedClicked; + final ValueChanged onImportOrderClicked; + + // Build the UI according to https://docs-assets.developer.apple.com/published/e5ec23af37b6a9d9cbc90e5d5f47bf8a/wallet-ot-status-on-the-way-fields@2x.png + // https://developer.apple.com/design/human-interface-guidelines/wallet#Order-tracking + @override + Widget build(BuildContext context) { + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + final l10n = EnOrderLocalizations(); + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: Text(order.order.merchant.displayName), + leading: isOrderImport + ? CupertinoButton( + onPressed: () => Navigator.maybePop(context), + padding: EdgeInsets.zero, + child: Text(l10n.cancel), + ) + : null, + trailing: isOrderImport + ? CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => onImportOrderClicked(order), + child: Text(l10n.track), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () => onShareClicked(order), + icon: Icon(Icons.adaptive.share), + ), + IconButton( + onPressed: () => onShareClicked(order), + icon: Icon(Icons.adaptive.more_outlined), + ), + ], + ), + ), + backgroundColor: + CupertinoColors.systemGroupedBackground.resolveFrom(context), + child: ListView( + children: [ + if (order.order.merchant.logo != null) + SizedBox( + width: 150, + height: 150, + child: Center( + child: Squircle( + radius: 20, + child: Image.memory( + order + .loadImage(order.order.merchant.logo!) + .forCorrectPixelRatio(devicePixelRatio), + fit: BoxFit.cover, + width: 120, + height: 120, + ), + ), + ), + ), + Text( + order.order.merchant.displayName, + textAlign: TextAlign.center, + style: CupertinoTheme.of(context).textTheme.navLargeTitleTextStyle, + ), + Text( + l10n.orderedAt(order.order.createdAt), + textAlign: TextAlign.center, + style: CupertinoTheme.of(context) + .textTheme + .textStyle + .copyWith(color: CupertinoColors.systemGrey), + ), + if (order.order.orderProvider?.displayName != null) + CupertinoListTile( + title: Text(l10n.courier), + subtitle: Text(order.order.orderProvider!.displayName), + ), + if (order.order.fulfillments != null) + for (final fulfillment in order.order.fulfillments!.indexed) + FulfillmentSection( + fulfillment: fulfillment.$2, + order: order, + onTrackingLinkClicked: onTrackingLinkClicked, + index: fulfillment.$1, + totalOrders: order.order.fulfillments!.length, + ), + DetailsSection(order: order), + InfoSection( + order: order, + onManageOrderClicked: onManageOrderClicked, + onVisitMerchantWebsiteClicked: onVisitMerchantWebsiteClicked, + ), + ], + ), + ); + } +} + +class DetailsSection extends StatelessWidget { + const DetailsSection({super.key, required this.order}); + + final PkOrder order; + + @override + Widget build(BuildContext context) { + final l10n = EnOrderLocalizations(); + + return CupertinoListSection.insetGrouped( + header: Text(l10n.details), + children: [ + CupertinoContextMenu( + actions: [ + CupertinoContextMenuAction( + onPressed: () => Clipboard.setData( + ClipboardData(text: order.order.orderIdentifier), + ), + isDefaultAction: true, + trailingIcon: CupertinoIcons.number, + child: const Text('Copy Order Number'), + ), + ], + child: CupertinoListTile.notched( + title: Text(l10n.orderId), + subtitle: Text(order.order.orderIdentifier), + ), + ), + if (order.order.payment != null) + CupertinoListTile.notched( + title: Text(l10n.orderTotal), + subtitle: order.order.payment == null + ? null + : Text( + l10n.formatCurrency( + order.order.payment!.total.amount, + order.order.payment!.total.currency, + ), + ), + trailing: const CupertinoListTileChevron(), + onTap: () => showOrderDetailSheet(context, order), + ), + ], + ); + } +} + +class InfoSection extends StatelessWidget { + const InfoSection({ + super.key, + required this.order, + required this.onManageOrderClicked, + required this.onVisitMerchantWebsiteClicked, + }); + + final PkOrder order; + final ValueChanged onManageOrderClicked; + final ValueChanged onVisitMerchantWebsiteClicked; + + @override + Widget build(BuildContext context) { + final l10n = EnOrderLocalizations(); + + const linkColor = CupertinoColors.link; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoListSection.insetGrouped( + children: [ + CupertinoListTile.notched( + title: Text( + l10n.manageOrder, + style: const TextStyle(color: linkColor), + ), + trailing: + const Icon(CupertinoIcons.arrow_up_right, color: linkColor), + onTap: () => onManageOrderClicked(order.order.orderManagementURL), + ), + CupertinoListTile.notched( + title: Text( + l10n.visitMerchantWebsite, + style: const TextStyle(color: linkColor), + ), + trailing: const Icon(CupertinoIcons.compass, color: linkColor), + onTap: () => + onVisitMerchantWebsiteClicked(order.order.merchant.url), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 8), + child: Text( + l10n.merchantIsResponsibleNote, + textAlign: TextAlign.center, + style: CupertinoTheme.of(context) + .textTheme + .textStyle + .copyWith(color: CupertinoColors.systemGrey), + ), + ), + ], + ); + } +} diff --git a/passkit_ui/lib/src/order/pickup_fulfillment.dart b/passkit_ui/lib/src/order/pickup_fulfillment.dart new file mode 100644 index 0000000..8f62798 --- /dev/null +++ b/passkit_ui/lib/src/order/pickup_fulfillment.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:passkit/passkit.dart'; + +class OrderPickupFulfillmentWidget extends StatelessWidget { + const OrderPickupFulfillmentWidget({ + super.key, + required this.fulfillment, + required this.index, + required this.totalOrders, + }); + + final OrderPickupFulfillment fulfillment; + final int index; + final int totalOrders; + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/passkit_ui/lib/src/order/shipping_fulfillment.dart b/passkit_ui/lib/src/order/shipping_fulfillment.dart new file mode 100644 index 0000000..5b4ce8d --- /dev/null +++ b/passkit_ui/lib/src/order/shipping_fulfillment.dart @@ -0,0 +1,228 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:passkit/passkit.dart'; +import 'package:passkit_ui/src/extensions/pk_pass_image_extension.dart'; +import 'package:passkit_ui/src/order/l10n.dart'; +import 'package:passkit_ui/src/widgets/squircle.dart'; + +class OrderShippingFulfillmentWidget extends StatelessWidget { + const OrderShippingFulfillmentWidget({ + super.key, + required this.fulfillment, + required this.order, + required this.onTrackingLinkClicked, + required this.index, + required this.totalOrders, + }); + + final OrderShippingFulfillment fulfillment; + final PkOrder order; + final ValueChanged onTrackingLinkClicked; + final int index; + final int totalOrders; + + @override + Widget build(BuildContext context) { + final l10n = EnOrderLocalizations(); + final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); + + return CupertinoListSection.insetGrouped( + children: [ + _header(), + if (fulfillment.carrier != null) + CupertinoListTile.notched( + title: Text(l10n.courier), + subtitle: Text(fulfillment.carrier!), + ), + if (fulfillment.trackingNumber != null) + CupertinoListTile.notched( + title: Text(l10n.trackingId), + subtitle: Text(fulfillment.trackingNumber!), + ), + if (fulfillment.trackingURL != null) + CupertinoListTile.notched( + title: Text( + l10n.trackShipment, + style: const TextStyle(color: CupertinoColors.link), + ), + onTap: () => onTrackingLinkClicked(fulfillment.trackingURL!), + ), + if (fulfillment.lineItems != null) + for (final lineItem in fulfillment.lineItems!) + CupertinoListTile.notched( + leading: lineItem.image != null + ? Image.memory( + order + .loadImage(lineItem.image!) + .forCorrectPixelRatio(devicePixelRatio), + fit: BoxFit.contain, + width: 150, + height: 150, + ) + : null, + title: Text(lineItem.title), + subtitle: + lineItem.subtitle != null ? Text(lineItem.subtitle!) : null, + ), + ], + ); + } + + _StatusHeader _header() { + return switch (fulfillment.status) { + OrderShippingFulfillmentStatus.open => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + OrderShippingFulfillmentStatus.processing => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + OrderShippingFulfillmentStatus.onTheWay => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + OrderShippingFulfillmentStatus.outForDelivery => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + OrderShippingFulfillmentStatus.delivered => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + OrderShippingFulfillmentStatus.shipped => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + OrderShippingFulfillmentStatus.issue => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + OrderShippingFulfillmentStatus.cancelled => _StatusHeader( + fulfillment: fulfillment, + index: index, + totalOrders: totalOrders, + order: order, + ), + }; + } +} + +class _StatusHeader extends StatelessWidget { + const _StatusHeader({ + required this.fulfillment, + required this.order, + required this.index, + required this.totalOrders, + }); + + final OrderShippingFulfillment fulfillment; + final PkOrder order; + + final int index; + final int totalOrders; + + @override + Widget build(BuildContext context) { + final l10n = EnOrderLocalizations(); + return Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.shipmentXFromY(index + 1, totalOrders), + style: CupertinoTheme.of(context) + .textTheme + .textStyle + .copyWith(color: CupertinoColors.systemGrey), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + l10n.shippingStatus(fulfillment.status), + style: CupertinoTheme.of(context) + .textTheme + .navLargeTitleTextStyle + .copyWith(fontSize: 20), + ), + if (fulfillment.notes != null) + CupertinoButton( + onPressed: () { + showBottomSheet( + context: context, + builder: (_) { + return Text(fulfillment.notes!); + }, + ); + }, + child: const Icon( + CupertinoIcons.info_circle, + color: CupertinoColors.systemBlue, + ), + ), + ], + ), + Text( + fulfillment.estimatedDeliveryAt != null + ? l10n.estimatedDeliveryAt(fulfillment.estimatedDeliveryAt!) + : '', + style: CupertinoTheme.of(context).textTheme.textStyle, + ), + const SizedBox(height: 10), + LinearProgressIndicator( + valueColor: const AlwaysStoppedAnimation( + CupertinoColors.systemGreen, + ), + minHeight: 20, + borderRadius: BorderRadius.circular(10), + value: fulfillment.status.toProgress(), + ), + if (fulfillment.statusDescription != null) ...[ + const SizedBox(height: 10), + Squircle( + radius: 20, + child: ColoredBox( + color: CupertinoColors.extraLightBackgroundGray, + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.from(order.order.merchant.displayName), + style: CupertinoTheme.of(context) + .textTheme + .textStyle + .copyWith(color: CupertinoColors.systemGrey), + ), + Text( + fulfillment.statusDescription!, + style: CupertinoTheme.of(context).textTheme.textStyle, + ), + ], + ), + ), + ), + ), + ], + ], + ), + ); + } +} diff --git a/passkit_ui/lib/src/widgets/squircle.dart b/passkit_ui/lib/src/widgets/squircle.dart new file mode 100644 index 0000000..8a737df --- /dev/null +++ b/passkit_ui/lib/src/widgets/squircle.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class Squircle extends StatelessWidget { + const Squircle({super.key, required this.radius, required this.child}); + + final Widget child; + final double radius; + + @override + Widget build(BuildContext context) { + final r = BorderRadius.all(Radius.circular(radius)); + + return ClipPath( + clipper: ShapeBorderClipper( + shape: ContinuousRectangleBorder(borderRadius: r), + ), + child: child, + ); + } +} diff --git a/passkit_ui/pubspec.yaml b/passkit_ui/pubspec.yaml index 2260f9d..b6958bd 100644 --- a/passkit_ui/pubspec.yaml +++ b/passkit_ui/pubspec.yaml @@ -19,9 +19,11 @@ dependencies: barcode_widget: ^2.0.0 collection: ^1.18.0 csslib: ^1.0.0 + cupertino_icons: ^1.0.8 flutter: sdk: flutter - meta: any + intl: ">=0.18.0 <0.20.0" + meta: ^1.0.0 passkit: ^0.0.8 dev_dependencies: