Skip to content

Commit 379d9c4

Browse files
authored
Merge pull request #428 from privacybydesign/ux
Passport UX improvement
2 parents 1e15244 + 775c35a commit 379d9c4

File tree

14 files changed

+5387
-79
lines changed

14 files changed

+5387
-79
lines changed

assets/locales/en.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -671,7 +671,7 @@
671671
}
672672
},
673673
"nfc": {
674-
"introduction": "You'll now scan the data inside your passport.\n\nOpen your passport and hold the back of your phone against photo. Press start to start the scanning process.",
674+
"introduction": "You'll now scan the data inside your passport.\n\nHold the back of your phone against your passport. Press start to start the scanning process.",
675675
"start_scanning": "Start scanning",
676676
"title": "Read passport",
677677
"nfc_enabled": "NFC enabled",
@@ -682,13 +682,13 @@
682682
"nfc_disabled_explanation": "NFC is disabled. Please enable NFC in the system settings and try again.",
683683
"nfc_interrupted": "Reading interrupted. Try again, holding your phone steady on your passport.",
684684
"tip_1": "Don't move your phone. The reading usually only takes 10-20 seconds.",
685-
"tip_2": "Hold the back of your phone against the photo page of your passport. That's where the chip we need to read is located.",
685+
"tip_2": "Hold the back of your phone against your passport.",
686686
"tip_3": "Having trouble reading? Try removing your phone's case. This often helps strengthen the NFC signal.",
687687
"success": "Success",
688688
"success_explanation": "Passport reading completed successfully",
689689
"idle": "Ready to start passport reading",
690690
"cancelling": "Cancelling...",
691-
"hold_near_photo_page": "Hold your phone near the passport photo page",
691+
"hold_near_photo_page": "Hold your phone near your passport.",
692692
"cancelled": "Cancelled",
693693
"connecting": "Connecting to passport...",
694694
"finished": "Finished",

assets/locales/nl.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -650,10 +650,10 @@
650650
},
651651
"passport": {
652652
"scan": {
653-
"title": "Scan paspoort",
653+
"title": "Paspoort scannen",
654654
"success": "Succes",
655655
"success_explanation": "Het paspoort is succesvol gescand.",
656-
"manual": "Handmatig invoeren",
656+
"manual": "Voer handmatig in",
657657
"error": "Kon paspoort niet lezen.\nProbeer het opnieuw."
658658
},
659659
"manual": {
@@ -671,7 +671,7 @@
671671
}
672672
},
673673
"nfc": {
674-
"introduction": "Je gaat nu de gegevens uit je paspoort lezen.\n\nOpen je paspoort en houdt de achterkant van je telefoon tegen de foto aan. Druk vervolgens op start om het uitlezen te starten.",
674+
"introduction": "Je gaat nu de gegevens uit je paspoort lezen.\n\nHoudt de achterkant van je telefoon tegen je passpoort aan. Druk vervolgens op start om het uitlezen te starten.",
675675
"start_scanning": "Start uitlezen",
676676
"title": "Lees paspoort",
677677
"nfc_enabled": "NFC ingeschakeld",
@@ -682,13 +682,13 @@
682682
"nfc_disabled_explanation": "NFC is uitgeschakeld. Schakel NFC in via de systeeminstellingen en probeer het opnieuw.",
683683
"nfc_interrupted": "Lezen onderbroken. Probeer opnieuw, houd uw telefoon stil op uw paspoort.",
684684
"tip_1": "Beweeg uw telefoon niet. Het lezen duurt meestal slechts 10-20 seconden.",
685-
"tip_2": "Houd de achterkant van uw telefoon tegen de fotopagina van uw paspoort. Daar bevindt zich de chip die we moeten lezen.",
685+
"tip_2": "Houd de achterkant van uw telefoon tegen uw paspoort.",
686686
"tip_3": "Problemen met lezen? Probeer de hoes van uw telefoon te verwijderen. Dit helpt vaak het NFC-signaal te versterken.",
687687
"success": "Succes",
688688
"success_explanation": "Paspoort succesvol uitgelezen.",
689689
"idle": "Klaar om paspoort te lezen",
690690
"cancelling": "Bezig met annuleren...",
691-
"hold_near_photo_page": "Houd uw telefoon bij de fotopagina van het paspoort",
691+
"hold_near_photo_page": "Houd uw telefoon bij het paspoort",
692692
"cancelled": "Geannuleerd",
693693
"connecting": "Verbinden met paspoort...",
694694
"finished": "Voltooid",

assets/passport/nfc.json

Lines changed: 5191 additions & 1 deletion
Large diffs are not rendered by default.

integration_test/disclose_sdjwt_over_openid4vp_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'package:irmamobile/src/screens/activity/widgets/activity_card.dart';
1111
import 'package:irmamobile/src/screens/add_data/add_data_details_screen.dart';
1212
import 'package:irmamobile/src/screens/passport/mrz_reader_screen.dart';
1313
import 'package:irmamobile/src/screens/passport/nfc_reading_screen.dart';
14-
import 'package:irmamobile/src/screens/passport/widgets/mzr_scanner.dart';
14+
import 'package:irmamobile/src/screens/passport/widgets/mrz_scanner.dart';
1515
import 'package:irmamobile/src/screens/session/disclosure/widgets/disclosure_permission_choices_screen.dart';
1616
import 'package:irmamobile/src/screens/session/disclosure/widgets/disclosure_permission_issue_wizard_screen.dart';
1717
import 'package:irmamobile/src/screens/session/disclosure/widgets/disclosure_permission_make_choice_screen.dart';

integration_test/passport_test.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import 'package:irmamobile/src/screens/add_data/add_data_details_screen.dart';
99
import 'package:irmamobile/src/screens/data/data_tab.dart';
1010
import 'package:irmamobile/src/screens/passport/mrz_reader_screen.dart';
1111
import 'package:irmamobile/src/screens/passport/nfc_reading_screen.dart';
12-
import 'package:irmamobile/src/screens/passport/widgets/mzr_scanner.dart';
12+
import 'package:irmamobile/src/screens/passport/widgets/mrz_scanner.dart';
1313
import 'package:irmamobile/src/widgets/irma_app_bar.dart';
1414
import 'package:vcmrtd/vcmrtd.dart';
1515

lib/src/screens/passport/manual_entry_screen.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import '../../widgets/irma_bottom_bar.dart';
66
import '../../widgets/translated_text.dart';
77
import 'widgets/date_input_field.dart';
88
import 'widgets/document_nr_input_field.dart';
9-
import 'widgets/mzr_scanner.dart';
9+
import 'widgets/mrz_scanner.dart';
1010

1111
typedef MRZController = GlobalKey<MRZScannerState>;
1212

lib/src/screens/passport/mrz_reader_screen.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import 'package:mrz_parser/mrz_parser.dart';
66
import '../../theme/theme.dart';
77
import '../../widgets/irma_app_bar.dart';
88
import '../../widgets/irma_bottom_bar.dart';
9-
import 'widgets/mzr_scanner.dart';
9+
import 'widgets/mrz_scanner.dart';
1010

1111
typedef MRZController = GlobalKey<MRZScannerState>;
1212

lib/src/screens/passport/nfc_reading_screen.dart

Lines changed: 51 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'dart:async';
2+
import 'dart:io';
23

34
import 'package:flutter/foundation.dart';
45
import 'package:flutter/material.dart';
@@ -299,103 +300,125 @@ class _NfcReadingScreenState extends ConsumerState<NfcReadingScreen> with RouteA
299300

300301
_UiState passportReadingStateToUiState(PassportReaderState state) {
301302
final progress = progressForState(state);
303+
final stateKey = _getTranslationKeyForState(state);
304+
302305
return switch (state) {
303306
PassportReaderPending() => _UiState(
304307
progress: progress,
305-
stateKey: 'passport.nfc.connecting',
308+
stateKey: stateKey,
306309
tipKey: 'passport.nfc.hold_near_photo_page',
307310
),
308311
PassportReaderConnecting() => _UiState(
309312
progress: progress,
310-
stateKey: 'passport.nfc.connecting',
313+
stateKey: stateKey,
311314
tipKey: 'passport.nfc.tip_2',
312315
),
313316
PassportReaderAuthenticating() => _UiState(
314317
progress: progress,
315-
stateKey: 'passport.nfc.connecting',
318+
stateKey: stateKey,
316319
tipKey: 'passport.nfc.tip_2',
317320
),
318321
PassportReaderReadingCOM() => _UiState(
319322
progress: progress,
320-
stateKey: 'passport.nfc.reading_passport_data',
323+
stateKey: stateKey,
321324
tipKey: 'passport.nfc.tip_3',
322325
),
323326
PassportReaderReadingCardAccess() => _UiState(
324327
progress: progress,
325-
stateKey: 'passport.nfc.reading_card_security',
328+
stateKey: stateKey,
326329
tipKey: 'passport.nfc.tip_3',
327330
),
328331
PassportReaderReadingDataGroup() => _UiState(
329332
progress: progress,
330-
stateKey: 'passport.nfc.reading_passport_data',
333+
stateKey: stateKey,
331334
tipKey: 'passport.nfc.tip_1',
332335
),
333336
PassportReaderReadingSOD() => _UiState(
334337
progress: progress,
335-
stateKey: 'passport.nfc.reading_passport_data',
338+
stateKey: stateKey,
336339
tipKey: 'passport.nfc.tip_2',
337340
),
338341
PassportReaderActiveAuthentication() => _UiState(
339342
progress: progress,
340-
stateKey: 'passport.nfc.performing_security_verification',
343+
stateKey: stateKey,
341344
tipKey: 'passport.nfc.tip_1',
342345
),
343346
PassportReaderSuccess() => _UiState(
344347
progress: progress,
345-
stateKey: 'passport.nfc.success',
348+
stateKey: stateKey,
346349
tipKey: 'passport.nfc.success_explanation',
347350
),
348351
PassportReaderFailed(:final error) => _UiState(
349352
progress: progress,
350-
stateKey: 'passport.nfc.error',
353+
stateKey: stateKey,
351354
tipKey: _readingErrorToHintKey(error),
352355
),
353356
PassportReaderCancelling() => _UiState(
354357
progress: progress,
355-
stateKey: 'passport.nfc.cancelled',
358+
stateKey: stateKey,
356359
tipKey: 'passport.nfc.cancelled_by_user',
357360
),
358361
PassportReaderCancelled() => _UiState(
359362
progress: progress,
360-
stateKey: 'passport.nfc.cancelled',
363+
stateKey: stateKey,
361364
tipKey: 'passport.nfc.cancelled_by_user',
362365
),
363366
_ => throw Exception('unexpected state: $state'),
364367
};
365368
}
366369

370+
String _getTranslationKeyForState(PassportReaderState state) {
371+
return switch (state) {
372+
PassportReaderPending() => 'passport.nfc.hold_near_photo_page',
373+
PassportReaderCancelled() => 'passport.nfc.cancelled',
374+
PassportReaderCancelling() => 'passport.nfc.cancelling',
375+
PassportReaderFailed() => 'passport.nfc.error',
376+
PassportReaderConnecting() => 'passport.nfc.connecting',
377+
PassportReaderReadingCardAccess() => 'passport.nfc.reading_card_security',
378+
PassportReaderReadingCOM() => 'passport.nfc.reading_passport_data',
379+
PassportReaderAuthenticating() => 'passport.nfc.authenticating',
380+
PassportReaderReadingDataGroup() => 'passport.nfc.reading_passport_data',
381+
PassportReaderReadingSOD() => 'passport.nfc.reading_passport_data',
382+
PassportReaderActiveAuthentication() => 'passport.nfc.performing_security_verification',
383+
PassportReaderSuccess() => 'passport.nfc.success',
384+
_ => '',
385+
};
386+
}
387+
367388
IosNfcMessageMapper _createIosNfcMessageMapper() {
368389
String progressFormatter(double progress) {
369390
const numStages = 10;
370391
final prog = (progress * numStages).toInt();
371392
return '🟢' * prog + '⚪️' * (numStages - prog);
372393
}
373394

395+
final ios16OrHigher = _isiOS26OrHigher();
396+
374397
return (state) {
375398
final progress = progressFormatter(progressForState(state));
376399

377-
final message = switch (state) {
378-
PassportReaderPending() => FlutterI18n.translate(context, 'passport.nfc.hold_near_photo_page'),
379-
PassportReaderCancelled() => FlutterI18n.translate(context, 'passport.nfc.cancelled'),
380-
PassportReaderCancelling() => FlutterI18n.translate(context, 'passport.nfc.cancelling'),
381-
PassportReaderFailed() => FlutterI18n.translate(context, 'passport.nfc.error'),
382-
PassportReaderConnecting() => FlutterI18n.translate(context, 'passport.nfc.connecting'),
383-
PassportReaderReadingCardAccess() => FlutterI18n.translate(context, 'passport.nfc.reading_card_security'),
384-
PassportReaderReadingCOM() => FlutterI18n.translate(context, 'passport.nfc.reading_passport_data'),
385-
PassportReaderAuthenticating() => FlutterI18n.translate(context, 'passport.nfc.authenticating'),
386-
PassportReaderReadingDataGroup() => FlutterI18n.translate(context, 'passport.nfc.reading_passport_data'),
387-
PassportReaderReadingSOD() => FlutterI18n.translate(context, 'passport.nfc.reading_passport_data'),
388-
PassportReaderActiveAuthentication() =>
389-
FlutterI18n.translate(context, 'passport.nfc.performing_security_verification'),
390-
PassportReaderSuccess() => FlutterI18n.translate(context, 'passport.nfc.success_explanation'),
391-
_ => '',
392-
};
400+
// on iOS 26 only one line is shown, so we'll use that for progress
401+
if (ios16OrHigher) {
402+
return progress;
403+
}
393404

405+
// on lower iOS versions a second line can be shown, so we'll use that for showing a message
406+
final message = FlutterI18n.translate(context, _getTranslationKeyForState(state));
394407
return '$progress\n$message';
395408
};
396409
}
397410
}
398411

