Skip to content

Commit 368fdd2

Browse files
committed
[desktop] Preserve Gift Card artwork and receiving state
1 parent 42b0e1e commit 368fdd2

10 files changed

Lines changed: 369 additions & 9 deletions

lib/src/features/payment_links/models/vizor_payment_link.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class VizorPaymentLink {
99
required this.birthdayHeight,
1010
required this.label,
1111
required this.createdAt,
12+
this.artworkId,
1213
});
1314

1415
static const scheme = 'vizor';
@@ -23,6 +24,7 @@ class VizorPaymentLink {
2324
final int birthdayHeight;
2425
final String label;
2526
final DateTime createdAt;
27+
final String? artworkId;
2628

2729
String encode() {
2830
final payload = <String, Object?>{
@@ -35,6 +37,10 @@ class VizorPaymentLink {
3537
'label': label.trim(),
3638
'createdAt': createdAt.toUtc().toIso8601String(),
3739
};
40+
final normalizedArtworkId = artworkId?.trim();
41+
if (normalizedArtworkId != null && normalizedArtworkId.isNotEmpty) {
42+
payload['artworkId'] = normalizedArtworkId;
43+
}
3844
final encoded = base64UrlEncode(utf8.encode(jsonEncode(payload)));
3945
return Uri(
4046
scheme: scheme,
@@ -83,6 +89,7 @@ class VizorPaymentLink {
8389
final label = _readString(payload, 'label');
8490
final createdAtRaw = _readString(payload, 'createdAt');
8591
final createdAt = DateTime.tryParse(createdAtRaw);
92+
final artworkId = _readOptionalString(payload, 'artworkId');
8693

8794
if (network.isEmpty) {
8895
throw const FormatException('Payment link network is missing.');
@@ -102,6 +109,10 @@ class VizorPaymentLink {
102109
if (createdAt == null) {
103110
throw const FormatException('Payment link timestamp is invalid.');
104111
}
112+
if (artworkId != null &&
113+
!RegExp(r'^[a-zA-Z0-9_-]{1,64}$').hasMatch(artworkId)) {
114+
throw const FormatException('Payment link artwork is invalid.');
115+
}
105116

106117
return VizorPaymentLink(
107118
network: network,
@@ -111,6 +122,7 @@ class VizorPaymentLink {
111122
birthdayHeight: birthdayHeight,
112123
label: label,
113124
createdAt: createdAt,
125+
artworkId: artworkId,
114126
);
115127
}
116128

@@ -122,6 +134,16 @@ class VizorPaymentLink {
122134
return value.trim();
123135
}
124136

137+
static String? _readOptionalString(Map<String, Object?> payload, String key) {
138+
final value = payload[key];
139+
if (value == null) return null;
140+
if (value is! String) {
141+
throw FormatException('Payment link "$key" is invalid.');
142+
}
143+
final trimmed = value.trim();
144+
return trimmed.isEmpty ? null : trimmed;
145+
}
146+
125147
static int _readInt(Map<String, Object?> payload, String key) {
126148
final value = payload[key];
127149
if (value is int) return value;

lib/src/features/payment_links/screens/payment_links_desktop_screen.dart

Lines changed: 151 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ enum _PaymentLinksLocalPage {
4343
///
4444
/// The presentation remains local to this screen, while all secret creation,
4545
/// recovery persistence, sync, and transaction work stays behind
46-
/// [PaymentLinkOperations]. Artwork and message are not protocol fields in the
47-
/// current v1 link, so received links intentionally render a generic card.
46+
/// [PaymentLinkOperations]. Artwork is an optional, backward-compatible v1
47+
/// field. The message remains local to the sender in the current protocol.
4848
class PaymentLinksDesktopScreen extends ConsumerStatefulWidget {
4949
const PaymentLinksDesktopScreen({super.key});
5050

@@ -91,6 +91,7 @@ class _PaymentLinksDesktopScreenState
9191
PaymentLinkRedeemVisualState.paste;
9292
PaymentLinkCardsTab _activeCardsTab = PaymentLinkCardsTab.created;
9393
List<PaymentLinkRecoveryRecord> _recoveries = const [];
94+
List<_ReceivedPaymentLinkRecord> _receivedCards = const [];
9495
VizorPaymentLink? _readyLink;
9596
VizorPaymentLink? _receivedLink;
9697
PaymentLinkRecoveryRecord? _reclaimCandidate;
@@ -169,6 +170,7 @@ class _PaymentLinksDesktopScreenState
169170
if (link == null || !mounted) return;
170171
setState(() {
171172
_receivedLink = link;
173+
_rememberReceivedLink(link);
172174
_redeemState = PaymentLinkRedeemVisualState.paste;
173175
_page = _PaymentLinksLocalPage.received;
174176
_showHelp = false;
@@ -191,6 +193,45 @@ class _PaymentLinksDesktopScreenState
191193
}
192194
}
193195

196+
void _rememberReceivedLink(VizorPaymentLink link) {
197+
final existingIndex = _receivedCards.indexWhere(
198+
(record) => record.address == link.address,
199+
);
200+
final existing = existingIndex < 0 ? null : _receivedCards[existingIndex];
201+
final status = existing?.status ?? _ReceivedPaymentLinkStatus.readyToClaim;
202+
final updated = _ReceivedPaymentLinkRecord.fromLink(
203+
link,
204+
status: status,
205+
retainClaimLink: status != _ReceivedPaymentLinkStatus.received,
206+
);
207+
final records = List<_ReceivedPaymentLinkRecord>.of(_receivedCards);
208+
if (existingIndex >= 0) records.removeAt(existingIndex);
209+
_receivedCards = [updated, ...records];
210+
}
211+
212+
void _setReceivedCardStatus(
213+
String address,
214+
_ReceivedPaymentLinkStatus status, {
215+
bool clearClaimLink = false,
216+
}) {
217+
_receivedCards = [
218+
for (final record in _receivedCards)
219+
if (record.address == address)
220+
record.withStatus(status, clearClaimLink: clearClaimLink)
221+
else
222+
record,
223+
];
224+
}
225+
226+
void _openReceivedCard(_ReceivedPaymentLinkRecord record) {
227+
final link = record.claimLink;
228+
if (link == null || _operationInProgress) return;
229+
setState(() {
230+
_receivedLink = link;
231+
_page = _PaymentLinksLocalPage.received;
232+
});
233+
}
234+
194235
bool get _hasPositiveAmount {
195236
final amount = parseZecAmount(_amountController.text);
196237
return amount != null && amount > BigInt.zero;
@@ -278,6 +319,7 @@ class _PaymentLinksDesktopScreenState
278319
_keystoneFundingRequest = _PaymentLinkKeystoneFundingRequest(
279320
amountZatoshi: amount,
280321
sourceAccountUuid: sourceAccountUuid,
322+
artworkId: _selectedArtwork.protocolId,
281323
);
282324
});
283325
return;
@@ -290,6 +332,7 @@ class _PaymentLinksDesktopScreenState
290332
.createFundedLink(
291333
amountZatoshi: amount,
292334
sourceAccountUuid: sourceAccountUuid,
335+
artworkId: _selectedArtwork.protocolId,
293336
);
294337
await _loadRecoveries(showError: false);
295338
if (!mounted) return;
@@ -386,6 +429,7 @@ class _PaymentLinksDesktopScreenState
386429
if (link == null || !mounted) return;
387430
setState(() {
388431
_receivedLink = link;
432+
_rememberReceivedLink(link);
389433
_redeemState = PaymentLinkRedeemVisualState.paste;
390434
_page = _PaymentLinksLocalPage.received;
391435
});
@@ -409,17 +453,36 @@ class _PaymentLinksDesktopScreenState
409453
Future<void> _claimReceivedLink() async {
410454
final link = _receivedLink;
411455
if (link == null || _operationInProgress) return;
412-
setState(() => _operationInProgress = true);
456+
setState(() {
457+
_operationInProgress = true;
458+
_receivedLink = null;
459+
_setReceivedCardStatus(
460+
link.address,
461+
_ReceivedPaymentLinkStatus.receiving,
462+
);
463+
_activeCardsTab = PaymentLinkCardsTab.received;
464+
_page = _PaymentLinksLocalPage.home;
465+
_showHelp = false;
466+
});
413467
try {
414468
await ref.read(paymentLinkOperationsProvider).claimLink(link);
415469
if (!mounted) return;
416470
setState(() {
417-
_receivedLink = null;
418-
_page = _PaymentLinksLocalPage.home;
471+
_setReceivedCardStatus(
472+
link.address,
473+
_ReceivedPaymentLinkStatus.received,
474+
clearClaimLink: true,
475+
);
419476
});
420477
showAppToast(context, 'Gift claimed');
421478
} catch (_) {
422479
if (mounted) {
480+
setState(() {
481+
_setReceivedCardStatus(
482+
link.address,
483+
_ReceivedPaymentLinkStatus.readyToClaim,
484+
);
485+
});
423486
_showError(
424487
'Gift Card claim failed. It may still be waiting for confirmation '
425488
'or may already be spent.',
@@ -489,6 +552,7 @@ class _PaymentLinksDesktopScreenState
489552
child: PaymentLinkKeystoneSigningOverlay(
490553
amountZatoshi: keystoneRequest.amountZatoshi,
491554
sourceAccountUuid: keystoneRequest.sourceAccountUuid,
555+
artworkId: keystoneRequest.artworkId,
492556
onCancel: _cancelKeystoneFunding,
493557
onFundingBroadcast: _completeKeystoneFunding,
494558
),
@@ -539,7 +603,9 @@ class _PaymentLinksDesktopScreenState
539603
}
540604

541605
Widget _buildHome() {
542-
if (_recoveries.isNotEmpty) return _buildCardsList();
606+
if (_recoveries.isNotEmpty || _receivedCards.isNotEmpty) {
607+
return _buildCardsList();
608+
}
543609
return PaymentLinksHomeDesktopView(
544610
illustration: Image.asset(
545611
'assets/illustrations/payment_links/payment_link_empty_card.png',
@@ -564,6 +630,8 @@ class _PaymentLinksDesktopScreenState
564630
)
565631
.map(_buildRecoveryRow)
566632
.toList()
633+
: _receivedCards.isNotEmpty
634+
? _receivedCards.map(_buildReceivedRow).toList()
567635
: <Widget>[
568636
Padding(
569637
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
@@ -615,7 +683,7 @@ class _PaymentLinksDesktopScreenState
615683
return PaymentLinkCardListRow(
616684
key: ValueKey('payment_link_recovery_${record.link.address}'),
617685
thumbnail: Image.asset(
618-
PaymentLinkCardArtwork.gift.assetPath,
686+
PaymentLinkCardArtwork.fromProtocolId(record.link.artworkId).assetPath,
619687
fit: BoxFit.cover,
620688
excludeFromSemantics: true,
621689
),
@@ -631,6 +699,30 @@ class _PaymentLinksDesktopScreenState
631699
);
632700
}
633701

702+
Widget _buildReceivedRow(_ReceivedPaymentLinkRecord record) {
703+
final statusText = switch (record.status) {
704+
_ReceivedPaymentLinkStatus.readyToClaim => 'Claim',
705+
_ReceivedPaymentLinkStatus.receiving => 'Receiving',
706+
_ReceivedPaymentLinkStatus.received => 'Received',
707+
};
708+
final canClaim =
709+
record.status == _ReceivedPaymentLinkStatus.readyToClaim &&
710+
record.claimLink != null &&
711+
!_operationInProgress;
712+
return PaymentLinkCardListRow(
713+
key: ValueKey('payment_link_received_${record.address}'),
714+
thumbnail: Image.asset(
715+
PaymentLinkCardArtwork.fromProtocolId(record.artworkId).assetPath,
716+
fit: BoxFit.cover,
717+
excludeFromSemantics: true,
718+
),
719+
amountText: '${formatZecAmount(record.amountZatoshi)} ZEC',
720+
dateText: _formatCardDate(record.createdAt),
721+
statusText: statusText,
722+
onAction: canClaim ? () => _openReceivedCard(record) : null,
723+
);
724+
}
725+
634726
Widget _buildAmount() {
635727
final maxAmountText = _maxAmountText;
636728
return PaymentLinkAmountDesktopView(
@@ -730,7 +822,7 @@ class _PaymentLinksDesktopScreenState
730822
if (link == null) return _buildHome();
731823
return PaymentLinkReceivedDesktopView(
732824
card: PaymentLinkGiftCard(
733-
artwork: PaymentLinkCardArtwork.gift,
825+
artwork: PaymentLinkCardArtwork.fromProtocolId(link.artworkId),
734826
amountText: formatZecAmount(link.amountZatoshi),
735827
showCaret: false,
736828
),
@@ -804,8 +896,59 @@ class _PaymentLinkKeystoneFundingRequest {
804896
const _PaymentLinkKeystoneFundingRequest({
805897
required this.amountZatoshi,
806898
required this.sourceAccountUuid,
899+
required this.artworkId,
807900
});
808901

809902
final BigInt amountZatoshi;
810903
final String sourceAccountUuid;
904+
final String artworkId;
905+
}
906+
907+
enum _ReceivedPaymentLinkStatus { readyToClaim, receiving, received }
908+
909+
class _ReceivedPaymentLinkRecord {
910+
const _ReceivedPaymentLinkRecord({
911+
required this.address,
912+
required this.amountZatoshi,
913+
required this.createdAt,
914+
required this.artworkId,
915+
required this.status,
916+
required this.claimLink,
917+
});
918+
919+
factory _ReceivedPaymentLinkRecord.fromLink(
920+
VizorPaymentLink link, {
921+
_ReceivedPaymentLinkStatus status = _ReceivedPaymentLinkStatus.readyToClaim,
922+
bool retainClaimLink = true,
923+
}) {
924+
return _ReceivedPaymentLinkRecord(
925+
address: link.address,
926+
amountZatoshi: link.amountZatoshi,
927+
createdAt: link.createdAt,
928+
artworkId: link.artworkId,
929+
status: status,
930+
claimLink: retainClaimLink ? link : null,
931+
);
932+
}
933+
934+
final String address;
935+
final BigInt amountZatoshi;
936+
final DateTime createdAt;
937+
final String? artworkId;
938+
final _ReceivedPaymentLinkStatus status;
939+
final VizorPaymentLink? claimLink;
940+
941+
_ReceivedPaymentLinkRecord withStatus(
942+
_ReceivedPaymentLinkStatus nextStatus, {
943+
bool clearClaimLink = false,
944+
}) {
945+
return _ReceivedPaymentLinkRecord(
946+
address: address,
947+
amountZatoshi: amountZatoshi,
948+
createdAt: createdAt,
949+
artworkId: artworkId,
950+
status: nextStatus,
951+
claimLink: clearClaimLink ? null : claimLink,
952+
);
953+
}
811954
}

lib/src/features/payment_links/services/payment_link_hardware_signing_service.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ abstract interface class PaymentLinkHardwareSigningService {
2323
Future<PaymentLinkHardwarePcztDraft> createFundingPczt({
2424
required BigInt amountZatoshi,
2525
required String sourceAccountUuid,
26+
String? artworkId,
2627
});
2728

2829
Future<List<String>> encodeSigningUrParts({
@@ -80,10 +81,12 @@ class RustPaymentLinkHardwareSigningService
8081
Future<PaymentLinkHardwarePcztDraft> createFundingPczt({
8182
required BigInt amountZatoshi,
8283
required String sourceAccountUuid,
84+
String? artworkId,
8385
}) async {
8486
final link = await _paymentLinkService.createFundingDraft(
8587
amountZatoshi: amountZatoshi,
8688
sourceAccountUuid: sourceAccountUuid,
89+
artworkId: artworkId,
8790
);
8891
final sendFlowId =
8992
'payment-link-hw-${DateTime.now().microsecondsSinceEpoch}';

0 commit comments

Comments
 (0)