412+
bool _isiOS26OrHigher() {
413+
if (!Platform.isIOS) return false;
414+
415+
final match = RegExp(r'iOS (\d+)(?:\.(\d+))?').firstMatch(Platform.operatingSystemVersion);
416+
if (match == null) return false;
417+
418+
final major = int.tryParse(match.group(1) ?? '0') ?? 0;
419+
return major >= 26; // replace with 26 or whichever major version you want
420+
}
421+
399422
Future _showLogsDialog(BuildContext context, String logs) async {
400423
return showDialog(
401424
context: context,

lib/src/screens/passport/widgets/camera_overlay.dart

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import '../../../theme/theme.dart';
55
class MRZCameraOverlay extends StatelessWidget {
66
const MRZCameraOverlay({
77
required this.child,
8+
required this.success,
89
super.key,
910
});
1011

1112
static const _documentFrameRatio = 1.42; // Passport's size (ISO/IEC 7810 ID-3) is 125mm × 88mm
1213
final Widget child;
14+
final bool success;
1315

1416
@override
1517
Widget build(BuildContext context) {
@@ -18,6 +20,8 @@ class MRZCameraOverlay extends StatelessWidget {
1820
return LayoutBuilder(
1921
builder: (_, c) {
2022
final overlayRect = _calculateOverlaySize(Size(c.maxWidth, c.maxHeight));
23+
final numChars = maxLtApprox(overlayRect.width - theme.tinySpacing, theme.mrzLabel);
24+
final guidelines = '<' * numChars;
2125
return Stack(
2226
children: [
2327
child,
@@ -29,28 +33,47 @@ class MRZCameraOverlay extends StatelessWidget {
2933
),
3034
),
3135
),
32-
_WhiteOverlay(rect: overlayRect),
33-
Align(
34-
alignment: Alignment.bottomCenter,
35-
child: Padding(
36-
padding: EdgeInsets.only(bottom: c.maxHeight - overlayRect.bottom + 20), // 20px above the bottom
37-
child: Column(
38-
crossAxisAlignment: CrossAxisAlignment.start,
39-
mainAxisSize: MainAxisSize.min,
40-
children: [
41-
Text(
42-
'<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<',
43-
style: theme.mrzLabel,
44-
),
45-
SizedBox(height: theme.tinySpacing),
46-
Text(
47-
'<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<',
48-
style: theme.mrzLabel,
49-
),
50-
],
36+
if (success) ...[
37+
_ColoredBoxOverlay(
38+
rect: overlayRect,
39+
borderColor: theme.success,
40+
color: theme.success.withAlpha(150),
41+
),
42+
Center(
43+
child: Icon(
44+
Icons.check,
45+
color: Colors.white,
46+
size: 200,
5147
),
48+
)
49+
] else ...[
50+
_ColoredBoxOverlay(
51+
rect: overlayRect,
52+
borderColor: Colors.white,
53+
color: Colors.transparent,
5254
),
53-
)
55+
Align(
56+
alignment: Alignment.bottomCenter,
57+
child: Padding(
58+
padding: EdgeInsets.only(bottom: c.maxHeight - overlayRect.bottom + 20), // 20px above the bottom
59+
child: Column(
60+
crossAxisAlignment: CrossAxisAlignment.start,
61+
mainAxisSize: MainAxisSize.min,
62+
children: [
63+
Text(
64+
guidelines,
65+
style: theme.mrzLabel,
66+
),
67+
SizedBox(height: theme.tinySpacing),
68+
Text(
69+
guidelines,
70+
style: theme.mrzLabel,
71+
),
72+
],
73+
),
74+
),
75+
)
76+
]
5477
],
5578
);
5679
},
@@ -92,11 +115,16 @@ class _DocumentClipper extends CustomClipper<Path> {
92115
bool shouldReclip(_DocumentClipper oldClipper) => false;
93116
}
94117

95-
class _WhiteOverlay extends StatelessWidget {
96-
const _WhiteOverlay({
118+
class _ColoredBoxOverlay extends StatelessWidget {
119+
const _ColoredBoxOverlay({
97120
required this.rect,
121+
required this.borderColor,
122+
required this.color,
98123
});
124+
99125
final RRect rect;
126+
final Color borderColor;
127+
final Color color;
100128

101129
@override
102130
Widget build(BuildContext context) {
@@ -107,10 +135,27 @@ class _WhiteOverlay extends StatelessWidget {
107135
width: rect.width,
108136
height: rect.height,
109137
decoration: BoxDecoration(
110-
border: Border.all(width: 2.0, color: const Color(0xFFFFFFFF)),
138+
color: color,
139+
border: Border.all(width: 2.0, color: borderColor),
111140
borderRadius: BorderRadius.all(rect.tlRadius),
112141
),
113142
),
114143
);
115144
}
116145
}
146+
147+
double textWidth(String s, TextStyle style) {
148+
final tp = TextPainter(
149+
text: TextSpan(text: s, style: style),
150+
textDirection: TextDirection.ltr,
151+
maxLines: 1,
152+
)..layout(); // no maxWidth => measures intrinsic width
153+
return tp.size.width;
154+
}
155+
156+
int maxLtApprox(double maxWidth, TextStyle style, {double padding = 0}) {
157+
final available = (maxWidth - padding).clamp(0, double.infinity);
158+
final one = textWidth('<', style);
159+
if (one == 0) return 0;
160+
return (available / one).floor();
161+
}

0 commit comments

Comments
 (0)