From ec9b91c25c5b6eb30bd8a016f70988678d3194b4 Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Fri, 28 Nov 2025 17:04:03 +0100 Subject: [PATCH 01/57] feat: Introduce recovery phrase and rejoin study features with new onboarding screens, core utilities, and localization. --- app/lib/l10n/app_de.arb | 25 +- app/lib/l10n/app_en.arb | 25 +- app/lib/l10n/app_localizations.dart | 90 + app/lib/l10n/app_localizations_de.dart | 54 +- app/lib/l10n/app_localizations_en.dart | 51 + app/lib/routes.dart | 14 + .../recovery_phrase_screen.dart | 140 + .../app_onboarding/rejoin_study_screen.dart | 159 + app/lib/screens/app_onboarding/terms.dart | 2 +- app/lib/screens/app_onboarding/welcome.dart | 9 + core/lib/src/util/recovery.dart | 137 + core/lib/src/util/wordlists.dart | 4103 +++++++++++++++++ core/pubspec.yaml | 1 + pubspec.lock | 24 + 14 files changed, 4830 insertions(+), 4 deletions(-) create mode 100644 app/lib/screens/app_onboarding/recovery_phrase_screen.dart create mode 100644 app/lib/screens/app_onboarding/rejoin_study_screen.dart create mode 100644 core/lib/src/util/recovery.dart create mode 100644 core/lib/src/util/wordlists.dart diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index b20f5c171..5fa7d68d7 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -455,5 +455,28 @@ "preview_mode_description": "Sie befinden sich derzeit im Vorschau-Modus. Dies ermöglicht Ihnen:\n\n• Schneller Vorlauf durch Studientage mit der \"Nächster Tag\" Schaltfläche\n• Mehrfaches Abschließen von Aufgaben ohne Einschränkungen\n• Erleben des vollständigen Studienablaufs ohne Beeinflussung echter Daten\n\nWichtig: Ergebnisse und Daten aus dem Vorschau-Modus werden nicht gespeichert oder mit tatsächlichen Teilnehmerergebnissen aus laufenden Studien vermischt.", "preview_mode_results_not_saved": "Ergebnisse werden zum Schutz der Studienintegrität nicht gespeichert.", "ok": "OK", - "submit" : "Absenden" + "submit" : "Absenden", + + "recovery_phrase_setup_title": "Wiederherstellungsphrase einrichten", + "recovery_phrase_header": "Deine geheime Wiederherstellungsphrase", + "copy_to_clipboard": "In die Zwischenablage kopieren", + "copied_to_clipboard": "In die Zwischenablage kopiert", + "recovery_phrase_purpose_header": "Du benötigst diese Phrase um:", + "recovery_phrase_purpose_1": "✓ Auf einem anderen Gerät fortzufahren", + "recovery_phrase_purpose_2": "✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen", + "recovery_phrase_save_hint": "Speichere sie jetzt – du findest sie später auch in den Einstellungen.", + "recovery_phrase_saved_confirmation": "Ich habe meine Wiederherstellungsphrase sicher gespeichert.", + "continue_to_study": "Weiter zur Studie", + "rejoin_study": "Studie wieder beitreten", + "enter_recovery_phrase": "Gib deine Wiederherstellungsphrase ein", + "required": "Erforderlich", + "invalid_recovery_phrase": "Ungültige Wiederherstellungsphrase. Bitte überprüfe deine Wörter.", + "recovery_successful": "Wiederherstellung erfolgreich! ID: {id}", + "@recovery_successful": { + "placeholders": { + "id": { + "type": "String" + } + } + } } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index cb2e186a3..dfa280e7f 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -451,5 +451,28 @@ "preview_mode_description": "You are currently in preview mode. This allows you to:\n\n• Fast-forward through study days using the \"Next Day\" button\n• Complete tasks multiple times without restrictions\n• Experience the full study flow without affecting real data\n\nImportant: Results and data from preview mode are not stored or mixed with actual participant results from running studies.", "preview_mode_results_not_saved": "Task completed in preview mode - results are not saved to protect study data integrity.", "ok": "OK", - "submit" : "Submit" + "submit" : "Submit", + + "recovery_phrase_setup_title": "Recovery Phrase Setup", + "recovery_phrase_header": "Your secret recovery phrase", + "copy_to_clipboard": "Copy to clipboard", + "copied_to_clipboard": "Copied to clipboard", + "recovery_phrase_purpose_header": "You'll need this phrase to:", + "recovery_phrase_purpose_1": "✓ Continue on a different phone", + "recovery_phrase_purpose_2": "✓ Restore your progress after reinstalling", + "recovery_phrase_save_hint": "Save it now—you can also find it later in Settings.", + "recovery_phrase_saved_confirmation": "I saved my recovery phrase somewhere I can find it later.", + "continue_to_study": "Continue to study", + "rejoin_study": "Rejoin Study", + "enter_recovery_phrase": "Enter your recovery phrase", + "required": "Required", + "invalid_recovery_phrase": "Invalid recovery phrase. Please check your words.", + "recovery_successful": "Recovery successful! ID: {id}", + "@recovery_successful": { + "placeholders": { + "id": { + "type": "String" + } + } + } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 2d8505c23..3a5560111 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2251,6 +2251,96 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Submit'** String get submit; + + /// No description provided for @recovery_phrase_setup_title. + /// + /// In en, this message translates to: + /// **'Recovery Phrase Setup'** + String get recovery_phrase_setup_title; + + /// No description provided for @recovery_phrase_header. + /// + /// In en, this message translates to: + /// **'Your secret recovery phrase'** + String get recovery_phrase_header; + + /// No description provided for @copy_to_clipboard. + /// + /// In en, this message translates to: + /// **'Copy to clipboard'** + String get copy_to_clipboard; + + /// No description provided for @copied_to_clipboard. + /// + /// In en, this message translates to: + /// **'Copied to clipboard'** + String get copied_to_clipboard; + + /// No description provided for @recovery_phrase_purpose_header. + /// + /// In en, this message translates to: + /// **'You\'ll need this phrase to:'** + String get recovery_phrase_purpose_header; + + /// No description provided for @recovery_phrase_purpose_1. + /// + /// In en, this message translates to: + /// **'✓ Continue on a different phone'** + String get recovery_phrase_purpose_1; + + /// No description provided for @recovery_phrase_purpose_2. + /// + /// In en, this message translates to: + /// **'✓ Restore your progress after reinstalling'** + String get recovery_phrase_purpose_2; + + /// No description provided for @recovery_phrase_save_hint. + /// + /// In en, this message translates to: + /// **'Save it now—you can also find it later in Settings.'** + String get recovery_phrase_save_hint; + + /// No description provided for @recovery_phrase_saved_confirmation. + /// + /// In en, this message translates to: + /// **'I saved my recovery phrase somewhere I can find it later.'** + String get recovery_phrase_saved_confirmation; + + /// No description provided for @continue_to_study. + /// + /// In en, this message translates to: + /// **'Continue to study'** + String get continue_to_study; + + /// No description provided for @rejoin_study. + /// + /// In en, this message translates to: + /// **'Rejoin Study'** + String get rejoin_study; + + /// No description provided for @enter_recovery_phrase. + /// + /// In en, this message translates to: + /// **'Enter your recovery phrase'** + String get enter_recovery_phrase; + + /// No description provided for @required. + /// + /// In en, this message translates to: + /// **'Required'** + String get required; + + /// No description provided for @invalid_recovery_phrase. + /// + /// In en, this message translates to: + /// **'Invalid recovery phrase. Please check your words.'** + String get invalid_recovery_phrase; + + /// No description provided for @recovery_successful. + /// + /// In en, this message translates to: + /// **'Recovery successful! ID: {id}'** + String recovery_successful(String id); } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 831fe10f7..397b336bf 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -1167,4 +1166,57 @@ class AppLocalizationsDe extends AppLocalizations { @override String get submit => 'Absenden'; + + @override + String get recovery_phrase_setup_title => + 'Wiederherstellungsphrase einrichten'; + + @override + String get recovery_phrase_header => 'Deine geheime Wiederherstellungsphrase'; + + @override + String get copy_to_clipboard => 'In die Zwischenablage kopieren'; + + @override + String get copied_to_clipboard => 'In die Zwischenablage kopiert'; + + @override + String get recovery_phrase_purpose_header => 'Du benötigst diese Phrase um:'; + + @override + String get recovery_phrase_purpose_1 => + '✓ Auf einem anderen Gerät fortzufahren'; + + @override + String get recovery_phrase_purpose_2 => + '✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen'; + + @override + String get recovery_phrase_save_hint => + 'Speichere sie jetzt – du findest sie später auch in den Einstellungen.'; + + @override + String get recovery_phrase_saved_confirmation => + 'Ich habe meine Wiederherstellungsphrase sicher gespeichert.'; + + @override + String get continue_to_study => 'Weiter zur Studie'; + + @override + String get rejoin_study => 'Studie wieder beitreten'; + + @override + String get enter_recovery_phrase => 'Gib deine Wiederherstellungsphrase ein'; + + @override + String get required => 'Erforderlich'; + + @override + String get invalid_recovery_phrase => + 'Ungültige Wiederherstellungsphrase. Bitte überprüfe deine Wörter.'; + + @override + String recovery_successful(String id) { + return 'Wiederherstellung erfolgreich! ID: $id'; + } } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 194435c68..a4e506025 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1156,4 +1156,55 @@ class AppLocalizationsEn extends AppLocalizations { @override String get submit => 'Submit'; + + @override + String get recovery_phrase_setup_title => 'Recovery Phrase Setup'; + + @override + String get recovery_phrase_header => 'Your secret recovery phrase'; + + @override + String get copy_to_clipboard => 'Copy to clipboard'; + + @override + String get copied_to_clipboard => 'Copied to clipboard'; + + @override + String get recovery_phrase_purpose_header => 'You\'ll need this phrase to:'; + + @override + String get recovery_phrase_purpose_1 => '✓ Continue on a different phone'; + + @override + String get recovery_phrase_purpose_2 => + '✓ Restore your progress after reinstalling'; + + @override + String get recovery_phrase_save_hint => + 'Save it now—you can also find it later in Settings.'; + + @override + String get recovery_phrase_saved_confirmation => + 'I saved my recovery phrase somewhere I can find it later.'; + + @override + String get continue_to_study => 'Continue to study'; + + @override + String get rejoin_study => 'Rejoin Study'; + + @override + String get enter_recovery_phrase => 'Enter your recovery phrase'; + + @override + String get required => 'Required'; + + @override + String get invalid_recovery_phrase => + 'Invalid recovery phrase. Please check your words.'; + + @override + String recovery_successful(String id) { + return 'Recovery successful! ID: $id'; + } } diff --git a/app/lib/routes.dart b/app/lib/routes.dart index b60f73068..f2a21197b 100644 --- a/app/lib/routes.dart +++ b/app/lib/routes.dart @@ -7,6 +7,8 @@ import 'package:studyu_app/screens/app_onboarding/app_outdated_screen.dart'; import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; import 'package:studyu_app/screens/app_onboarding/welcome.dart'; +import 'package:studyu_app/screens/app_onboarding/recovery_phrase_screen.dart'; +import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; import 'package:studyu_app/screens/study/dashboard/contact_tab/contact_screen.dart'; import 'package:studyu_app/screens/study/dashboard/contact_tab/faq.dart'; import 'package:studyu_app/screens/study/dashboard/dashboard.dart'; @@ -41,6 +43,8 @@ class Routes { static const String reportHistory = '/reportHistory'; static const String reportDetails = '/reportDetails'; static const String performanceDetails = '/performanceDetails'; + static const String recoveryPhrase = '/recoveryPhrase'; + static const String rejoinStudy = '/rejoinStudy'; static Route unknownRoute(RouteSettings settings) { FlutterNativeSplash.remove(); @@ -160,6 +164,16 @@ class Routes { builder: (_) => const ReportHistoryScreen(), settings: settings, ); + case recoveryPhrase: + return MaterialPageRoute( + builder: (_) => const RecoveryPhraseScreen(), + settings: settings, + ); + case rejoinStudy: + return MaterialPageRoute( + builder: (_) => const RejoinStudyScreen(), + settings: settings, + ); default: //final potentialSessionString = Uri.decodeComponent(settings.name.replaceFirst('/', '')); //return MaterialPageRoute(builder: (_) => LoadingScreen(sessionString: potentialSessionString)); diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart new file mode 100644 index 000000000..42d7f5cc4 --- /dev/null +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:studyu_app/routes.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:uuid/uuid.dart'; +import 'package:studyu_core/src/util/recovery.dart'; + +class RecoveryPhraseScreen extends StatefulWidget { + final BigInt? + userId; // Optional, if not provided, we might generate one or handle error + // Actually, we usually have the user ID from the auth state or passed in. + // For now, let's assume it's passed or we can get it. + + const RecoveryPhraseScreen({super.key, this.userId}); + + @override + State createState() => _RecoveryPhraseScreenState(); +} + +class _RecoveryPhraseScreenState extends State { + late List _phrase; + bool _isChecked = false; + + @override + void initState() { + super.initState(); + _generatePhrase(); + } + + void _generatePhrase() { + final user = Supabase.instance.client.auth.currentUser; + final id = + widget.userId ?? + (user != null + ? BigInt.parse(user.id.replaceAll('-', ''), radix: 16) + : BigInt.parse(const Uuid().v4().replaceAll('-', ''), radix: 16)); + + BigInt id64 = id; + if (id.bitLength > 64) { + id64 = id >> (id.bitLength - 64); + } + + _phrase = encode(id64); + } + + void _copyToClipboard() { + final text = _phrase.join(' '); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.copied_to_clipboard), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.recovery_phrase_setup_title), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.recovery_phrase_header, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: _phrase + .map((word) => Chip(label: Text(word))) + .toList(), + ), + const SizedBox(height: 16), + IconButton( + icon: const Icon(Icons.copy), + onPressed: _copyToClipboard, + tooltip: AppLocalizations.of(context)!.copy_to_clipboard, + ), + ], + ), + ), + const SizedBox(height: 24), + Text( + AppLocalizations.of(context)!.recovery_phrase_purpose_header, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text(AppLocalizations.of(context)!.recovery_phrase_purpose_1), + Text(AppLocalizations.of(context)!.recovery_phrase_purpose_2), + const SizedBox(height: 16), + Text(AppLocalizations.of(context)!.recovery_phrase_save_hint), + const SizedBox(height: 24), + + // TODO: Share and Email buttons + CheckboxListTile( + value: _isChecked, + onChanged: (value) { + setState(() { + _isChecked = value ?? false; + }); + }, + title: Text( + AppLocalizations.of( + context, + )!.recovery_phrase_saved_confirmation, + ), + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _isChecked + ? () { + Navigator.pushNamed(context, Routes.studySelection); + } + : null, + child: Text(AppLocalizations.of(context)!.continue_to_study), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart new file mode 100644 index 000000000..981a64af9 --- /dev/null +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:studyu_app/routes.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:go_router/go_router.dart'; +import 'package:studyu_core/src/util/recovery.dart'; +import 'package:studyu_core/src/util/wordlists.dart'; + +class RejoinStudyScreen extends StatefulWidget { + const RejoinStudyScreen({super.key}); + + @override + State createState() => _RejoinStudyScreenState(); +} + +class _RejoinStudyScreenState extends State { + final List _controllers = List.generate( + 7, + (_) => TextEditingController(), + ); + final _formKey = GlobalKey(); + String? _errorMessage; + + @override + void dispose() { + for (final controller in _controllers) { + controller.dispose(); + } + super.dispose(); + } + + void _validateAndSubmit() { + setState(() { + _errorMessage = null; + }); + + if (_formKey.currentState!.validate()) { + final words = _controllers + .map((c) => c.text.trim().toLowerCase()) + .toList(); + + try { + try { + final id = decode(words, wordlist: WORDLIST_EN); + _onSuccess(id); + return; + } catch (e) { + try { + final id = decode(words, wordlist: WORDLIST_DE); + _onSuccess(id); + return; + } catch (e2) { + setState(() { + _errorMessage = AppLocalizations.of( + context, + )!.invalid_recovery_phrase; + }); + } + } + } catch (e) { + setState(() { + _errorMessage = e.toString(); + }); + } + } + } + + void _onSuccess(BigInt id) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.recovery_successful(id.toString()), + ), + ), + ); + context.goNamed(Routes.loading); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.rejoin_study)), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.enter_recovery_phrase, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + childAspectRatio: 2.5, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: 7, + itemBuilder: (context, index) { + return TextFormField( + controller: _controllers[index], + decoration: InputDecoration( + labelText: '${index + 1}', + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + ), + textInputAction: index < 6 + ? TextInputAction.next + : TextInputAction.done, + validator: (value) { + if (value == null || value.isEmpty) { + return AppLocalizations.of(context)!.required; + } + return null; + }, + onChanged: (value) { + if (value.contains(' ')) { + final words = value.trim().split(RegExp(r'\s+')); + if (words.length > 1) { + for (int i = 0; i < words.length; i++) { + if (index + i < _controllers.length) { + _controllers[index + i].text = words[i]; + } + } + } + } + }, + ); + }, + ), + if (_errorMessage != null) ...[ + const SizedBox(height: 16), + Text( + _errorMessage!, + style: const TextStyle(color: Colors.red), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 24), + FilledButton( + onPressed: _validateAndSubmit, + child: Text(AppLocalizations.of(context)!.rejoin_study), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/screens/app_onboarding/terms.dart b/app/lib/screens/app_onboarding/terms.dart index 14a306228..39f181b5c 100644 --- a/app/lib/screens/app_onboarding/terms.dart +++ b/app/lib/screens/app_onboarding/terms.dart @@ -41,7 +41,7 @@ class _TermsScreenState extends State { final success = await anonymousSignUp(); if (success) { if (!context.mounted) return; - Navigator.pushNamed(context, Routes.studySelection); + Navigator.pushNamed(context, Routes.recoveryPhrase); } } : null, diff --git a/app/lib/screens/app_onboarding/welcome.dart b/app/lib/screens/app_onboarding/welcome.dart index a072057e2..01c28a9fc 100644 --- a/app/lib/screens/app_onboarding/welcome.dart +++ b/app/lib/screens/app_onboarding/welcome.dart @@ -61,6 +61,15 @@ class WelcomeScreen extends StatelessWidget { style: const TextStyle(fontSize: 20), ), ), + const SizedBox(height: 20), + TextButton( + onPressed: () => + Navigator.pushNamed(context, Routes.rejoinStudy), + child: Text( + AppLocalizations.of(context)!.rejoin_study, + style: const TextStyle(fontSize: 16, color: Colors.grey), + ), + ), const Spacer(), ], ), diff --git a/core/lib/src/util/recovery.dart b/core/lib/src/util/recovery.dart new file mode 100644 index 000000000..4d1ae97d6 --- /dev/null +++ b/core/lib/src/util/recovery.dart @@ -0,0 +1,137 @@ +import 'package:studyu_core/src/util/wordlists.dart'; + +class EncodingConfig { + final int bitsPerWord; + final bool useChecksum; + + const EncodingConfig({this.bitsPerWord = 11, this.useChecksum = true}); + + int wordsNeeded(int bitCount) { + return (bitCount / bitsPerWord).ceil(); + } +} + +List encodeIdToIndices( + BigInt participantId, + int bitCount, + EncodingConfig config, +) { + if (participantId < BigInt.zero || + participantId >= (BigInt.one << bitCount)) { + throw ArgumentError('ID must fit within $bitCount bits'); + } + + final numWords = config.wordsNeeded(bitCount); + final mask = (1 << config.bitsPerWord) - 1; + final indices = []; + + BigInt remaining = participantId; + for (int i = 0; i < numWords; i++) { + final index = (remaining & BigInt.from(mask)).toInt(); + indices.insert(0, index); + remaining = remaining >> config.bitsPerWord; + } + + return indices; +} + +BigInt decodeFromWords( + List words, + List wordlist, + EncodingConfig config, +) { + BigInt participantId = BigInt.zero; + + for (final word in words) { + final index = wordlist.indexOf(word); + if (index == -1) { + throw ArgumentError('Invalid word: $word'); + } + participantId = (participantId << config.bitsPerWord) | BigInt.from(index); + } + + return participantId; +} + +int computeChecksum(List indices, int wordlistSize) { + if (indices.isEmpty) { + throw ArgumentError('Need at least one word index'); + } + + const primes = [31, 37, 41, 43, 47, 53]; + + int hash = 0; + for (int i = 0; i < indices.length; i++) { + final prime = primes[i % primes.length]; + hash ^= indices[i] * prime; + } + + return hash % wordlistSize; +} + +List encode( + BigInt participantId, { + List? wordlist, + int bitCount = 64, + EncodingConfig config = const EncodingConfig(), +}) { + final effectiveWordlist = wordlist ?? WORDLIST_EN; + + if (effectiveWordlist.length < (1 << config.bitsPerWord)) { + throw ArgumentError( + 'Wordlist must have at least ${1 << config.bitsPerWord} words', + ); + } + + final indices = encodeIdToIndices(participantId, bitCount, config); + + if (config.useChecksum) { + final checksumIndex = computeChecksum(indices, effectiveWordlist.length); + indices.add(checksumIndex); + } + + return indices.map((i) => effectiveWordlist[i]).toList(); +} + +BigInt decode( + List words, { + List? wordlist, + EncodingConfig config = const EncodingConfig(), +}) { + if (words.isEmpty) { + throw ArgumentError('Must provide at least one word'); + } + + final effectiveWordlist = wordlist ?? WORDLIST_EN; + + List dataWords = words; + + if (config.useChecksum) { + if (words.length < 2) { + throw ArgumentError('Need at least 2 words when using checksum'); + } + + dataWords = words.sublist(0, words.length - 1); + final indices = dataWords.map((w) { + final idx = effectiveWordlist.indexOf(w); + if (idx == -1) throw ArgumentError('Invalid word: $w'); + return idx; + }).toList(); + + final expectedChecksum = computeChecksum(indices, effectiveWordlist.length); + final actualChecksum = effectiveWordlist.indexOf(words.last); + + if (actualChecksum == -1) { + throw ArgumentError('Invalid checksum word: ${words.last}'); + } + + if (expectedChecksum != actualChecksum) { + throw ArgumentError( + 'Checksum mismatch: expected ${effectiveWordlist[expectedChecksum]}, ' + 'got ${words.last}', + ); + } + } + + return decodeFromWords(dataWords, effectiveWordlist, config); +} diff --git a/core/lib/src/util/wordlists.dart b/core/lib/src/util/wordlists.dart new file mode 100644 index 000000000..13ac230f6 --- /dev/null +++ b/core/lib/src/util/wordlists.dart @@ -0,0 +1,4103 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +const List WORDLIST_EN = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', + 'album', + 'alcohol', + 'alert', + 'alien', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', + 'annual', + 'another', + 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', + 'apple', + 'approve', + 'april', + 'arch', + 'arctic', + 'area', + 'arena', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', + 'aspect', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', + 'august', + 'aunt', + 'author', + 'auto', + 'autumn', + 'average', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', + 'balance', + 'balcony', + 'ball', + 'bamboo', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', + 'basic', + 'basket', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', + 'benefit', + 'best', + 'betray', + 'better', + 'between', + 'beyond', + 'bicycle', + 'bid', + 'bike', + 'bind', + 'biology', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', + 'bridge', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', + 'bronze', + 'broom', + 'brother', + 'brown', + 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', + 'burger', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', + 'cactus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', + 'canal', + 'cancel', + 'candy', + 'cannon', + 'canoe', + 'canvas', + 'canyon', + 'capable', + 'capital', + 'captain', + 'car', + 'carbon', + 'card', + 'cargo', + 'carpet', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', + 'castle', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', + 'cement', + 'census', + 'century', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', + 'change', + 'chaos', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', + 'cheese', + 'chef', + 'cherry', + 'chest', + 'chicken', + 'chief', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', + 'cigar', + 'cinnamon', + 'circle', + 'citizen', + 'city', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', + 'clean', + 'clerk', + 'clever', + 'click', + 'client', + 'cliff', + 'climb', + 'clinic', + 'clip', + 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', + 'club', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', + 'coconut', + 'code', + 'coffee', + 'coil', + 'coin', + 'collect', + 'color', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', + 'company', + 'concert', + 'conduct', + 'confirm', + 'congress', + 'connect', + 'consider', + 'control', + 'convince', + 'cook', + 'cool', + 'copper', + 'copy', + 'coral', + 'core', + 'corn', + 'correct', + 'cost', + 'cotton', + 'couch', + 'country', + 'couple', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', + 'craft', + 'cram', + 'crane', + 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', + 'cricket', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', + 'dance', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', + 'decade', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', + 'degree', + 'delay', + 'deliver', + 'demand', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', + 'design', + 'desk', + 'despair', + 'destroy', + 'detail', + 'detect', + 'develop', + 'device', + 'devote', + 'diagram', + 'dial', + 'diamond', + 'diary', + 'dice', + 'diesel', + 'diet', + 'differ', + 'digital', + 'dignity', + 'dilemma', + 'dinner', + 'dinosaur', + 'direct', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', + 'dolphin', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', + 'drink', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', + 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', + 'earth', + 'easily', + 'east', + 'easy', + 'echo', + 'ecology', + 'economy', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', + 'elegant', + 'element', + 'elephant', + 'elevator', + 'elite', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', + 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', + 'energy', + 'enforce', + 'engage', + 'engine', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', + 'episode', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', + 'escape', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', + 'exact', + 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', + 'exit', + 'exotic', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', + 'express', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', + 'family', + 'famous', + 'fan', + 'fancy', + 'fantasy', + 'farm', + 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', + 'fiber', + 'fiction', + 'field', + 'figure', + 'file', + 'film', + 'filter', + 'final', + 'find', + 'fine', + 'finger', + 'finish', + 'fire', + 'firm', + 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', + 'flag', + 'flame', + 'flash', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', + 'flower', + 'fluid', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', + 'forest', + 'forget', + 'fork', + 'fortune', + 'forum', + 'forward', + 'fossil', + 'foster', + 'found', + 'fox', + 'fragile', + 'frame', + 'frequent', + 'fresh', + 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', + 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', + 'future', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', + 'garage', + 'garbage', + 'garden', + 'garlic', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', + 'general', + 'genius', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', + 'gold', + 'good', + 'goose', + 'gorilla', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', + 'green', + 'grid', + 'grief', + 'grit', + 'grocery', + 'group', + 'grow', + 'grunt', + 'guard', + 'guess', + 'guide', + 'guilt', + 'guitar', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', + 'hand', + 'happy', + 'harbor', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', + 'hello', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', + 'history', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', + 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', + 'icon', + 'idea', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', + 'image', + 'imitate', + 'immense', + 'immune', + 'impact', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', + 'input', + 'inquiry', + 'insane', + 'insect', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', + 'jacket', + 'jaguar', + 'jar', + 'jazz', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', + 'jungle', + 'junior', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', + 'labor', + 'ladder', + 'lady', + 'lake', + 'lamp', + 'language', + 'laptop', + 'large', + 'later', + 'latin', + 'laugh', + 'laundry', + 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', + 'lecture', + 'left', + 'leg', + 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', + 'level', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', + 'limit', + 'link', + 'lion', + 'liquid', + 'list', + 'little', + 'live', + 'lizard', + 'load', + 'loan', + 'lobster', + 'local', + 'lock', + 'logic', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', + 'lunar', + 'lunch', + 'luxury', + 'lyrics', + 'machine', + 'mad', + 'magic', + 'magnet', + 'maid', + 'mail', + 'main', + 'major', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', + 'market', + 'marriage', + 'mask', + 'mass', + 'master', + 'match', + 'material', + 'math', + 'matrix', + 'matter', + 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', + 'media', + 'melody', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', + 'message', + 'metal', + 'method', + 'middle', + 'midnight', + 'milk', + 'million', + 'mimic', + 'mind', + 'minimum', + 'minor', + 'minute', + 'miracle', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', + 'mobile', + 'model', + 'modify', + 'mom', + 'moment', + 'monitor', + 'monkey', + 'monster', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', + 'motor', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', + 'museum', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', + 'nerve', + 'nest', + 'net', + 'network', + 'neutral', + 'never', + 'news', + 'next', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', + 'normal', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', + 'novel', + 'now', + 'nuclear', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', + 'observe', + 'obtain', + 'obvious', + 'occur', + 'ocean', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', + 'olympic', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', + 'opera', + 'opinion', + 'oppose', + 'option', + 'orange', + 'orbit', + 'orchard', + 'order', + 'ordinary', + 'organ', + 'orient', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', + 'oxygen', + 'oyster', + 'ozone', + 'pact', + 'paddle', + 'page', + 'pair', + 'palace', + 'palm', + 'panda', + 'panel', + 'panic', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', + 'patient', + 'patrol', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', + 'pelican', + 'pen', + 'penalty', + 'pencil', + 'people', + 'pepper', + 'perfect', + 'permit', + 'person', + 'pet', + 'phone', + 'photo', + 'phrase', + 'physical', + 'piano', + 'picnic', + 'picture', + 'piece', + 'pig', + 'pigeon', + 'pill', + 'pilot', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', + 'pizza', + 'place', + 'planet', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', + 'poem', + 'poet', + 'point', + 'polar', + 'pole', + 'police', + 'pond', + 'pony', + 'pool', + 'popular', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', + 'present', + 'pretty', + 'prevent', + 'price', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', + 'private', + 'prize', + 'problem', + 'process', + 'produce', + 'profit', + 'program', + 'project', + 'promote', + 'proof', + 'property', + 'prosper', + 'protect', + 'proud', + 'provide', + 'public', + 'pudding', + 'pull', + 'pulp', + 'pulse', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', + 'pyramid', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', + 'radar', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', + 'random', + 'range', + 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', + 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', + 'record', + 'recycle', + 'reduce', + 'reflect', + 'reform', + 'refuse', + 'region', + 'regret', + 'regular', + 'reject', + 'relax', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', + 'right', + 'rigid', + 'ring', + 'riot', + 'ripple', + 'risk', + 'ritual', + 'rival', + 'river', + 'road', + 'roast', + 'robot', + 'robust', + 'rocket', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', + 'royal', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', + 'salad', + 'salmon', + 'salon', + 'salt', + 'salute', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', + 'school', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', + 'screen', + 'script', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', + 'second', + 'secret', + 'section', + 'security', + 'seed', + 'seek', + 'segment', + 'select', + 'sell', + 'seminar', + 'senior', + 'sense', + 'sentence', + 'series', + 'service', + 'session', + 'settle', + 'setup', + 'seven', + 'shadow', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', + 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', + 'shock', + 'shoe', + 'shoot', + 'shop', + 'short', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', + 'silk', + 'silly', + 'silver', + 'similar', + 'simple', + 'since', + 'sing', + 'siren', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', + 'slogan', + 'slot', + 'slow', + 'slush', + 'small', + 'smart', + 'smile', + 'smoke', + 'smooth', + 'snack', + 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', + 'social', + 'sock', + 'soda', + 'soft', + 'solar', + 'soldier', + 'solid', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', + 'source', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', + 'special', + 'speed', + 'spell', + 'spend', + 'sphere', + 'spice', + 'spider', + 'spike', + 'spin', + 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', + 'spring', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', + 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', + 'stone', + 'stool', + 'story', + 'stove', + 'strategy', + 'street', + 'strike', + 'strong', + 'struggle', + 'student', + 'stuff', + 'stumble', + 'style', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', + 'switch', + 'sword', + 'symbol', + 'symptom', + 'syrup', + 'system', + 'table', + 'tackle', + 'tag', + 'tail', + 'talent', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', + 'theory', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', + 'tiger', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', + 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', + 'topic', + 'topple', + 'torch', + 'tornado', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', + 'traffic', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', + 'travel', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', + 'trumpet', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', + 'tuna', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', + 'twist', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', + 'uniform', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', + 'vacuum', + 'vague', + 'valid', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', + 'velvet', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', + 'veteran', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', + 'village', + 'vintage', + 'violin', + 'virtual', + 'virus', + 'visa', + 'visit', + 'visual', + 'vital', + 'vivid', + 'vocal', + 'voice', + 'void', + 'volcano', + 'volume', + 'vote', + 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', + 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', + 'weekend', + 'weird', + 'welcome', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', + 'window', + 'wine', + 'wing', + 'wink', + 'winner', + 'winter', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', + 'wolf', + 'woman', + 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', + 'year', + 'yellow', + 'you', + 'young', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo' +]; + +const List WORDLIST_DE = [ + 'abbau', + 'abbild', + 'abbruch', + 'abdruck', + 'abend', + 'abfall', + 'abflug', + 'abgas', + 'abgrund', + 'abitur', + 'abkommen', + 'ablauf', + 'ablehnen', + 'abluft', + 'abpfiff', + 'abreise', + 'abriss', + 'absage', + 'abschied', + 'abseits', + 'absicht', + 'absolut', + 'abstand', + 'absurd', + 'abteil', + 'abwarten', + 'abwehr', + 'abzug', + 'achse', + 'acht', + 'acker', + 'adapter', + 'ader', + 'adler', + 'adresse', + 'advent', + 'affe', + 'agent', + 'agieren', + 'ahnen', + 'ahnung', + 'ahorn', + 'akademie', + 'akkord', + 'akte', + 'aktie', + 'aktuell', + 'akustik', + 'akzent', + 'alarm', + 'albatros', + 'album', + 'alge', + 'alkohol', + 'allee', + 'allianz', + 'alltag', + 'alpen', + 'alptraum', + 'alter', + 'altglas', + 'altstadt', + 'alufolie', + 'amboss', + 'ameise', + 'ampel', + 'amsel', + 'amulett', + 'analyse', + 'ananas', + 'anbau', + 'anbieten', + 'anblick', + 'anfang', + 'anfrage', + 'angabe', + 'angel', + 'angriff', + 'angst', + 'anhand', + 'anheben', + 'anhieb', + 'anker', + 'anklage', + 'ankommt', + 'ankunft', + 'anlage', + 'anleiten', + 'anliegen', + 'anmelden', + 'annehmen', + 'annonce', + 'anomalie', + 'anpassen', + 'anregung', + 'anruf', + 'ansatz', + 'anschein', + 'ansehen', + 'ansicht', + 'anspruch', + 'anstalt', + 'anteil', + 'antik', + 'antrag', + 'antwort', + 'anwalt', + 'anwesen', + 'anwohner', + 'anzahl', + 'anzeige', + 'anzug', + 'apfel', + 'apotheke', + 'apparat', + 'appell', + 'applaus', + 'april', + 'aquarell', + 'arbeit', + 'archiv', + 'areal', + 'arena', + 'argument', + 'armband', + 'armut', + 'aroma', + 'arten', + 'artikel', + 'arzt', + 'asche', + 'aspekt', + 'asphalt', + 'atelier', + 'atem', + 'athlet', + 'atlas', + 'atom', + 'attacke', + 'auerhahn', + 'aufbau', + 'aufgabe', + 'auflage', + 'aufnahme', + 'aufruf', + 'aufstand', + 'auftrag', + 'aufwand', + 'aufzug', + 'auge', + 'august', + 'auktion', + 'aula', + 'ausbruch', + 'ausdruck', + 'ausflug', + 'ausgang', + 'auskunft', + 'ausnahme', + 'aussicht', + 'auswahl', + 'auszug', + 'autark', + 'auto', + 'avocado', + 'baby', + 'bach', + 'backen', + 'baden', + 'bagger', + 'bahn', + 'baldrian', + 'balkon', + 'ball', + 'balsam', + 'bambus', + 'banane', + 'band', + 'bank', + 'bargeld', + 'barsch', + 'bart', + 'basis', + 'bass', + 'basteln', + 'batterie', + 'bauch', + 'bauer', + 'bauland', + 'baum', + 'bauplan', + 'bausatz', + 'baut', + 'bauwagen', + 'bauzaun', + 'beachten', + 'beamte', + 'bebauung', + 'beben', + 'becher', + 'becken', + 'bedarf', + 'bedenken', + 'bedienen', + 'bedroht', + 'beenden', + 'beere', + 'befahren', + 'befehl', + 'befinden', + 'befragen', + 'befund', + 'begabt', + 'begeben', + 'beginn', + 'begonnen', + 'begriff', + 'behalten', + 'beide', + 'beifall', + 'beige', + 'beihilfe', + 'beil', + 'bein', + 'beirat', + 'beispiel', + 'beitrag', + 'bekannt', + 'bekennen', + 'beklagen', + 'bekommen', + 'belasten', + 'belegen', + 'beliebt', + 'belohnen', + 'bemerkt', + 'benannt', + 'benutzen', + 'benzin', + 'bequem', + 'beraten', + 'bereich', + 'berg', + 'bericht', + 'beruf', + 'bescheid', + 'besen', + 'besitz', + 'besorgen', + 'besser', + 'bestand', + 'besuch', + 'beton', + 'betrieb', + 'bett', + 'beule', + 'beute', + 'bewahren', + 'bewegen', + 'bewirken', + 'bewohner', + 'bezahlen', + 'bezirk', + 'bezog', + 'bezug', + 'biber', + 'bieder', + 'biene', + 'bier', + 'biest', + 'bieten', + 'bilanz', + 'bild', + 'billig', + 'binden', + 'binnen', + 'biologie', + 'biotonne', + 'birgt', + 'birke', + 'birne', + 'bitter', + 'blasen', + 'blatt', + 'blau', + 'blech', + 'blick', + 'blind', + 'blitz', + 'block', + 'blume', + 'blut', + 'boden', + 'bogen', + 'bohne', + 'bohren', + 'boje', + 'bolzen', + 'bombe', + 'bonus', + 'boot', + 'bord', + 'botanik', + 'bote', + 'boxen', + 'boxring', + 'boykott', + 'brachten', + 'brand', + 'braten', + 'braun', + 'brav', + 'brechen', + 'brei', + 'bremsen', + 'brennen', + 'brett', + 'brief', + 'brille', + 'bringen', + 'brisant', + 'brokkoli', + 'bronze', + 'brosche', + 'brot', + 'bruch', + 'bruder', + 'brunnen', + 'brust', + 'bube', + 'buch', + 'bude', + 'budget', + 'bunker', + 'bunt', + 'burg', + 'busch', + 'busfahrt', + 'bussard', + 'butter', + 'campen', + 'caravan', + 'chance', + 'chaos', + 'charme', + 'chat', + 'chemie', + 'chillen', + 'chlor', + 'chor', + 'chrom', + 'clever', + 'clown', + 'code', + 'computer', + 'couch', + 'creme', + 'dach', + 'damals', + 'dame', + 'damm', + 'dampf', + 'danken', + 'darm', + 'datei', + 'dattel', + 'datum', + 'dauer', + 'daumen', + 'deal', + 'debatte', + 'decke', + 'defekt', + 'defizit', + 'dehnen', + 'deich', + 'delfin', + 'delle', + 'denkmal', + 'depot', + 'design', + 'dessert', + 'detail', + 'detektiv', + 'deuten', + 'devise', + 'dezember', + 'diagnose', + 'dialog', + 'dichter', + 'dick', + 'dieb', + 'dienstag', + 'diesel', + 'digital', + 'diktat', + 'dilemma', + 'dill', + 'ding', + 'diplomat', + 'direktor', + 'dirigent', + 'diskette', + 'distel', + 'diverse', + 'docht', + 'doktor', + 'dokument', + 'dolch', + 'domizil', + 'donner', + 'doppelt', + 'dorf', + 'dorn', + 'dose', + 'dozent', + 'drache', + 'draht', + 'drama', + 'dreck', + 'drehbuch', + 'drei', + 'dringend', + 'drohne', + 'drossel', + 'drucker', + 'ducken', + 'duell', + 'duft', + 'dunkel', + 'dunst', + 'durst', + 'dusche', + 'dynamik', + 'ebbe', + 'ebene', + 'echo', + 'echse', + 'echt', + 'ecke', + 'efeu', + 'effekt', + 'egal', + 'ehefrau', + 'ehemann', + 'ehepaar', + 'ehre', + 'ehrgeiz', + 'ehrlich', + 'eichel', + 'eidechse', + 'eier', + 'eigentum', + 'eile', + 'eimer', + 'einblick', + 'eindruck', + 'einfach', + 'eingang', + 'einheit', + 'einigung', + 'einkauf', + 'einladen', + 'einmal', + 'einnahme', + 'einrad', + 'eins', + 'eintritt', + 'einzeln', + 'eisberg', + 'eisdecke', + 'eisen', + 'eistee', + 'eisvogel', + 'eiszeit', + 'elch', + 'elefant', + 'elegant', + 'element', + 'elend', + 'elite', + 'elle', + 'elster', + 'eltern', + 'empfang', + 'ende', + 'endlich', + 'energie', + 'engel', + 'engpass', + 'enkel', + 'enorm', + 'ensemble', + 'ente', + 'entgegen', + 'entlang', + 'entwurf', + 'entzogen', + 'epoche', + 'erbe', + 'erbracht', + 'erbse', + 'erdbeere', + 'erde', + 'erdgas', + 'erdnuss', + 'ereignis', + 'erfassen', + 'erfinden', + 'erfolg', + 'erfreuen', + 'ergebnis', + 'erhalten', + 'erheben', + 'erholung', + 'erinnern', + 'erkennen', + 'erlauben', + 'erlebnis', + 'erlitten', + 'erneut', + 'ernst', + 'ernte', + 'erobern', + 'erproben', + 'erregen', + 'ersatz', + 'ersetzen', + 'ersparen', + 'erteilen', + 'ertrag', + 'erwarten', + 'erwerben', + 'erwiesen', + 'erworben', + 'erzeugen', + 'erzielen', + 'esel', + 'essen', + 'essig', + 'esstisch', + 'etage', + 'etappe', + 'etat', + 'ethik', + 'etikett', + 'etliche', + 'eule', + 'euphorie', + 'event', + 'ewig', + 'exakt', + 'examen', + 'exil', + 'existenz', + 'exkurs', + 'experte', + 'export', + 'express', + 'extern', + 'extrem', + 'fabel', + 'fabrik', + 'fach', + 'fackel', + 'faden', + 'fahne', + 'fahrrad', + 'faktor', + 'falke', + 'fallen', + 'falsch', + 'falter', + 'familie', + 'fangen', + 'fans', + 'fantasie', + 'farbe', + 'farn', + 'fasching', + 'fass', + 'faultier', + 'fauna', + 'faust', + 'favorit', + 'faxen', + 'fazit', + 'februar', + 'fechten', + 'feder', + 'fegen', + 'fehler', + 'feier', + 'feile', + 'fein', + 'feld', + 'fell', + 'fels', + 'fenchel', + 'fenster', + 'ferien', + 'fern', + 'ferse', + 'fertig', + 'fest', + 'fett', + 'feucht', + 'feuer', + 'fichte', + 'fieber', + 'figur', + 'fiktion', + 'filiale', + 'film', + 'filter', + 'filz', + 'finale', + 'finden', + 'finger', + 'fink', + 'finster', + 'firma', + 'fisch', + 'flach', + 'flagge', + 'flamme', + 'flasche', + 'fleck', + 'fleisch', + 'flexibel', + 'fliege', + 'flink', + 'flocke', + 'floh', + 'flora', + 'flucht', + 'flugzeug', + 'flur', + 'fluss', + 'flut', + 'fokus', + 'folge', + 'folie', + 'fordern', + 'forelle', + 'formel', + 'forst', + 'foto', + 'foyer', + 'fracht', + 'frage', + 'fraktion', + 'frau', + 'frech', + 'freizeit', + 'fremd', + 'frequenz', + 'freund', + 'frieden', + 'friseur', + 'froh', + 'front', + 'frosch', + 'frucht', + 'frust', + 'fuchs', + 'fund', + 'funktion', + 'furcht', + 'fusion', + 'futter', + 'gabel', + 'galaxie', + 'galerie', + 'gang', + 'ganove', + 'gans', + 'ganz', + 'garage', + 'gardine', + 'garn', + 'garten', + 'gasse', + 'gast', + 'gattung', + 'gauner', + 'gazelle', + 'geben', + 'gebiet', + 'geboren', + 'gebracht', + 'geburt', + 'gecko', + 'gedanke', + 'gedicht', + 'geduld', + 'gefahr', + 'gefieder', + 'geflecht', + 'gegend', + 'gegner', + 'gehen', + 'gehirn', + 'geier', + 'geige', + 'geist', + 'geiz', + 'gelassen', + 'gelb', + 'geld', + 'gelee', + 'gelten', + 'gelungen', + 'gemacht', + 'gemein', + 'genau', + 'generell', + 'genie', + 'genug', + 'gepard', + 'gerade', + 'gerecht', + 'gericht', + 'gern', + 'gerste', + 'geruch', + 'gesamt', + 'geschenk', + 'gesetz', + 'gesicht', + 'gespenst', + 'gestalt', + 'gesund', + 'getan', + 'getreide', + 'gewalt', + 'gewerbe', + 'gewitter', + 'gewonnen', + 'giebel', + 'gier', + 'gift', + 'gigant', + 'gipfel', + 'gips', + 'giraffe', + 'girlande', + 'gitarre', + 'gitter', + 'glanz', + 'glas', + 'glatt', + 'glaube', + 'gleis', + 'glitzer', + 'globus', + 'glocke', + 'glut', + 'gnade', + 'gold', + 'golf', + 'gondel', + 'gorilla', + 'grab', + 'grad', + 'grafik', + 'gramm', + 'granit', + 'gras', + 'gratis', + 'grau', + 'gravur', + 'greifen', + 'gremium', + 'grenze', + 'griff', + 'grill', + 'grinsen', + 'groll', + 'grotte', + 'grube', + 'gruft', + 'grund', + 'gruppe', + 'gruselig', + 'gulasch', + 'gully', + 'gummi', + 'gunst', + 'gurke', + 'gurt', + 'guthaben', + 'haar', + 'habgier', + 'habicht', + 'hacken', + 'hafen', + 'haft', + 'hagel', + 'hahn', + 'haken', + 'halb', + 'halde', + 'halle', + 'halm', + 'hals', + 'halten', + 'hammer', + 'hamster', + 'hand', + 'hanger', + 'hantel', + 'harfe', + 'harke', + 'harmonie', + 'hart', + 'hase', + 'haube', + 'hauch', + 'haufen', + 'haus', + 'haut', + 'hebamme', + 'hebel', + 'hecht', + 'hecke', + 'hefe', + 'heft', + 'heilen', + 'heim', + 'heiraten', + 'heizung', + 'hektar', + 'held', + 'helfen', + 'hell', + 'helm', + 'hemd', + 'henkel', + 'herbst', + 'herd', + 'hering', + 'herkunft', + 'herr', + 'herz', + 'heute', + 'hilfe', + 'himbeere', + 'himmel', + 'hinblick', + 'hinsicht', + 'hinten', + 'hinweis', + 'hirse', + 'hirte', + 'hitze', + 'hobel', + 'hoch', + 'hoffen', + 'hohl', + 'holen', + 'holunder', + 'holz', + 'honig', + 'honorar', + 'hopfen', + 'horizont', + 'horn', + 'hose', + 'hotel', + 'hufeisen', + 'huhn', + 'hummer', + 'humor', + 'hund', + 'hunger', + 'hupe', + 'husten', + 'hydrant', + 'hygiene', + 'ideal', + 'idee', + 'idol', + 'idyll', + 'igel', + 'illegal', + 'illusion', + 'imbiss', + 'imker', + 'immun', + 'impfen', + 'import', + 'impuls', + 'index', + 'indiz', + 'infolge', + 'ingwer', + 'inhalt', + 'innen', + 'insasse', + 'insel', + 'institut', + 'internet', + 'investor', + 'irgendwo', + 'ironie', + 'irrtum', + 'isoliert', + 'jacht', + 'jacke', + 'jagd', + 'jagen', + 'jaguar', + 'jahr', + 'januar', + 'jargon', + 'jazz', + 'jemand', + 'joggen', + 'joghurt', + 'jubel', + 'jugend', + 'juli', + 'jung', + 'juni', + 'jurist', + 'jury', + 'justiz', + 'juwel', + 'kabarett', + 'kabel', + 'kabine', + 'kaffee', + 'kahl', + 'kajak', + 'kakao', + 'kaktus', + 'kalender', + 'kalt', + 'kamera', + 'kamin', + 'kamm', + 'kampf', + 'kanal', + 'kandidat', + 'kanister', + 'kanne', + 'kante', + 'kanu', + 'kapelle', + 'kapitel', + 'kapsel', + 'kaputt', + 'karneval', + 'karotte', + 'karriere', + 'karte', + 'kasse', + 'kasten', + 'katalog', + 'katze', + 'kaufhaus', + 'kauz', + 'kegel', + 'kehren', + 'keks', + 'kelch', + 'keller', + 'kennen', + 'keramik', + 'kern', + 'kerze', + 'kessel', + 'ketchup', + 'kette', + 'keule', + 'kiefer', + 'kiesel', + 'kilo', + 'kind', + 'kino', + 'kiosk', + 'kirsche', + 'kissen', + 'kiste', + 'kittel', + 'kiwi', + 'klage', + 'klammer', + 'klang', + 'klappe', + 'klar', + 'klasse', + 'klavier', + 'kleben', + 'klee', + 'kleid', + 'klettern', + 'klientel', + 'klima', + 'klinik', + 'klippe', + 'klon', + 'klopfen', + 'klotz', + 'klug', + 'knapp', + 'kneipe', + 'knie', + 'knochen', + 'knopf', + 'knoten', + 'koala', + 'kochen', + 'koffer', + 'kohle', + 'koje', + 'kolibri', + 'kollege', + 'komisch', + 'kommen', + 'komplett', + 'konflikt', + 'konkurs', + 'konsum', + 'kontakt', + 'konzert', + 'kopf', + 'kopie', + 'korb', + 'korn', + 'korrekt', + 'kosten', + 'krabbe', + 'kraft', + 'kralle', + 'kran', + 'kraut', + 'krawatte', + 'krebs', + 'kredit', + 'kreis', + 'kresse', + 'kreuz', + 'kriegen', + 'krippe', + 'krise', + 'kritik', + 'krokodil', + 'krone', + 'krug', + 'krumm', + 'kruste', + 'kuchen', + 'kugel', + 'kuhstall', + 'kulisse', + 'kultur', + 'kunde', + 'kunst', + 'kupfer', + 'kurier', + 'kurs', + 'kurve', + 'kurz', + 'kuss', + 'kutsche', + 'label', + 'labor', + 'lachen', + 'lack', + 'laden', + 'ladung', + 'lager', + 'laie', + 'lama', + 'lamm', + 'lampe', + 'land', + 'lang', + 'lappen', + 'larve', + 'lassen', + 'last', + 'laterne', + 'latte', + 'laub', + 'lauch', + 'laufen', + 'laune', + 'laut', + 'lavendel', + 'lawine', + 'leben', + 'lecker', + 'leder', + 'leer', + 'legen', + 'lehm', + 'lehnen', + 'lehrer', + 'leib', + 'leicht', + 'leid', + 'leim', + 'leinwand', + 'leiste', + 'leiter', + 'lektor', + 'lemming', + 'lenken', + 'leopard', + 'lernen', + 'lesen', + 'lesung', + 'leuchte', + 'leute', + 'lexikon', + 'libelle', + 'licht', + 'liebe', + 'lied', + 'liefern', + 'liegen', + 'lila', + 'lilie', + 'limette', + 'linde', + 'lineal', + 'linie', + 'links', + 'lippe', + 'liste', + 'liter', + 'lizenz', + 'loch', + 'locke', + 'logistik', + 'lohn', + 'lokal', + 'lotse', + 'loyal', + 'luchs', + 'luft', + 'lunge', + 'lupe', + 'lustig', + 'luxus', + 'lyrik', + 'machen', + 'made', + 'magazin', + 'magen', + 'magie', + 'magnet', + 'mahnen', + 'mais', + 'malen', + 'mama', + 'mango', + 'mann', + 'mantel', + 'marder', + 'markt', + 'marmor', + 'marsch', + 'maschine', + 'maske', + 'masse', + 'mast', + 'material', + 'matrose', + 'matte', + 'mauer', + 'maulwurf', + 'maus', + 'maximal', + 'medaille', + 'medizin', + 'meer', + 'mehl', + 'mehrweg', + 'meinung', + 'meister', + 'melden', + 'melken', + 'melone', + 'membran', + 'menge', + 'mensch', + 'mentor', + 'merkmal', + 'messer', + 'metall', + 'meter', + 'methode', + 'miene', + 'mieten', + 'milan', + 'milch', + 'milde', + 'milieu', + 'mimik', + 'mineral', + 'minigolf', + 'minute', + 'minze', + 'mischung', + 'mitglied', + 'mitleid', + 'mittag', + 'mode', + 'molch', + 'moment', + 'monat', + 'mond', + 'monitor', + 'monster', + 'montag', + 'moos', + 'moped', + 'moral', + 'morgen', + 'motiv', + 'motor', + 'motte', + 'mulde', + 'mund', + 'muschel', + 'museum', + 'musik', + 'muskel', + 'muster', + 'mutig', + 'mutter', + 'mythos', + 'nacht', + 'nacken', + 'nadel', + 'nagel', + 'nahrung', + 'name', + 'napf', + 'narbe', + 'narr', + 'narzisse', + 'nase', + 'nashorn', + 'nass', + 'natter', + 'natur', + 'nebel', + 'negativ', + 'nehmen', + 'neid', + 'neigung', + 'nektar', + 'nennen', + 'nerven', + 'nest', + 'nett', + 'netz', + 'neubau', + 'neugier', + 'neuland', + 'neun', + 'niedrig', + 'niemand', + 'nilpferd', + 'niveau', + 'nobel', + 'nochmal', + 'norden', + 'normal', + 'note', + 'notfall', + 'notiz', + 'november', + 'nudel', + 'null', + 'nummer', + 'nuss', + 'nutzen', + 'oase', + 'oben', + 'objekt', + 'obst', + 'ofen', + 'offen', + 'ohne', + 'ohren', + 'ohrring', + 'oktober', + 'olive', + 'olympia', + 'omelett', + 'onkel', + 'online', + 'oper', + 'option', + 'orange', + 'ordnung', + 'organ', + 'orgel', + 'original', + 'orkan', + 'ortsrand', + 'ostern', + 'otter', + 'oval', + 'paar', + 'packen', + 'paket', + 'palast', + 'palette', + 'palme', + 'panda', + 'panik', + 'papagei', + 'papier', + 'pappe', + 'paprika', + 'parade', + 'park', + 'parole', + 'party', + 'passage', + 'patent', + 'pathos', + 'patient', + 'pause', + 'pavian', + 'pech', + 'pedal', + 'pegel', + 'peinlich', + 'peitsche', + 'pelikan', + 'pelz', + 'pendel', + 'perfekt', + 'periode', + 'perle', + 'person', + 'pfad', + 'pfahl', + 'pfanne', + 'pfau', + 'pfeffer', + 'pfeil', + 'pferd', + 'pfiff', + 'pfirsich', + 'pflaume', + 'pflegen', + 'pflicht', + 'pflug', + 'pforte', + 'pfosten', + 'pfote', + 'phase', + 'physik', + 'picknick', + 'pier', + 'pigment', + 'pille', + 'pilot', + 'pilz', + 'pinguin', + 'pink', + 'pinnwand', + 'pinsel', + 'pinzette', + 'pirat', + 'piste', + 'pixel', + 'plakat', + 'planet', + 'plastik', + 'platz', + 'pleite', + 'plus', + 'podest', + 'podium', + 'poesie', + 'pokal', + 'politik', + 'pollen', + 'polster', + 'pommes', + 'pony', + 'pool', + 'portrait', + 'positiv', + 'post', + 'pracht', + 'praxis', + 'preis', + 'presse', + 'prinzip', + 'privat', + 'probe', + 'produkt', + 'profil', + 'programm', + 'projekt', + 'prospekt', + 'protest', + 'provinz', + 'prozent', + 'psyche', + 'publikum', + 'pudding', + 'puder', + 'puls', + 'pulver', + 'puma', + 'pumpe', + 'punkt', + 'punsch', + 'puppe', + 'pute', + 'putzen', + 'puzzel', + 'pyjama', + 'pyramide', + 'quadrat', + 'qualle', + 'quark', + 'quatsch', + 'quelle', + 'quer', + 'quittung', + 'quiz', + 'quote', + 'rabatt', + 'rabe', + 'rache', + 'radar', + 'radio', + 'radtour', + 'radweg', + 'rahmen', + 'rakete', + 'rampe', + 'rand', + 'rang', + 'ranke', + 'raps', + 'rasen', + 'rassel', + 'rast', + 'rasur', + 'raten', + 'ratgeber', + 'rathaus', + 'ratte', + 'rauch', + 'raum', + 'raupe', + 'raus', + 'raute', + 'razzia', + 'reaktion', + 'real', + 'rebell', + 'rechnen', + 'reden', + 'redner', + 'referent', + 'reform', + 'regal', + 'regen', + 'region', + 'rehkitz', + 'reibe', + 'reich', + 'reifen', + 'reihe', + 'reim', + 'rein', + 'reise', + 'reiten', + 'reiz', + 'rekord', + 'rektor', + 'relativ', + 'rennen', + 'rentier', + 'reporter', + 'reptil', + 'reserve', + 'residenz', + 'resonanz', + 'respekt', + 'rest', + 'resultat', + 'retten', + 'revier', + 'rezept', + 'rhythmus', + 'richtig', + 'riechen', + 'riegel', + 'riesig', + 'rind', + 'ring', + 'rinnsaal', + 'risiko', + 'riss', + 'ritter', + 'ritual', + 'ritze', + 'robbe', + 'roboter', + 'rock', + 'roggen', + 'rohbau', + 'rohkost', + 'rohr', + 'rohstoff', + 'roller', + 'roman', + 'rosa', + 'rose', + 'rosine', + 'rost', + 'rotkohl', + 'rotor', + 'rucksack', + 'rudel', + 'rufen', + 'ruhe', + 'ruhig', + 'ruhm', + 'ruine', + 'rummel', + 'rund', + 'runter', + 'rute', + 'rutsche', + 'saal', + 'saat', + 'sache', + 'sack', + 'safran', + 'saft', + 'sagen', + 'sahne', + 'saison', + 'salat', + 'salbe', + 'saloon', + 'salz', + 'samen', + 'sammeln', + 'samstag', + 'samt', + 'sand', + 'sanft', + 'saniert', + 'sardine', + 'satellit', + 'satire', + 'sattel', + 'satz', + 'sauber', + 'sauer', + 'saugen', + 'sauna', + 'saurier', + 'schaf', + 'schere', + 'schirm', + 'schlange', + 'schmuck', + 'schnee', + 'schock', + 'schrank', + 'schuh', + 'schwan', + 'sechs', + 'seefahrt', + 'seehund', + 'seekuh', + 'seele', + 'seestern', + 'segel', + 'segment', + 'sehen', + 'seide', + 'seife', + 'seil', + 'seite', + 'sektor', + 'sekunde', + 'sellerie', + 'selten', + 'semester', + 'seminar', + 'senden', + 'senf', + 'senior', + 'senken', + 'sense', + 'serie', + 'serum', + 'server', + 'sessel', + 'setzen', + 'shop', + 'sichel', + 'sieb', + 'siedlung', + 'sieg', + 'signal', + 'silber', + 'simpel', + 'singen', + 'sinken', + 'sinn', + 'sirene', + 'sirup', + 'sitzen', + 'skala', + 'skandal', + 'skelett', + 'skizze', + 'skript', + 'skulptur', + 'socke', + 'sofa', + 'sohle', + 'sohn', + 'soja', + 'solide', + 'sollen', + 'sommer', + 'sonne', + 'sorge', + 'sorte', + 'sozial', + 'spachtel', + 'spagat', + 'spalten', + 'spange', + 'spargel', + 'spaten', + 'specht', + 'speise', + 'spektrum', + 'spende', + 'sperling', + 'speziell', + 'spiegel', + 'spinne', + 'spion', + 'spitze', + 'sponsor', + 'sport', + 'sprache', + 'sprechen', + 'springen', + 'sprotte', + 'sprung', + 'spur', + 'stabil', + 'stachel', + 'stadt', + 'stahl', + 'stall', + 'stamm', + 'standort', + 'stapel', + 'stark', + 'station', + 'staub', + 'stecken', + 'steg', + 'stehen', + 'stein', + 'stellen', + 'stempel', + 'steppe', + 'stern', + 'stetig', + 'steuer', + 'stichtag', + 'stier', + 'stift', + 'still', + 'stimme', + 'stirn', + 'stock', + 'stoff', + 'stolz', + 'stoppen', + 'storch', + 'strand', + 'strecke', + 'strich', + 'strom', + 'strumpf', + 'stube', + 'stuck', + 'studium', + 'stufe', + 'stuhl', + 'stumm', + 'stunde', + 'sturm', + 'substanz', + 'suche', + 'summe', + 'sumpf', + 'suppe', + 'surfen', + 'symbol', + 'symptome', + 'system', + 'szenario', + 'tabelle', + 'tabu', + 'tacker', + 'tadel', + 'tafel', + 'tagebuch', + 'takt', + 'talent', + 'talfahrt', + 'tango', + 'tank', + 'tanne', + 'tante', + 'tanz', + 'tapfer', + 'tapir', + 'tarif', + 'tarnen', + 'tasche', + 'tasse', + 'tastatur', + 'tatort', + 'tatsache', + 'taube', + 'tauchen', + 'tausch', + 'taxi', + 'team', + 'technik', + 'teekanne', + 'teer', + 'teesieb', + 'teich', + 'teig', + 'teilen', + 'telefon', + 'teller', + 'tempo', + 'tendenz', + 'tennis', + 'tenor', + 'teppich', + 'termin', + 'terrasse', + 'test', + 'teuer', + 'text', + 'theater', + 'thema', + 'theorie', + 'therapie', + 'these', + 'tief', + 'tier', + 'tiger', + 'tinte', + 'tipp', + 'tisch', + 'titel', + 'tochter', + 'toilette', + 'toleranz', + 'toll', + 'tomate', + 'tonband', + 'tonne', + 'topf', + 'torbogen', + 'torte', + 'torwart', + 'total', + 'tracht', + 'tragen', + 'training', + 'trapez', + 'trasse', + 'traum', + 'treffen', + 'treiben', + 'trennen', + 'treppe', + 'tresor', + 'treten', + 'treu', + 'triangel', + 'trick', + 'trinken', + 'trocken', + 'trommel', + 'tropfen', + 'trost', + 'trubel', + 'truhe', + 'trumpf', + 'trunk', + 'truthahn', + 'tuch', + 'tukan', + 'tulpe', + 'tunnel', + 'turbine', + 'turm', + 'turnen', + 'tusche', + 'typisch', + 'ufer', + 'uhrwerk', + 'umbau', + 'umbruch', + 'umfang', + 'umfeld', + 'umfrage', + 'umgang', + 'umgebung', + 'umhang', + 'umkreis', + 'umland', + 'umriss', + 'umsatz', + 'umschlag', + 'umsetzen', + 'umsonst', + 'umstand', + 'umwelt', + 'umzug', + 'unfall', + 'unikat', + 'unmut', + 'unrat', + 'unrecht', + 'unruhe', + 'unschuld', + 'unsinn', + 'unten', + 'unweit', + 'urkunde', + 'urlaub', + 'ursache', + 'ursprung', + 'urteil', + 'utopie', + 'vage', + 'vakuum', + 'vanille', + 'variante', + 'vase', + 'vater', + 'ventil', + 'veranda', + 'verband', + 'verdacht', + 'verein', + 'verfall', + 'verkehr', + 'verloren', + 'vernunft', + 'verrat', + 'verstand', + 'vertrag', + 'verwandt', + 'verzicht', + 'video', + 'vieh', + 'viel', + 'vier', + 'villa', + 'virus', + 'vision', + 'vitamine', + 'vitrine', + 'vogel', + 'voliere', + 'voll', + 'volumen', + 'vorbild', + 'vorfall', + 'vorgabe', + 'vorhang', + 'vorlage', + 'vorn', + 'vorort', + 'vorrat', + 'vorsicht', + 'vortrag', + 'vorwurf', + 'votum', + 'vulkan', + 'waage', + 'wachs', + 'wade', + 'waffel', + 'wagen', + 'waggon', + 'wahl', + 'wahrheit', + 'wald', + 'walnuss', + 'walross', + 'walze', + 'wand', + 'wanne', + 'wanze', + 'wappen', + 'ware', + 'warm', + 'warnung', + 'warten', + 'warze', + 'waschen', + 'wasser', + 'webstuhl', + 'wechsel', + 'wecker', + 'wedel', + 'weggabel', + 'wehren', + 'weich', + 'weide', + 'wein', + 'weisheit', + 'weit', + 'weizen', + 'welken', + 'welle', + 'welpe', + 'welt', + 'wende', + 'wenig', + 'werbung', + 'werfen', + 'werkzeug', + 'wert', + 'wesen', + 'wespe', + 'weste', + 'wetter', + 'wichtig', + 'widder', + 'wiegen', + 'wiese', + 'wild', + 'wille', + 'wimper', + 'wind', + 'winkel', + 'winter', + 'winzig', + 'wippe', + 'wirbel', + 'wirkung', + 'wirt', + 'wischen', + 'wisent', + 'wissen', + 'witz', + 'woche', + 'wohl', + 'wohnen', + 'wolf', + 'wolke', + 'wolle', + 'wort', + 'wunder', + 'wunsch', + 'wurm', + 'wurzel', + 'zacke', + 'zahl', + 'zahm', + 'zahn', + 'zander', + 'zange', + 'zapfen', + 'zart', + 'zauber', + 'zaun', + 'zebra', + 'zeche', + 'zecke', + 'zehe', + 'zehn', + 'zeichen', + 'zeigen', + 'zeile', + 'zeit', + 'zelle', + 'zelt', + 'zement', + 'zensur', + 'zentrum', + 'zettel', + 'zeug', + 'ziege', + 'ziehen', + 'ziel', + 'ziffer', + 'zimmer', + 'zimt', + 'zins', + 'zipfel', + 'zirkus', + 'zitat', + 'zitrone', + 'zocken', + 'zollfrei', + 'zone', + 'zorn', + 'zucchini', + 'zucker', + 'zufall', + 'zuflucht', + 'zugang', + 'zugriff', + 'zukunft', + 'zunge', + 'zusatz', + 'zuschlag', + 'zustand', + 'zutat', + 'zwang', + 'zweck', + 'zwei', + 'zwiebel', + 'zwilling', + 'zwingen', + 'zwirn', + 'zyklus' +]; diff --git a/core/pubspec.yaml b/core/pubspec.yaml index f5a142c4c..aec54d677 100644 --- a/core/pubspec.yaml +++ b/core/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: sentry: ^9.8.0 supabase: ^2.10.0 uuid: ^4.5.2 + bip39: ^1.0.6 dev_dependencies: build_runner: ^2.10.3 diff --git a/pubspec.lock b/pubspec.lock index c93277b1b..ff6f9d7d9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.13" + bip39: + dependency: transitive + description: + name: bip39 + sha256: de1ee27ebe7d96b84bb3a04a4132a0a3007dcdd5ad27dd14aa87a29d97c45edc + url: "https://pub.dev" + source: hosted + version: "1.0.6" boolean_selector: dependency: transitive description: @@ -920,6 +928,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" hotreloader: dependency: transitive description: @@ -1453,6 +1469,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.3" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" pool: dependency: transitive description: From c4cada63dae86eae5d82a30a156c98c87e7d4a06 Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Mon, 1 Dec 2025 14:51:52 +0100 Subject: [PATCH 02/57] feat: Upgrade recovery phrase system to 128-bit IDs, updating encoding logic and UI to support 13 words. --- .../app_onboarding/recovery_phrase_screen.dart | 7 +------ .../app_onboarding/rejoin_study_screen.dart | 14 +++++++------- core/lib/src/util/recovery.dart | 4 ++-- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index 42d7f5cc4..f6b2d334f 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -36,12 +36,7 @@ class _RecoveryPhraseScreenState extends State { ? BigInt.parse(user.id.replaceAll('-', ''), radix: 16) : BigInt.parse(const Uuid().v4().replaceAll('-', ''), radix: 16)); - BigInt id64 = id; - if (id.bitLength > 64) { - id64 = id >> (id.bitLength - 64); - } - - _phrase = encode(id64); + _phrase = encode(id); } void _copyToClipboard() { diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index 981a64af9..c88cf5830 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -14,7 +14,7 @@ class RejoinStudyScreen extends StatefulWidget { class _RejoinStudyScreenState extends State { final List _controllers = List.generate( - 7, + 13, (_) => TextEditingController(), ); final _formKey = GlobalKey(); @@ -99,12 +99,12 @@ class _RejoinStudyScreenState extends State { shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - childAspectRatio: 2.5, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + crossAxisCount: 2, + childAspectRatio: 3.0, + crossAxisSpacing: 12, + mainAxisSpacing: 12, ), - itemCount: 7, + itemCount: 13, itemBuilder: (context, index) { return TextFormField( controller: _controllers[index], @@ -113,7 +113,7 @@ class _RejoinStudyScreenState extends State { border: const OutlineInputBorder(), contentPadding: const EdgeInsets.symmetric(horizontal: 8), ), - textInputAction: index < 6 + textInputAction: index < 12 ? TextInputAction.next : TextInputAction.done, validator: (value) { diff --git a/core/lib/src/util/recovery.dart b/core/lib/src/util/recovery.dart index 4d1ae97d6..1045d53fa 100644 --- a/core/lib/src/util/recovery.dart +++ b/core/lib/src/util/recovery.dart @@ -58,7 +58,7 @@ int computeChecksum(List indices, int wordlistSize) { throw ArgumentError('Need at least one word index'); } - const primes = [31, 37, 41, 43, 47, 53]; + const primes = [31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79]; int hash = 0; for (int i = 0; i < indices.length; i++) { @@ -72,7 +72,7 @@ int computeChecksum(List indices, int wordlistSize) { List encode( BigInt participantId, { List? wordlist, - int bitCount = 64, + int bitCount = 128, EncodingConfig config = const EncodingConfig(), }) { final effectiveWordlist = wordlist ?? WORDLIST_EN; From 5f174dc940be032438d616df127b3540adb4ae5e Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Mon, 1 Dec 2025 17:35:12 +0100 Subject: [PATCH 03/57] feat: implement account recovery and study rejoining functionality with a new service and backend migration. --- app/lib/l10n/app_de.arb | 8 +- app/lib/l10n/app_en.arb | 8 +- app/lib/l10n/app_localizations.dart | 30 ++++ app/lib/l10n/app_localizations_de.dart | 19 +++ app/lib/l10n/app_localizations_en.dart | 18 ++ .../app_onboarding/rejoin_study_screen.dart | 134 +++++++++++---- app/lib/services/rejoin_study_service.dart | 158 ++++++++++++++++++ .../20251201155709_recover_account.sql | 101 +++++++++++ 8 files changed, 441 insertions(+), 35 deletions(-) create mode 100644 app/lib/services/rejoin_study_service.dart create mode 100644 supabase/migrations/20251201155709_recover_account.sql diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 5fa7d68d7..5519ba3fc 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -478,5 +478,11 @@ "type": "String" } } - } + }, + + "recovery_in_progress": "Konto wird wiederhergestellt...", + "recovery_failed": "Wiederherstellung fehlgeschlagen. Bitte überprüfe deine Wiederherstellungsphrase und versuche es erneut.", + "recovery_user_not_found": "Kein Konto mit dieser Wiederherstellungsphrase gefunden.", + "recovery_rate_limit": "Bitte warte einen Moment, bevor du es erneut versuchst.", + "recovery_network_error": "Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut." } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index dfa280e7f..dfb14866c 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -474,5 +474,11 @@ "type": "String" } } - } + }, + + "recovery_in_progress": "Recovering your account...", + "recovery_failed": "Recovery failed. Please check your recovery phrase and try again.", + "recovery_user_not_found": "No account found with this recovery phrase.", + "recovery_rate_limit": "Please wait a moment before trying again.", + "recovery_network_error": "Network error. Please check your connection and try again." } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 3a5560111..36dab77a8 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2341,6 +2341,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Recovery successful! ID: {id}'** String recovery_successful(String id); + + /// No description provided for @recovery_in_progress. + /// + /// In en, this message translates to: + /// **'Recovering your account...'** + String get recovery_in_progress; + + /// No description provided for @recovery_failed. + /// + /// In en, this message translates to: + /// **'Recovery failed. Please check your recovery phrase and try again.'** + String get recovery_failed; + + /// No description provided for @recovery_user_not_found. + /// + /// In en, this message translates to: + /// **'No account found with this recovery phrase.'** + String get recovery_user_not_found; + + /// No description provided for @recovery_rate_limit. + /// + /// In en, this message translates to: + /// **'Please wait a moment before trying again.'** + String get recovery_rate_limit; + + /// No description provided for @recovery_network_error. + /// + /// In en, this message translates to: + /// **'Network error. Please check your connection and try again.'** + String get recovery_network_error; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 397b336bf..2c3199466 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1219,4 +1219,23 @@ class AppLocalizationsDe extends AppLocalizations { String recovery_successful(String id) { return 'Wiederherstellung erfolgreich! ID: $id'; } + + @override + String get recovery_in_progress => 'Konto wird wiederhergestellt...'; + + @override + String get recovery_failed => + 'Wiederherstellung fehlgeschlagen. Bitte überprüfe deine Wiederherstellungsphrase und versuche es erneut.'; + + @override + String get recovery_user_not_found => + 'Kein Konto mit dieser Wiederherstellungsphrase gefunden.'; + + @override + String get recovery_rate_limit => + 'Bitte warte einen Moment, bevor du es erneut versuchst.'; + + @override + String get recovery_network_error => + 'Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index a4e506025..404d038dc 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1207,4 +1207,22 @@ class AppLocalizationsEn extends AppLocalizations { String recovery_successful(String id) { return 'Recovery successful! ID: $id'; } + + @override + String get recovery_in_progress => 'Recovering your account...'; + + @override + String get recovery_failed => + 'Recovery failed. Please check your recovery phrase and try again.'; + + @override + String get recovery_user_not_found => + 'No account found with this recovery phrase.'; + + @override + String get recovery_rate_limit => 'Please wait a moment before trying again.'; + + @override + String get recovery_network_error => + 'Network error. Please check your connection and try again.'; } diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index c88cf5830..c58f6ddda 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:studyu_app/routes.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:go_router/go_router.dart'; -import 'package:studyu_core/src/util/recovery.dart'; -import 'package:studyu_core/src/util/wordlists.dart'; +import 'package:studyu_app/routes.dart'; +import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_core/core.dart'; class RejoinStudyScreen extends StatefulWidget { const RejoinStudyScreen({super.key}); @@ -19,6 +18,9 @@ class _RejoinStudyScreenState extends State { ); final _formKey = GlobalKey(); String? _errorMessage; + bool _isLoading = false; + DateTime? _lastAttempt; + final _cooldownDuration = const Duration(seconds: 5); @override void dispose() { @@ -28,7 +30,34 @@ class _RejoinStudyScreenState extends State { super.dispose(); } + String _getErrorMessage(String? errorKey) { + final localizations = AppLocalizations.of(context)!; + + switch (errorKey) { + case 'User not found': + return localizations.recovery_user_not_found; + case 'recovery_user_not_found': + return localizations.recovery_user_not_found; + case 'recovery_network_error': + return localizations.recovery_network_error; + case 'recovery_failed': + return localizations.recovery_failed; + default: + return localizations.recovery_failed; + } + } + void _validateAndSubmit() { + // Rate limiting check + if (_lastAttempt != null && + DateTime.now().difference(_lastAttempt!) < _cooldownDuration) { + setState(() { + _errorMessage = AppLocalizations.of(context)!.recovery_rate_limit; + }); + return; + } + _lastAttempt = DateTime.now(); + setState(() { _errorMessage = null; }); @@ -39,40 +68,59 @@ class _RejoinStudyScreenState extends State { .toList(); try { - try { - final id = decode(words, wordlist: WORDLIST_EN); - _onSuccess(id); - return; - } catch (e) { - try { - final id = decode(words, wordlist: WORDLIST_DE); - _onSuccess(id); - return; - } catch (e2) { - setState(() { - _errorMessage = AppLocalizations.of( - context, - )!.invalid_recovery_phrase; - }); - } - } + final id = RejoinStudyService.decodeRecoveryPhrase(words); + _onSuccess(id); } catch (e) { setState(() { - _errorMessage = e.toString(); + _errorMessage = AppLocalizations.of(context)!.invalid_recovery_phrase; }); } } } - void _onSuccess(BigInt id) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.recovery_successful(id.toString()), - ), - ), - ); - context.goNamed(Routes.loading); + Future _onSuccess(BigInt id) async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final result = await RejoinStudyService.performRecovery(id); + + if (!mounted) return; + + if (!result.success) { + setState(() { + _errorMessage = _getErrorMessage(result.error); + _isLoading = false; + }); + return; + } + + if (!mounted) return; + + if (result.subjectId != null) { + Navigator.pushNamedAndRemoveUntil( + context, + Routes.loading, + (_) => false, + ); + } else { + Navigator.pushNamedAndRemoveUntil( + context, + Routes.studySelection, + (route) => route.settings.name == Routes.welcome, + ); + } + } catch (e, stackTrace) { + if (!mounted) return; + StudyULogger.warning('Recovery error: $e\n$stackTrace'); + + setState(() { + _errorMessage = AppLocalizations.of(context)!.recovery_network_error; + _isLoading = false; + }); + } } @override @@ -147,8 +195,28 @@ class _RejoinStudyScreenState extends State { ], const SizedBox(height: 24), FilledButton( - onPressed: _validateAndSubmit, - child: Text(AppLocalizations.of(context)!.rejoin_study), + onPressed: _isLoading ? null : _validateAndSubmit, + child: _isLoading + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white, + ), + ), + ), + const SizedBox(width: 12), + Text( + AppLocalizations.of(context)!.recovery_in_progress, + ), + ], + ) + : Text(AppLocalizations.of(context)!.rejoin_study), ), ], ), diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/rejoin_study_service.dart new file mode 100644 index 000000000..b4c440555 --- /dev/null +++ b/app/lib/services/rejoin_study_service.dart @@ -0,0 +1,158 @@ +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:studyu_core/core.dart'; +import 'package:studyu_core/src/util/recovery.dart'; +import 'package:studyu_core/src/util/wordlists.dart'; +import 'package:studyu_flutter_common/studyu_flutter_common.dart'; + +/// Model for recovery account response from Supabase RPC function +class RecoveryResult { + final bool success; + final String? email; + final String? password; + final String? subjectId; + final String? error; + + RecoveryResult({ + required this.success, + this.email, + this.password, + this.subjectId, + this.error, + }); + + factory RecoveryResult.fromJson(Map json) { + return RecoveryResult( + success: json['success'] as bool? ?? false, + email: json['email'] as String?, + password: json['password'] as String?, + subjectId: json['subject_id'] as String?, + error: json['error'] as String?, + ); + } +} + +/// Service class for handling account recovery and study rejoining logic +class RejoinStudyService { + /// Validates and decodes a recovery phrase to a user ID + /// + /// Tries English wordlist first, then German wordlist + /// Returns the decoded user ID or throws an exception if invalid + static BigInt decodeRecoveryPhrase(List words) { + try { + return decode(words, wordlist: WORDLIST_EN); + } catch (e) { + return decode(words, wordlist: WORDLIST_DE); + } + } + + /// Converts BigInt user ID to UUID string format + /// + /// Example: BigInt(0x550e8400e29b41d4a716446655440000) + /// -> "550e8400-e29b-41d4-a716-446655440000" + static String convertBigIntToUuid(BigInt userId) { + final hexString = userId.toRadixString(16).padLeft(32, '0'); + return '${hexString.substring(0, 8)}-' + '${hexString.substring(8, 12)}-' + '${hexString.substring(12, 16)}-' + '${hexString.substring(16, 20)}-' + '${hexString.substring(20, 32)}'; + } + + /// Calls Supabase RPC function to recover account + /// + /// Takes user UUID and returns recovery result with credentials and + /// optional subject ID + static Future recoverAccount(BigInt userId) async { + try { + final uuidString = convertBigIntToUuid(userId); + final response = await Supabase.instance.client.rpc( + 'recover_account', + params: {'p_user_id': uuidString}, + ); + + if (response is Map) { + return RecoveryResult.fromJson(response); + } else { + StudyULogger.warning('Unexpected response format: $response'); + return RecoveryResult( + success: false, + error: 'recovery_failed', // Will be translated by UI + ); + } + } catch (e) { + StudyULogger.warning('RPC call failed: $e'); + return RecoveryResult( + success: false, + error: 'recovery_network_error', // Will be translated by UI + ); + } + } + + /// Validates that a subject is not deleted (user hasn't dropped from study) + /// + /// Returns true if subject is valid (not deleted), false otherwise + static Future validateSubject(String subjectId) async { + try { + final subject = await SupabaseQuery.getById( + subjectId, + selectedColumns: ['*'], + ); + + // Check if user has dropped from the study + return !subject.isDeleted; + } catch (e) { + // If we can't fetch the subject, assume invalid + return false; + } + } + + /// Orchestrates the complete recovery flow + /// + /// Steps: + /// 1. Call recovery RPC function + /// 2. Store new credentials + /// 3. Sign in with new credentials + /// 4. Validate subject (check is_deleted flag) + /// 5. Store active subject ID if found and valid + /// + /// Returns RecoveryResult indicating success or failure + static Future performRecovery(BigInt userId) async { + try { + final result = await recoverAccount(userId); + + if (!result.success) { + return result; + } + + await storeFakeUserEmailAndPassword(result.email!, result.password!); + + final signInResult = await signInParticipant(); + if (!signInResult) { + StudyULogger.warning('Sign in failed after recovery'); + return RecoveryResult( + success: false, + error: 'recovery_failed', // Will be translated by UI + ); + } + + if (result.subjectId != null) { + final isValid = await validateSubject(result.subjectId!); + + if (!isValid) { + return RecoveryResult( + success: true, + email: result.email, + password: result.password, + ); + } + + await storeActiveSubjectId(result.subjectId!); + } + + return result; + } catch (e, stackTrace) { + StudyULogger.warning('Error in performRecovery: $e\n$stackTrace'); + return RecoveryResult(success: false, error: 'recovery_network_error'); + } + } +} diff --git a/supabase/migrations/20251201155709_recover_account.sql b/supabase/migrations/20251201155709_recover_account.sql new file mode 100644 index 000000000..db81cf3f6 --- /dev/null +++ b/supabase/migrations/20251201155709_recover_account.sql @@ -0,0 +1,101 @@ +BEGIN; + +/* + * This function implements account recovery for users who have lost access to their account. + * It takes a user ID, generates a new password, finds their latest active study, and returns + * the credentials needed to log back in. + * + * @param p_user_id - The UUID of the user to recover (decoded from 13-word recovery phrase) + * + * @returns jsonb - { + * "success": true/false, + * "email": "user-email@domain.com", + * "password": "newly-generated-password", + * "subject_id": "study-subject-uuid-or-null", + * "error": "error-message-if-failed" + * } + */ +CREATE OR REPLACE FUNCTION public.recover_account( + p_user_id uuid +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_email text; + v_new_password text; + v_encrypted_password text; + v_subject_id uuid; + v_latest_progress_date timestamptz; +BEGIN + -- 1. Validate user exists in auth.users + SELECT email INTO v_user_email + FROM auth.users + WHERE id = p_user_id; + + IF v_user_email IS NULL THEN + RETURN jsonb_build_object( + 'success', false, + 'error', 'User not found' + ); + END IF; + + -- 2. Generate new random password + v_new_password := gen_random_uuid()::text; + v_encrypted_password := extensions.crypt(v_new_password, extensions.gen_salt('bf', 12)); + + -- 3. Update user's password in auth.users + UPDATE auth.users + SET + encrypted_password = v_encrypted_password, + updated_at = now() at time zone 'utc' + WHERE id = p_user_id; + + -- 4. Find latest active study subject + -- Priority: most recent progress > most recent start date > deterministic ID ordering + SELECT + ss.id, + MAX(sp.completed_at) as latest_progress + INTO v_subject_id, v_latest_progress_date + FROM public.study_subject ss + LEFT JOIN public.subject_progress sp ON sp.subject_id = ss.id + WHERE + ss.user_id = p_user_id + AND ss.is_deleted = false + AND ss.started_at IS NOT NULL + GROUP BY ss.id + ORDER BY + MAX(sp.completed_at) DESC NULLS LAST, -- Most recent progress first + ss.started_at DESC, -- Most recent start date as tiebreaker + ss.id DESC -- Deterministic ordering for identical dates + LIMIT 1; + + -- 5. Return success with credentials and optional subject_id + RETURN jsonb_build_object( + 'success', true, + 'email', v_user_email, + 'password', v_new_password, + 'subject_id', v_subject_id -- Will be null if no active studies found + ); + +EXCEPTION + WHEN OTHERS THEN + -- Log error and return failure + RAISE WARNING 'Error in recover_account for user %: %', p_user_id, SQLERRM; + RETURN jsonb_build_object( + 'success', false, + 'error', 'Recovery failed: ' || SQLERRM + ); +END; +$$; + +-- Grant execute permission to anon and authenticated roles +-- (anon is required since users call this before authentication) +GRANT EXECUTE ON FUNCTION public.recover_account(uuid) TO anon, authenticated; + +-- Add comment for documentation +COMMENT ON FUNCTION public.recover_account(uuid) IS +'Recovers user account by generating new password and finding latest active study. Used for 13-word recovery phrase feature.'; + +COMMIT; From 6c83fbf524729b7f6c12485191f682b36cfdd0cd Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Mon, 1 Dec 2025 17:53:03 +0100 Subject: [PATCH 04/57] feat: enhance rejoin study screen with a single recovery phrase input field and comprehensive help instructions. --- app/lib/l10n/app_de.arb | 8 +- app/lib/l10n/app_en.arb | 8 +- app/lib/l10n/app_localizations.dart | 30 +++ app/lib/l10n/app_localizations_de.dart | 20 ++ app/lib/l10n/app_localizations_en.dart | 18 ++ .../app_onboarding/rejoin_study_screen.dart | 187 +++++++++++++----- 6 files changed, 220 insertions(+), 51 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 5519ba3fc..3e96d8b7d 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -484,5 +484,11 @@ "recovery_failed": "Wiederherstellung fehlgeschlagen. Bitte überprüfe deine Wiederherstellungsphrase und versuche es erneut.", "recovery_user_not_found": "Kein Konto mit dieser Wiederherstellungsphrase gefunden.", "recovery_rate_limit": "Bitte warte einen Moment, bevor du es erneut versuchst.", - "recovery_network_error": "Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut." + "recovery_network_error": "Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.", + + "rejoin_study_description": "Gib deine 13-Wörter-Wiederherstellungsphrase ein, um deine Studie auf diesem Gerät fortzusetzen.", + "rejoin_study_help_title": "So verwendest du deine Wiederherstellungsphrase", + "rejoin_study_help_1": "Gib alle 13 Wörter deiner gespeicherten Wiederherstellungsphrase in der richtigen Reihenfolge ein", + "rejoin_study_help_2": "Wörter können eingefügt werden—sie füllen automatisch die Felder aus", + "rejoin_study_help_3": "Die Wiederherstellungsphrase unterscheidet nicht zwischen Groß- und Kleinschreibung" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index dfb14866c..0d8399e77 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -480,5 +480,11 @@ "recovery_failed": "Recovery failed. Please check your recovery phrase and try again.", "recovery_user_not_found": "No account found with this recovery phrase.", "recovery_rate_limit": "Please wait a moment before trying again.", - "recovery_network_error": "Network error. Please check your connection and try again." + "recovery_network_error": "Network error. Please check your connection and try again.", + + "rejoin_study_description": "Enter your 13-word recovery phrase to continue your study on this device.", + "rejoin_study_help_title": "How to use your recovery phrase", + "rejoin_study_help_1": "Enter all 13 words from your saved recovery phrase in order", + "rejoin_study_help_2": "Words can be pasted—they'll automatically fill the fields", + "rejoin_study_help_3": "The recovery phrase is case-insensitive" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 36dab77a8..73efad6b3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2371,6 +2371,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Network error. Please check your connection and try again.'** String get recovery_network_error; + + /// No description provided for @rejoin_study_description. + /// + /// In en, this message translates to: + /// **'Enter your 13-word recovery phrase to continue your study on this device.'** + String get rejoin_study_description; + + /// No description provided for @rejoin_study_help_title. + /// + /// In en, this message translates to: + /// **'How to use your recovery phrase'** + String get rejoin_study_help_title; + + /// No description provided for @rejoin_study_help_1. + /// + /// In en, this message translates to: + /// **'Enter all 13 words from your saved recovery phrase in order'** + String get rejoin_study_help_1; + + /// No description provided for @rejoin_study_help_2. + /// + /// In en, this message translates to: + /// **'Words can be pasted—they\'ll automatically fill the fields'** + String get rejoin_study_help_2; + + /// No description provided for @rejoin_study_help_3. + /// + /// In en, this message translates to: + /// **'The recovery phrase is case-insensitive'** + String get rejoin_study_help_3; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 2c3199466..9b26415c8 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1238,4 +1238,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recovery_network_error => 'Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.'; + + @override + String get rejoin_study_description => + 'Gib deine 13-Wörter-Wiederherstellungsphrase ein, um deine Studie auf diesem Gerät fortzusetzen.'; + + @override + String get rejoin_study_help_title => + 'So verwendest du deine Wiederherstellungsphrase'; + + @override + String get rejoin_study_help_1 => + 'Gib alle 13 Wörter deiner gespeicherten Wiederherstellungsphrase in der richtigen Reihenfolge ein'; + + @override + String get rejoin_study_help_2 => + 'Wörter können eingefügt werden—sie füllen automatisch die Felder aus'; + + @override + String get rejoin_study_help_3 => + 'Die Wiederherstellungsphrase unterscheidet nicht zwischen Groß- und Kleinschreibung'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 404d038dc..43fbbe0c9 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1225,4 +1225,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recovery_network_error => 'Network error. Please check your connection and try again.'; + + @override + String get rejoin_study_description => + 'Enter your 13-word recovery phrase to continue your study on this device.'; + + @override + String get rejoin_study_help_title => 'How to use your recovery phrase'; + + @override + String get rejoin_study_help_1 => + 'Enter all 13 words from your saved recovery phrase in order'; + + @override + String get rejoin_study_help_2 => + 'Words can be pasted—they\'ll automatically fill the fields'; + + @override + String get rejoin_study_help_3 => 'The recovery phrase is case-insensitive'; } diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index c58f6ddda..2e21844d9 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -12,24 +12,37 @@ class RejoinStudyScreen extends StatefulWidget { } class _RejoinStudyScreenState extends State { - final List _controllers = List.generate( - 13, - (_) => TextEditingController(), - ); + final TextEditingController _phraseController = TextEditingController(); final _formKey = GlobalKey(); String? _errorMessage; bool _isLoading = false; DateTime? _lastAttempt; final _cooldownDuration = const Duration(seconds: 5); + List _words = []; + + @override + void initState() { + super.initState(); + _phraseController.addListener(_onPhraseChanged); + } @override void dispose() { - for (final controller in _controllers) { - controller.dispose(); - } + _phraseController.removeListener(_onPhraseChanged); + _phraseController.dispose(); super.dispose(); } + void _onPhraseChanged() { + setState(() { + _words = _phraseController.text + .trim() + .split(RegExp(r'\s+')) + .where((word) => word.isNotEmpty) + .toList(); + }); + } + String _getErrorMessage(String? errorKey) { final localizations = AppLocalizations.of(context)!; @@ -63,9 +76,14 @@ class _RejoinStudyScreenState extends State { }); if (_formKey.currentState!.validate()) { - final words = _controllers - .map((c) => c.text.trim().toLowerCase()) - .toList(); + final words = _words.map((w) => w.trim().toLowerCase()).toList(); + + if (words.length != 13) { + setState(() { + _errorMessage = AppLocalizations.of(context)!.invalid_recovery_phrase; + }); + return; + } try { final id = RejoinStudyService.decodeRecoveryPhrase(words); @@ -123,6 +141,28 @@ class _RejoinStudyScreenState extends State { } } + Widget _buildHelpItem(BuildContext context, IconData icon, String text) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + icon, + size: 18, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -142,47 +182,96 @@ class _RejoinStudyScreenState extends State { ), textAlign: TextAlign.center, ), + const SizedBox(height: 16), + Text( + AppLocalizations.of(context)!.rejoin_study_description, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + Card( + elevation: 0, + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline, + size: 20, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.of( + context, + )!.rejoin_study_help_title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildHelpItem( + context, + Icons.list_alt, + AppLocalizations.of(context)!.rejoin_study_help_1, + ), + const SizedBox(height: 8), + _buildHelpItem( + context, + Icons.content_paste, + AppLocalizations.of(context)!.rejoin_study_help_2, + ), + const SizedBox(height: 8), + _buildHelpItem( + context, + Icons.text_fields, + AppLocalizations.of(context)!.rejoin_study_help_3, + ), + ], + ), + ), + ), const SizedBox(height: 24), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 3.0, - crossAxisSpacing: 12, - mainAxisSpacing: 12, + // Single Text Input Field with validation feedback + TextFormField( + controller: _phraseController, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.enter_recovery_phrase, + hintText: 'apple banana cherry ...', + border: const OutlineInputBorder(), + helperText: '${_words.length}/13 words', + helperStyle: TextStyle( + color: _words.length == 13 + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: _words.length == 13 + ? FontWeight.bold + : FontWeight.normal, + ), + suffixIcon: _words.length == 13 + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, ), - itemCount: 13, - itemBuilder: (context, index) { - return TextFormField( - controller: _controllers[index], - decoration: InputDecoration( - labelText: '${index + 1}', - border: const OutlineInputBorder(), - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - ), - textInputAction: index < 12 - ? TextInputAction.next - : TextInputAction.done, - validator: (value) { - if (value == null || value.isEmpty) { - return AppLocalizations.of(context)!.required; - } - return null; - }, - onChanged: (value) { - if (value.contains(' ')) { - final words = value.trim().split(RegExp(r'\s+')); - if (words.length > 1) { - for (int i = 0; i < words.length; i++) { - if (index + i < _controllers.length) { - _controllers[index + i].text = words[i]; - } - } - } - } - }, - ); + maxLines: 4, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _validateAndSubmit(), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return AppLocalizations.of(context)!.required; + } + return null; }, ), if (_errorMessage != null) ...[ From 66c948d6e93311e73cc92929ce1ac9fd4c62aa0d Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Mon, 1 Dec 2025 19:05:39 +0100 Subject: [PATCH 05/57] refactor: Rename wordlist constants and export recovery utilities from the core package. --- app/lib/routes.dart | 4 ++-- .../app_onboarding/recovery_phrase_screen.dart | 11 ++++------- app/lib/services/rejoin_study_service.dart | 8 +++----- core/lib/core.dart | 2 ++ core/lib/src/util/recovery.dart | 4 ++-- core/lib/src/util/wordlists.dart | 8 ++++---- 6 files changed, 17 insertions(+), 20 deletions(-) diff --git a/app/lib/routes.dart b/app/lib/routes.dart index f2a21197b..f5631c9ca 100644 --- a/app/lib/routes.dart +++ b/app/lib/routes.dart @@ -5,10 +5,10 @@ import 'package:studyu_app/screens/app_onboarding/about.dart'; import 'package:studyu_app/screens/app_onboarding/app_error_screen.dart'; import 'package:studyu_app/screens/app_onboarding/app_outdated_screen.dart'; import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; -import 'package:studyu_app/screens/app_onboarding/terms.dart'; -import 'package:studyu_app/screens/app_onboarding/welcome.dart'; import 'package:studyu_app/screens/app_onboarding/recovery_phrase_screen.dart'; import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; +import 'package:studyu_app/screens/app_onboarding/terms.dart'; +import 'package:studyu_app/screens/app_onboarding/welcome.dart'; import 'package:studyu_app/screens/study/dashboard/contact_tab/contact_screen.dart'; import 'package:studyu_app/screens/study/dashboard/contact_tab/faq.dart'; import 'package:studyu_app/screens/study/dashboard/dashboard.dart'; diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index f6b2d334f..414907420 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -1,16 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:studyu_app/routes.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/routes.dart'; +import 'package:studyu_core/core.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; -import 'package:studyu_core/src/util/recovery.dart'; class RecoveryPhraseScreen extends StatefulWidget { - final BigInt? - userId; // Optional, if not provided, we might generate one or handle error - // Actually, we usually have the user ID from the auth state or passed in. - // For now, let's assume it's passed or we can get it. + final BigInt? userId; const RecoveryPhraseScreen({super.key, this.userId}); diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/rejoin_study_service.dart index b4c440555..abf551835 100644 --- a/app/lib/services/rejoin_study_service.dart +++ b/app/lib/services/rejoin_study_service.dart @@ -1,8 +1,6 @@ -import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:studyu_core/core.dart'; -import 'package:studyu_core/src/util/recovery.dart'; -import 'package:studyu_core/src/util/wordlists.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; /// Model for recovery account response from Supabase RPC function class RecoveryResult { @@ -39,9 +37,9 @@ class RejoinStudyService { /// Returns the decoded user ID or throws an exception if invalid static BigInt decodeRecoveryPhrase(List words) { try { - return decode(words, wordlist: WORDLIST_EN); + return decode(words, wordlist: wordlistEn); } catch (e) { - return decode(words, wordlist: WORDLIST_DE); + return decode(words, wordlist: wordlistDe); } } diff --git a/core/lib/core.dart b/core/lib/core.dart index 88c7ac9bb..aca001f93 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -1,3 +1,5 @@ export 'src/models/models.dart'; export 'src/util/analytics.dart'; +export 'src/util/recovery.dart'; export 'src/util/util.dart'; +export 'src/util/wordlists.dart'; diff --git a/core/lib/src/util/recovery.dart b/core/lib/src/util/recovery.dart index 1045d53fa..1dd16e8a9 100644 --- a/core/lib/src/util/recovery.dart +++ b/core/lib/src/util/recovery.dart @@ -75,7 +75,7 @@ List encode( int bitCount = 128, EncodingConfig config = const EncodingConfig(), }) { - final effectiveWordlist = wordlist ?? WORDLIST_EN; + final effectiveWordlist = wordlist ?? wordlistEn; if (effectiveWordlist.length < (1 << config.bitsPerWord)) { throw ArgumentError( @@ -102,7 +102,7 @@ BigInt decode( throw ArgumentError('Must provide at least one word'); } - final effectiveWordlist = wordlist ?? WORDLIST_EN; + final effectiveWordlist = wordlist ?? wordlistEn; List dataWords = words; diff --git a/core/lib/src/util/wordlists.dart b/core/lib/src/util/wordlists.dart index 13ac230f6..df0c0e5a5 100644 --- a/core/lib/src/util/wordlists.dart +++ b/core/lib/src/util/wordlists.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -const List WORDLIST_EN = [ +const List wordlistEn = [ 'abandon', 'ability', 'able', @@ -2048,10 +2048,10 @@ const List WORDLIST_EN = [ 'zebra', 'zero', 'zone', - 'zoo' + 'zoo', ]; -const List WORDLIST_DE = [ +const List wordlistDe = [ 'abbau', 'abbild', 'abbruch', @@ -4099,5 +4099,5 @@ const List WORDLIST_DE = [ 'zwilling', 'zwingen', 'zwirn', - 'zyklus' + 'zyklus', ]; From c3d19967fc65eab082f4a93c15837ad8f890aa19 Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Tue, 2 Dec 2025 13:53:10 +0100 Subject: [PATCH 06/57] feat: Add QR code support for recovery phrase management, enabling scanning, sharing, and downloading options. --- app/lib/l10n/app_de.arb | 22 +- app/lib/l10n/app_en.arb | 22 +- app/lib/l10n/app_localizations.dart | 114 ++++++ app/lib/l10n/app_localizations_de.dart | 61 +++ app/lib/l10n/app_localizations_en.dart | 60 +++ app/lib/routes.dart | 7 + .../qr_code_scanner_screen.dart | 208 ++++++++++ .../recovery_phrase_screen.dart | 377 +++++++++++++++--- .../app_onboarding/rejoin_study_screen.dart | 29 +- app/lib/screens/study/dashboard/settings.dart | 255 ++++++++++++ app/lib/utils/recovery_qr_utils.dart | 290 ++++++++++++++ app/pubspec.yaml | 4 + pubspec.lock | 24 ++ 13 files changed, 1402 insertions(+), 71 deletions(-) create mode 100644 app/lib/screens/app_onboarding/qr_code_scanner_screen.dart create mode 100644 app/lib/utils/recovery_qr_utils.dart diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 3e96d8b7d..ff22b9c1b 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -490,5 +490,25 @@ "rejoin_study_help_title": "So verwendest du deine Wiederherstellungsphrase", "rejoin_study_help_1": "Gib alle 13 Wörter deiner gespeicherten Wiederherstellungsphrase in der richtigen Reihenfolge ein", "rejoin_study_help_2": "Wörter können eingefügt werden—sie füllen automatisch die Felder aus", - "rejoin_study_help_3": "Die Wiederherstellungsphrase unterscheidet nicht zwischen Groß- und Kleinschreibung" + "rejoin_study_help_3": "Die Wiederherstellungsphrase unterscheidet nicht zwischen Groß- und Kleinschreibung", + + "share_recovery": "Wiederherstellung teilen", + "share_as_text": "Als Text teilen", + "share_as_qr": "Als QR-Code teilen", + "download_recovery": "Wiederherstellung herunterladen", + "download_as_text": "Als Textdatei herunterladen", + "download_as_qr": "Als QR-Bild herunterladen", + "scan_qr_code": "QR-Code scannen", + "scan_qr_description": "Scanne einen QR-Code mit deiner Wiederherstellungsphrase", + "scanning_qr": "QR-Code wird gescannt", + "qr_scan_instruction": "Richte deine Kamera auf den QR-Code", + "invalid_qr_code": "Ungültiger QR-Code. Bitte scanne einen gültigen Wiederherstellungs-QR-Code.", + "qr_scan_error": "Fehler beim Scannen des QR-Codes. Bitte versuche es erneut.", + "recovery_security_warning": "Bewahre deine Wiederherstellungsphrase und den QR-Code sicher auf. Jeder mit Zugriff kann dein Konto wiederherstellen.", + "file_saved": "Datei erfolgreich gespeichert", + "file_save_error": "Fehler beim Speichern der Datei", + "share_btn": "Teilen", + "copy_btn": "Kopieren", + "download_btn": "Download", + "qr_code_btn": "QR-Code" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 0d8399e77..f24a1a678 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -486,5 +486,25 @@ "rejoin_study_help_title": "How to use your recovery phrase", "rejoin_study_help_1": "Enter all 13 words from your saved recovery phrase in order", "rejoin_study_help_2": "Words can be pasted—they'll automatically fill the fields", - "rejoin_study_help_3": "The recovery phrase is case-insensitive" + "rejoin_study_help_3": "The recovery phrase is case-insensitive", + + "share_recovery": "Share Recovery", + "share_as_text": "Share as Text", + "share_as_qr": "Share as QR Code", + "download_recovery": "Download Recovery", + "download_as_text": "Download as Text File", + "download_as_qr": "Download as QR Image", + "scan_qr_code": "Scan QR Code", + "scan_qr_description": "Scan a QR code containing your recovery phrase", + "scanning_qr": "Scanning QR Code", + "qr_scan_instruction": "Point your camera at the QR code", + "invalid_qr_code": "Invalid QR code. Please scan a valid recovery QR code.", + "qr_scan_error": "Error scanning QR code. Please try again.", + "recovery_security_warning": "Keep your recovery phrase and QR code safe. Anyone with access can recover your account.", + "file_saved": "File saved successfully", + "file_save_error": "Error saving file", + "share_btn": "Share", + "copy_btn": "Copy", + "download_btn": "Download", + "qr_code_btn": "QR Code" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 73efad6b3..cfc925e5f 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2401,6 +2401,120 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'The recovery phrase is case-insensitive'** String get rejoin_study_help_3; + + /// No description provided for @share_recovery. + /// + /// In en, this message translates to: + /// **'Share Recovery'** + String get share_recovery; + + /// No description provided for @share_as_text. + /// + /// In en, this message translates to: + /// **'Share as Text'** + String get share_as_text; + + /// No description provided for @share_as_qr. + /// + /// In en, this message translates to: + /// **'Share as QR Code'** + String get share_as_qr; + + /// No description provided for @download_recovery. + /// + /// In en, this message translates to: + /// **'Download Recovery'** + String get download_recovery; + + /// No description provided for @download_as_text. + /// + /// In en, this message translates to: + /// **'Download as Text File'** + String get download_as_text; + + /// No description provided for @download_as_qr. + /// + /// In en, this message translates to: + /// **'Download as QR Image'** + String get download_as_qr; + + /// No description provided for @scan_qr_code. + /// + /// In en, this message translates to: + /// **'Scan QR Code'** + String get scan_qr_code; + + /// No description provided for @scan_qr_description. + /// + /// In en, this message translates to: + /// **'Scan a QR code containing your recovery phrase'** + String get scan_qr_description; + + /// No description provided for @scanning_qr. + /// + /// In en, this message translates to: + /// **'Scanning QR Code'** + String get scanning_qr; + + /// No description provided for @qr_scan_instruction. + /// + /// In en, this message translates to: + /// **'Point your camera at the QR code'** + String get qr_scan_instruction; + + /// No description provided for @invalid_qr_code. + /// + /// In en, this message translates to: + /// **'Invalid QR code. Please scan a valid recovery QR code.'** + String get invalid_qr_code; + + /// No description provided for @qr_scan_error. + /// + /// In en, this message translates to: + /// **'Error scanning QR code. Please try again.'** + String get qr_scan_error; + + /// No description provided for @recovery_security_warning. + /// + /// In en, this message translates to: + /// **'Keep your recovery phrase and QR code safe. Anyone with access can recover your account.'** + String get recovery_security_warning; + + /// No description provided for @file_saved. + /// + /// In en, this message translates to: + /// **'File saved successfully'** + String get file_saved; + + /// No description provided for @file_save_error. + /// + /// In en, this message translates to: + /// **'Error saving file'** + String get file_save_error; + + /// No description provided for @share_btn. + /// + /// In en, this message translates to: + /// **'Share'** + String get share_btn; + + /// No description provided for @copy_btn. + /// + /// In en, this message translates to: + /// **'Copy'** + String get copy_btn; + + /// No description provided for @download_btn. + /// + /// In en, this message translates to: + /// **'Download'** + String get download_btn; + + /// No description provided for @qr_code_btn. + /// + /// In en, this message translates to: + /// **'QR Code'** + String get qr_code_btn; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 9b26415c8..90ecb8a62 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1258,4 +1258,65 @@ class AppLocalizationsDe extends AppLocalizations { @override String get rejoin_study_help_3 => 'Die Wiederherstellungsphrase unterscheidet nicht zwischen Groß- und Kleinschreibung'; + + @override + String get share_recovery => 'Wiederherstellung teilen'; + + @override + String get share_as_text => 'Als Text teilen'; + + @override + String get share_as_qr => 'Als QR-Code teilen'; + + @override + String get download_recovery => 'Wiederherstellung herunterladen'; + + @override + String get download_as_text => 'Als Textdatei herunterladen'; + + @override + String get download_as_qr => 'Als QR-Bild herunterladen'; + + @override + String get scan_qr_code => 'QR-Code scannen'; + + @override + String get scan_qr_description => + 'Scanne einen QR-Code mit deiner Wiederherstellungsphrase'; + + @override + String get scanning_qr => 'QR-Code wird gescannt'; + + @override + String get qr_scan_instruction => 'Richte deine Kamera auf den QR-Code'; + + @override + String get invalid_qr_code => + 'Ungültiger QR-Code. Bitte scanne einen gültigen Wiederherstellungs-QR-Code.'; + + @override + String get qr_scan_error => + 'Fehler beim Scannen des QR-Codes. Bitte versuche es erneut.'; + + @override + String get recovery_security_warning => + 'Bewahre deine Wiederherstellungsphrase und den QR-Code sicher auf. Jeder mit Zugriff kann dein Konto wiederherstellen.'; + + @override + String get file_saved => 'Datei erfolgreich gespeichert'; + + @override + String get file_save_error => 'Fehler beim Speichern der Datei'; + + @override + String get share_btn => 'Teilen'; + + @override + String get copy_btn => 'Kopieren'; + + @override + String get download_btn => 'Download'; + + @override + String get qr_code_btn => 'QR-Code'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 43fbbe0c9..cbc231f52 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1243,4 +1243,64 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rejoin_study_help_3 => 'The recovery phrase is case-insensitive'; + + @override + String get share_recovery => 'Share Recovery'; + + @override + String get share_as_text => 'Share as Text'; + + @override + String get share_as_qr => 'Share as QR Code'; + + @override + String get download_recovery => 'Download Recovery'; + + @override + String get download_as_text => 'Download as Text File'; + + @override + String get download_as_qr => 'Download as QR Image'; + + @override + String get scan_qr_code => 'Scan QR Code'; + + @override + String get scan_qr_description => + 'Scan a QR code containing your recovery phrase'; + + @override + String get scanning_qr => 'Scanning QR Code'; + + @override + String get qr_scan_instruction => 'Point your camera at the QR code'; + + @override + String get invalid_qr_code => + 'Invalid QR code. Please scan a valid recovery QR code.'; + + @override + String get qr_scan_error => 'Error scanning QR code. Please try again.'; + + @override + String get recovery_security_warning => + 'Keep your recovery phrase and QR code safe. Anyone with access can recover your account.'; + + @override + String get file_saved => 'File saved successfully'; + + @override + String get file_save_error => 'Error saving file'; + + @override + String get share_btn => 'Share'; + + @override + String get copy_btn => 'Copy'; + + @override + String get download_btn => 'Download'; + + @override + String get qr_code_btn => 'QR Code'; } diff --git a/app/lib/routes.dart b/app/lib/routes.dart index f5631c9ca..ff5882a42 100644 --- a/app/lib/routes.dart +++ b/app/lib/routes.dart @@ -5,6 +5,7 @@ import 'package:studyu_app/screens/app_onboarding/about.dart'; import 'package:studyu_app/screens/app_onboarding/app_error_screen.dart'; import 'package:studyu_app/screens/app_onboarding/app_outdated_screen.dart'; import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; +import 'package:studyu_app/screens/app_onboarding/qr_code_scanner_screen.dart'; import 'package:studyu_app/screens/app_onboarding/recovery_phrase_screen.dart'; import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; @@ -45,6 +46,7 @@ class Routes { static const String performanceDetails = '/performanceDetails'; static const String recoveryPhrase = '/recoveryPhrase'; static const String rejoinStudy = '/rejoinStudy'; + static const String qrScanner = '/qrScanner'; static Route unknownRoute(RouteSettings settings) { FlutterNativeSplash.remove(); @@ -169,6 +171,11 @@ class Routes { builder: (_) => const RecoveryPhraseScreen(), settings: settings, ); + case qrScanner: + return MaterialPageRoute( + builder: (_) => const QrCodeScannerScreen(), + settings: settings, + ); case rejoinStudy: return MaterialPageRoute( builder: (_) => const RejoinStudyScreen(), diff --git a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart new file mode 100644 index 000000000..a60d4a541 --- /dev/null +++ b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/utils/recovery_qr_utils.dart'; + +class QrCodeScannerScreen extends StatefulWidget { + const QrCodeScannerScreen({super.key}); + + @override + State createState() => _QrCodeScannerScreenState(); +} + +class _QrCodeScannerScreenState extends State { + final MobileScannerController _controller = MobileScannerController( + detectionSpeed: DetectionSpeed.noDuplicates, + facing: CameraFacing.back, + ); + + bool _isProcessing = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_isProcessing) return; + + final barcodes = capture.barcodes; + if (barcodes.isEmpty) return; + + final barcode = barcodes.first; + final code = barcode.rawValue; + + if (code == null || code.isEmpty) return; + + setState(() { + _isProcessing = true; + }); + + final phrase = RecoveryQrUtils.parseDeepLink(code); + + if (phrase != null) { + Navigator.pop(context, phrase); + } else { + setState(() { + _isProcessing = false; + }); + _showError(AppLocalizations.of(context)!.invalid_qr_code); + } + } + + void _showError(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: Colors.red, + duration: const Duration(seconds: 3), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.scanning_qr), + backgroundColor: Colors.black, + foregroundColor: Colors.white, + ), + body: Stack( + children: [ + MobileScanner(controller: _controller, onDetect: _onDetect), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withOpacity(0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withOpacity(0.7), + ], + stops: const [0.0, 0.3, 0.7, 1.0], + ), + ), + ), + Center( + child: Container( + width: 280, + height: 280, + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 3, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + Positioned( + top: 0, + left: 0, + child: _buildCorner( + Alignment.topLeft, + Theme.of(context).colorScheme.primary, + ), + ), + Positioned( + top: 0, + right: 0, + child: _buildCorner( + Alignment.topRight, + Theme.of(context).colorScheme.primary, + ), + ), + Positioned( + bottom: 0, + left: 0, + child: _buildCorner( + Alignment.bottomLeft, + Theme.of(context).colorScheme.primary, + ), + ), + Positioned( + bottom: 0, + right: 0, + child: _buildCorner( + Alignment.bottomRight, + Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ), + ), + Positioned( + bottom: 100, + left: 0, + right: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + AppLocalizations.of(context)!.qr_scan_instruction, + style: const TextStyle(color: Colors.white, fontSize: 16), + textAlign: TextAlign.center, + ), + ), + ), + ), + // Cancel button + Positioned( + bottom: 30, + left: 0, + right: 0, + child: Center( + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text(AppLocalizations.of(context)!.cancel), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCorner(Alignment alignment, Color color) { + return Container( + width: 30, + height: 30, + decoration: BoxDecoration( + border: Border( + top: alignment.y < 0 + ? BorderSide(color: color, width: 4) + : BorderSide.none, + bottom: alignment.y > 0 + ? BorderSide(color: color, width: 4) + : BorderSide.none, + left: alignment.x < 0 + ? BorderSide(color: color, width: 4) + : BorderSide.none, + right: alignment.x > 0 + ? BorderSide(color: color, width: 4) + : BorderSide.none, + ), + ), + ); + } +} diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index 414907420..49f610df1 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/routes.dart'; +import 'package:studyu_app/utils/recovery_qr_utils.dart'; import 'package:studyu_core/core.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; @@ -46,87 +47,331 @@ class _RecoveryPhraseScreenState extends State { ); } + Future _shareText() async { + try { + await RecoveryQrUtils.shareRecoveryText(_phrase); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + Future _shareQr() async { + try { + await RecoveryQrUtils.shareRecoveryQr(_phrase); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + Future _downloadText() async { + try { + await RecoveryQrUtils.downloadRecoveryText(_phrase); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.file_save_error), + ), + ); + } + } + } + + Future _downloadQr() async { + try { + await RecoveryQrUtils.downloadRecoveryQr(_phrase); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.file_save_error), + ), + ); + } + } + } + + void _showQrCodeDialog() { + showDialog( + context: context, + builder: (context) => Dialog( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.qr_code_btn, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + RecoveryQrUtils.renderQrWidget( + RecoveryQrUtils.buildQrCode( + RecoveryQrUtils.generateDeepLink(_phrase), + ), + size: 240, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ActionButton( + icon: Icons.share, + label: AppLocalizations.of(context)!.share_as_qr, + onTap: _shareQr, + ), + _ActionButton( + icon: Icons.download, + label: AppLocalizations.of(context)!.download_as_qr, + onTap: _downloadQr, + ), + ], + ), + ], + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.recovery_phrase_setup_title), ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - AppLocalizations.of(context)!.recovery_phrase_header, - style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), - textAlign: TextAlign.center, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 700), + child: Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 16.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.recovery_phrase_header, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + AppLocalizations.of( + context, + )!.recovery_phrase_save_hint, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 32), + _buildPhraseGrid(), + const SizedBox(height: 32), + _buildActionButtons(), + ], + ), + ), + ), + _buildBottomSection(), + ], ), - const SizedBox(height: 24), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(8), + ), + ), + ), + ); + } + + Widget _buildPhraseGrid() { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3.5, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _phrase.length, + itemBuilder: (context, index) { + return Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.2), ), - child: Column( - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: _phrase - .map((word) => Chip(label: Text(word))) - .toList(), - ), - const SizedBox(height: 16), - IconButton( - icon: const Icon(Icons.copy), - onPressed: _copyToClipboard, - tooltip: AppLocalizations.of(context)!.copy_to_clipboard, + ), + ), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${index + 1}. ', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor, ), - ], - ), + ), + TextSpan( + text: _phrase[index], + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + ], ), - const SizedBox(height: 24), - Text( - AppLocalizations.of(context)!.recovery_phrase_purpose_header, - style: const TextStyle(fontWeight: FontWeight.bold), + ), + ); + }, + ); + } + + Widget _buildActionButtons() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ActionButton( + icon: Icons.share_outlined, + label: AppLocalizations.of(context)!.share_btn, + onTap: _shareText, + ), + _ActionButton( + icon: Icons.copy_outlined, + label: AppLocalizations.of(context)!.copy_btn, + onTap: _copyToClipboard, + ), + _ActionButton( + icon: Icons.download_outlined, + label: AppLocalizations.of(context)!.download_btn, + onTap: _downloadText, + ), + _ActionButton( + icon: Icons.qr_code_2, + label: AppLocalizations.of(context)!.qr_code_btn, + onTap: _showQrCodeDialog, + ), + ], + ); + } + + Widget _buildBottomSection() { + return Container( + padding: const EdgeInsets.all(24.0), + decoration: BoxDecoration( + color: Theme.of(context).scaffoldBackgroundColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, -4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CheckboxListTile( + value: _isChecked, + onChanged: (value) { + setState(() { + _isChecked = value ?? false; + }); + }, + title: Text( + AppLocalizations.of(context)!.recovery_phrase_saved_confirmation, + style: Theme.of(context).textTheme.bodyMedium, ), - const SizedBox(height: 8), - Text(AppLocalizations.of(context)!.recovery_phrase_purpose_1), - Text(AppLocalizations.of(context)!.recovery_phrase_purpose_2), - const SizedBox(height: 16), - Text(AppLocalizations.of(context)!.recovery_phrase_save_hint), - const SizedBox(height: 24), - - // TODO: Share and Email buttons - CheckboxListTile( - value: _isChecked, - onChanged: (value) { - setState(() { - _isChecked = value ?? false; - }); - }, - title: Text( - AppLocalizations.of( - context, - )!.recovery_phrase_saved_confirmation, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 16), + FilledButton( + onPressed: _isChecked + ? () { + Navigator.pushNamed(context, Routes.studySelection); + } + : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), - controlAffinity: ListTileControlAffinity.leading, ), - const SizedBox(height: 24), - FilledButton( - onPressed: _isChecked - ? () { - Navigator.pushNamed(context, Routes.studySelection); - } - : null, - child: Text(AppLocalizations.of(context)!.continue_to_study), + child: Text(AppLocalizations.of(context)!.continue_to_study), + ), + ], + ), + ); + } +} + +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(30), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.5), + shape: BoxShape.circle, ), - ], + child: Icon(icon, color: Theme.of(context).colorScheme.primary), + ), ), - ), + const SizedBox(height: 8), + Text( + label, + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ], ); } } diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index 2e21844d9..33b0938bd 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/routes.dart'; +import 'package:studyu_app/screens/app_onboarding/qr_code_scanner_screen.dart'; import 'package:studyu_app/services/rejoin_study_service.dart'; import 'package:studyu_core/core.dart'; @@ -60,6 +61,19 @@ class _RejoinStudyScreenState extends State { } } + Future _scanQrCode() async { + final result = await Navigator.push>( + context, + MaterialPageRoute(builder: (context) => const QrCodeScannerScreen()), + ); + + if (result != null && mounted) { + setState(() { + _phraseController.text = result.join(' '); + }); + } + } + void _validateAndSubmit() { // Rate limiting check if (_lastAttempt != null && @@ -278,10 +292,19 @@ class _RejoinStudyScreenState extends State { const SizedBox(height: 16), Text( _errorMessage!, - style: const TextStyle(color: Colors.red), + style: TextStyle(color: Theme.of(context).colorScheme.error), textAlign: TextAlign.center, ), ], + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _scanQrCode, + icon: const Icon(Icons.qr_code_scanner), + label: Text(AppLocalizations.of(context)!.scan_qr_code), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + ), const SizedBox(height: 24), FilledButton( onPressed: _isLoading ? null : _validateAndSubmit, @@ -289,13 +312,13 @@ class _RejoinStudyScreenState extends State { ? Row( mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( + SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - Colors.white, + Theme.of(context).colorScheme.onPrimary, ), ), ), diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 262552054..742ba649e 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:provider/provider.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; @@ -10,8 +11,10 @@ import 'package:studyu_app/util/app_analytics.dart'; import 'package:studyu_app/util/fitbit_handler.dart'; import 'package:studyu_app/util/localization.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; +import 'package:studyu_app/utils/recovery_qr_utils.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; class Settings extends StatefulWidget { const Settings({super.key}); @@ -33,6 +36,85 @@ class _SettingsState extends State { subject = context.read().activeSubject; } + List get _phrase { + final user = Supabase.instance.client.auth.currentUser; + if (user == null) return []; + final id = BigInt.parse(user.id.replaceAll('-', ''), radix: 16); + return encode(id); + } + + void _copyToClipboard() { + final text = _phrase.join(' '); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.copied_to_clipboard), + ), + ); + } + + Future _shareText() async { + try { + await RecoveryQrUtils.shareRecoveryText(_phrase); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + Future _shareQr() async { + try { + await RecoveryQrUtils.shareRecoveryQr(_phrase); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } + } + + Future _downloadText() async { + try { + await RecoveryQrUtils.downloadRecoveryText(_phrase); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.file_save_error), + ), + ); + } + } + } + + Future _downloadQr() async { + try { + await RecoveryQrUtils.downloadRecoveryQr(_phrase); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.file_save_error), + ), + ); + } + } + } + Widget getDropdownRow(BuildContext context) { final dropDownItems = >[]; @@ -104,6 +186,179 @@ class _SettingsState extends State { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ getDropdownRow(context), + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Card( + child: ExpansionTile( + leading: const Icon(Icons.key, color: Colors.blue), + title: Text( + AppLocalizations.of(context)!.recovery_phrase_header, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + AppLocalizations.of(context)!.recovery_phrase_save_hint, + style: theme.textTheme.bodySmall, + ), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Card( + color: Colors.amber.shade50, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.warning_amber, + color: Colors.amber.shade900, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of( + context, + )!.recovery_security_warning, + style: TextStyle( + color: Colors.amber.shade900, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: _phrase + .map((word) => Chip(label: Text(word))) + .toList(), + ), + const SizedBox(height: 16), + IconButton( + icon: const Icon(Icons.copy), + onPressed: _copyToClipboard, + tooltip: AppLocalizations.of( + context, + )!.copy_to_clipboard, + ), + ], + ), + ), + const SizedBox(height: 16), + Center( + child: RecoveryQrUtils.renderQrWidget( + RecoveryQrUtils.buildQrCode( + RecoveryQrUtils.generateDeepLink(_phrase), + ), + size: 200, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () async { + await showMenu( + context: context, + position: const RelativeRect.fromLTRB( + 0, + 0, + 0, + 0, + ), + items: [ + PopupMenuItem( + onTap: _shareText, + child: Text( + AppLocalizations.of( + context, + )!.share_as_text, + ), + ), + PopupMenuItem( + onTap: _shareQr, + child: Text( + AppLocalizations.of( + context, + )!.share_as_qr, + ), + ), + ], + ); + }, + icon: const Icon(Icons.share), + label: Text( + AppLocalizations.of( + context, + )!.share_recovery, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: OutlinedButton.icon( + onPressed: () async { + await showMenu( + context: context, + position: const RelativeRect.fromLTRB( + 0, + 0, + 0, + 0, + ), + items: [ + PopupMenuItem( + onTap: _downloadText, + child: Text( + AppLocalizations.of( + context, + )!.download_as_text, + ), + ), + PopupMenuItem( + onTap: _downloadQr, + child: Text( + AppLocalizations.of( + context, + )!.download_as_qr, + ), + ), + ], + ); + }, + icon: const Icon(Icons.download), + label: Text( + AppLocalizations.of( + context, + )!.download_recovery, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ), const SizedBox(height: 24), Text( '${AppLocalizations.of(context)!.study_current} ${subject!.study.title}', diff --git a/app/lib/utils/recovery_qr_utils.dart b/app/lib/utils/recovery_qr_utils.dart new file mode 100644 index 000000000..fb6bbdf10 --- /dev/null +++ b/app/lib/utils/recovery_qr_utils.dart @@ -0,0 +1,290 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; + +import 'package:image/image.dart' as img; +import 'package:path_provider/path_provider.dart'; +import 'package:qr/qr.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:universal_html/html.dart' as html; + +class RecoveryQrUtils { + static const String deepLinkDomain = 'app.studyu.health'; + static const String deepLinkPath = '/recover'; + static const String customScheme = 'studyu'; + + static String generateDeepLink(List phrase) { + final phraseString = phrase.join('+'); + return 'https://$deepLinkDomain$deepLinkPath?phrase=$phraseString'; + } + + static String generateCustomSchemeLink(List phrase) { + final phraseString = phrase.join('+'); + return '$customScheme://recover?phrase=$phraseString'; + } + + static List? parseDeepLink(String url) { + try { + final uri = Uri.parse(url); + + final isHttpsLink = + uri.scheme == 'https' && + uri.host == deepLinkDomain && + uri.path == deepLinkPath; + final isCustomScheme = + uri.scheme == customScheme && uri.host == 'recover'; + + if (!isHttpsLink && !isCustomScheme) { + return null; + } + + final phraseParam = uri.queryParameters['phrase']; + if (phraseParam == null || phraseParam.isEmpty) { + return null; + } + + final words = phraseParam + .split(RegExp(r'[\s+]')) + .where((word) => word.isNotEmpty) + .toList(); + + if (words.length != 13) { + return null; + } + + return words; + } catch (e) { + return null; + } + } + + static QrCode buildQrCode(String data) { + final qrCode = QrCode(8, QrErrorCorrectLevel.M); + qrCode.addData(data); + return qrCode; + } + + static Widget renderQrWidget( + QrCode qrCode, { + double size = 280.0, + Color foregroundColor = Colors.black, + Color backgroundColor = Colors.white, + }) { + return CustomPaint( + size: Size(size, size), + painter: _QrPainter( + qrCode: qrCode, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + ), + ); + } + + static Uint8List qrCodeToImage( + QrCode qrCode, { + int size = 512, + int foregroundColor = 0xFF000000, + int backgroundColor = 0xFFFFFFFF, + }) { + final qrImage = QrImage(qrCode); + final moduleCount = qrImage.moduleCount; + final pixelSize = size ~/ moduleCount; + final actualSize = pixelSize * moduleCount; + + final image = img.Image(width: actualSize, height: actualSize); + + img.fill( + image, + color: img.ColorRgba8( + (backgroundColor >> 16) & 0xFF, + (backgroundColor >> 8) & 0xFF, + backgroundColor & 0xFF, + (backgroundColor >> 24) & 0xFF, + ), + ); + + for (var y = 0; y < moduleCount; y++) { + for (var x = 0; x < moduleCount; x++) { + if (qrImage.isDark(y, x)) { + final startX = x * pixelSize; + final startY = y * pixelSize; + + img.fillRect( + image, + x1: startX, + y1: startY, + x2: startX + pixelSize, + y2: startY + pixelSize, + color: img.ColorRgba8( + (foregroundColor >> 16) & 0xFF, + (foregroundColor >> 8) & 0xFF, + foregroundColor & 0xFF, + (foregroundColor >> 24) & 0xFF, + ), + ); + } + } + } + + return Uint8List.fromList(img.encodePng(image)); + } + + static Future shareRecoveryText(List phrase) async { + final text = phrase.join(' '); + await Share.share(text, subject: 'StudyU Recovery Phrase'); + } + + static Future shareRecoveryQr(List phrase) async { + try { + final deepLink = generateDeepLink(phrase); + final qrCode = buildQrCode(deepLink); + final imageData = qrCodeToImage(qrCode); + + if (kIsWeb) { + final xFile = XFile.fromData( + imageData, + mimeType: 'image/png', + name: 'recovery_qr.png', + ); + await Share.shareXFiles([xFile], subject: 'StudyU Recovery QR Code'); + return; + } + + final tempDir = await getTemporaryDirectory(); + final tempFile = File('${tempDir.path}/recovery_qr.png'); + await tempFile.writeAsBytes(imageData); + final xFile = XFile(tempFile.path); + await Share.shareXFiles([xFile], subject: 'StudyU Recovery QR Code'); + + Future.delayed(const Duration(seconds: 5), () { + if (tempFile.existsSync()) { + tempFile.delete(); + } + }); + } catch (e) { + rethrow; + } + } + + static Future downloadRecoveryText(List phrase) async { + try { + final text = phrase.join(' '); + final bytes = Uint8List.fromList(text.codeUnits); + + if (kIsWeb) { + await _downloadFileWeb( + bytes, + 'studyu_recovery_phrase.txt', + 'text/plain', + ); + return; + } + + final params = SaveFileDialogParams( + data: bytes, + fileName: 'studyu_recovery_phrase.txt', + mimeTypesFilter: ['text/plain'], + ); + + await FlutterFileDialog.saveFile(params: params); + } catch (e) { + rethrow; + } + } + + static Future downloadRecoveryQr(List phrase) async { + try { + final deepLink = generateDeepLink(phrase); + final qrCode = buildQrCode(deepLink); + final imageData = qrCodeToImage(qrCode); + + if (kIsWeb) { + await _downloadFileWeb( + imageData, + 'studyu_recovery_qr.png', + 'image/png', + ); + return; + } + + final params = SaveFileDialogParams( + data: imageData, + fileName: 'studyu_recovery_qr.png', + mimeTypesFilter: ['image/png'], + ); + + await FlutterFileDialog.saveFile(params: params); + } catch (e) { + rethrow; + } + } + + static Future _downloadFileWeb( + Uint8List bytes, + String fileName, + String mimeType, + ) async { + final blob = html.Blob([bytes], mimeType); + final url = html.Url.createObjectUrlFromBlob(blob); + final anchor = html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); + html.Url.revokeObjectUrl(url); + } +} + +class _QrPainter extends CustomPainter { + final QrCode qrCode; + final Color foregroundColor; + final Color backgroundColor; + + _QrPainter({ + required this.qrCode, + required this.foregroundColor, + required this.backgroundColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final qrImage = QrImage(qrCode); + final moduleCount = qrImage.moduleCount; + final pixelSize = size.width / moduleCount; + + final backgroundPaint = Paint() + ..color = backgroundColor + ..style = PaintingStyle.fill; + canvas.drawRect( + Rect.fromLTWH(0, 0, size.width, size.height), + backgroundPaint, + ); + + final paint = Paint() + ..color = foregroundColor + ..style = PaintingStyle.fill; + + for (var y = 0; y < moduleCount; y++) { + for (var x = 0; x < moduleCount; x++) { + if (qrImage.isDark(y, x)) { + final rect = Rect.fromLTWH( + x * pixelSize, + y * pixelSize, + pixelSize, + pixelSize, + ); + canvas.drawRect(rect, paint); + } + } + } + } + + @override + bool shouldRepaint(covariant _QrPainter oldDelegate) { + return oldDelegate.qrCode != qrCode || + oldDelegate.foregroundColor != foregroundColor || + oldDelegate.backgroundColor != backgroundColor; + } +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 2778d6a64..4addc7782 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -32,9 +32,11 @@ dependencies: sdk: flutter flutter_widget_from_html: ^0.17.1 gauge_indicator: ^0.4.3 + image: ^4.5.4 intersperse: ^2.0.0 intl: ^0.20.2 material_design_icons_flutter: ^7.0.7296 + mobile_scanner: ^7.1.3 package_info_plus: ^9.0.0 path: ^1.9.1 path_drawing: ^1.0.1 @@ -42,9 +44,11 @@ dependencies: pdf: ^3.11.3 permission_handler: ^12.0.1 provider: ^6.1.5+1 + qr: ^3.0.2 quiver: ^3.2.2 rainbow_color: ^2.0.1 record: ^6.1.2 + share_plus: ^12.0.1 sentry_flutter: ^9.8.0 sentry_logging: ^9.8.0 shared_preferences: ^2.5.3 diff --git a/pubspec.lock b/pubspec.lock index ff6f9d7d9..691300a42 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1189,6 +1189,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: transitive + description: + name: mobile_scanner + sha256: "023a71afb4d7cfb5529d0f2636aa8b43db66257905b9486d702085989769c5f2" + url: "https://pub.dev" + source: hosted + version: "7.1.3" mockito: dependency: transitive description: @@ -1765,6 +1773,22 @@ packages: url: "https://pub.dev" source: hosted version: "9.8.0" + share_plus: + dependency: transitive + description: + name: share_plus + sha256: "14c8860d4de93d3a7e53af51bff479598c4e999605290756bbbe45cf65b37840" + url: "https://pub.dev" + source: hosted + version: "12.0.1" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: transitive description: From 6c6c3ee3371f61e1fb6b30e9a05d969770913a7f Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Thu, 4 Dec 2025 13:40:17 +0100 Subject: [PATCH 07/57] refactor: Update `share_plus` API usage, simplify QR scanner camera setup and color opacity, and streamline menu positioning. --- .../app_onboarding/qr_code_scanner_screen.dart | 7 +++---- app/lib/screens/study/dashboard/settings.dart | 14 ++------------ app/lib/utils/recovery_qr_utils.dart | 17 +++++++++-------- app/pubspec.yaml | 2 +- core/pubspec.yaml | 2 +- 5 files changed, 16 insertions(+), 26 deletions(-) diff --git a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart index a60d4a541..444f19d9f 100644 --- a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart +++ b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart @@ -13,7 +13,6 @@ class QrCodeScannerScreen extends StatefulWidget { class _QrCodeScannerScreenState extends State { final MobileScannerController _controller = MobileScannerController( detectionSpeed: DetectionSpeed.noDuplicates, - facing: CameraFacing.back, ); bool _isProcessing = false; @@ -78,10 +77,10 @@ class _QrCodeScannerScreenState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withOpacity(0.7), + Colors.black.withValues(alpha: 0.7), Colors.transparent, Colors.transparent, - Colors.black.withOpacity(0.7), + Colors.black.withValues(alpha: 0.7), ], stops: const [0.0, 0.3, 0.7, 1.0], ), @@ -147,7 +146,7 @@ class _QrCodeScannerScreenState extends State { vertical: 12, ), decoration: BoxDecoration( - color: Colors.black.withOpacity(0.7), + color: Colors.black.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(8), ), child: Text( diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 742ba649e..4eeef6ed7 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -276,12 +276,7 @@ class _SettingsState extends State { onPressed: () async { await showMenu( context: context, - position: const RelativeRect.fromLTRB( - 0, - 0, - 0, - 0, - ), + position: RelativeRect.fill, items: [ PopupMenuItem( onTap: _shareText, @@ -316,12 +311,7 @@ class _SettingsState extends State { onPressed: () async { await showMenu( context: context, - position: const RelativeRect.fromLTRB( - 0, - 0, - 0, - 0, - ), + position: RelativeRect.fill, items: [ PopupMenuItem( onTap: _downloadText, diff --git a/app/lib/utils/recovery_qr_utils.dart b/app/lib/utils/recovery_qr_utils.dart index fb6bbdf10..5cead784e 100644 --- a/app/lib/utils/recovery_qr_utils.dart +++ b/app/lib/utils/recovery_qr_utils.dart @@ -1,9 +1,7 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; import 'package:image/image.dart' as img; @@ -135,7 +133,9 @@ class RecoveryQrUtils { static Future shareRecoveryText(List phrase) async { final text = phrase.join(' '); - await Share.share(text, subject: 'StudyU Recovery Phrase'); + await SharePlus.instance.share( + ShareParams(text: text, subject: 'StudyU Recovery Phrase'), + ); } static Future shareRecoveryQr(List phrase) async { @@ -150,7 +150,9 @@ class RecoveryQrUtils { mimeType: 'image/png', name: 'recovery_qr.png', ); - await Share.shareXFiles([xFile], subject: 'StudyU Recovery QR Code'); + await SharePlus.instance.share( + ShareParams(files: [xFile], subject: 'StudyU Recovery QR Code'), + ); return; } @@ -158,7 +160,9 @@ class RecoveryQrUtils { final tempFile = File('${tempDir.path}/recovery_qr.png'); await tempFile.writeAsBytes(imageData); final xFile = XFile(tempFile.path); - await Share.shareXFiles([xFile], subject: 'StudyU Recovery QR Code'); + await SharePlus.instance.share( + ShareParams(files: [xFile], subject: 'StudyU Recovery QR Code'), + ); Future.delayed(const Duration(seconds: 5), () { if (tempFile.existsSync()) { @@ -230,9 +234,6 @@ class RecoveryQrUtils { ) async { final blob = html.Blob([bytes], mimeType); final url = html.Url.createObjectUrlFromBlob(blob); - final anchor = html.AnchorElement(href: url) - ..setAttribute('download', fileName) - ..click(); html.Url.revokeObjectUrl(url); } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 4addc7782..07b35c4ff 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -48,9 +48,9 @@ dependencies: quiver: ^3.2.2 rainbow_color: ^2.0.1 record: ^6.1.2 - share_plus: ^12.0.1 sentry_flutter: ^9.8.0 sentry_logging: ^9.8.0 + share_plus: ^12.0.1 shared_preferences: ^2.5.3 statistics: ^1.2.0 studyu_core: ^4.7.1 diff --git a/core/pubspec.yaml b/core/pubspec.yaml index aec54d677..302eb6694 100644 --- a/core/pubspec.yaml +++ b/core/pubspec.yaml @@ -9,6 +9,7 @@ environment: resolution: workspace dependencies: + bip39: ^1.0.6 collection: ^1.19.1 csv: ^6.0.0 json_annotation: ^4.9.0 @@ -17,7 +18,6 @@ dependencies: sentry: ^9.8.0 supabase: ^2.10.0 uuid: ^4.5.2 - bip39: ^1.0.6 dev_dependencies: build_runner: ^2.10.3 From 3845e6090f65c3c5c59d1b65d2b07574f8af268f Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Thu, 4 Dec 2025 16:16:54 +0100 Subject: [PATCH 08/57] refactor: Update import paths for recovery QR utilities and enhance UI theming in QR code scanner and settings screens --- .../qr_code_scanner_screen.dart | 31 +- .../recovery_phrase_screen.dart | 2 +- app/lib/screens/study/dashboard/settings.dart | 547 +++++++++--------- .../dashboard/task_overview_tab/task_box.dart | 2 +- .../{utils => util}/recovery_qr_utils.dart | 25 +- 5 files changed, 299 insertions(+), 308 deletions(-) rename app/lib/{utils => util}/recovery_qr_utils.dart (92%) diff --git a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart index 444f19d9f..15c847456 100644 --- a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart +++ b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/utils/recovery_qr_utils.dart'; +import 'package:studyu_app/theme.dart'; +import 'package:studyu_app/util/recovery_qr_utils.dart'; class QrCodeScannerScreen extends StatefulWidget { const QrCodeScannerScreen({super.key}); @@ -43,10 +44,9 @@ class _QrCodeScannerScreenState extends State { if (phrase != null) { Navigator.pop(context, phrase); } else { - setState(() { - _isProcessing = false; - }); _showError(AppLocalizations.of(context)!.invalid_qr_code); + // Keep _isProcessing = true to prevent repeated error messages + // User can go back and try again with a different QR code } } @@ -54,7 +54,7 @@ class _QrCodeScannerScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), - backgroundColor: Colors.red, + backgroundColor: Theme.of(context).colorScheme.error, duration: const Duration(seconds: 3), ), ); @@ -65,8 +65,8 @@ class _QrCodeScannerScreenState extends State { return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.scanning_qr), - backgroundColor: Colors.black, - foregroundColor: Colors.white, + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).colorScheme.onSurface, ), body: Stack( children: [ @@ -77,10 +77,10 @@ class _QrCodeScannerScreenState extends State { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - Colors.black.withValues(alpha: 0.7), + Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), Colors.transparent, Colors.transparent, - Colors.black.withValues(alpha: 0.7), + Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), ], stops: const [0.0, 0.3, 0.7, 1.0], ), @@ -146,12 +146,17 @@ class _QrCodeScannerScreenState extends State { vertical: 12, ), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.7), + color: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.7), borderRadius: BorderRadius.circular(8), ), child: Text( AppLocalizations.of(context)!.qr_scan_instruction, - style: const TextStyle(color: Colors.white, fontSize: 16), + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 16, + ), textAlign: TextAlign.center, ), ), @@ -166,8 +171,8 @@ class _QrCodeScannerScreenState extends State { child: ElevatedButton( onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, + backgroundColor: Theme.of(context).colorScheme.surface, + foregroundColor: Theme.of(context).colorScheme.onSurface, padding: const EdgeInsets.symmetric( horizontal: 32, vertical: 12, diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index 49f610df1..4330cb3a5 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/routes.dart'; -import 'package:studyu_app/utils/recovery_qr_utils.dart'; +import 'package:studyu_app/util/recovery_qr_utils.dart'; import 'package:studyu_core/core.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:uuid/uuid.dart'; diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 4eeef6ed7..4aec4b13e 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -11,7 +11,7 @@ import 'package:studyu_app/util/app_analytics.dart'; import 'package:studyu_app/util/fitbit_handler.dart'; import 'package:studyu_app/util/localization.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; -import 'package:studyu_app/utils/recovery_qr_utils.dart'; +import 'package:studyu_app/util/recovery_qr_utils.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -36,6 +36,205 @@ class _SettingsState extends State { subject = context.read().activeSubject; } + List> _buildDropdownItems(BuildContext context) { + final dropDownItems = >[]; + + for (final locale in AppLocalizations.supportedLocales) { + dropDownItems.add( + DropdownMenuItem( + value: locale, + child: Text(localeName(context, locale.languageCode)!), + ), + ); + } + + dropDownItems.add(const DropdownMenuItem(child: Text('System'))); + return dropDownItems; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // General section header + Text( + 'General', + style: theme.textTheme.titleMedium!.copyWith( + color: theme.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + + // Language card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.language, + style: theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + DropdownButton( + value: _selectedValue, + underline: const SizedBox(), + items: _buildDropdownItems(context), + onChanged: (value) { + setState(() { + _selectedValue = value; + }); + context.read().changeLanguage(value); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 8), + + // Analytics card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Text( + AppLocalizations.of(context)!.allow_analytics, + style: theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + Switch( + value: _analyticsValue!, + onChanged: (value) { + setState(() { + _analyticsValue = value; + }); + AppAnalytics.setEnabled(value); + }, + ), + ], + ), + ), + ), + const SizedBox(height: 8), + // Recovery phrase card + RecoveryPhraseWidget(), + const SizedBox(height: 24), + + // Current study section header + Text( + 'Current study', + style: theme.textTheme.titleMedium!.copyWith( + color: theme.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + + SizedBox( + width: double.infinity, + child: Card( + margin: const EdgeInsets.only(top: 12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + textAlign: TextAlign.center, + subject!.study.title ?? '', + style: theme.textTheme.bodyMedium, + ), + ), + ), + ), + + const SizedBox(height: 24), + + //wrap buttons to fill the width for mobile phones but for web fixed width + Text( + textAlign: TextAlign.start, + 'Participation options', + style: theme.textTheme.titleMedium!.copyWith( + color: theme.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + Align( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width < 600 + ? double.infinity + : 400, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 12), + // Leave study button + FilledButton.icon( + icon: Icon(MdiIcons.exitToApp), + label: Text(AppLocalizations.of(context)!.opt_out), + style: FilledButton.styleFrom( + backgroundColor: Colors.red[700], + foregroundColor: Colors.white, + side: BorderSide(color: Colors.red[700]!), + ), + onPressed: () { + showDialog( + context: context, + builder: (_) => OptOutAlertDialog(subject: subject), + ); + }, + ), + const SizedBox(height: 12), + + // Leave and delete button + FilledButton.tonalIcon( + icon: const Icon(Icons.delete), + label: Text(AppLocalizations.of(context)!.delete_data), + style: FilledButton.styleFrom( + backgroundColor: Colors.pink[50], + foregroundColor: Colors.red[900], + ), + onPressed: () { + showDialog( + context: context, + builder: (_) => DeleteAlertDialog(subject: subject), + ); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class RecoveryPhraseWidget extends StatefulWidget { + const RecoveryPhraseWidget({super.key}); + + @override + State createState() => _RecoveryPhraseWidgetState(); +} + +class _RecoveryPhraseWidgetState extends State { List get _phrase { final user = Supabase.instance.client.auth.currentUser; if (user == null) return []; @@ -65,37 +264,6 @@ class _SettingsState extends State { } } - Future _shareQr() async { - try { - await RecoveryQrUtils.shareRecoveryQr(_phrase); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); - } - } - } - - Future _downloadText() async { - try { - await RecoveryQrUtils.downloadRecoveryText(_phrase); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.file_save_error), - ), - ); - } - } - } - Future _downloadQr() async { try { await RecoveryQrUtils.downloadRecoveryQr(_phrase); @@ -115,273 +283,80 @@ class _SettingsState extends State { } } - Widget getDropdownRow(BuildContext context) { - final dropDownItems = >[]; - - for (final locale in AppLocalizations.supportedLocales) { - dropDownItems.add( - DropdownMenuItem( - value: locale, - child: Text(localeName(context, locale.languageCode)!), - ), - ); - } - - dropDownItems.add(const DropdownMenuItem(child: Text('System'))); - - return Column( - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('${AppLocalizations.of(context)!.language}:'), - const SizedBox(width: 5), - DropdownButton( - value: _selectedValue, - items: dropDownItems, - onChanged: (value) { - setState(() { - _selectedValue = value; - }); - context.read().changeLanguage(value); - }, - ), - ], - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text('${AppLocalizations.of(context)!.allow_analytics}: '), - Tooltip( - triggerMode: TooltipTriggerMode.tap, - showDuration: const Duration(milliseconds: 10000), - margin: const EdgeInsets.fromLTRB(30, 0, 30, 0), - message: AppLocalizations.of(context)!.allow_analytics_desc, - child: const Icon(Icons.info), - ), - const SizedBox(width: 5), - Switch( - value: _analyticsValue!, - onChanged: (value) { - setState(() { - _analyticsValue = value; - }); - AppAnalytics.setEnabled(value); - }, - ), - ], - ), - ], - ); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), - body: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - getDropdownRow(context), - const SizedBox(height: 32), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Card( - child: ExpansionTile( - leading: const Icon(Icons.key, color: Colors.blue), - title: Text( - AppLocalizations.of(context)!.recovery_phrase_header, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - AppLocalizations.of(context)!.recovery_phrase_save_hint, - style: theme.textTheme.bodySmall, - ), - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return Card( + child: ExpansionTile( + leading: Icon(Icons.key, color: theme.primaryColor), + title: Text( + AppLocalizations.of(context)!.recovery_phrase_header, + style: theme.textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w500, + ), + ), + subtitle: Text( + AppLocalizations.of(context)!.recovery_phrase_save_hint, + style: theme.textTheme.bodySmall, + ), + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: theme.colorScheme.surface), + child: Column( + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: _phrase + .map((word) => Chip(label: Text(word))) + .toList(), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Card( - color: Colors.amber.shade50, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon( - Icons.warning_amber, - color: Colors.amber.shade900, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - AppLocalizations.of( - context, - )!.recovery_security_warning, - style: TextStyle( - color: Colors.amber.shade900, - ), - ), - ), - ], - ), + FilledButton.icon( + icon: const Icon(Icons.copy), + onPressed: _copyToClipboard, + label: Text( + AppLocalizations.of(context)!.copy_to_clipboard, ), ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.grey.shade200, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: _phrase - .map((word) => Chip(label: Text(word))) - .toList(), - ), - const SizedBox(height: 16), - IconButton( - icon: const Icon(Icons.copy), - onPressed: _copyToClipboard, - tooltip: AppLocalizations.of( - context, - )!.copy_to_clipboard, - ), - ], + const SizedBox(width: 16), + FilledButton.tonalIcon( + style: FilledButton.styleFrom( + foregroundColor: theme.primaryColor, + backgroundColor: + theme.colorScheme.surfaceContainerHighest, ), + icon: const Icon(Icons.qr_code), + onPressed: _downloadQr, + label: Text("Download as QR"), ), - const SizedBox(height: 16), - Center( - child: RecoveryQrUtils.renderQrWidget( - RecoveryQrUtils.buildQrCode( - RecoveryQrUtils.generateDeepLink(_phrase), - ), - size: 200, - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () async { - await showMenu( - context: context, - position: RelativeRect.fill, - items: [ - PopupMenuItem( - onTap: _shareText, - child: Text( - AppLocalizations.of( - context, - )!.share_as_text, - ), - ), - PopupMenuItem( - onTap: _shareQr, - child: Text( - AppLocalizations.of( - context, - )!.share_as_qr, - ), - ), - ], - ); - }, - icon: const Icon(Icons.share), - label: Text( - AppLocalizations.of( - context, - )!.share_recovery, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: OutlinedButton.icon( - onPressed: () async { - await showMenu( - context: context, - position: RelativeRect.fill, - items: [ - PopupMenuItem( - onTap: _downloadText, - child: Text( - AppLocalizations.of( - context, - )!.download_as_text, - ), - ), - PopupMenuItem( - onTap: _downloadQr, - child: Text( - AppLocalizations.of( - context, - )!.download_as_qr, - ), - ), - ], - ); - }, - icon: const Icon(Icons.download), - label: Text( - AppLocalizations.of( - context, - )!.download_recovery, - ), - ), - ), - ], + const SizedBox(width: 16), + OutlinedButton.icon( + onPressed: () async { + _shareText(); + }, + icon: const Icon(Icons.share), + label: Text("Share Recovery Text"), ), ], ), - ), - ], + ], + ), ), - ), - ), - const SizedBox(height: 24), - Text( - '${AppLocalizations.of(context)!.study_current} ${subject!.study.title}', - style: theme.textTheme.titleLarge, - ), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: Icon(MdiIcons.exitToApp), - label: Text(AppLocalizations.of(context)!.opt_out), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.orange[800], - ), - onPressed: () { - showDialog( - context: context, - builder: (_) => OptOutAlertDialog(subject: subject), - ); - }, + ], ), - const SizedBox(height: 24), - ElevatedButton.icon( - icon: const Icon(Icons.delete), - label: Text(AppLocalizations.of(context)!.delete_data), - style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - onPressed: () { - showDialog( - context: context, - builder: (_) => DeleteAlertDialog(subject: subject), - ); - }, - ), - ], - ), + ), + ], ), ); } diff --git a/app/lib/screens/study/dashboard/task_overview_tab/task_box.dart b/app/lib/screens/study/dashboard/task_overview_tab/task_box.dart index 6c187080a..10e7e5a98 100644 --- a/app/lib/screens/study/dashboard/task_overview_tab/task_box.dart +++ b/app/lib/screens/study/dashboard/task_overview_tab/task_box.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/study/tasks/task_screen.dart'; -import 'package:studyu_app/theme.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; import 'package:studyu_app/widgets/round_checkbox.dart'; import 'package:studyu_core/core.dart'; @@ -40,6 +39,7 @@ class _TaskBoxState extends State { @override Widget build(BuildContext context) { + final theme = Theme.of(context); final completed = context .watch() .activeSubject! diff --git a/app/lib/utils/recovery_qr_utils.dart b/app/lib/util/recovery_qr_utils.dart similarity index 92% rename from app/lib/utils/recovery_qr_utils.dart rename to app/lib/util/recovery_qr_utils.dart index 5cead784e..47ab81c3e 100644 --- a/app/lib/utils/recovery_qr_utils.dart +++ b/app/lib/util/recovery_qr_utils.dart @@ -133,9 +133,8 @@ class RecoveryQrUtils { static Future shareRecoveryText(List phrase) async { final text = phrase.join(' '); - await SharePlus.instance.share( - ShareParams(text: text, subject: 'StudyU Recovery Phrase'), - ); + final params = ShareParams(text: text, subject: 'StudyU Recovery Phrase'); + await SharePlus.instance.share(params); } static Future shareRecoveryQr(List phrase) async { @@ -150,9 +149,12 @@ class RecoveryQrUtils { mimeType: 'image/png', name: 'recovery_qr.png', ); - await SharePlus.instance.share( - ShareParams(files: [xFile], subject: 'StudyU Recovery QR Code'), + + final params = ShareParams( + files: [xFile], + subject: 'StudyU Recovery QR Code', ); + await SharePlus.instance.share(params); return; } @@ -160,9 +162,11 @@ class RecoveryQrUtils { final tempFile = File('${tempDir.path}/recovery_qr.png'); await tempFile.writeAsBytes(imageData); final xFile = XFile(tempFile.path); - await SharePlus.instance.share( - ShareParams(files: [xFile], subject: 'StudyU Recovery QR Code'), + final params = ShareParams( + files: [xFile], + subject: 'StudyU Recovery QR Code', ); + await SharePlus.instance.share(params); Future.delayed(const Duration(seconds: 5), () { if (tempFile.existsSync()) { @@ -233,8 +237,15 @@ class RecoveryQrUtils { String mimeType, ) async { final blob = html.Blob([bytes], mimeType); + final url = html.Url.createObjectUrlFromBlob(blob); + + final anchor = html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); html.Url.revokeObjectUrl(url); + + anchor.remove(); } } From b3055fe5461b60b5dc97613e1184d24e32fb5612 Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Thu, 4 Dec 2025 16:37:19 +0100 Subject: [PATCH 09/57] feat: Add localized strings for general settings, current study, participation options, and recovery actions in English and German --- app/lib/l10n/app_de.arb | 7 ++++- app/lib/l10n/app_en.arb | 7 ++++- app/lib/l10n/app_localizations.dart | 30 +++++++++++++++++++ app/lib/l10n/app_localizations_de.dart | 15 ++++++++++ app/lib/l10n/app_localizations_en.dart | 15 ++++++++++ .../qr_code_scanner_screen.dart | 1 - app/lib/screens/study/dashboard/settings.dart | 10 +++---- 7 files changed, 77 insertions(+), 8 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index ff22b9c1b..c346c6eb9 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -510,5 +510,10 @@ "share_btn": "Teilen", "copy_btn": "Kopieren", "download_btn": "Download", - "qr_code_btn": "QR-Code" + "qr_code_btn": "QR-Code", + "general_section": "Allgemein", + "current_study_section": "Aktuelle Studie", + "participation_options_section": "Teilnahmeoptionen", + "download_as_qr_btn": "Als QR herunterladen", + "share_recovery_text_btn": "Wiederherstellungstext teilen" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index f24a1a678..747bb9404 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -506,5 +506,10 @@ "share_btn": "Share", "copy_btn": "Copy", "download_btn": "Download", - "qr_code_btn": "QR Code" + "qr_code_btn": "QR Code", + "general_section": "General", + "current_study_section": "Current study", + "participation_options_section": "Participation options", + "download_as_qr_btn": "Download as QR", + "share_recovery_text_btn": "Share Recovery Text" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index cfc925e5f..125a6ccac 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2515,6 +2515,36 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'QR Code'** String get qr_code_btn; + + /// No description provided for @general_section. + /// + /// In en, this message translates to: + /// **'General'** + String get general_section; + + /// No description provided for @current_study_section. + /// + /// In en, this message translates to: + /// **'Current study'** + String get current_study_section; + + /// No description provided for @participation_options_section. + /// + /// In en, this message translates to: + /// **'Participation options'** + String get participation_options_section; + + /// No description provided for @download_as_qr_btn. + /// + /// In en, this message translates to: + /// **'Download as QR'** + String get download_as_qr_btn; + + /// No description provided for @share_recovery_text_btn. + /// + /// In en, this message translates to: + /// **'Share Recovery Text'** + String get share_recovery_text_btn; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 90ecb8a62..637876bd6 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1319,4 +1319,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get qr_code_btn => 'QR-Code'; + + @override + String get general_section => 'Allgemein'; + + @override + String get current_study_section => 'Aktuelle Studie'; + + @override + String get participation_options_section => 'Teilnahmeoptionen'; + + @override + String get download_as_qr_btn => 'Als QR herunterladen'; + + @override + String get share_recovery_text_btn => 'Wiederherstellungstext teilen'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index cbc231f52..1263a909b 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1303,4 +1303,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get qr_code_btn => 'QR Code'; + + @override + String get general_section => 'General'; + + @override + String get current_study_section => 'Current study'; + + @override + String get participation_options_section => 'Participation options'; + + @override + String get download_as_qr_btn => 'Download as QR'; + + @override + String get share_recovery_text_btn => 'Share Recovery Text'; } diff --git a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart index 15c847456..bacb56940 100644 --- a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart +++ b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/theme.dart'; import 'package:studyu_app/util/recovery_qr_utils.dart'; class QrCodeScannerScreen extends StatefulWidget { diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 4aec4b13e..a0412c7c9 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -65,7 +65,7 @@ class _SettingsState extends State { children: [ // General section header Text( - 'General', + AppLocalizations.of(context)!.general_section, style: theme.textTheme.titleMedium!.copyWith( color: theme.primaryColor, fontWeight: FontWeight.w600, @@ -138,7 +138,7 @@ class _SettingsState extends State { // Current study section header Text( - 'Current study', + AppLocalizations.of(context)!.current_study_section, style: theme.textTheme.titleMedium!.copyWith( color: theme.primaryColor, fontWeight: FontWeight.w600, @@ -165,7 +165,7 @@ class _SettingsState extends State { //wrap buttons to fill the width for mobile phones but for web fixed width Text( textAlign: TextAlign.start, - 'Participation options', + AppLocalizations.of(context)!.participation_options_section, style: theme.textTheme.titleMedium!.copyWith( color: theme.primaryColor, fontWeight: FontWeight.w600, @@ -338,7 +338,7 @@ class _RecoveryPhraseWidgetState extends State { ), icon: const Icon(Icons.qr_code), onPressed: _downloadQr, - label: Text("Download as QR"), + label: Text(AppLocalizations.of(context)!.download_as_qr_btn), ), const SizedBox(width: 16), OutlinedButton.icon( @@ -346,7 +346,7 @@ class _RecoveryPhraseWidgetState extends State { _shareText(); }, icon: const Icon(Icons.share), - label: Text("Share Recovery Text"), + label: Text(AppLocalizations.of(context)!.share_recovery_text_btn), ), ], ), From 71f4a53b2aa53bfb5fef30bb037ad835aa87031a Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Thu, 4 Dec 2025 16:37:24 +0100 Subject: [PATCH 10/57] refactor: Improve formatting of button labels in recovery phrase widget for better readability --- app/lib/screens/study/dashboard/settings.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index a0412c7c9..4ef2b7af2 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -338,7 +338,9 @@ class _RecoveryPhraseWidgetState extends State { ), icon: const Icon(Icons.qr_code), onPressed: _downloadQr, - label: Text(AppLocalizations.of(context)!.download_as_qr_btn), + label: Text( + AppLocalizations.of(context)!.download_as_qr_btn, + ), ), const SizedBox(width: 16), OutlinedButton.icon( @@ -346,7 +348,11 @@ class _RecoveryPhraseWidgetState extends State { _shareText(); }, icon: const Icon(Icons.share), - label: Text(AppLocalizations.of(context)!.share_recovery_text_btn), + label: Text( + AppLocalizations.of( + context, + )!.share_recovery_text_btn, + ), ), ], ), From 5bf9504494d02862e2d665b44dddb7c3b6b2ee75 Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Thu, 4 Dec 2025 16:43:23 +0100 Subject: [PATCH 11/57] feat: Update recovery phrase save hint for improved clarity in English and German localizations --- app/lib/l10n/app_de.arb | 2 +- app/lib/l10n/app_en.arb | 2 +- app/lib/l10n/app_localizations.dart | 2 +- app/lib/l10n/app_localizations_de.dart | 2 +- app/lib/l10n/app_localizations_en.dart | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index c346c6eb9..4ca65a6b2 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -464,7 +464,7 @@ "recovery_phrase_purpose_header": "Du benötigst diese Phrase um:", "recovery_phrase_purpose_1": "✓ Auf einem anderen Gerät fortzufahren", "recovery_phrase_purpose_2": "✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen", - "recovery_phrase_save_hint": "Speichere sie jetzt – du findest sie später auch in den Einstellungen.", + "recovery_phrase_save_hint": "Speichere sie jetzt an einem sicheren Ort.", "recovery_phrase_saved_confirmation": "Ich habe meine Wiederherstellungsphrase sicher gespeichert.", "continue_to_study": "Weiter zur Studie", "rejoin_study": "Studie wieder beitreten", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 747bb9404..6cd358810 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -460,7 +460,7 @@ "recovery_phrase_purpose_header": "You'll need this phrase to:", "recovery_phrase_purpose_1": "✓ Continue on a different phone", "recovery_phrase_purpose_2": "✓ Restore your progress after reinstalling", - "recovery_phrase_save_hint": "Save it now—you can also find it later in Settings.", + "recovery_phrase_save_hint": "Save it now in a safe place you won't lose.", "recovery_phrase_saved_confirmation": "I saved my recovery phrase somewhere I can find it later.", "continue_to_study": "Continue to study", "rejoin_study": "Rejoin Study", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 125a6ccac..e2f1c5a55 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2297,7 +2297,7 @@ abstract class AppLocalizations { /// No description provided for @recovery_phrase_save_hint. /// /// In en, this message translates to: - /// **'Save it now—you can also find it later in Settings.'** + /// **'Save it now in a safe place you won\'t lose.'** String get recovery_phrase_save_hint; /// No description provided for @recovery_phrase_saved_confirmation. diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 637876bd6..2a0e1f6b2 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1193,7 +1193,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Speichere sie jetzt – du findest sie später auch in den Einstellungen.'; + 'Speichere sie jetzt an einem sicheren Ort.'; @override String get recovery_phrase_saved_confirmation => diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 1263a909b..abdc36008 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1181,7 +1181,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Save it now—you can also find it later in Settings.'; + 'Save it now in a safe place you won\'t lose.'; @override String get recovery_phrase_saved_confirmation => From 48248a929a3ed27a58ec82c3af174de6fc9b0966 Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Thu, 4 Dec 2025 16:43:41 +0100 Subject: [PATCH 12/57] fix: Correctly instantiate RecoveryPhraseWidget with const for performance optimization --- app/lib/screens/study/dashboard/settings.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 4ef2b7af2..54451db14 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -10,8 +10,8 @@ import 'package:studyu_app/routes.dart'; import 'package:studyu_app/util/app_analytics.dart'; import 'package:studyu_app/util/fitbit_handler.dart'; import 'package:studyu_app/util/localization.dart'; -import 'package:studyu_app/util/schedule_notifications.dart'; import 'package:studyu_app/util/recovery_qr_utils.dart'; +import 'package:studyu_app/util/schedule_notifications.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -133,7 +133,7 @@ class _SettingsState extends State { ), const SizedBox(height: 8), // Recovery phrase card - RecoveryPhraseWidget(), + const RecoveryPhraseWidget(), const SizedBox(height: 24), // Current study section header From b1863904d401133f7b0d2a1312fc0e2eec006db8 Mon Sep 17 00:00:00 2001 From: ibrahimozkan Date: Mon, 8 Dec 2025 20:29:25 +0100 Subject: [PATCH 13/57] feat: Implement secure account recovery with a dedicated recovery ID and refactor phrase display into a new widget. --- app/lib/l10n/app_de.arb | 11 +- app/lib/l10n/app_en.arb | 11 +- app/lib/l10n/app_localizations.dart | 12 + app/lib/l10n/app_localizations_de.dart | 9 + app/lib/l10n/app_localizations_en.dart | 8 + .../recovery_phrase_screen.dart | 262 +----------- app/lib/screens/study/dashboard/settings.dart | 119 +----- app/lib/services/rejoin_study_service.dart | 98 ++--- app/lib/widgets/recovery_phrase_content.dart | 381 ++++++++++++++++++ .../20251201155709_recover_account.sql | 101 ----- .../migrations/20251208_user_recovery.sql | 195 +++++++++ 11 files changed, 691 insertions(+), 516 deletions(-) create mode 100644 app/lib/widgets/recovery_phrase_content.dart delete mode 100644 supabase/migrations/20251201155709_recover_account.sql create mode 100644 supabase/migrations/20251208_user_recovery.sql diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 4ca65a6b2..c320c6908 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -515,5 +515,14 @@ "current_study_section": "Aktuelle Studie", "participation_options_section": "Teilnahmeoptionen", "download_as_qr_btn": "Als QR herunterladen", - "share_recovery_text_btn": "Wiederherstellungstext teilen" + "share_recovery_text_btn": "Wiederherstellungstext teilen", + "recovery_phrase_load_error": "Wiederherstellungsphrase konnte nicht geladen werden", + "share_error": "Fehler beim Teilen: {error}", + "@share_error": { + "placeholders": { + "error": { + "type": "String" + } + } + } } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 6cd358810..79ee85154 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -511,5 +511,14 @@ "current_study_section": "Current study", "participation_options_section": "Participation options", "download_as_qr_btn": "Download as QR", - "share_recovery_text_btn": "Share Recovery Text" + "share_recovery_text_btn": "Share Recovery Text", + "recovery_phrase_load_error": "Failed to load recovery phrase", + "share_error": "Error sharing: {error}", + "@share_error": { + "placeholders": { + "error": { + "type": "String" + } + } + } } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index e2f1c5a55..e1e99460b 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2545,6 +2545,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Share Recovery Text'** String get share_recovery_text_btn; + + /// No description provided for @recovery_phrase_load_error. + /// + /// In en, this message translates to: + /// **'Failed to load recovery phrase'** + String get recovery_phrase_load_error; + + /// No description provided for @share_error. + /// + /// In en, this message translates to: + /// **'Error sharing: {error}'** + String share_error(String error); } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 2a0e1f6b2..63b7e9bc2 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1334,4 +1334,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get share_recovery_text_btn => 'Wiederherstellungstext teilen'; + + @override + String get recovery_phrase_load_error => + 'Wiederherstellungsphrase konnte nicht geladen werden'; + + @override + String share_error(String error) { + return 'Fehler beim Teilen: $error'; + } } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index abdc36008..965d8c55c 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1318,4 +1318,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get share_recovery_text_btn => 'Share Recovery Text'; + + @override + String get recovery_phrase_load_error => 'Failed to load recovery phrase'; + + @override + String share_error(String error) { + return 'Error sharing: $error'; + } } diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index 4330cb3a5..e3a537c90 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -1,157 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/routes.dart'; -import 'package:studyu_app/util/recovery_qr_utils.dart'; -import 'package:studyu_core/core.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:uuid/uuid.dart'; +import 'package:studyu_app/widgets/recovery_phrase_content.dart'; class RecoveryPhraseScreen extends StatefulWidget { - final BigInt? userId; - - const RecoveryPhraseScreen({super.key, this.userId}); + const RecoveryPhraseScreen({super.key}); @override State createState() => _RecoveryPhraseScreenState(); } class _RecoveryPhraseScreenState extends State { - late List _phrase; bool _isChecked = false; - @override - void initState() { - super.initState(); - _generatePhrase(); - } - - void _generatePhrase() { - final user = Supabase.instance.client.auth.currentUser; - final id = - widget.userId ?? - (user != null - ? BigInt.parse(user.id.replaceAll('-', ''), radix: 16) - : BigInt.parse(const Uuid().v4().replaceAll('-', ''), radix: 16)); - - _phrase = encode(id); - } - - void _copyToClipboard() { - final text = _phrase.join(' '); - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.copied_to_clipboard), - ), - ); - } - - Future _shareText() async { - try { - await RecoveryQrUtils.shareRecoveryText(_phrase); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); - } - } - } - - Future _shareQr() async { - try { - await RecoveryQrUtils.shareRecoveryQr(_phrase); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); - } - } - } - - Future _downloadText() async { - try { - await RecoveryQrUtils.downloadRecoveryText(_phrase); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.file_save_error), - ), - ); - } - } - } - - Future _downloadQr() async { - try { - await RecoveryQrUtils.downloadRecoveryQr(_phrase); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.file_save_error), - ), - ); - } - } - } - - void _showQrCodeDialog() { - showDialog( - context: context, - builder: (context) => Dialog( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - AppLocalizations.of(context)!.qr_code_btn, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 24), - RecoveryQrUtils.renderQrWidget( - RecoveryQrUtils.buildQrCode( - RecoveryQrUtils.generateDeepLink(_phrase), - ), - size: 240, - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _ActionButton( - icon: Icons.share, - label: AppLocalizations.of(context)!.share_as_qr, - onTap: _shareQr, - ), - _ActionButton( - icon: Icons.download, - label: AppLocalizations.of(context)!.download_as_qr, - onTap: _downloadQr, - ), - ], - ), - ], - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -193,9 +54,7 @@ class _RecoveryPhraseScreenState extends State { ), ), const SizedBox(height: 32), - _buildPhraseGrid(), - const SizedBox(height: 32), - _buildActionButtons(), + const RecoveryPhraseContent(), ], ), ), @@ -209,79 +68,6 @@ class _RecoveryPhraseScreenState extends State { ); } - Widget _buildPhraseGrid() { - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 3.5, - crossAxisSpacing: 16, - mainAxisSpacing: 16, - ), - itemCount: _phrase.length, - itemBuilder: (context, index) { - return Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor.withValues(alpha: 0.2), - ), - ), - ), - alignment: Alignment.centerLeft, - padding: const EdgeInsets.symmetric(horizontal: 8), - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: '${index + 1}. ', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - ), - ), - TextSpan( - text: _phrase[index], - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), - ), - ], - ), - ), - ); - }, - ); - } - - Widget _buildActionButtons() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _ActionButton( - icon: Icons.share_outlined, - label: AppLocalizations.of(context)!.share_btn, - onTap: _shareText, - ), - _ActionButton( - icon: Icons.copy_outlined, - label: AppLocalizations.of(context)!.copy_btn, - onTap: _copyToClipboard, - ), - _ActionButton( - icon: Icons.download_outlined, - label: AppLocalizations.of(context)!.download_btn, - onTap: _downloadText, - ), - _ActionButton( - icon: Icons.qr_code_2, - label: AppLocalizations.of(context)!.qr_code_btn, - onTap: _showQrCodeDialog, - ), - ], - ); - } - Widget _buildBottomSection() { return Container( padding: const EdgeInsets.all(24.0), @@ -333,45 +119,3 @@ class _RecoveryPhraseScreenState extends State { ); } } - -class _ActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - - const _ActionButton({ - required this.icon, - required this.label, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(30), - child: Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primaryContainer.withValues(alpha: 0.5), - shape: BoxShape.circle, - ), - child: Icon(icon, color: Theme.of(context).colorScheme.primary), - ), - ), - const SizedBox(height: 8), - Text( - label, - style: Theme.of(context).textTheme.labelSmall, - textAlign: TextAlign.center, - ), - ], - ); - } -} diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 54451db14..7023180bb 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -1,7 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:provider/provider.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; @@ -10,11 +9,10 @@ import 'package:studyu_app/routes.dart'; import 'package:studyu_app/util/app_analytics.dart'; import 'package:studyu_app/util/fitbit_handler.dart'; import 'package:studyu_app/util/localization.dart'; -import 'package:studyu_app/util/recovery_qr_utils.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; +import 'package:studyu_app/widgets/recovery_phrase_content.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; -import 'package:supabase_flutter/supabase_flutter.dart'; class Settings extends StatefulWidget { const Settings({super.key}); @@ -235,51 +233,12 @@ class RecoveryPhraseWidget extends StatefulWidget { } class _RecoveryPhraseWidgetState extends State { - List get _phrase { - final user = Supabase.instance.client.auth.currentUser; - if (user == null) return []; - final id = BigInt.parse(user.id.replaceAll('-', ''), radix: 16); - return encode(id); - } - - void _copyToClipboard() { - final text = _phrase.join(' '); - Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.copied_to_clipboard), - ), - ); - } + final GlobalKey _contentKey = GlobalKey(); + bool _hasLoaded = false; - Future _shareText() async { - try { - await RecoveryQrUtils.shareRecoveryText(_phrase); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text('Error: $e'))); - } - } - } - - Future _downloadQr() async { - try { - await RecoveryQrUtils.downloadRecoveryQr(_phrase); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.file_save_error), - ), - ); - } + void _onExpansionChanged(bool expanded) { + if (expanded && !_hasLoaded) { + _hasLoaded = true; } } @@ -299,67 +258,17 @@ class _RecoveryPhraseWidgetState extends State { AppLocalizations.of(context)!.recovery_phrase_save_hint, style: theme.textTheme.bodySmall, ), + onExpansionChanged: _onExpansionChanged, children: [ Padding( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: theme.colorScheme.surface), - child: Column( - children: [ - Wrap( - spacing: 8, - runSpacing: 8, - alignment: WrapAlignment.center, - children: _phrase - .map((word) => Chip(label: Text(word))) - .toList(), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - FilledButton.icon( - icon: const Icon(Icons.copy), - onPressed: _copyToClipboard, - label: Text( - AppLocalizations.of(context)!.copy_to_clipboard, - ), - ), - const SizedBox(width: 16), - FilledButton.tonalIcon( - style: FilledButton.styleFrom( - foregroundColor: theme.primaryColor, - backgroundColor: - theme.colorScheme.surfaceContainerHighest, - ), - icon: const Icon(Icons.qr_code), - onPressed: _downloadQr, - label: Text( - AppLocalizations.of(context)!.download_as_qr_btn, - ), - ), - const SizedBox(width: 16), - OutlinedButton.icon( - onPressed: () async { - _shareText(); - }, - icon: const Icon(Icons.share), - label: Text( - AppLocalizations.of( - context, - )!.share_recovery_text_btn, - ), - ), - ], - ), - ], - ), - ), - ], + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: theme.colorScheme.surface), + child: RecoveryPhraseContent( + key: _contentKey, + useGridLayout: false, + ), ), ), ], diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/rejoin_study_service.dart index abf551835..ad6d97f88 100644 --- a/app/lib/services/rejoin_study_service.dart +++ b/app/lib/services/rejoin_study_service.dart @@ -2,7 +2,6 @@ import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -/// Model for recovery account response from Supabase RPC function class RecoveryResult { final bool success; final String? email; @@ -29,12 +28,46 @@ class RecoveryResult { } } -/// Service class for handling account recovery and study rejoining logic class RejoinStudyService { - /// Validates and decodes a recovery phrase to a user ID - /// - /// Tries English wordlist first, then German wordlist - /// Returns the decoded user ID or throws an exception if invalid + static List? _cachedPhrase; + static String? _cachedRecoveryId; + + static void clearCache() { + _cachedPhrase = null; + _cachedRecoveryId = null; + } + + static Future?> getRecoveryPhrase() async { + if (_cachedPhrase != null) return _cachedPhrase; + + final recoveryId = await getOrCreateRecoveryId(); + if (recoveryId == null) return null; + + final id = BigInt.parse(recoveryId.replaceAll('-', ''), radix: 16); + return _cachedPhrase = encode(id); + } + + static Future getOrCreateRecoveryId() async { + if (_cachedRecoveryId != null) return _cachedRecoveryId; + + try { + final response = await Supabase.instance.client.rpc( + 'get_or_create_recovery', + ); + + if (response is Map && response['success'] == true) { + return _cachedRecoveryId = response['recovery_id'] as String?; + } else { + final error = response is Map ? response['error'] : 'Unknown error'; + StudyULogger.warning('Failed to get recovery_id: $error'); + return null; + } + } catch (e) { + StudyULogger.warning('Error getting recovery_id: $e'); + return null; + } + } + static BigInt decodeRecoveryPhrase(List words) { try { return decode(words, wordlist: wordlistEn); @@ -43,12 +76,8 @@ class RejoinStudyService { } } - /// Converts BigInt user ID to UUID string format - /// - /// Example: BigInt(0x550e8400e29b41d4a716446655440000) - /// -> "550e8400-e29b-41d4-a716-446655440000" - static String convertBigIntToUuid(BigInt userId) { - final hexString = userId.toRadixString(16).padLeft(32, '0'); + static String convertBigIntToUuid(BigInt id) { + final hexString = id.toRadixString(16).padLeft(32, '0'); return '${hexString.substring(0, 8)}-' '${hexString.substring(8, 12)}-' '${hexString.substring(12, 16)}-' @@ -56,67 +85,41 @@ class RejoinStudyService { '${hexString.substring(20, 32)}'; } - /// Calls Supabase RPC function to recover account - /// - /// Takes user UUID and returns recovery result with credentials and - /// optional subject ID - static Future recoverAccount(BigInt userId) async { + static Future recoverAccount(BigInt recoveryId) async { try { - final uuidString = convertBigIntToUuid(userId); + final uuidString = convertBigIntToUuid(recoveryId); final response = await Supabase.instance.client.rpc( 'recover_account', - params: {'p_user_id': uuidString}, + params: {'p_recovery_id': uuidString}, ); if (response is Map) { return RecoveryResult.fromJson(response); } else { StudyULogger.warning('Unexpected response format: $response'); - return RecoveryResult( - success: false, - error: 'recovery_failed', // Will be translated by UI - ); + return RecoveryResult(success: false, error: 'recovery_failed'); } } catch (e) { StudyULogger.warning('RPC call failed: $e'); - return RecoveryResult( - success: false, - error: 'recovery_network_error', // Will be translated by UI - ); + return RecoveryResult(success: false, error: 'recovery_network_error'); } } - /// Validates that a subject is not deleted (user hasn't dropped from study) - /// - /// Returns true if subject is valid (not deleted), false otherwise static Future validateSubject(String subjectId) async { try { final subject = await SupabaseQuery.getById( subjectId, selectedColumns: ['*'], ); - - // Check if user has dropped from the study return !subject.isDeleted; } catch (e) { - // If we can't fetch the subject, assume invalid return false; } } - /// Orchestrates the complete recovery flow - /// - /// Steps: - /// 1. Call recovery RPC function - /// 2. Store new credentials - /// 3. Sign in with new credentials - /// 4. Validate subject (check is_deleted flag) - /// 5. Store active subject ID if found and valid - /// - /// Returns RecoveryResult indicating success or failure - static Future performRecovery(BigInt userId) async { + static Future performRecovery(BigInt recoveryId) async { try { - final result = await recoverAccount(userId); + final result = await recoverAccount(recoveryId); if (!result.success) { return result; @@ -127,10 +130,7 @@ class RejoinStudyService { final signInResult = await signInParticipant(); if (!signInResult) { StudyULogger.warning('Sign in failed after recovery'); - return RecoveryResult( - success: false, - error: 'recovery_failed', // Will be translated by UI - ); + return RecoveryResult(success: false, error: 'recovery_failed'); } if (result.subjectId != null) { diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart new file mode 100644 index 000000000..144f43412 --- /dev/null +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_app/util/recovery_qr_utils.dart'; + +class RecoveryPhraseContent extends StatefulWidget { + final bool useGridLayout; + final bool showConfirmation; + final ValueChanged? onConfirmationChanged; + + const RecoveryPhraseContent({ + super.key, + this.useGridLayout = true, + this.showConfirmation = false, + this.onConfirmationChanged, + }); + + @override + State createState() => RecoveryPhraseContentState(); +} + +class RecoveryPhraseContentState extends State { + List? _phrase; + bool _isLoading = true; + String? _error; + + List? get phrase => _phrase; + bool get isLoading => _isLoading; + bool get hasError => _error != null; + + @override + void initState() { + super.initState(); + loadPhrase(); + } + + Future loadPhrase() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final phrase = await RejoinStudyService.getRecoveryPhrase(); + if (mounted) { + setState(() { + _phrase = phrase; + _isLoading = false; + _error = phrase == null + ? AppLocalizations.of(context)!.recovery_phrase_load_error + : null; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _isLoading = false; + _error = AppLocalizations.of(context)!.share_error(e.toString()); + }); + } + } + } + + void _copyToClipboard() { + if (_phrase == null) return; + final text = _phrase!.join(' '); + Clipboard.setData(ClipboardData(text: text)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.copied_to_clipboard), + ), + ); + } + + Future _shareText() async { + if (_phrase == null) return; + try { + await RecoveryQrUtils.shareRecoveryText(_phrase!); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.share_error(e.toString()), + ), + ), + ); + } + } + } + + Future _shareQr() async { + if (_phrase == null) return; + try { + await RecoveryQrUtils.shareRecoveryQr(_phrase!); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context)!.share_error(e.toString()), + ), + ), + ); + } + } + } + + Future _downloadText() async { + if (_phrase == null) return; + try { + await RecoveryQrUtils.downloadRecoveryText(_phrase!); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.file_save_error), + ), + ); + } + } + } + + Future _downloadQr() async { + if (_phrase == null) return; + try { + await RecoveryQrUtils.downloadRecoveryQr(_phrase!); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.file_save_error), + ), + ); + } + } + } + + void _showQrCodeDialog() { + if (_phrase == null) return; + showDialog( + context: context, + builder: (context) => Dialog( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + AppLocalizations.of(context)!.qr_code_btn, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 24), + RecoveryQrUtils.renderQrWidget( + RecoveryQrUtils.buildQrCode( + RecoveryQrUtils.generateDeepLink(_phrase!), + ), + size: 240, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ActionButton( + icon: Icons.share, + label: AppLocalizations.of(context)!.share_as_qr, + onTap: _shareQr, + ), + _ActionButton( + icon: Icons.download, + label: AppLocalizations.of(context)!.download_as_qr, + onTap: _downloadQr, + ), + ], + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: CircularProgressIndicator(), + ), + ); + } + + if (_error != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24.0), + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + textAlign: TextAlign.center, + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.useGridLayout) _buildPhraseGrid() else _buildPhraseChips(), + const SizedBox(height: 24), + _buildActionButtons(), + ], + ); + } + + Widget _buildPhraseGrid() { + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 3.5, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + ), + itemCount: _phrase?.length ?? 0, + itemBuilder: (context, index) { + return Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Theme.of(context).dividerColor.withValues(alpha: 0.2), + ), + ), + ), + alignment: Alignment.centerLeft, + padding: const EdgeInsets.symmetric(horizontal: 8), + child: RichText( + text: TextSpan( + children: [ + TextSpan( + text: '${index + 1}. ', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).disabledColor, + ), + ), + TextSpan( + text: _phrase![index], + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ), + ], + ), + ), + ); + }, + ); + } + + Widget _buildPhraseChips() { + return Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: _phrase!.map((word) => Chip(label: Text(word))).toList(), + ); + } + + Widget _buildActionButtons() { + if (widget.useGridLayout) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _ActionButton( + icon: Icons.share_outlined, + label: AppLocalizations.of(context)!.share_btn, + onTap: _shareText, + ), + _ActionButton( + icon: Icons.copy_outlined, + label: AppLocalizations.of(context)!.copy_btn, + onTap: _copyToClipboard, + ), + _ActionButton( + icon: Icons.download_outlined, + label: AppLocalizations.of(context)!.download_btn, + onTap: _downloadText, + ), + _ActionButton( + icon: Icons.qr_code_2, + label: AppLocalizations.of(context)!.qr_code_btn, + onTap: _showQrCodeDialog, + ), + ], + ); + } else { + final theme = Theme.of(context); + return Wrap( + spacing: 16, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + FilledButton.icon( + icon: const Icon(Icons.copy), + onPressed: _copyToClipboard, + label: Text(AppLocalizations.of(context)!.copy_to_clipboard), + ), + FilledButton.tonalIcon( + style: FilledButton.styleFrom( + foregroundColor: theme.primaryColor, + backgroundColor: theme.colorScheme.surfaceContainerHighest, + ), + icon: const Icon(Icons.qr_code), + onPressed: _downloadQr, + label: Text(AppLocalizations.of(context)!.download_as_qr_btn), + ), + OutlinedButton.icon( + onPressed: _shareText, + icon: const Icon(Icons.share), + label: Text(AppLocalizations.of(context)!.share_recovery_text_btn), + ), + ], + ); + } + } +} + +class _ActionButton extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(30), + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.5), + shape: BoxShape.circle, + ), + child: Icon(icon, color: Theme.of(context).colorScheme.primary), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: Theme.of(context).textTheme.labelSmall, + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/supabase/migrations/20251201155709_recover_account.sql b/supabase/migrations/20251201155709_recover_account.sql deleted file mode 100644 index db81cf3f6..000000000 --- a/supabase/migrations/20251201155709_recover_account.sql +++ /dev/null @@ -1,101 +0,0 @@ -BEGIN; - -/* - * This function implements account recovery for users who have lost access to their account. - * It takes a user ID, generates a new password, finds their latest active study, and returns - * the credentials needed to log back in. - * - * @param p_user_id - The UUID of the user to recover (decoded from 13-word recovery phrase) - * - * @returns jsonb - { - * "success": true/false, - * "email": "user-email@domain.com", - * "password": "newly-generated-password", - * "subject_id": "study-subject-uuid-or-null", - * "error": "error-message-if-failed" - * } - */ -CREATE OR REPLACE FUNCTION public.recover_account( - p_user_id uuid -) RETURNS jsonb -LANGUAGE plpgsql -SECURITY DEFINER -SET search_path = '' -AS $$ -DECLARE - v_user_email text; - v_new_password text; - v_encrypted_password text; - v_subject_id uuid; - v_latest_progress_date timestamptz; -BEGIN - -- 1. Validate user exists in auth.users - SELECT email INTO v_user_email - FROM auth.users - WHERE id = p_user_id; - - IF v_user_email IS NULL THEN - RETURN jsonb_build_object( - 'success', false, - 'error', 'User not found' - ); - END IF; - - -- 2. Generate new random password - v_new_password := gen_random_uuid()::text; - v_encrypted_password := extensions.crypt(v_new_password, extensions.gen_salt('bf', 12)); - - -- 3. Update user's password in auth.users - UPDATE auth.users - SET - encrypted_password = v_encrypted_password, - updated_at = now() at time zone 'utc' - WHERE id = p_user_id; - - -- 4. Find latest active study subject - -- Priority: most recent progress > most recent start date > deterministic ID ordering - SELECT - ss.id, - MAX(sp.completed_at) as latest_progress - INTO v_subject_id, v_latest_progress_date - FROM public.study_subject ss - LEFT JOIN public.subject_progress sp ON sp.subject_id = ss.id - WHERE - ss.user_id = p_user_id - AND ss.is_deleted = false - AND ss.started_at IS NOT NULL - GROUP BY ss.id - ORDER BY - MAX(sp.completed_at) DESC NULLS LAST, -- Most recent progress first - ss.started_at DESC, -- Most recent start date as tiebreaker - ss.id DESC -- Deterministic ordering for identical dates - LIMIT 1; - - -- 5. Return success with credentials and optional subject_id - RETURN jsonb_build_object( - 'success', true, - 'email', v_user_email, - 'password', v_new_password, - 'subject_id', v_subject_id -- Will be null if no active studies found - ); - -EXCEPTION - WHEN OTHERS THEN - -- Log error and return failure - RAISE WARNING 'Error in recover_account for user %: %', p_user_id, SQLERRM; - RETURN jsonb_build_object( - 'success', false, - 'error', 'Recovery failed: ' || SQLERRM - ); -END; -$$; - --- Grant execute permission to anon and authenticated roles --- (anon is required since users call this before authentication) -GRANT EXECUTE ON FUNCTION public.recover_account(uuid) TO anon, authenticated; - --- Add comment for documentation -COMMENT ON FUNCTION public.recover_account(uuid) IS -'Recovers user account by generating new password and finding latest active study. Used for 13-word recovery phrase feature.'; - -COMMIT; diff --git a/supabase/migrations/20251208_user_recovery.sql b/supabase/migrations/20251208_user_recovery.sql new file mode 100644 index 000000000..99543dbb2 --- /dev/null +++ b/supabase/migrations/20251208_user_recovery.sql @@ -0,0 +1,195 @@ +BEGIN; + +/* + * User Recovery Table Migration + * + * Creates a separate table for recovery IDs to decouple recovery phrases from user IDs. + * This prevents potential session hijacking if user IDs are accidentally shared. + */ + +-- Create the user_recovery table +CREATE TABLE IF NOT EXISTS public.user_recovery ( + recovery_id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + user_id uuid NOT NULL UNIQUE REFERENCES public.user(id) ON DELETE CASCADE, + created_at timestamptz DEFAULT now() NOT NULL +); + +ALTER TABLE public.user_recovery OWNER TO postgres; + +COMMENT ON TABLE public.user_recovery IS 'Maps recovery IDs to users for secure account recovery. Recovery phrases are derived from recovery_id, not user_id.'; + +-- Enable RLS +ALTER TABLE public.user_recovery ENABLE ROW LEVEL SECURITY; + +-- Users can only see their own recovery entry +CREATE POLICY "Users can view their own recovery entry" + ON public.user_recovery + FOR SELECT + TO authenticated + USING (user_id = auth.uid()); + +/* + * Function to get or create a recovery ID for the current user. + * Called when user accesses the settings/recovery phrase screen. + * + * @returns jsonb - { + * "recovery_id": "uuid-string", + * "created": true/false (whether a new entry was created) + * } + */ +CREATE OR REPLACE FUNCTION public.get_or_create_recovery() +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid; + v_recovery_id uuid; + v_created boolean := false; +BEGIN + -- Get current user ID + v_user_id := auth.uid(); + + IF v_user_id IS NULL THEN + RETURN jsonb_build_object( + 'success', false, + 'error', 'Not authenticated' + ); + END IF; + + -- Try to get existing recovery_id + SELECT recovery_id INTO v_recovery_id + FROM public.user_recovery + WHERE user_id = v_user_id; + + -- If not found, create one + IF v_recovery_id IS NULL THEN + INSERT INTO public.user_recovery (user_id) + VALUES (v_user_id) + ON CONFLICT (user_id) DO UPDATE SET user_id = EXCLUDED.user_id + RETURNING recovery_id INTO v_recovery_id; + v_created := true; + END IF; + + RETURN jsonb_build_object( + 'success', true, + 'recovery_id', v_recovery_id, + 'created', v_created + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_or_create_recovery() TO authenticated; + +COMMENT ON FUNCTION public.get_or_create_recovery() IS 'Gets existing recovery_id or creates one for the current authenticated user.'; + +/* + * Updated account recovery function that uses recovery_id instead of user_id. + * + * @param p_recovery_id - The UUID of the recovery entry (decoded from 13-word recovery phrase) + * + * @returns jsonb - { + * "success": true/false, + * "email": "user-email@domain.com", + * "password": "newly-generated-password", + * "subject_id": "study-subject-uuid-or-null", + * "error": "error-message-if-failed" + * } + */ +CREATE OR REPLACE FUNCTION public.recover_account( + p_recovery_id uuid +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid; + v_user_email text; + v_new_password text; + v_encrypted_password text; + v_subject_id uuid; + v_latest_progress_date timestamptz; +BEGIN + -- 1. Look up user_id from recovery table + SELECT user_id INTO v_user_id + FROM public.user_recovery + WHERE recovery_id = p_recovery_id; + + IF v_user_id IS NULL THEN + RETURN jsonb_build_object( + 'success', false, + 'error', 'Recovery ID not found' + ); + END IF; + + -- 2. Validate user exists in auth.users + SELECT email INTO v_user_email + FROM auth.users + WHERE id = v_user_id; + + IF v_user_email IS NULL THEN + RETURN jsonb_build_object( + 'success', false, + 'error', 'User not found' + ); + END IF; + + -- 3. Generate new random password + v_new_password := gen_random_uuid()::text; + v_encrypted_password := extensions.crypt(v_new_password, extensions.gen_salt('bf', 12)); + + -- 4. Update user's password in auth.users + UPDATE auth.users + SET + encrypted_password = v_encrypted_password, + updated_at = now() at time zone 'utc' + WHERE id = v_user_id; + + -- 5. Find latest active study subject + -- Priority: most recent progress > most recent start date > deterministic ID ordering + SELECT + ss.id, + MAX(sp.completed_at) as latest_progress + INTO v_subject_id, v_latest_progress_date + FROM public.study_subject ss + LEFT JOIN public.subject_progress sp ON sp.subject_id = ss.id + WHERE + ss.user_id = v_user_id + AND ss.is_deleted = false + AND ss.started_at IS NOT NULL + GROUP BY ss.id + ORDER BY + MAX(sp.completed_at) DESC NULLS LAST, -- Most recent progress first + ss.started_at DESC, -- Most recent start date as tiebreaker + ss.id DESC -- Deterministic ordering for identical dates + LIMIT 1; + + -- 6. Return success with credentials and optional subject_id + RETURN jsonb_build_object( + 'success', true, + 'email', v_user_email, + 'password', v_new_password, + 'subject_id', v_subject_id -- Will be null if no active studies found + ); + +EXCEPTION + WHEN OTHERS THEN + -- Log error and return failure + RAISE WARNING 'Error in recover_account for recovery_id %: %', p_recovery_id, SQLERRM; + RETURN jsonb_build_object( + 'success', false, + 'error', 'Recovery failed: ' || SQLERRM + ); +END; +$$; + +-- Grant execute permission to anon and authenticated roles +-- (anon is required since users call this before authentication) +GRANT EXECUTE ON FUNCTION public.recover_account(uuid) TO anon, authenticated; + +COMMENT ON FUNCTION public.recover_account(uuid) IS +'Recovers user account by looking up user via recovery_id, generating new password, and finding latest active study.'; + +COMMIT; From 0b659b28c98c74c1fbbf7030c3d50ace6aece2a5 Mon Sep 17 00:00:00 2001 From: Ibrahim Ozkan <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:14:56 +0100 Subject: [PATCH 14/57] feat: Enhance recovery flow with validation, constants, and integration tests --- .github/workflows/recovery_tests.yml | 162 +++++++++++ app/integration_test/recovery_flow_test.dart | 187 ++++++++++++ .../app_onboarding/rejoin_study_screen.dart | 10 +- app/lib/services/rejoin_study_service.dart | 79 ++++- app/lib/util/recovery_qr_utils.dart | 6 +- core/lib/core.dart | 1 + core/lib/src/env/env.dart | 3 + core/lib/src/util/recovery.dart | 51 +++- core/test/util/recovery_test.dart | 275 ++++++++++++++++++ .../localization/app_localizations_de.dart | 2 +- .../localization/app_localizations_en.dart | 2 +- flutter_common/lib/envs/.env | 1 + flutter_common/lib/envs/.env.dev | 1 + flutter_common/lib/envs/.env.local.example | 1 + flutter_common/lib/src/utils/env_loader.dart | 2 + 15 files changed, 764 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/recovery_tests.yml create mode 100644 app/integration_test/recovery_flow_test.dart create mode 100644 core/test/util/recovery_test.dart diff --git a/.github/workflows/recovery_tests.yml b/.github/workflows/recovery_tests.yml new file mode 100644 index 000000000..7677d5a56 --- /dev/null +++ b/.github/workflows/recovery_tests.yml @@ -0,0 +1,162 @@ +name: Recovery Tests + +on: + push: + paths: + - 'core/lib/src/util/recovery.dart' + - 'core/lib/src/util/wordlists.dart' + - 'core/test/util/recovery_test.dart' + - 'app/lib/services/rejoin_study_service.dart' + - 'app/lib/util/recovery_qr_utils.dart' + - 'app/integration_test/recovery_flow_test.dart' + - 'supabase/migrations/*user_recovery.sql' + - 'supabase/migrations/*recovery_rate_limiting.sql' + - '.github/workflows/recovery_tests.yml' + - '.github/workflows/init-workspace/**' + workflow_dispatch: + +env: + MELOS_SDK_PATH: 'auto' + +concurrency: + group: ${{ github.ref }}-recovery-tests + cancel-in-progress: true + +jobs: + unit_tests: + name: Recovery Unit Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Init workspace + uses: ./.github/workflows/init-workspace + + - name: Run Recovery Unit Tests + working-directory: ./core + run: flutter test test/util/recovery_test.dart --reporter=expanded + + integration_tests: + name: Recovery Integration Tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Init workspace + uses: ./.github/workflows/init-workspace + + - name: Setup ChromeDriver + uses: nanasess/setup-chromedriver@v2 + + - name: Enable web support + run: flutter config --enable-web + + - name: Prepare environment + run: | + dart pub get + dart pub global activate melos + melos bootstrap + + - name: Prepare Supabase CI Seed + run: | + cp supabase/seed-ci.sql supabase/seed.sql + + - name: Set up Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: latest + + - name: Start Supabase + run: | + supabase start -x studio,imgproxy + + - name: Configure local StudyU environment + run: | + cp flutter_common/lib/envs/.env.local.example flutter_common/lib/envs/.env.local + LC_ALL=C sed -i.bak '/STUDYU_SUPABASE_PUBLIC_ANON_KEY/d' flutter_common/lib/envs/.env.local + echo "STUDYU_SUPABASE_PUBLIC_ANON_KEY=$(supabase status -o env | grep ANON_KEY | cut -d'=' -f2)" >> flutter_common/lib/envs/.env.local + + - name: Start Chrome Driver + run: | + export DISPLAY=:99 + chromedriver --port=4444 & + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + + - name: Run Recovery Integration Tests + working-directory: ./app + run: | + flutter test integration_test/recovery_flow_test.dart \ + -d web-server \ + --dart-define="STUDYU_ENV=.env.local" + + sql_tests: + name: Recovery SQL Tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Supabase CLI + uses: supabase/setup-cli@v1 + with: + version: latest + + - name: Start Supabase + run: | + supabase start -x studio,imgproxy + + - name: Run Recovery Migrations + run: | + supabase db reset + + - name: Test Recovery Functions + run: | + # Test get_or_create_recovery function + supabase sql " + DO \$\$ + DECLARE + result jsonb; + BEGIN + -- Test that the function exists and returns proper structure + SELECT public.get_or_create_recovery() INTO result; + + IF result IS NULL THEN + RAISE EXCEPTION 'get_or_create_recovery returned NULL'; + END IF; + + IF NOT result ? 'success' THEN + RAISE EXCEPTION 'get_or_create_response missing success field'; + END IF; + END; + \$\$; + " + echo "✅ SQL function tests passed" + + - name: Test Rate Limiting Functions + run: | + # Test check_recovery_rate_limit function + supabase sql " + DO \$\$ + DECLARE + result jsonb; + BEGIN + -- Test rate limit check + result := public.check_recovery_rate_limit('127.0.0.1'::inet, NULL); + + IF NOT (result->>'allowed')::boolean THEN + RAISE EXCEPTION 'Rate limit should allow first attempt'; + END IF; + + -- Test that cleanup function exists + PERFORM public.cleanup_recovery_rate_limits(); + END; + \$\$; + " + echo "✅ Rate limiting tests passed" diff --git a/app/integration_test/recovery_flow_test.dart b/app/integration_test/recovery_flow_test.dart new file mode 100644 index 000000000..a08386907 --- /dev/null +++ b/app/integration_test/recovery_flow_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; +import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_app/util/recovery_qr_utils.dart'; +import 'package:studyu_app/widgets/recovery_phrase_content.dart'; +import 'package:studyu_core/core.dart'; + +/// Integration tests for the recovery flow +/// Run with: flutter test integration_test/recovery_flow_test.dart +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('Recovery Flow Integration Tests', () { + setUp(() async { + // Clear any cached recovery data before each test + RejoinStudyService.clearCache(); + }); + + testWidgets('Recovery phrase encoding and decoding roundtrip', + (tester) async { + // Test with a known ID + final testId = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + + // Encode to words + final words = encode(testId); + expect(words.length, equals(RecoveryConstants.totalWordCount)); + + // Decode the words + final decodedId = RejoinStudyService.decodeRecoveryPhrase(words); + expect(decodedId, equals(testId)); + }); + + testWidgets('Recovery phrase validation detects invalid word count', + (tester) async { + // Test with too few words + final shortWords = ['word1', 'word2', 'word3']; + expect( + () => RejoinStudyService.decodeRecoveryPhrase(shortWords), + throwsArgumentError, + ); + + // Test with too many words + final longWords = List.generate(20, (i) => 'word$i'); + expect( + () => RejoinStudyService.decodeRecoveryPhrase(longWords), + throwsArgumentError, + ); + }); + + testWidgets('Recovery phrase validation detects corrupted phrase', + (tester) async { + final testId = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(testId); + + // Corrupt one word + final corruptedWords = List.from(words); + corruptedWords[5] = 'differentword'; + + // Should throw due to checksum mismatch or invalid word + expect( + () => RejoinStudyService.decodeRecoveryPhrase(corruptedWords), + throwsA(isA()), + ); + }); + + testWidgets('UUID conversion works correctly', (tester) async { + final testId = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final uuid = RejoinStudyService.convertBigIntToUuid(testId); + + expect(uuid, isNotNull); + expect(uuid, matches( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + )); + + // Verify the UUID can be parsed back + final hexString = uuid!.replaceAll('-', ''); + final recoveredId = BigInt.parse(hexString, radix: 16); + expect(recoveredId, equals(testId)); + }); + + testWidgets('UUID conversion validates out of range values', (tester) async { + // Negative ID should return null + final negativeId = BigInt.from(-1); + final negativeUuid = RejoinStudyService.convertBigIntToUuid(negativeId); + expect(negativeUuid, isNull); + + // ID exceeding 128 bits should return null + final tooLargeId = BigInt.one << 128; + final largeUuid = RejoinStudyService.convertBigIntToUuid(tooLargeId); + expect(largeUuid, isNull); + }); + + testWidgets('RejoinStudyScreen renders correctly', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const RejoinStudyScreen(), + ), + ); + + await tester.pumpAndSettle(); + + // Verify screen elements are present + expect(find.byType(TextFormField), findsOneWidget); + expect(find.byType(FilledButton), findsOneWidget); + expect(find.byType(OutlinedButton), findsOneWidget); + }); + + testWidgets('RecoveryPhraseContent widget loads and displays phrase', + (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold( + body: RecoveryPhraseContent(), + ), + ), + ); + + // Initially shows loading indicator + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // Wait for async load + await tester.pumpAndSettle(const Duration(seconds: 2)); + + // Should either show error or the phrase content + final hasError = find.textContaining('Error').evaluate().isNotEmpty || + find.textContaining('Failed').evaluate().isNotEmpty; + final hasContent = find.byType(GridView).evaluate().isNotEmpty || + find.byType(Wrap).evaluate().isNotEmpty; + + expect(hasError || hasContent, isTrue); + }); + + testWidgets('Recovery QR utils generate valid deep links', (tester) async { + final testPhrase = [ + 'abandon', 'ability', 'able', 'about', 'above', 'absent', + 'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident', 'account' + ]; + + final deepLink = RecoveryQrUtils.generateDeepLink(testPhrase); + + // Verify deep link format + expect(deepLink, startsWith('https://')); + expect(deepLink, contains('/recover')); + expect(deepLink, contains('phrase=')); + + // Verify phrase can be parsed back + final parsedPhrase = RecoveryQrUtils.parseDeepLink(deepLink); + expect(parsedPhrase, isNotNull); + expect(parsedPhrase!.length, equals(RecoveryConstants.totalWordCount)); + + // Compare phrases (case-insensitive) + for (var i = 0; i < testPhrase.length; i++) { + expect( + parsedPhrase[i].toLowerCase(), + equals(testPhrase[i].toLowerCase()), + ); + } + }); + + testWidgets('Recovery QR utils reject invalid deep links', (tester) async { + // Invalid domain + final invalidDomain = RecoveryQrUtils.parseDeepLink( + 'https://invalid.domain/recover?phrase=test', + ); + expect(invalidDomain, isNull); + + // Missing phrase parameter + final missingPhrase = RecoveryQrUtils.parseDeepLink( + 'https://app.studyu.health/recover', + ); + expect(missingPhrase, isNull); + + // Invalid word count + final invalidCount = RecoveryQrUtils.parseDeepLink( + 'https://app.studyu.health/recover?phrase=one+two+three', + ); + expect(invalidCount, isNull); + }); + }); +} diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index 33b0938bd..3d82dd3bc 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -92,7 +92,7 @@ class _RejoinStudyScreenState extends State { if (_formKey.currentState!.validate()) { final words = _words.map((w) => w.trim().toLowerCase()).toList(); - if (words.length != 13) { + if (words.length != RecoveryConstants.totalWordCount) { setState(() { _errorMessage = AppLocalizations.of(context)!.invalid_recovery_phrase; }); @@ -262,16 +262,16 @@ class _RejoinStudyScreenState extends State { )!.enter_recovery_phrase, hintText: 'apple banana cherry ...', border: const OutlineInputBorder(), - helperText: '${_words.length}/13 words', + helperText: '${_words.length}/${RecoveryConstants.totalWordCount} words', helperStyle: TextStyle( - color: _words.length == 13 + color: _words.length == RecoveryConstants.totalWordCount ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: _words.length == 13 + fontWeight: _words.length == RecoveryConstants.totalWordCount ? FontWeight.bold : FontWeight.normal, ), - suffixIcon: _words.length == 13 + suffixIcon: _words.length == RecoveryConstants.totalWordCount ? Icon( Icons.check_circle, color: Theme.of(context).colorScheme.primary, diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/rejoin_study_service.dart index ad6d97f88..111db368a 100644 --- a/app/lib/services/rejoin_study_service.dart +++ b/app/lib/services/rejoin_study_service.dart @@ -43,8 +43,39 @@ class RejoinStudyService { final recoveryId = await getOrCreateRecoveryId(); if (recoveryId == null) return null; - final id = BigInt.parse(recoveryId.replaceAll('-', ''), radix: 16); - return _cachedPhrase = encode(id); + final sanitizedId = _sanitizeUuid(recoveryId); + if (sanitizedId == null) { + StudyULogger.warning('Invalid recovery ID format'); + return null; + } + + try { + final id = BigInt.parse(sanitizedId, radix: 16); + return _cachedPhrase = encode(id); + } on FormatException catch (e) { + StudyULogger.warning('Failed to parse recovery ID: $e'); + return null; + } + } + + /// Sanitizes a UUID string by removing hyphens and validating format + /// Returns null if the UUID format is invalid + static String? _sanitizeUuid(String uuid) { + // Remove all hyphens and convert to lowercase + final sanitized = uuid.replaceAll('-', '').toLowerCase().trim(); + + // UUID without hyphens should be exactly 32 hex characters + if (sanitized.length != 32) { + return null; + } + + // Validate hex characters only + final validHex = RegExp(r'^[0-9a-f]+$'); + if (!validHex.hasMatch(sanitized)) { + return null; + } + + return sanitized; } static Future getOrCreateRecoveryId() async { @@ -69,14 +100,42 @@ class RejoinStudyService { } static BigInt decodeRecoveryPhrase(List words) { + // Validate word count first + if (words.length != RecoveryConstants.totalWordCount) { + throw ArgumentError( + 'Expected ${RecoveryConstants.totalWordCount} words, got ${words.length}', + ); + } + + // Try English wordlist first try { - return decode(words, wordlist: wordlistEn); - } catch (e) { - return decode(words, wordlist: wordlistDe); + final enWords = words.map((w) => w.toLowerCase().trim()).toList(); + return decode(enWords, wordlist: wordlistEn); + } on ArgumentError catch (e) { + // Check if error is due to word not found in English list + final errorStr = e.toString(); + if (errorStr.contains('Invalid word') || + errorStr.contains('Checksum mismatch')) { + // Try German wordlist + try { + final deWords = words.map((w) => w.toLowerCase().trim()).toList(); + return decode(deWords, wordlist: wordlistDe); + } catch (_) { + // German also failed, throw original English error + rethrow; + } + } + rethrow; } } - static String convertBigIntToUuid(BigInt id) { + static String? convertBigIntToUuid(BigInt id) { + // Validate the ID fits within 128 bits + if (id < BigInt.zero || id > _max128BitValue) { + StudyULogger.warning('Recovery ID out of valid range'); + return null; + } + final hexString = id.toRadixString(16).padLeft(32, '0'); return '${hexString.substring(0, 8)}-' '${hexString.substring(8, 12)}-' @@ -85,9 +144,17 @@ class RejoinStudyService { '${hexString.substring(20, 32)}'; } + static final BigInt _max128BitValue = (BigInt.one << 128) - BigInt.one; + static Future recoverAccount(BigInt recoveryId) async { try { final uuidString = convertBigIntToUuid(recoveryId); + if (uuidString == null) { + return RecoveryResult( + success: false, + error: 'invalid_recovery_id', + ); + } final response = await Supabase.instance.client.rpc( 'recover_account', params: {'p_recovery_id': uuidString}, diff --git a/app/lib/util/recovery_qr_utils.dart b/app/lib/util/recovery_qr_utils.dart index 47ab81c3e..fc0710624 100644 --- a/app/lib/util/recovery_qr_utils.dart +++ b/app/lib/util/recovery_qr_utils.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:studyu_core/core.dart' as core; +import 'package:studyu_core/core.dart'; import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; @@ -11,7 +13,7 @@ import 'package:share_plus/share_plus.dart'; import 'package:universal_html/html.dart' as html; class RecoveryQrUtils { - static const String deepLinkDomain = 'app.studyu.health'; + static String get deepLinkDomain => core.deepLinkDomain ?? 'app.studyu.health'; static const String deepLinkPath = '/recover'; static const String customScheme = 'studyu'; @@ -50,7 +52,7 @@ class RecoveryQrUtils { .where((word) => word.isNotEmpty) .toList(); - if (words.length != 13) { + if (words.length != RecoveryConstants.totalWordCount) { return null; } diff --git a/core/lib/core.dart b/core/lib/core.dart index aca001f93..d1307cea4 100644 --- a/core/lib/core.dart +++ b/core/lib/core.dart @@ -1,3 +1,4 @@ +export 'src/env/env.dart'; export 'src/models/models.dart'; export 'src/util/analytics.dart'; export 'src/util/recovery.dart'; diff --git a/core/lib/src/env/env.dart b/core/lib/src/env/env.dart index e9cee6fde..b8a53e7f5 100644 --- a/core/lib/src/env/env.dart +++ b/core/lib/src/env/env.dart @@ -9,6 +9,7 @@ late final String? projectGeneratorUrl; late final String? playStoreUrl; late final String? appstoreUrl; late final String? developerEmail; +late final String? deepLinkDomain; void setEnv( String envSupabaseUrl, @@ -20,6 +21,7 @@ void setEnv( String? envPlayStoreUrl, String? envAppstoreUrl, String? envDeveloperEmail, + String? envDeepLinkDomain, }) { supabaseUrl = envSupabaseUrl; supabaseAnonKey = envSupabaseAnonKey; @@ -47,4 +49,5 @@ void setEnv( playStoreUrl = envPlayStoreUrl; appstoreUrl = envAppstoreUrl; developerEmail = envDeveloperEmail; + deepLinkDomain = envDeepLinkDomain ?? 'app.studyu.health'; } diff --git a/core/lib/src/util/recovery.dart b/core/lib/src/util/recovery.dart index 1dd16e8a9..cfdc2b938 100644 --- a/core/lib/src/util/recovery.dart +++ b/core/lib/src/util/recovery.dart @@ -1,10 +1,37 @@ import 'package:studyu_core/src/util/wordlists.dart'; +/// Recovery phrase configuration constants +class RecoveryConstants { + /// Number of bits for the recovery ID (128-bit UUID) + static const int defaultBitCount = 128; + + /// Number of bits per word (11 bits = 2048 wordlist) + static const int defaultBitsPerWord = 11; + + /// Number of data words (12 words for 128 bits) + static const int dataWordCount = 12; + + /// Total number of words including checksum (13 words) + static const int totalWordCount = 13; + + /// Whether to include a checksum word + static const bool defaultUseChecksum = true; + + /// Default encoding configuration + static const EncodingConfig defaultConfig = EncodingConfig( + bitsPerWord: defaultBitsPerWord, + useChecksum: defaultUseChecksum, + ); +} + class EncodingConfig { final int bitsPerWord; final bool useChecksum; - const EncodingConfig({this.bitsPerWord = 11, this.useChecksum = true}); + const EncodingConfig({ + this.bitsPerWord = RecoveryConstants.defaultBitsPerWord, + this.useChecksum = RecoveryConstants.defaultUseChecksum, + }); int wordsNeeded(int bitCount) { return (bitCount / bitsPerWord).ceil(); @@ -72,8 +99,8 @@ int computeChecksum(List indices, int wordlistSize) { List encode( BigInt participantId, { List? wordlist, - int bitCount = 128, - EncodingConfig config = const EncodingConfig(), + int bitCount = RecoveryConstants.defaultBitCount, + EncodingConfig config = RecoveryConstants.defaultConfig, }) { final effectiveWordlist = wordlist ?? wordlistEn; @@ -96,7 +123,7 @@ List encode( BigInt decode( List words, { List? wordlist, - EncodingConfig config = const EncodingConfig(), + EncodingConfig config = RecoveryConstants.defaultConfig, }) { if (words.isEmpty) { throw ArgumentError('Must provide at least one word'); @@ -135,3 +162,19 @@ BigInt decode( return decodeFromWords(dataWords, effectiveWordlist, config); } + +/// Validates that the word count matches the expected count for recovery +/// Throws [ArgumentError] if the count is invalid +void validateRecoveryWordCount(int wordCount) { + if (wordCount != RecoveryConstants.totalWordCount) { + throw ArgumentError( + 'Expected ${RecoveryConstants.totalWordCount} words, got $wordCount', + ); + } +} + +/// Returns the expected word count for a given configuration +int getExpectedWordCount(EncodingConfig config) { + final dataWords = config.wordsNeeded(RecoveryConstants.defaultBitCount); + return config.useChecksum ? dataWords + 1 : dataWords; +} diff --git a/core/test/util/recovery_test.dart b/core/test/util/recovery_test.dart new file mode 100644 index 000000000..ed6f02dae --- /dev/null +++ b/core/test/util/recovery_test.dart @@ -0,0 +1,275 @@ +import 'package:studyu_core/src/util/recovery.dart'; +import 'package:studyu_core/src/util/wordlists.dart'; +import 'package:test/test.dart'; + +void main() { + group('Recovery Encoding/Decoding', () { + test('should encode and decode 128-bit ID correctly', () { + // Test with a known 128-bit value + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + + final words = encode(id); + expect(words.length, equals(RecoveryConstants.totalWordCount)); + + final decoded = decode(words); + expect(decoded, equals(id)); + }); + + test('should encode and decode zero correctly', () { + final id = BigInt.zero; + + final words = encode(id); + expect(words.length, equals(RecoveryConstants.totalWordCount)); + + final decoded = decode(words); + expect(decoded, equals(id)); + }); + + test('should encode and decode max 128-bit value correctly', () { + final id = (BigInt.one << 128) - BigInt.one; + + final words = encode(id); + expect(words.length, equals(RecoveryConstants.totalWordCount)); + + final decoded = decode(words); + expect(decoded, equals(id)); + }); + + test('should throw when ID exceeds 128 bits', () { + final id = BigInt.one << 128; + + expect(() => encode(id), throwsArgumentError); + }); + + test('should throw when ID is negative', () { + final id = BigInt.from(-1); + + expect(() => encode(id), throwsArgumentError); + }); + }); + + group('Checksum Validation', () { + test('should detect single word corruption', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id); + + // Corrupt a single word + final corruptedWords = List.from(words); + corruptedWords[5] = 'different'; + + expect(() => decode(corruptedWords), throwsArgumentError); + }); + + test('should detect swapped words', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id); + + // Swap two adjacent words + final corruptedWords = List.from(words); + final temp = corruptedWords[3]; + corruptedWords[3] = corruptedWords[4]; + corruptedWords[4] = temp; + + expect(() => decode(corruptedWords), throwsArgumentError); + }); + + test('should detect missing words', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id); + + // Remove the last word (checksum) + final truncatedWords = words.sublist(0, words.length - 1); + + expect(() => decode(truncatedWords), throwsArgumentError); + }); + + test('should detect extra words', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id); + + // Add an extra word + final extendedWords = List.from(words)..add('extra'); + + expect(() => decode(extendedWords), throwsArgumentError); + }); + + test('should accept valid phrase with correct checksum', () { + final id = BigInt.parse('DEADBEEFCAFEBABE1234567890ABCDEF', radix: 16); + final words = encode(id); + + // Should not throw + final decoded = decode(words); + expect(decoded, equals(id)); + }); + }); + + group('Wordlist Validation', () { + test('should use English wordlist by default', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id); + + // All words should be from English wordlist + for (final word in words) { + expect(wordlistEn.contains(word), isTrue, + reason: 'Word "$word" not found in English wordlist'); + } + }); + + test('should accept German wordlist when specified', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id, wordlist: wordlistDe); + + // All words should be from German wordlist + for (final word in words) { + expect(wordlistDe.contains(word), isTrue, + reason: 'Word "$word" not found in German wordlist'); + } + + // Should decode correctly with German wordlist + final decoded = decode(words, wordlist: wordlistDe); + expect(decoded, equals(id)); + }); + + test('should throw on unknown words', () { + final invalidWords = [ + 'apple', + 'banana', + 'cherry', + 'date', + 'elderberry', + 'fig', + 'grape', + 'honeydew', + 'kiwi', + 'lemon', + 'mango', + 'nectarine', + 'orange' + ]; + + expect(() => decode(invalidWords), throwsArgumentError); + }); + }); + + group('Word Count Constants', () { + test('should have correct total word count', () { + expect(RecoveryConstants.totalWordCount, equals(13)); + expect(RecoveryConstants.dataWordCount, equals(12)); + }); + + test('should validate word count correctly', () { + expect(() => validateRecoveryWordCount(13), returnsNormally); + expect(() => validateRecoveryWordCount(12), throwsArgumentError); + expect(() => validateRecoveryWordCount(14), throwsArgumentError); + expect(() => validateRecoveryWordCount(0), throwsArgumentError); + }); + + test('should calculate expected word count correctly', () { + final config = EncodingConfig(); + final count = getExpectedWordCount(config); + expect(count, equals(RecoveryConstants.totalWordCount)); + }); + + test('should calculate word count without checksum', () { + final config = EncodingConfig(useChecksum: false); + final count = getExpectedWordCount(config); + expect(count, equals(RecoveryConstants.dataWordCount)); + }); + }); + + group('Encoding Configuration', () { + test('should use correct default bit count', () { + expect(RecoveryConstants.defaultBitCount, equals(128)); + }); + + test('should use correct default bits per word', () { + expect(RecoveryConstants.defaultBitsPerWord, equals(11)); + }); + + test('should calculate words needed correctly', () { + final config = EncodingConfig(); + expect(config.wordsNeeded(128), equals(12)); + expect(config.wordsNeeded(256), equals(24)); + }); + }); + + group('Random ID Roundtrips', () { + final testIds = [ + BigInt.parse('00000000000000000000000000000000', radix: 16), + BigInt.parse('FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF', radix: 16), + BigInt.parse('123456789ABCDEF0123456789ABCDEF0', radix: 16), + BigInt.parse('AABBCCDDEEFF00112233445566778899', radix: 16), + BigInt.parse('0102030405060708090A0B0C0D0E0F10', radix: 16), + ]; + + for (var i = 0; i < testIds.length; i++) { + test('roundtrip test case $i', () { + final id = testIds[i]; + final words = encode(id); + final decoded = decode(words); + expect(decoded, equals(id), + reason: 'Failed for ID: ${id.toRadixString(16)}'); + }); + } + }); + + group('Edge Cases', () { + test('should handle empty word list', () { + expect(() => decode([]), throwsArgumentError); + }); + + test('should handle single word', () { + expect(() => decode(['apple']), throwsArgumentError); + }); + + test('should handle words with different casing', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id); + + // Test uppercase + final upperWords = words.map((w) => w.toUpperCase()).toList(); + expect(() => decode(upperWords), throwsArgumentError); + + // Test mixed case + final mixedWords = words + .asMap() + .map((i, w) => MapEntry(i, i % 2 == 0 ? w.toUpperCase() : w)) + .values + .toList(); + expect(() => decode(mixedWords), throwsArgumentError); + }); + + test('should handle whitespace in words', () { + final id = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final words = encode(id); + + // Words with leading/trailing whitespace should fail + final whitespaceWords = words.map((w) => ' $w ').toList(); + expect(() => decode(whitespaceWords), throwsArgumentError); + }); + }); + + group('Checksum Computation', () { + test('should compute consistent checksums', () { + final indices = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + final checksum1 = computeChecksum(indices, wordlistEn.length); + final checksum2 = computeChecksum(indices, wordlistEn.length); + + expect(checksum1, equals(checksum2)); + }); + + test('should produce different checksums for different inputs', () { + final indices1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + final indices2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13]; + + final checksum1 = computeChecksum(indices1, wordlistEn.length); + final checksum2 = computeChecksum(indices2, wordlistEn.length); + + expect(checksum1, isNot(equals(checksum2))); + }); + + test('should throw on empty indices', () { + expect(() => computeChecksum([], wordlistEn.length), throwsArgumentError); + }); + }); +} diff --git a/designer_v2/lib/localization/app_localizations_de.dart b/designer_v2/lib/localization/app_localizations_de.dart index 4ecc7c430..2bf43bffc 100644 --- a/designer_v2/lib/localization/app_localizations_de.dart +++ b/designer_v2/lib/localization/app_localizations_de.dart @@ -2316,7 +2316,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get study_schedule_balanced_description => - 'Ausgeglichen: Teilnehmer werden zufällig ABAB- oder BABA-Sequenzen zugeordnet, um Reihenfolgeneffekte zu reduzieren.'; + 'Gegengewicht: Jeder Teilnehmer folgt einem ABBA-Muster und wechselt zwischen Interventionen in einer vorhersagbaren Sequenz.'; @override String get study_schedule_random_description => diff --git a/designer_v2/lib/localization/app_localizations_en.dart b/designer_v2/lib/localization/app_localizations_en.dart index 67672c2f5..9d6400b91 100644 --- a/designer_v2/lib/localization/app_localizations_en.dart +++ b/designer_v2/lib/localization/app_localizations_en.dart @@ -2282,7 +2282,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get study_schedule_balanced_description => - 'Balanced: Participants are randomly assigned to either ABAB or BABA sequences to reduce order effects.'; + 'Counterbalanced: Each participant follows an ABBA pattern, switching between interventions in a predictable sequence.'; @override String get study_schedule_random_description => diff --git a/flutter_common/lib/envs/.env b/flutter_common/lib/envs/.env index 82f63798f..153d214a7 100644 --- a/flutter_common/lib/envs/.env +++ b/flutter_common/lib/envs/.env @@ -6,3 +6,4 @@ STUDYU_DESIGNER_URL=https://designer.studyu.health STUDYU_PLAY_STORE_URL=https://play.google.com/store/apps/details?id=health.studyu.app STUDYU_APPSTORE_URL=https://itunes.apple.com/app/id1571991198 STUDYU_DEVELOPER_EMAIL=stefan.konigorski@hpi.de +STUDYU_DEEP_LINK_DOMAIN=app.studyu.health diff --git a/flutter_common/lib/envs/.env.dev b/flutter_common/lib/envs/.env.dev index 3b8957f2a..07dcd397f 100644 --- a/flutter_common/lib/envs/.env.dev +++ b/flutter_common/lib/envs/.env.dev @@ -6,3 +6,4 @@ STUDYU_DESIGNER_URL=https://designer.dev.studyu.health STUDYU_PLAY_STORE_URL=https://play.google.com/store/apps/details?id=health.studyu.app STUDYU_APPSTORE_URL=https://itunes.apple.com/app/id1571991198 STUDYU_DEVELOPER_EMAIL=stefan.konigorski@hpi.de +STUDYU_DEEP_LINK_DOMAIN=app.dev.studyu.health diff --git a/flutter_common/lib/envs/.env.local.example b/flutter_common/lib/envs/.env.local.example index 13b8253d8..863fc0a04 100644 --- a/flutter_common/lib/envs/.env.local.example +++ b/flutter_common/lib/envs/.env.local.example @@ -6,3 +6,4 @@ STUDYU_DESIGNER_URL=http://localhost:8081 STUDYU_PLAY_STORE_URL=https://play.google.com/store/apps/details?id=health.studyu.app STUDYU_APPSTORE_URL=https://itunes.apple.com/app/id1571991198 STUDYU_DEVELOPER_EMAIL=stefan.konigorski@hpi.de +STUDYU_DEEP_LINK_DOMAIN=app.studyu.health diff --git a/flutter_common/lib/src/utils/env_loader.dart b/flutter_common/lib/src/utils/env_loader.dart index 0226b6fdc..c80967c44 100644 --- a/flutter_common/lib/src/utils/env_loader.dart +++ b/flutter_common/lib/src/utils/env_loader.dart @@ -52,6 +52,7 @@ Future loadEnv() async { final envPlayStoreUrl = getEnv('STUDYU_PLAY_STORE_URL', optional: true); final envAppstoreUrl = getEnv('STUDYU_APPSTORE_URL', optional: true); final envDeveloperEmail = getEnv('STUDYU_DEVELOPER_EMAIL', optional: true); + final envDeepLinkDomain = getEnv('STUDYU_DEEP_LINK_DOMAIN', optional: true); // await SecureStorage.migrateSharedPreferencesToSecureStorage(); @@ -77,6 +78,7 @@ Future loadEnv() async { envPlayStoreUrl: envPlayStoreUrl, envAppstoreUrl: envAppstoreUrl, envDeveloperEmail: envDeveloperEmail, + envDeepLinkDomain: envDeepLinkDomain, supabaseClient: Supabase.instance.client, ); } From a06484ca5e986a069db65b4143f6b58bc0383c61 Mon Sep 17 00:00:00 2001 From: Ibrahim Ozkan <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:15:25 +0100 Subject: [PATCH 15/57] fix: Remove redundant rate limiting tests from recovery workflow --- .github/workflows/recovery_tests.yml | 24 +----------------------- 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/.github/workflows/recovery_tests.yml b/.github/workflows/recovery_tests.yml index 7677d5a56..696f9e099 100644 --- a/.github/workflows/recovery_tests.yml +++ b/.github/workflows/recovery_tests.yml @@ -137,26 +137,4 @@ jobs: END; \$\$; " - echo "✅ SQL function tests passed" - - - name: Test Rate Limiting Functions - run: | - # Test check_recovery_rate_limit function - supabase sql " - DO \$\$ - DECLARE - result jsonb; - BEGIN - -- Test rate limit check - result := public.check_recovery_rate_limit('127.0.0.1'::inet, NULL); - - IF NOT (result->>'allowed')::boolean THEN - RAISE EXCEPTION 'Rate limit should allow first attempt'; - END IF; - - -- Test that cleanup function exists - PERFORM public.cleanup_recovery_rate_limits(); - END; - \$\$; - " - echo "✅ Rate limiting tests passed" + echo "✅ SQL function tests passed" \ No newline at end of file From 59cdf626dd89fd5f863813ed0b632feb1866c3e5 Mon Sep 17 00:00:00 2001 From: Ibrahim Ozkan <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:17:02 +0100 Subject: [PATCH 16/57] refactor: Optimize widget instantiation and clean up imports in recovery flow tests --- app/integration_test/recovery_flow_test.dart | 8 ++++---- app/lib/screens/app_onboarding/app_error_screen.dart | 1 - app/lib/util/recovery_qr_utils.dart | 5 ++--- app/pubspec.yaml | 1 + core/lib/src/util/recovery.dart | 3 +-- core/test/util/recovery_test.dart | 6 +++--- 6 files changed, 11 insertions(+), 13 deletions(-) diff --git a/app/integration_test/recovery_flow_test.dart b/app/integration_test/recovery_flow_test.dart index a08386907..4eb5ad5b5 100644 --- a/app/integration_test/recovery_flow_test.dart +++ b/app/integration_test/recovery_flow_test.dart @@ -95,10 +95,10 @@ void main() { testWidgets('RejoinStudyScreen renders correctly', (tester) async { await tester.pumpWidget( - MaterialApp( + const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const RejoinStudyScreen(), + home: RejoinStudyScreen(), ), ); @@ -113,10 +113,10 @@ void main() { testWidgets('RecoveryPhraseContent widget loads and displays phrase', (tester) async { await tester.pumpWidget( - MaterialApp( + const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: const Scaffold( + home: Scaffold( body: RecoveryPhraseContent(), ), ), diff --git a/app/lib/screens/app_onboarding/app_error_screen.dart b/app/lib/screens/app_onboarding/app_error_screen.dart index 09eb0df61..0cc403719 100644 --- a/app/lib/screens/app_onboarding/app_error_screen.dart +++ b/app/lib/screens/app_onboarding/app_error_screen.dart @@ -4,7 +4,6 @@ import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/util/cache.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; import 'package:studyu_core/core.dart'; -import 'package:studyu_core/env.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; import 'package:url_launcher/url_launcher.dart'; diff --git a/app/lib/util/recovery_qr_utils.dart b/app/lib/util/recovery_qr_utils.dart index fc0710624..2b1c2fb51 100644 --- a/app/lib/util/recovery_qr_utils.dart +++ b/app/lib/util/recovery_qr_utils.dart @@ -3,13 +3,12 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_file_dialog/flutter_file_dialog.dart'; -import 'package:studyu_core/core.dart' as core; -import 'package:studyu_core/core.dart'; - import 'package:image/image.dart' as img; import 'package:path_provider/path_provider.dart'; import 'package:qr/qr.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:studyu_core/core.dart' as core; +import 'package:studyu_core/core.dart'; import 'package:universal_html/html.dart' as html; class RecoveryQrUtils { diff --git a/app/pubspec.yaml b/app/pubspec.yaml index f083de576..646cc4d46 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -73,6 +73,7 @@ dev_dependencies: sdk: flutter lint: ^2.8.0 + integration_test: any flutter: generate: true uses-material-design: true diff --git a/core/lib/src/util/recovery.dart b/core/lib/src/util/recovery.dart index cfdc2b938..ec22943b5 100644 --- a/core/lib/src/util/recovery.dart +++ b/core/lib/src/util/recovery.dart @@ -19,8 +19,7 @@ class RecoveryConstants { /// Default encoding configuration static const EncodingConfig defaultConfig = EncodingConfig( - bitsPerWord: defaultBitsPerWord, - useChecksum: defaultUseChecksum, + ); } diff --git a/core/test/util/recovery_test.dart b/core/test/util/recovery_test.dart index ed6f02dae..dcda563d2 100644 --- a/core/test/util/recovery_test.dart +++ b/core/test/util/recovery_test.dart @@ -165,13 +165,13 @@ void main() { }); test('should calculate expected word count correctly', () { - final config = EncodingConfig(); + const config = EncodingConfig(); final count = getExpectedWordCount(config); expect(count, equals(RecoveryConstants.totalWordCount)); }); test('should calculate word count without checksum', () { - final config = EncodingConfig(useChecksum: false); + const config = EncodingConfig(useChecksum: false); final count = getExpectedWordCount(config); expect(count, equals(RecoveryConstants.dataWordCount)); }); @@ -187,7 +187,7 @@ void main() { }); test('should calculate words needed correctly', () { - final config = EncodingConfig(); + const config = EncodingConfig(); expect(config.wordsNeeded(128), equals(12)); expect(config.wordsNeeded(256), equals(24)); }); From 26a02b1c9370202121d82342012c48ccf66667ea Mon Sep 17 00:00:00 2001 From: Ibrahim Ozkan <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:21:51 +0100 Subject: [PATCH 17/57] fix: Remove integration_test dependency from dev_dependencies in pubspec.yaml --- app/pubspec.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 646cc4d46..f083de576 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -73,7 +73,6 @@ dev_dependencies: sdk: flutter lint: ^2.8.0 - integration_test: any flutter: generate: true uses-material-design: true From 0ec8bbda5aeac2f006b05ccf227dedfd456cde71 Mon Sep 17 00:00:00 2001 From: Ibrahim Ozkan <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:30:13 +0100 Subject: [PATCH 18/57] chore: Remove recovery_tests workflow file --- .github/workflows/recovery_tests.yml | 140 --------------------------- 1 file changed, 140 deletions(-) delete mode 100644 .github/workflows/recovery_tests.yml diff --git a/.github/workflows/recovery_tests.yml b/.github/workflows/recovery_tests.yml deleted file mode 100644 index 696f9e099..000000000 --- a/.github/workflows/recovery_tests.yml +++ /dev/null @@ -1,140 +0,0 @@ -name: Recovery Tests - -on: - push: - paths: - - 'core/lib/src/util/recovery.dart' - - 'core/lib/src/util/wordlists.dart' - - 'core/test/util/recovery_test.dart' - - 'app/lib/services/rejoin_study_service.dart' - - 'app/lib/util/recovery_qr_utils.dart' - - 'app/integration_test/recovery_flow_test.dart' - - 'supabase/migrations/*user_recovery.sql' - - 'supabase/migrations/*recovery_rate_limiting.sql' - - '.github/workflows/recovery_tests.yml' - - '.github/workflows/init-workspace/**' - workflow_dispatch: - -env: - MELOS_SDK_PATH: 'auto' - -concurrency: - group: ${{ github.ref }}-recovery-tests - cancel-in-progress: true - -jobs: - unit_tests: - name: Recovery Unit Tests - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Init workspace - uses: ./.github/workflows/init-workspace - - - name: Run Recovery Unit Tests - working-directory: ./core - run: flutter test test/util/recovery_test.dart --reporter=expanded - - integration_tests: - name: Recovery Integration Tests - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout code - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Init workspace - uses: ./.github/workflows/init-workspace - - - name: Setup ChromeDriver - uses: nanasess/setup-chromedriver@v2 - - - name: Enable web support - run: flutter config --enable-web - - - name: Prepare environment - run: | - dart pub get - dart pub global activate melos - melos bootstrap - - - name: Prepare Supabase CI Seed - run: | - cp supabase/seed-ci.sql supabase/seed.sql - - - name: Set up Supabase CLI - uses: supabase/setup-cli@v1 - with: - version: latest - - - name: Start Supabase - run: | - supabase start -x studio,imgproxy - - - name: Configure local StudyU environment - run: | - cp flutter_common/lib/envs/.env.local.example flutter_common/lib/envs/.env.local - LC_ALL=C sed -i.bak '/STUDYU_SUPABASE_PUBLIC_ANON_KEY/d' flutter_common/lib/envs/.env.local - echo "STUDYU_SUPABASE_PUBLIC_ANON_KEY=$(supabase status -o env | grep ANON_KEY | cut -d'=' -f2)" >> flutter_common/lib/envs/.env.local - - - name: Start Chrome Driver - run: | - export DISPLAY=:99 - chromedriver --port=4444 & - sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & - - - name: Run Recovery Integration Tests - working-directory: ./app - run: | - flutter test integration_test/recovery_flow_test.dart \ - -d web-server \ - --dart-define="STUDYU_ENV=.env.local" - - sql_tests: - name: Recovery SQL Tests - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v5 - - - name: Set up Supabase CLI - uses: supabase/setup-cli@v1 - with: - version: latest - - - name: Start Supabase - run: | - supabase start -x studio,imgproxy - - - name: Run Recovery Migrations - run: | - supabase db reset - - - name: Test Recovery Functions - run: | - # Test get_or_create_recovery function - supabase sql " - DO \$\$ - DECLARE - result jsonb; - BEGIN - -- Test that the function exists and returns proper structure - SELECT public.get_or_create_recovery() INTO result; - - IF result IS NULL THEN - RAISE EXCEPTION 'get_or_create_recovery returned NULL'; - END IF; - - IF NOT result ? 'success' THEN - RAISE EXCEPTION 'get_or_create_response missing success field'; - END IF; - END; - \$\$; - " - echo "✅ SQL function tests passed" \ No newline at end of file From 008871ed5c1e3e053b056dd9cb943062d83110fe Mon Sep 17 00:00:00 2001 From: Ibrahim Ozkan <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:36:07 +0100 Subject: [PATCH 19/57] chore: Update package versions in pubspec.lock --- pubspec.lock | 86 +++++++++++++++++++--------------------------------- 1 file changed, 32 insertions(+), 54 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index ab15fbb77..8d7e1f7d9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -173,10 +173,10 @@ packages: dependency: transitive description: name: build - sha256: c1668065e9ba04752570ad7e038288559d1e2ca5c6d0131c0f5f55e39e777413 + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" url: "https://pub.dev" source: hosted - version: "4.0.3" + version: "4.0.4" build_config: dependency: transitive description: @@ -197,10 +197,10 @@ packages: dependency: transitive description: name: build_runner - sha256: "110c56ef29b5eb367b4d17fc79375fa8c18a6cd7acd92c05bb3986c17a079057" + sha256: b4d854962a32fd9f8efc0b76f98214790b833af8b2e9b2df6bfc927c0415a072 url: "https://pub.dev" source: hosted - version: "2.10.4" + version: "2.10.5" built_collection: dependency: transitive description: @@ -213,10 +213,10 @@ packages: dependency: transitive description: name: built_value - sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139" + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" url: "https://pub.dev" source: hosted - version: "8.12.1" + version: "8.12.3" cached_network_image: dependency: transitive description: @@ -253,18 +253,18 @@ packages: dependency: transitive description: name: camera_android_camerax - sha256: "474d8355961658d43f1c976e2fa1ca715505bea1adbd56df34c581aaa70ec41f" + sha256: dba476d6c316671ae943f20ad7d02b3d363eed0dbfd25465bb92d42e35faacd8 url: "https://pub.dev" source: hosted - version: "0.6.26+2" + version: "0.6.28" camera_avfoundation: dependency: transitive description: name: camera_avfoundation - sha256: "087a9fadef20325cb246b4c13344a3ce8e408acfc3e0c665ebff0ec3144d7163" + sha256: a600b60a7752cc5fa9de476cd0055539d7a3b9d62662f4f446bae49eba2267df url: "https://pub.dev" source: hosted - version: "0.9.22+8" + version: "0.9.22+9" camera_platform_interface: dependency: transitive description: @@ -461,10 +461,10 @@ packages: dependency: transitive description: name: dart_jsonwebtoken - sha256: "0de65691c1d736e9459f22f654ddd6fd8368a271d4e41aa07e53e6301eff5075" + sha256: b268ff9bb87a16360a397f1accb50e7c781384e1119010b34f6d7c25820c44d8 url: "https://pub.dev" source: hosted - version: "3.3.1" + version: "3.1.1" dart_style: dependency: transitive description: @@ -509,10 +509,10 @@ packages: dependency: transitive description: name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 url: "https://pub.dev" source: hosted - version: "5.9.0" + version: "5.9.1" dio_web_adapter: dependency: transitive description: @@ -952,7 +952,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" -<<<<<<< HEAD hex: dependency: transitive description: @@ -961,16 +960,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.0" - hotreloader: - dependency: transitive - description: - name: hotreloader - sha256: bc167a1163807b03bada490bfe2df25b0d744df359227880220a5cbd04e5734b - url: "https://pub.dev" - source: hosted - version: "4.3.0" -======= ->>>>>>> origin/dev html: dependency: transitive description: @@ -1196,10 +1185,10 @@ packages: dependency: "direct dev" description: name: melos - sha256: ff2da25990d83b0db883eb257e4fa25eb78150a329e7bfab7a379499d0f5f6f7 + sha256: "7dcc4444d79b538ab8353bb38308f97e90b032984f75e8fd516fedfe8160b35e" url: "https://pub.dev" source: hosted - version: "7.3.0" + version: "7.4.0" meta: dependency: transitive description: @@ -1220,18 +1209,18 @@ packages: dependency: transitive description: name: mobile_scanner - sha256: "023a71afb4d7cfb5529d0f2636aa8b43db66257905b9486d702085989769c5f2" + sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 url: "https://pub.dev" source: hosted - version: "7.1.3" + version: "7.1.4" mockito: dependency: transitive description: name: mockito - sha256: dac24d461418d363778d53198d9ac0510b9d073869f078450f195766ec48d05e + sha256: a45d1aa065b796922db7b9e7e7e45f921aed17adf3a8318a1f47097e7e695566 url: "https://pub.dev" source: hosted - version: "5.6.1" + version: "5.6.3" multi_select_flutter: dependency: transitive description: @@ -1500,17 +1489,10 @@ packages: dependency: transitive description: name: pointycastle -<<<<<<< HEAD sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted version: "3.9.1" -======= - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" - url: "https://pub.dev" - source: hosted - version: "4.0.0" ->>>>>>> origin/dev pool: dependency: transitive description: @@ -1779,27 +1761,26 @@ packages: dependency: transitive description: name: sentry - sha256: b6020007d35199f2be3170443c01243c8533232261fcd54237e38710788cebd0 + sha256: fa14b2177d2cd4489ec78817ec4e52affe1be9f595718de970a5e4ed2bccc530 url: "https://pub.dev" source: hosted - version: "9.9.2" + version: "9.10.0" sentry_flutter: dependency: transitive description: name: sentry_flutter - sha256: "9bc083608f406921f45836d409abf8713bc307486276a129e3fdd6a8df88b171" + sha256: "00d168260c9be3777e4f1c9ca7a2e4f2c48566926ec22a64bbacae5a94ff72a3" url: "https://pub.dev" source: hosted - version: "9.9.2" + version: "9.10.0" sentry_logging: dependency: transitive description: name: sentry_logging - sha256: "11418594d10ababb69706abb2e17f71c6720d8ba6011c0b69b2fb990a69d8a77" + sha256: eb7854fe038ee3b44ec24274575d4f02199feb44f964cbb3d0e521f39ed8ca74 url: "https://pub.dev" source: hosted -<<<<<<< HEAD - version: "9.8.0" + version: "9.10.0" share_plus: dependency: transitive description: @@ -1816,9 +1797,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" -======= - version: "9.9.2" ->>>>>>> origin/dev shared_preferences: dependency: transitive description: @@ -1831,10 +1809,10 @@ packages: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.20" shared_preferences_foundation: dependency: transitive description: @@ -1916,10 +1894,10 @@ packages: dependency: transitive description: name: source_gen - sha256: "07b277b67e0096c45196cbddddf2d8c6ffc49342e88bf31d460ce04605ddac75" + sha256: "1d562a3c1f713904ebbed50d2760217fd8a51ca170ac4b05b0db490699dbac17" url: "https://pub.dev" source: hosted - version: "4.1.1" + version: "4.2.0" source_helper: dependency: transitive description: @@ -2252,10 +2230,10 @@ packages: dependency: transitive description: name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" url: "https://pub.dev" source: hosted - version: "1.1.19" + version: "1.1.20" vector_math: dependency: transitive description: From 825d6881028f71484797291e4ad4585ab32e496b Mon Sep 17 00:00:00 2001 From: Ibrahim Ozkan <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:00:13 +0100 Subject: [PATCH 20/57] fix: Update exception handling in rejoin study service and improve recovery flow tests --- app/integration_test/recovery_flow_test.dart | 2 +- app/lib/services/rejoin_study_service.dart | 4 ++-- app/pubspec.yaml | 2 ++ core/test/util/recovery_test.dart | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/integration_test/recovery_flow_test.dart b/app/integration_test/recovery_flow_test.dart index 4eb5ad5b5..52816e21f 100644 --- a/app/integration_test/recovery_flow_test.dart +++ b/app/integration_test/recovery_flow_test.dart @@ -14,7 +14,7 @@ void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('Recovery Flow Integration Tests', () { - setUp(() async { + setUp(() { // Clear any cached recovery data before each test RejoinStudyService.clearCache(); }); diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/rejoin_study_service.dart index 111db368a..37feab396 100644 --- a/app/lib/services/rejoin_study_service.dart +++ b/app/lib/services/rejoin_study_service.dart @@ -111,7 +111,7 @@ class RejoinStudyService { try { final enWords = words.map((w) => w.toLowerCase().trim()).toList(); return decode(enWords, wordlist: wordlistEn); - } on ArgumentError catch (e) { + } on Exception catch (e) { // Check if error is due to word not found in English list final errorStr = e.toString(); if (errorStr.contains('Invalid word') || @@ -120,7 +120,7 @@ class RejoinStudyService { try { final deWords = words.map((w) => w.toLowerCase().trim()).toList(); return decode(deWords, wordlist: wordlistDe); - } catch (_) { + } on Exception catch (_) { // German also failed, throw original English error rethrow; } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index bddc47724..92651c202 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -71,6 +71,8 @@ dev_dependencies: flutter_lints: ^6.0.0 flutter_test: sdk: flutter + integration_test: + sdk: flutter lint: ^2.8.0 flutter: diff --git a/core/test/util/recovery_test.dart b/core/test/util/recovery_test.dart index dcda563d2..c831d0f12 100644 --- a/core/test/util/recovery_test.dart +++ b/core/test/util/recovery_test.dart @@ -233,7 +233,7 @@ void main() { // Test mixed case final mixedWords = words .asMap() - .map((i, w) => MapEntry(i, i % 2 == 0 ? w.toUpperCase() : w)) + .map((i, w) => MapEntry(i, i.isEven ? w.toUpperCase() : w)) .values .toList(); expect(() => decode(mixedWords), throwsArgumentError); From d40816c4198aca11a9158b8b3af4098271623de3 Mon Sep 17 00:00:00 2001 From: ibrahimozkn <51259479+ibrahimozkn@users.noreply.github.com> Date: Thu, 29 Jan 2026 21:04:13 +0000 Subject: [PATCH 21/57] chore: Apply static analysis changes --- app/integration_test/recovery_flow_test.dart | 73 +++++++++++++------ .../app_onboarding/rejoin_study_screen.dart | 6 +- app/lib/services/rejoin_study_service.dart | 5 +- app/lib/util/recovery_qr_utils.dart | 3 +- core/lib/src/util/recovery.dart | 4 +- core/test/util/recovery_test.dart | 23 ++++-- 6 files changed, 75 insertions(+), 39 deletions(-) diff --git a/app/integration_test/recovery_flow_test.dart b/app/integration_test/recovery_flow_test.dart index 52816e21f..f5761249c 100644 --- a/app/integration_test/recovery_flow_test.dart +++ b/app/integration_test/recovery_flow_test.dart @@ -19,10 +19,14 @@ void main() { RejoinStudyService.clearCache(); }); - testWidgets('Recovery phrase encoding and decoding roundtrip', - (tester) async { + testWidgets('Recovery phrase encoding and decoding roundtrip', ( + tester, + ) async { // Test with a known ID - final testId = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final testId = BigInt.parse( + '1234567890ABCDEF1234567890ABCDEF', + radix: 16, + ); // Encode to words final words = encode(testId); @@ -33,8 +37,9 @@ void main() { expect(decodedId, equals(testId)); }); - testWidgets('Recovery phrase validation detects invalid word count', - (tester) async { + testWidgets('Recovery phrase validation detects invalid word count', ( + tester, + ) async { // Test with too few words final shortWords = ['word1', 'word2', 'word3']; expect( @@ -50,9 +55,13 @@ void main() { ); }); - testWidgets('Recovery phrase validation detects corrupted phrase', - (tester) async { - final testId = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + testWidgets('Recovery phrase validation detects corrupted phrase', ( + tester, + ) async { + final testId = BigInt.parse( + '1234567890ABCDEF1234567890ABCDEF', + radix: 16, + ); final words = encode(testId); // Corrupt one word @@ -67,13 +76,19 @@ void main() { }); testWidgets('UUID conversion works correctly', (tester) async { - final testId = BigInt.parse('1234567890ABCDEF1234567890ABCDEF', radix: 16); + final testId = BigInt.parse( + '1234567890ABCDEF1234567890ABCDEF', + radix: 16, + ); final uuid = RejoinStudyService.convertBigIntToUuid(testId); expect(uuid, isNotNull); - expect(uuid, matches( - r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', - )); + expect( + uuid, + matches( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + ), + ); // Verify the UUID can be parsed back final hexString = uuid!.replaceAll('-', ''); @@ -81,7 +96,9 @@ void main() { expect(recoveredId, equals(testId)); }); - testWidgets('UUID conversion validates out of range values', (tester) async { + testWidgets('UUID conversion validates out of range values', ( + tester, + ) async { // Negative ID should return null final negativeId = BigInt.from(-1); final negativeUuid = RejoinStudyService.convertBigIntToUuid(negativeId); @@ -110,15 +127,14 @@ void main() { expect(find.byType(OutlinedButton), findsOneWidget); }); - testWidgets('RecoveryPhraseContent widget loads and displays phrase', - (tester) async { + testWidgets('RecoveryPhraseContent widget loads and displays phrase', ( + tester, + ) async { await tester.pumpWidget( const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: Scaffold( - body: RecoveryPhraseContent(), - ), + home: Scaffold(body: RecoveryPhraseContent()), ), ); @@ -129,9 +145,11 @@ void main() { await tester.pumpAndSettle(const Duration(seconds: 2)); // Should either show error or the phrase content - final hasError = find.textContaining('Error').evaluate().isNotEmpty || + final hasError = + find.textContaining('Error').evaluate().isNotEmpty || find.textContaining('Failed').evaluate().isNotEmpty; - final hasContent = find.byType(GridView).evaluate().isNotEmpty || + final hasContent = + find.byType(GridView).evaluate().isNotEmpty || find.byType(Wrap).evaluate().isNotEmpty; expect(hasError || hasContent, isTrue); @@ -139,8 +157,19 @@ void main() { testWidgets('Recovery QR utils generate valid deep links', (tester) async { final testPhrase = [ - 'abandon', 'ability', 'able', 'about', 'above', 'absent', - 'absorb', 'abstract', 'absurd', 'abuse', 'access', 'accident', 'account' + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', ]; final deepLink = RecoveryQrUtils.generateDeepLink(testPhrase); diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index 3d82dd3bc..44ddf6b6a 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -262,12 +262,14 @@ class _RejoinStudyScreenState extends State { )!.enter_recovery_phrase, hintText: 'apple banana cherry ...', border: const OutlineInputBorder(), - helperText: '${_words.length}/${RecoveryConstants.totalWordCount} words', + helperText: + '${_words.length}/${RecoveryConstants.totalWordCount} words', helperStyle: TextStyle( color: _words.length == RecoveryConstants.totalWordCount ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: _words.length == RecoveryConstants.totalWordCount + fontWeight: + _words.length == RecoveryConstants.totalWordCount ? FontWeight.bold : FontWeight.normal, ), diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/rejoin_study_service.dart index 37feab396..eaac6f5d3 100644 --- a/app/lib/services/rejoin_study_service.dart +++ b/app/lib/services/rejoin_study_service.dart @@ -150,10 +150,7 @@ class RejoinStudyService { try { final uuidString = convertBigIntToUuid(recoveryId); if (uuidString == null) { - return RecoveryResult( - success: false, - error: 'invalid_recovery_id', - ); + return RecoveryResult(success: false, error: 'invalid_recovery_id'); } final response = await Supabase.instance.client.rpc( 'recover_account', diff --git a/app/lib/util/recovery_qr_utils.dart b/app/lib/util/recovery_qr_utils.dart index 2b1c2fb51..8aca4036d 100644 --- a/app/lib/util/recovery_qr_utils.dart +++ b/app/lib/util/recovery_qr_utils.dart @@ -12,7 +12,8 @@ import 'package:studyu_core/core.dart'; import 'package:universal_html/html.dart' as html; class RecoveryQrUtils { - static String get deepLinkDomain => core.deepLinkDomain ?? 'app.studyu.health'; + static String get deepLinkDomain => + core.deepLinkDomain ?? 'app.studyu.health'; static const String deepLinkPath = '/recover'; static const String customScheme = 'studyu'; diff --git a/core/lib/src/util/recovery.dart b/core/lib/src/util/recovery.dart index ec22943b5..3403b7b96 100644 --- a/core/lib/src/util/recovery.dart +++ b/core/lib/src/util/recovery.dart @@ -18,9 +18,7 @@ class RecoveryConstants { static const bool defaultUseChecksum = true; /// Default encoding configuration - static const EncodingConfig defaultConfig = EncodingConfig( - - ); + static const EncodingConfig defaultConfig = EncodingConfig(); } class EncodingConfig { diff --git a/core/test/util/recovery_test.dart b/core/test/util/recovery_test.dart index c831d0f12..20cc170c7 100644 --- a/core/test/util/recovery_test.dart +++ b/core/test/util/recovery_test.dart @@ -110,8 +110,11 @@ void main() { // All words should be from English wordlist for (final word in words) { - expect(wordlistEn.contains(word), isTrue, - reason: 'Word "$word" not found in English wordlist'); + expect( + wordlistEn.contains(word), + isTrue, + reason: 'Word "$word" not found in English wordlist', + ); } }); @@ -121,8 +124,11 @@ void main() { // All words should be from German wordlist for (final word in words) { - expect(wordlistDe.contains(word), isTrue, - reason: 'Word "$word" not found in German wordlist'); + expect( + wordlistDe.contains(word), + isTrue, + reason: 'Word "$word" not found in German wordlist', + ); } // Should decode correctly with German wordlist @@ -144,7 +150,7 @@ void main() { 'lemon', 'mango', 'nectarine', - 'orange' + 'orange', ]; expect(() => decode(invalidWords), throwsArgumentError); @@ -207,8 +213,11 @@ void main() { final id = testIds[i]; final words = encode(id); final decoded = decode(words); - expect(decoded, equals(id), - reason: 'Failed for ID: ${id.toRadixString(16)}'); + expect( + decoded, + equals(id), + reason: 'Failed for ID: ${id.toRadixString(16)}', + ); }); } }); From caf6fb594e8c28d92868d8400230797ab4ddf227 Mon Sep 17 00:00:00 2001 From: johannesvedder <104295301+johannesvedder@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:52:56 +0000 Subject: [PATCH 22/57] chore: Apply static analysis changes --- app/lib/app_router.dart | 1 - app/lib/screens/app_onboarding/welcome.dart | 3 +-- app/lib/screens/study/dashboard/settings.dart | 2 +- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/app/lib/app_router.dart b/app/lib/app_router.dart index ae7b48ae7..f1726a2e4 100644 --- a/app/lib/app_router.dart +++ b/app/lib/app_router.dart @@ -27,7 +27,6 @@ import 'package:studyu_app/screens/study/report/report_details.dart'; import 'package:studyu_app/screens/study/report/report_history.dart'; import 'package:studyu_app/screens/study/tasks/task_screen.dart'; import 'package:studyu_core/core.dart'; -import 'package:studyu_core/env.dart'; /// Route name constants class RouteNames { diff --git a/app/lib/screens/app_onboarding/welcome.dart b/app/lib/screens/app_onboarding/welcome.dart index f49d0e973..11ccf962c 100644 --- a/app/lib/screens/app_onboarding/welcome.dart +++ b/app/lib/screens/app_onboarding/welcome.dart @@ -68,8 +68,7 @@ class WelcomeScreen extends StatelessWidget { ), const SizedBox(height: 20), TextButton( - onPressed: () => - context.pushNamed(RouteNames.rejoinStudy), + onPressed: () => context.pushNamed(RouteNames.rejoinStudy), child: Text( AppLocalizations.of(context)!.rejoin_study, style: const TextStyle(fontSize: 16, color: Colors.grey), diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 93c66b91f..c00e7cac2 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -226,7 +226,7 @@ class _SettingsState extends State { const SizedBox(height: 12), // Leave study button FilledButton.icon( - icon: const Icon(MdiIcons.exitToApp), + icon: const Icon(MdiIcons.exitToApp), label: Text(AppLocalizations.of(context)!.opt_out), style: FilledButton.styleFrom( backgroundColor: Colors.red[700], From 0fe7012103f4a3304303ec395f24b9d89cbc5afd Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Mon, 6 Jul 2026 13:19:57 +0200 Subject: [PATCH 23/57] fix(app,core,db): address code review findings from dev merge P1: env.dart late final String? -> plain String? for deepLinkDomain/appDeepLinkScheme; root cause of LateInitializationError crashes in GoRouter redirect and recovery QR tests. P1: RejoinStudyService.clearCache() called on logout/account deletion. P1: get_study_record_from_invite return type changed public.study -> jsonb; DROP+CREATE instead of CREATE OR REPLACE (SQLSTATE 42P13), with dependent study_subject policy dropped/recreated. P2: fetchByInviteCode preselected_intervention_ids now returned from SECURITY DEFINER RPC (anon RLS-safe). P2: _processInviteDeepLink threads activeStudyId. P2: InviteCodeDialog splits catch-all into PostgrestException/null/ArgumentError; showAppOutdatedDialog wired. P2: study_access_test anon count 4 -> 3. P3: remove unused material_design_icons_flutter dep; showcaseview caret; .env.local.example STUDYU_APP_DEEP_LINK_SCHEME; remove dead deferredInviteDeepLinkHost; RecoveryPhraseWidget expansion gate; remove dead showConfirmation params. --- .../app_onboarding/loading_screen.dart | 4 ++ app/lib/screens/study/dashboard/settings.dart | 29 +++++------ .../study/onboarding/study_selection.dart | 32 +++++++++++-- app/lib/services/deep_link_service.dart | 3 ++ app/lib/services/deferred_link_service.dart | 6 --- app/lib/services/rejoin_study_service.dart | 4 ++ app/lib/widgets/recovery_phrase_content.dart | 9 +--- app/pubspec.yaml | 3 +- .../services/deferred_link_service_test.dart | 9 ---- core/lib/src/env/env.dart | 4 +- core/lib/src/models/tables/study.dart | 21 ++++---- flutter_common/lib/envs/.env.local.example | 1 + pubspec.lock | 8 ---- .../20260706074059_fix_invite_permissions.sql | 48 +++++++++++++++---- 14 files changed, 103 insertions(+), 78 deletions(-) diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index bdd7c6fa3..ed80f4112 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -16,6 +16,7 @@ import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; import 'package:studyu_app/services/deep_link_error_helper.dart'; import 'package:studyu_app/services/deep_link_service.dart'; import 'package:studyu_app/services/deferred_link_service.dart'; +import 'package:studyu_app/services/rejoin_study_service.dart'; import 'package:studyu_app/util/cache.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; import 'package:studyu_app/widgets/deep_link_onboarding_widgets.dart'; @@ -352,6 +353,9 @@ class _LoadingScreenState extends State { StudyULogger.warning( "Subject $selectedSubjectId was deleted from backend. Showing recovery screen.", ); + // The cached recovery secret belongs to the deleted account; clear it + // so a subsequent user on this device cannot read it. + RejoinStudyService.clearCache(); if (!mounted) return; context.go( '/${RouteNames.appErrorScreen}', diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index c00e7cac2..013c298fb 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -277,14 +277,7 @@ class RecoveryPhraseWidget extends StatefulWidget { } class _RecoveryPhraseWidgetState extends State { - final GlobalKey _contentKey = GlobalKey(); - bool _hasLoaded = false; - - void _onExpansionChanged(bool expanded) { - if (expanded && !_hasLoaded) { - _hasLoaded = true; - } - } + bool _hasExpanded = false; @override Widget build(BuildContext context) { @@ -302,19 +295,21 @@ class _RecoveryPhraseWidgetState extends State { AppLocalizations.of(context)!.recovery_phrase_save_hint, style: theme.textTheme.bodySmall, ), - onExpansionChanged: _onExpansionChanged, + onExpansionChanged: (expanded) { + if (expanded && !_hasExpanded) { + setState(() => _hasExpanded = true); + } + }, children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Container( + if (_hasExpanded) + Padding( padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: theme.colorScheme.surface), - child: RecoveryPhraseContent( - key: _contentKey, - useGridLayout: false, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: theme.colorScheme.surface), + child: const RecoveryPhraseContent(useGridLayout: false), ), ), - ), ], ), ); diff --git a/app/lib/screens/study/onboarding/study_selection.dart b/app/lib/screens/study/onboarding/study_selection.dart index 7043b24c8..d2e35e02b 100644 --- a/app/lib/screens/study/onboarding/study_selection.dart +++ b/app/lib/screens/study/onboarding/study_selection.dart @@ -12,6 +12,7 @@ import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/study_tile.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; +import 'package:supabase/supabase.dart' show PostgrestException; Future navigateToStudyOverview( BuildContext context, @@ -290,10 +291,33 @@ class _InviteCodeDialogState extends State { preselectedIds: invite?.preselectedInterventionIds, ); } catch (e) { - if (!mounted) return; - setState(() { - _errorMessage = AppLocalizations.of(context)!.invalid_invite_code; - }); + if (e is ArgumentError) { + // Study.fromJson schema mismatch — the study was authored with a + // newer app/schema than this client understands. Signal the user + // to update the app rather than mislabeling it as an invalid code. + if (!mounted) return; + setState(() { + _errorMessage = null; + }); + if (!context.mounted) return; + context.pop(); + await showAppOutdatedDialog(context); + } else if (e is PostgrestException) { + // RPC / network failure while looking up the invite code. + if (!mounted) return; + setState(() { + _errorMessage = AppLocalizations.of( + context, + )!.error_occurred_with_message(e.message); + }); + } else { + if (!mounted) return; + setState(() { + _errorMessage = AppLocalizations.of( + context, + )!.invalid_invite_code; + }); + } } }, ), diff --git a/app/lib/services/deep_link_service.dart b/app/lib/services/deep_link_service.dart index 1b8a12e5b..433f4d42a 100644 --- a/app/lib/services/deep_link_service.dart +++ b/app/lib/services/deep_link_service.dart @@ -86,6 +86,7 @@ class DeepLinkService { final result = await _processInviteDeepLink( inviteCode: inviteCode, isAuthenticated: isAuthenticated, + activeStudyId: activeStudyId, ); if (result is DeepLinkError && result.errorValue == null) { return DeepLinkError( @@ -145,6 +146,7 @@ class DeepLinkService { static Future _processInviteDeepLink({ required String inviteCode, required bool isAuthenticated, + String? activeStudyId, }) async { try { final (invite, study) = await Study.fetchByInviteCode(inviteCode); @@ -165,6 +167,7 @@ class DeepLinkService { study: study, inviteCode: inviteCode, preselectedInterventionIds: invite.preselectedInterventionIds, + alreadyEnrolled: activeStudyId == study.id, ); } catch (e) { debugPrint('Failed to fetch study by invite code: $e'); diff --git a/app/lib/services/deferred_link_service.dart b/app/lib/services/deferred_link_service.dart index 43f24eeaf..d3287576b 100644 --- a/app/lib/services/deferred_link_service.dart +++ b/app/lib/services/deferred_link_service.dart @@ -3,12 +3,6 @@ import 'package:stack_deferred_link/stack_deferred_link.dart'; import 'package:studyu_core/env.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; -@visibleForTesting -String deferredInviteDeepLinkHost(String? configuredDeepLinkScheme) { - final scheme = configuredDeepLinkScheme ?? 'https://app.studyu.health'; - return Uri.parse(scheme).host; -} - class DeferredLink { final String? inviteCode; final String? studyId; diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/rejoin_study_service.dart index eaac6f5d3..21fc5af71 100644 --- a/app/lib/services/rejoin_study_service.dart +++ b/app/lib/services/rejoin_study_service.dart @@ -182,6 +182,10 @@ class RejoinStudyService { } static Future performRecovery(BigInt recoveryId) async { + // Invalidate any cached recovery secret from a prior session before + // establishing the recovered identity, so it cannot leak to the new + // account via the static cache on a shared device. + clearCache(); try { final result = await recoverAccount(recoveryId); diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart index 144f43412..37c258876 100644 --- a/app/lib/widgets/recovery_phrase_content.dart +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -6,15 +6,8 @@ import 'package:studyu_app/util/recovery_qr_utils.dart'; class RecoveryPhraseContent extends StatefulWidget { final bool useGridLayout; - final bool showConfirmation; - final ValueChanged? onConfirmationChanged; - const RecoveryPhraseContent({ - super.key, - this.useGridLayout = true, - this.showConfirmation = false, - this.onConfirmationChanged, - }); + const RecoveryPhraseContent({super.key, this.useGridLayout = true}); @override State createState() => RecoveryPhraseContentState(); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 07ec4b466..eca79a307 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -40,7 +40,6 @@ dependencies: intersperse: ^2.0.0 intl: ^0.20.2 introduction_screen: ^4.0.0 - material_design_icons_flutter: ^7.0.7296 mobile_scanner: ^7.1.3 package_info_plus: ^10.2.0 path: ^1.9.1 @@ -57,7 +56,7 @@ dependencies: sentry_logging: ^9.22.0 share_plus: ^13.0.0 shared_preferences: ^2.5.5 - showcaseview: 5.1.0 + showcaseview: ^5.1.0 stack_deferred_link: ^1.0.1 statistics: ^1.2.1 studyu_core: ^4.9.0 diff --git a/app/test/services/deferred_link_service_test.dart b/app/test/services/deferred_link_service_test.dart index b92d580fb..14955daa2 100644 --- a/app/test/services/deferred_link_service_test.dart +++ b/app/test/services/deferred_link_service_test.dart @@ -2,15 +2,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:studyu_app/services/deferred_link_service.dart'; void main() { - group('deferredInviteDeepLinkHost', () { - test('uses configured deep link host', () { - expect( - deferredInviteDeepLinkHost('https://example.studyu.health'), - 'example.studyu.health', - ); - }); - }); - group('parseAndroidDeferredLink', () { test('parses invite links from new Android referrer key', () { final link = parseAndroidDeferredLink( diff --git a/core/lib/src/env/env.dart b/core/lib/src/env/env.dart index dc7b7dfff..184ac78bf 100644 --- a/core/lib/src/env/env.dart +++ b/core/lib/src/env/env.dart @@ -9,8 +9,8 @@ late final String? projectGeneratorUrl; late final String? androidPackageName; late final String? iosAppStoreId; late final String? developerEmail; -late final String? deepLinkDomain; -late final String? appDeepLinkScheme; +String? deepLinkDomain; +String? appDeepLinkScheme; String get appScheme { if (appDeepLinkScheme != null) { diff --git a/core/lib/src/models/tables/study.dart b/core/lib/src/models/tables/study.dart index 6a922de81..09eb4a9a3 100644 --- a/core/lib/src/models/tables/study.dart +++ b/core/lib/src/models/tables/study.dart @@ -288,6 +288,11 @@ class Study extends SupabaseObjectFunctions } /// Fetches a study by invite code using the RPC function. + /// + /// The RPC is SECURITY DEFINER and returns the full study row plus the + /// matched invite's preselected_intervention_ids as a single jsonb object, + /// so anon deep-link callers can resolve both in one call without needing + /// SELECT access to study_invite (RLS blocks non-editors). /// Returns the Study and StudyInvite, or nulls if not found. static Future<(StudyInvite?, Study?)> fetchByInviteCode(String code) async { final cleanCode = code.trim().toLowerCase(); @@ -305,20 +310,10 @@ class Study extends SupabaseObjectFunctions final study = Study.fromJson(studyResult); - // Fetch preselected_intervention_ids from study_invite table - final inviteResult = await env.client - .from(StudyInvite.tableName) - .select('preselected_intervention_ids') - .eq('code', cleanCode) - .maybeSingle(); - List? preselectedIds; - if (inviteResult != null && - inviteResult.containsKey('preselected_intervention_ids') && - inviteResult['preselected_intervention_ids'] != null) { - preselectedIds = List.from( - inviteResult['preselected_intervention_ids'] as List, - ); + final preselected = studyResult['preselected_intervention_ids']; + if (preselected != null) { + preselectedIds = List.from(preselected as List); } final invite = StudyInvite(cleanCode, study.id) diff --git a/flutter_common/lib/envs/.env.local.example b/flutter_common/lib/envs/.env.local.example index f4e76a5df..5a78ed557 100644 --- a/flutter_common/lib/envs/.env.local.example +++ b/flutter_common/lib/envs/.env.local.example @@ -7,3 +7,4 @@ STUDYU_ANDROID_PACKAGE_ID=health.studyu.app STUDYU_IOS_APP_STORE_ID=1571991198 STUDYU_DEVELOPER_EMAIL=stefan.konigorski@hpi.de STUDYU_DEEP_LINK_DOMAIN=app.studyu.health +STUDYU_APP_DEEP_LINK_SCHEME=studyu-app:// diff --git a/pubspec.lock b/pubspec.lock index cdf1f5eae..98523ba2e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1213,14 +1213,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" - material_design_icons_flutter: - dependency: transitive - description: - name: material_design_icons_flutter - sha256: "6f986b7a51f3ad4c00e33c5c84e8de1bdd140489bbcdc8b66fc1283dad4dea5a" - url: "https://pub.dev" - source: hosted - version: "7.0.7296" melos: dependency: "direct dev" description: diff --git a/supabase/migrations/20260706074059_fix_invite_permissions.sql b/supabase/migrations/20260706074059_fix_invite_permissions.sql index fa2b1e8f9..ce0932a30 100644 --- a/supabase/migrations/20260706074059_fix_invite_permissions.sql +++ b/supabase/migrations/20260706074059_fix_invite_permissions.sql @@ -10,23 +10,40 @@ BEGIN; -- links. The app needs to resolve invite links and open-study links before the -- participant has signed in or completed onboarding. -- +-- Additionally, get_study_record_from_invite now returns the invite's +-- preselected_intervention_ids alongside the study row so the app can resolve +-- an invite (including its preselected interventions) in a single RPC. Anon +-- deep-link callers cannot SELECT study_invite (RLS blocks non-edititors), so +-- the preselected ids must come from this SECURITY DEFINER function rather +-- than a second client query. The return type changes from public.study to +-- jsonb to carry the extra field without a fragile flat composite type. -- ============================================================================ +-- The return type changes from public.study to jsonb, so CREATE OR REPLACE +-- is rejected (SQLSTATE 42P13: cannot change return type of existing +-- function). Drop the dependent INSERT policy first (it references the +-- function), then DROP the function itself, then recreate with jsonb. +DROP POLICY IF EXISTS "Invite code must match study_id" ON public.study_subject; + +DROP FUNCTION IF EXISTS public.get_study_record_from_invite(text); + -- Normalize invite-code lookup so mobile keyboard capitalization and copied --- whitespace do not make otherwise valid invite links fail. -CREATE OR REPLACE FUNCTION public.get_study_record_from_invite(invite_code text) -RETURNS public.study +-- whitespace do not make otherwise valid invite links fail. Returns the full +-- study row plus the matched invite's preselected_intervention_ids as a single +-- jsonb object, or NULL when the code matches no invite. +CREATE FUNCTION public.get_study_record_from_invite(invite_code text) +RETURNS jsonb LANGUAGE sql STABLE SECURITY DEFINER SET search_path = '' AS $$ - SELECT * FROM public.study - WHERE study.id = ( - SELECT study_invite.study_id - FROM public.study_invite - WHERE lower(trim(invite_code)) = lower(trim(study_invite.code)) - ); + SELECT to_jsonb(s.*) || jsonb_build_object( + 'preselected_intervention_ids', si.preselected_intervention_ids + ) + FROM public.study s + JOIN public.study_invite si ON si.study_id = s.id + WHERE lower(trim(invite_code)) = lower(trim(si.code)); $$; -- Deep-link validation calls this RPC before authentication. Keep PUBLIC @@ -35,6 +52,19 @@ REVOKE EXECUTE ON FUNCTION public.get_study_record_from_invite(text) FROM PUBLIC GRANT EXECUTE ON FUNCTION public.get_study_record_from_invite(text) TO anon; GRANT EXECUTE ON FUNCTION public.get_study_record_from_invite(text) TO authenticated; +-- The restrictive study_subject INSERT policy validates the invite code by +-- looking up the study it belongs to. With the function now returning jsonb, +-- access the id via the jsonb text operator and cast to uuid. +CREATE POLICY "Invite code must match study_id" ON public.study_subject +AS RESTRICTIVE FOR INSERT TO "authenticated" +WITH CHECK ( + (invite_code IS NULL) + OR (study_id IN ( + SELECT (public.get_study_record_from_invite(study_subject.invite_code) ->> 'id')::uuid + )) +); + + -- Public /study/ deep links need anonymous SELECT access for open studies. -- RLS still limits rows to running/closed studies with public visibility. DROP POLICY IF EXISTS "Study visibility" ON public.study; From 2da3f2a12402723f91c6f7cf9567f4322b0d8064 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 7 Jul 2026 11:18:39 +0200 Subject: [PATCH 24/57] fix(db): whitelist invite RPC fields and fix lint warnings - Replace to_jsonb(s.*) with explicit participant-facing fields in get_study_record_from_invite to avoid leaking collaborator_emails and user_id to anon invite holders. - Remove unused v_latest_progress_date from recover_account. - Remove unused original_auth_data from authenticate_as helper. - Qualify ok()/diag() with extensions. schema in is_either_true. --- .../migrations/20251208_user_recovery.sql | 7 ++- .../20260706074059_fix_invite_permissions.sql | 46 ++++++++++++++----- supabase/tests/_helpers/00_test_helpers.sql | 9 ---- .../tests/_helpers/10_generic_helpers.sql | 4 +- 4 files changed, 40 insertions(+), 26 deletions(-) diff --git a/supabase/migrations/20251208_user_recovery.sql b/supabase/migrations/20251208_user_recovery.sql index 99543dbb2..5203e22f7 100644 --- a/supabase/migrations/20251208_user_recovery.sql +++ b/supabase/migrations/20251208_user_recovery.sql @@ -110,7 +110,6 @@ DECLARE v_new_password text; v_encrypted_password text; v_subject_id uuid; - v_latest_progress_date timestamptz; BEGIN -- 1. Look up user_id from recovery table SELECT user_id INTO v_user_id @@ -150,9 +149,9 @@ BEGIN -- 5. Find latest active study subject -- Priority: most recent progress > most recent start date > deterministic ID ordering SELECT - ss.id, - MAX(sp.completed_at) as latest_progress - INTO v_subject_id, v_latest_progress_date + ss.id + INTO v_subject_id + FROM public.study_subject ss LEFT JOIN public.subject_progress sp ON sp.subject_id = ss.id WHERE diff --git a/supabase/migrations/20260706074059_fix_invite_permissions.sql b/supabase/migrations/20260706074059_fix_invite_permissions.sql index ce0932a30..b880d95ca 100644 --- a/supabase/migrations/20260706074059_fix_invite_permissions.sql +++ b/supabase/migrations/20260706074059_fix_invite_permissions.sql @@ -10,13 +10,13 @@ BEGIN; -- links. The app needs to resolve invite links and open-study links before the -- participant has signed in or completed onboarding. -- --- Additionally, get_study_record_from_invite now returns the invite's --- preselected_intervention_ids alongside the study row so the app can resolve --- an invite (including its preselected interventions) in a single RPC. Anon --- deep-link callers cannot SELECT study_invite (RLS blocks non-edititors), so --- the preselected ids must come from this SECURITY DEFINER function rather --- than a second client query. The return type changes from public.study to --- jsonb to carry the extra field without a fragile flat composite type. +-- Additionally, get_study_record_from_invite now returns only the participant- +-- facing study payload plus the invite's preselected_intervention_ids, so the +-- app can resolve an invite (including its preselected interventions) in a +-- single RPC. Anon deep-link callers cannot SELECT study_invite (RLS blocks +-- non-editors), so the preselected ids must come from this SECURITY DEFINER +-- function rather than a second client query. Draft studies remain resolvable +-- by invite code so researchers can test them before publishing. -- ============================================================================ -- The return type changes from public.study to jsonb, so CREATE OR REPLACE @@ -28,9 +28,10 @@ DROP POLICY IF EXISTS "Invite code must match study_id" ON public.study_subject; DROP FUNCTION IF EXISTS public.get_study_record_from_invite(text); -- Normalize invite-code lookup so mobile keyboard capitalization and copied --- whitespace do not make otherwise valid invite links fail. Returns the full --- study row plus the matched invite's preselected_intervention_ids as a single --- jsonb object, or NULL when the code matches no invite. +-- whitespace do not make otherwise valid invite links fail. Returns the study +-- fields needed by the participant app plus the matched invite's +-- preselected_intervention_ids as a single jsonb object, or NULL when the code +-- matches no invite. CREATE FUNCTION public.get_study_record_from_invite(invite_code text) RETURNS jsonb LANGUAGE sql @@ -38,7 +39,30 @@ STABLE SECURITY DEFINER SET search_path = '' AS $$ - SELECT to_jsonb(s.*) || jsonb_build_object( + SELECT jsonb_build_object( + 'id', s.id, + -- Study.fromJson requires user_id, but invite callers do not need the + -- owner UUID. Return a neutral UUID instead of leaking editor identity. + 'user_id', '00000000-0000-0000-0000-000000000000', + 'title', s.title, + 'description', s.description, + 'participation', s.participation, + 'result_sharing', s.result_sharing, + 'contact', s.contact, + 'icon_name', s.icon_name, + 'published', s.published, + 'status', s.status, + 'questionnaire', s.questionnaire, + 'eligibility_criteria', s.eligibility_criteria, + 'consent', s.consent, + 'interventions', s.interventions, + 'observations', s.observations, + 'schedule', s.schedule, + 'report_specification', s.report_specification, + 'results', s.results, + -- Editor emails are not participant-facing invite data. + 'collaborator_emails', '[]'::jsonb, + 'registry_published', s.registry_published, 'preselected_intervention_ids', si.preselected_intervention_ids ) FROM public.study s diff --git a/supabase/tests/_helpers/00_test_helpers.sql b/supabase/tests/_helpers/00_test_helpers.sql index 52ab1f1b3..608f1abfc 100644 --- a/supabase/tests/_helpers/00_test_helpers.sql +++ b/supabase/tests/_helpers/00_test_helpers.sql @@ -143,10 +143,7 @@ CREATE OR REPLACE FUNCTION tests.authenticate_as (identifier text) AS $$ DECLARE user_data json; - original_auth_data text; BEGIN - -- store the request.jwt.claims in a variable in case we need it - original_auth_data := current_setting('request.jwt.claims', true); user_data := tests.get_supabase_user(identifier); if user_data is null OR user_data ->> 'id' IS NULL then @@ -157,12 +154,6 @@ CREATE OR REPLACE FUNCTION tests.authenticate_as (identifier text) perform set_config('role', 'authenticated', true); perform set_config('request.jwt.claims', json_build_object('sub', user_data ->> 'id', 'email', user_data ->> 'email', 'phone', user_data ->> 'phone')::text, true); - EXCEPTION - -- revert back to original auth data - WHEN OTHERS THEN - set local role authenticated; - set local "request.jwt.claims" to original_auth_data; - RAISE; END $$ LANGUAGE plpgsql; diff --git a/supabase/tests/_helpers/10_generic_helpers.sql b/supabase/tests/_helpers/10_generic_helpers.sql index 48d9e7699..1133f0b4f 100644 --- a/supabase/tests/_helpers/10_generic_helpers.sql +++ b/supabase/tests/_helpers/10_generic_helpers.sql @@ -21,7 +21,7 @@ BEGIN END IF; END LOOP; - output := ok(result, description); + output := extensions.ok(result, description); -- Build the result details only if there are false results IF NOT result THEN @@ -30,7 +30,7 @@ BEGIN END LOOP; END IF; - RETURN output || CASE result WHEN TRUE THEN '' ELSE E'\n' || diag(result_details) END; + RETURN output || CASE result WHEN TRUE THEN '' ELSE E'\n' || extensions.diag(result_details) END; END; $$ LANGUAGE plpgsql; From c7b75868e307cfd8dbf631743d92a7f1042bd1e7 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 13:33:30 +0200 Subject: [PATCH 25/57] refactor(app): simplify onboarding layouts --- app/lib/screens/app_onboarding/terms.dart | 171 +++++++------- app/lib/screens/study/onboarding/consent.dart | 2 - .../onboarding/intervention_selection.dart | 2 - .../study/onboarding/journey_overview.dart | 6 +- .../study/onboarding/study_overview.dart | 1 - .../study/onboarding/study_selection.dart | 220 +++++++++--------- app/lib/widgets/onboarding_page.dart | 116 +++++++++ 7 files changed, 302 insertions(+), 216 deletions(-) create mode 100644 app/lib/widgets/onboarding_page.dart diff --git a/app/lib/screens/app_onboarding/terms.dart b/app/lib/screens/app_onboarding/terms.dart index 4d6f7c180..ec372d17b 100644 --- a/app/lib/screens/app_onboarding/terms.dart +++ b/app/lib/screens/app_onboarding/terms.dart @@ -9,6 +9,7 @@ import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/services/deep_link_error_helper.dart'; import 'package:studyu_app/services/deep_link_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; +import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -74,95 +75,93 @@ class _TermsScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: SafeArea( - child: Column( - children: [ - Expanded( - child: Center( - child: RetryFutureBuilder( - tryFunction: AppConfig.getAppConfig, - successBuilder: - (BuildContext context, AppConfig? appConfig) => - legalSection(context, appConfig), - ), - ), - ), - ], + body: OnboardingPage( + title: '', + description: '', + bottomCheckboxItems: _acknowledgmentItems(), + bottomNavigationBar: _buildNavigation(), + child: RetryFutureBuilder( + tryFunction: AppConfig.getAppConfig, + successBuilder: (BuildContext context, AppConfig? appConfig) => + legalSection(context, appConfig), ), ), - bottomNavigationBar: BottomOnboardingNavigation( - backButtonKey: const ValueKey('terms_back'), - onBack: () { - if (context.canPop()) { - context.pop(); - } else { - context.go('/${RouteNames.welcome}'); - } - }, - nextButtonKey: const ValueKey('terms_continue'), - onNext: userCanContinue() - ? () async { - final success = await ensureParticipantSignedIn(); - if (success) { - if (!context.mounted) return; - final state = context.read(); - if (state.hasPendingDeepLink) { - await _handlePendingDeepLink(state); - } else { - context.push('/${RouteNames.recoveryPhrase}'); - } - } - } - : null, - ), ); } Widget legalSection(BuildContext context, AppConfig? appConfig) { final appLocale = Localizations.localeOf(context); - return SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - LegalSection( - title: AppLocalizations.of(context)!.terms, - description: AppLocalizations.of(context)!.terms_content, - acknowledgment: AppLocalizations.of(context)!.terms_agree, - onChange: (val) => setState(() => _acceptedTerms = val!), - isChecked: _acceptedTerms, - icon: const Icon(MdiIcons.fileDocumentEdit), - pdfUrl: appConfig!.appTerms[appLocale.languageCode], - pdfUrlLabel: AppLocalizations.of(context)!.terms_read, - ), - const SizedBox(height: 20), - LegalSection( - title: AppLocalizations.of(context)!.privacy, - description: AppLocalizations.of(context)!.privacy_content, - acknowledgment: AppLocalizations.of(context)!.privacy_agree, - onChange: (val) => setState(() => _acceptedPrivacy = val!), - isChecked: _acceptedPrivacy, - icon: const Icon(MdiIcons.shieldLock), - pdfUrl: appConfig.appPrivacy[appLocale.languageCode], - pdfUrlLabel: AppLocalizations.of(context)!.privacy_read, - ), - const SizedBox(height: 30), - OutlinedButton.icon( - icon: const Icon(MdiIcons.scaleBalance), - onPressed: () async { - final uri = Uri.parse( - appConfig.imprint[appLocale.languageCode]!, - ); - if (await canLaunchUrl(uri)) { - launchUrl(uri, mode: LaunchMode.externalApplication); - } - }, - label: Text(AppLocalizations.of(context)!.imprint_read), - ), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + LegalSection( + title: AppLocalizations.of(context)!.terms, + description: AppLocalizations.of(context)!.terms_content, + icon: const Icon(MdiIcons.fileDocumentEdit), + pdfUrl: appConfig!.appTerms[appLocale.languageCode], + pdfUrlLabel: AppLocalizations.of(context)!.terms_read, ), + const SizedBox(height: 20), + LegalSection( + title: AppLocalizations.of(context)!.privacy, + description: AppLocalizations.of(context)!.privacy_content, + icon: const Icon(MdiIcons.shieldLock), + pdfUrl: appConfig.appPrivacy[appLocale.languageCode], + pdfUrlLabel: AppLocalizations.of(context)!.privacy_read, + ), + const SizedBox(height: 20), + LegalSection( + title: AppLocalizations.of(context)!.legal_notice, + description: AppLocalizations.of(context)!.legal_notice_content, + icon: const Icon(MdiIcons.scaleBalance), + pdfUrl: appConfig.imprint[appLocale.languageCode], + pdfUrlLabel: AppLocalizations.of(context)!.imprint_read, + ), + ], + ); + } + + List _acknowledgmentItems() { + return [ + OnboardingCheckboxItem( + label: AppLocalizations.of(context)!.terms_agree, + value: _acceptedTerms, + onChanged: (val) => setState(() => _acceptedTerms = val ?? false), + ), + OnboardingCheckboxItem( + label: AppLocalizations.of(context)!.privacy_agree, + value: _acceptedPrivacy, + onChanged: (val) => setState(() => _acceptedPrivacy = val ?? false), ), + ]; + } + + Widget _buildNavigation() { + return BottomOnboardingNavigation( + backButtonKey: const ValueKey('terms_back'), + onBack: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/${RouteNames.welcome}'); + } + }, + nextButtonKey: const ValueKey('terms_continue'), + onNext: userCanContinue() + ? () async { + final success = await ensureParticipantSignedIn(); + if (success) { + if (!mounted) return; + final state = context.read(); + if (state.hasPendingDeepLink) { + await _handlePendingDeepLink(state); + } else { + context.push('/${RouteNames.recoveryPhrase}'); + } + } + } + : null, ); } } @@ -173,9 +172,6 @@ class LegalSection extends StatelessWidget { final Icon? icon; final String? pdfUrl; final String? pdfUrlLabel; - final String? acknowledgment; - final bool? isChecked; - final ValueChanged? onChange; const LegalSection({ super.key, @@ -184,14 +180,12 @@ class LegalSection extends StatelessWidget { this.icon, this.pdfUrl, this.pdfUrlLabel, - this.acknowledgment, - this.isChecked, - this.onChange, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); + return Column( children: [ Text( @@ -213,11 +207,6 @@ class LegalSection extends StatelessWidget { }, label: Text(pdfUrlLabel!), ), - CheckboxListTile( - title: Text(acknowledgment!), - value: isChecked, - onChanged: onChange, - ), ], ); } diff --git a/app/lib/screens/study/onboarding/consent.dart b/app/lib/screens/study/onboarding/consent.dart index 8d50297fb..4c9d29bb8 100644 --- a/app/lib/screens/study/onboarding/consent.dart +++ b/app/lib/screens/study/onboarding/consent.dart @@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart' show kDebugMode, kIsWeb; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:provider/provider.dart'; @@ -75,7 +74,6 @@ class _ConsentScreenState extends State { return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.consent), - leading: const Icon(MdiIcons.textBoxCheck), actions: [ IconButton( icon: const Icon(Icons.save), diff --git a/app/lib/screens/study/onboarding/intervention_selection.dart b/app/lib/screens/study/onboarding/intervention_selection.dart index 9f15e2d24..8a222cbe4 100644 --- a/app/lib/screens/study/onboarding/intervention_selection.dart +++ b/app/lib/screens/study/onboarding/intervention_selection.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:studyu_app/app_router.dart'; @@ -107,7 +106,6 @@ class _InterventionSelectionScreenState return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context)!.intervention_selection_title), - leading: const Icon(MdiIcons.formatListChecks), ), body: SingleChildScrollView( child: Center( diff --git a/app/lib/screens/study/onboarding/journey_overview.dart b/app/lib/screens/study/onboarding/journey_overview.dart index 2fd148e71..30f8a4658 100644 --- a/app/lib/screens/study/onboarding/journey_overview.dart +++ b/app/lib/screens/study/onboarding/journey_overview.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:provider/provider.dart'; @@ -53,10 +52,7 @@ class _JourneyOverviewScreen extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.your_journey), - leading: const Icon(MdiIcons.mapMarkerPath), - ), + appBar: AppBar(title: Text(AppLocalizations.of(context)!.your_journey)), body: Center( child: SingleChildScrollView( child: Padding( diff --git a/app/lib/screens/study/onboarding/study_overview.dart b/app/lib/screens/study/onboarding/study_overview.dart index fc804bd30..47541f9d3 100644 --- a/app/lib/screens/study/onboarding/study_overview.dart +++ b/app/lib/screens/study/onboarding/study_overview.dart @@ -76,7 +76,6 @@ class _StudyOverviewScreen extends State { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - leading: const Icon(MdiIcons.textLong), title: Text(AppLocalizations.of(context)!.study_overview_title), ), body: SingleChildScrollView( diff --git a/app/lib/screens/study/onboarding/study_selection.dart b/app/lib/screens/study/onboarding/study_selection.dart index d2e35e02b..56d34c69d 100644 --- a/app/lib/screens/study/onboarding/study_selection.dart +++ b/app/lib/screens/study/onboarding/study_selection.dart @@ -9,10 +9,11 @@ import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; +import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_app/widgets/study_tile.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; -import 'package:supabase/supabase.dart' show PostgrestException; +import 'package:supabase_flutter/supabase_flutter.dart'; Future navigateToStudyOverview( BuildContext context, @@ -78,127 +79,103 @@ class _StudySelectionScreenState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( - body: SafeArea( - child: Center( - child: Column( + body: OnboardingPage( + title: AppLocalizations.of(context)!.study_selection_description, + description: '', + descriptionWidget: RichText( + text: TextSpan( children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Text( - AppLocalizations.of(context)!.study_selection_description, - style: theme.textTheme.headlineSmall, - ), - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: AppLocalizations.of( - context, - )!.study_selection_single, - style: theme.textTheme.titleSmall, - ), - TextSpan( - text: ' ', - style: theme.textTheme.titleSmall, - ), - TextSpan( - text: AppLocalizations.of( - context, - )!.study_selection_single_why, - style: theme.textTheme.titleSmall!.copyWith( - color: theme.primaryColor, - ), - recognizer: TapGestureRecognizer() - ..onTap = () => showDialog( - context: context, - builder: (context) => AlertDialog( - content: Text( - AppLocalizations.of( - context, - )!.study_selection_single_reason, - ), - ), - ), - ), - ], - ), - ), - ], - ), + TextSpan( + text: AppLocalizations.of(context)!.study_selection_single, + style: theme.textTheme.titleSmall, ), - if (_hiddenStudies) - Column( - children: [ - MaterialBanner( - padding: const EdgeInsets.all(8), - leading: const Icon( - MdiIcons.exclamationThick, - color: Colors.orange, - size: 32, - ), + TextSpan(text: ' ', style: theme.textTheme.titleSmall), + TextSpan( + text: AppLocalizations.of(context)!.study_selection_single_why, + style: theme.textTheme.titleSmall!.copyWith( + color: theme.primaryColor, + ), + recognizer: TapGestureRecognizer() + ..onTap = () => showDialog( + context: context, + builder: (context) => AlertDialog( content: Text( AppLocalizations.of( context, - )!.study_selection_hidden_studies, - style: Theme.of(context).textTheme.titleSmall, + )!.study_selection_single_reason, ), - actions: const [SizedBox.shrink()], - backgroundColor: Colors.yellow[100], ), - const SizedBox(height: 16), - ], - ) - else - const SizedBox.shrink(), - Expanded( - child: RetryFutureBuilder>( - tryFunction: () => publishedStudies, - successBuilder: - ( - BuildContext context, - ExtractionResult? extractionResult, - ) { - final studies = extractionResult!.extracted; - if (extractionResult - is ExtractionFailedException) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_hiddenStudies) return; - debugPrint( - '${extractionResult.notExtracted.length} studies could not be extracted.', - ); - setState(() { - _hiddenStudies = true; - }); + ), + ), + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (_hiddenStudies) ...[ + MaterialBanner( + padding: const EdgeInsets.all(8), + leading: const Icon( + MdiIcons.exclamationThick, + color: Colors.orange, + size: 32, + ), + content: Text( + AppLocalizations.of(context)!.study_selection_hidden_studies, + style: Theme.of(context).textTheme.titleSmall, + ), + actions: const [SizedBox.shrink()], + backgroundColor: Colors.yellow[100], + ), + const SizedBox(height: 16), + ], + SizedBox( + height: 360, + child: RetryFutureBuilder>( + tryFunction: () => publishedStudies, + successBuilder: + ( + BuildContext context, + ExtractionResult? extractionResult, + ) { + final studies = extractionResult!.extracted; + if (extractionResult + is ExtractionFailedException) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_hiddenStudies) return; + debugPrint( + '${extractionResult.notExtracted.length} studies could not be extracted.', + ); + setState(() { + _hiddenStudies = true; }); - } - return ListView.builder( - itemCount: studies.length, - itemBuilder: (context, index) { - final study = studies[index]; - return Hero( - tag: 'study_tile_${studies[index].id}', - child: Material( - child: StudyTile.fromStudy( - study: study, - onTap: () async { - await navigateToStudyOverview( - context, - study, - ); - }, - ), + }); + } + return ListView.builder( + itemCount: studies.length, + itemBuilder: (context, index) { + final study = studies[index]; + return Hero( + tag: 'study_tile_${studies[index].id}', + child: Material( + child: StudyTile.fromStudy( + study: study, + onTap: () async { + await navigateToStudyOverview(context, study); + }, ), - ); - }, - ); - }, - ), + ), + ); + }, + ); + }, ), - Padding( - padding: const EdgeInsets.all(8), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Center( child: OutlinedButton.icon( icon: const Icon(MdiIcons.key), onPressed: () async { @@ -210,11 +187,24 @@ class _StudySelectionScreenState extends State { label: Text(AppLocalizations.of(context)!.invite_code_button), ), ), - ], - ), + ), + ], ), ), - bottomNavigationBar: const BottomOnboardingNavigation(hideNext: true), + bottomNavigationBar: BottomOnboardingNavigation( + hideNext: true, + onBack: () { + if (context.canPop()) { + context.pop(); + } else { + context.goNamed( + Supabase.instance.client.auth.currentUser == null + ? RouteNames.welcome + : RouteNames.recoveryPhrase, + ); + } + }, + ), ); } } diff --git a/app/lib/widgets/onboarding_page.dart b/app/lib/widgets/onboarding_page.dart new file mode 100644 index 000000000..56c6cf558 --- /dev/null +++ b/app/lib/widgets/onboarding_page.dart @@ -0,0 +1,116 @@ +import 'package:flutter/material.dart'; + +class OnboardingPage extends StatelessWidget { + final String title; + final String description; + final Widget? descriptionWidget; + final Widget child; + final Widget? bottomContent; + final Widget? bottomNavigationBar; + final List bottomCheckboxItems; + final double maxWidth; + final EdgeInsetsGeometry padding; + + const OnboardingPage({ + super.key, + required this.title, + required this.description, + this.descriptionWidget, + required this.child, + this.bottomContent, + this.bottomCheckboxItems = const [], + this.bottomNavigationBar, + this.maxWidth = 700, + this.padding = const EdgeInsets.fromLTRB(24.0, 56.0, 24.0, 16.0), + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SafeArea( + child: Column( + children: [ + Expanded( + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: SingleChildScrollView( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (title.isNotEmpty) ...[ + Text( + title, + style: theme.textTheme.headlineMedium!.copyWith( + color: theme.primaryColor, + ), + ), + const SizedBox(height: 20), + ], + if (descriptionWidget != null || + description.isNotEmpty) ...[ + descriptionWidget ?? Text(description), + const SizedBox(height: 24), + ], + child, + ], + ), + ), + ), + ), + ), + if (bottomContent != null || bottomCheckboxItems.isNotEmpty) + Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: + bottomContent ?? + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 24.0, + vertical: 8.0, + ), + child: Material( + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final item in bottomCheckboxItems) + CheckboxListTile( + title: Text( + item.label, + style: Theme.of(context).textTheme.bodyMedium, + ), + value: item.value, + onChanged: item.onChanged, + contentPadding: EdgeInsets.zero, + controlAffinity: + ListTileControlAffinity.leading, + ), + ], + ), + ), + ), + ), + ), + if (bottomNavigationBar != null) bottomNavigationBar!, + ], + ), + ); + } +} + +class OnboardingCheckboxItem { + final String label; + final bool value; + final ValueChanged onChanged; + + const OnboardingCheckboxItem({ + required this.label, + required this.value, + required this.onChanged, + }); +} From ef11fb52411d2481ea9fc5722b0370020bec28ab Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 13:33:52 +0200 Subject: [PATCH 26/57] fix(app): improve account recovery flow --- app/lib/l10n/app_de.arb | 27 +- app/lib/l10n/app_en.arb | 27 +- app/lib/l10n/app_localizations.dart | 62 +++-- app/lib/l10n/app_localizations_de.dart | 46 ++-- app/lib/l10n/app_localizations_en.dart | 47 ++-- .../recovery_phrase_screen.dart | 133 +++------- .../app_onboarding/rejoin_study_screen.dart | 239 +++++++++++------- app/lib/screens/study/dashboard/settings.dart | 6 +- app/lib/widgets/recovery_phrase_content.dart | 18 +- .../rejoin_study_screen_test.dart | 42 +++ 10 files changed, 359 insertions(+), 288 deletions(-) create mode 100644 app/test/screens/app_onboarding/rejoin_study_screen_test.dart diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 8ffced825..7d3a526e0 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -150,6 +150,8 @@ "not_enough_data": "Nicht genügend Daten", "barely_enough_data": "Kaum genügend Daten", "enough_data": "Genügend Daten", + "legal_documents": "Rechtliche Dokumente", + "legal_documents_description": "Bitte lies und akzeptiere die Nutzungsbedingungen und Datenschutzbestimmung, bevor du fortfährst.", "terms": "Bedingungen", "terms_read": "Nutzungsbedingungen lesen", "terms_content": "Die Nutzungsbedingungen geben einen Überblick zu dem Zweck und der Nutzung der StudyU App. Bei Fragen, bitte wenden Sie sich an uns mit den Kontaktinformationen im Impressum.", @@ -158,6 +160,8 @@ "privacy_read": "Datenschutzbestimmung lesen", "privacy_content": "Die Datenschutzbestimmung beschreibt welche Daten gespeichert werden, warum, wann, wo, Zugangsrechte und welche Rechte Sie haben. Bei Fragen, bitte wenden Sie sich an uns mit den Kontaktinformationen im Impressum.", "privacy_agree": "Ich habe die Datenschutzbestimmung gelesen und bin damit einverstanden", + "legal_notice": "Impressum", + "legal_notice_content": "Im Impressum steht, wer für StudyU verantwortlich ist und wie du uns kontaktieren kannst.", "imprint_read": "Impressum lesen", "invite_code_button": "Einladungscode verwenden", "private_study_invite_code": "Privater Studien Einladungscode", @@ -428,20 +432,20 @@ "submit": "Absenden", "go_back": "Zurück", - "recovery_phrase_setup_title": "Wiederherstellungsphrase einrichten", - "recovery_phrase_header": "Deine geheime Wiederherstellungsphrase", + "recovery_phrase_header": "Wiederherstellungsphrase speichern", "copy_to_clipboard": "In die Zwischenablage kopieren", "copied_to_clipboard": "In die Zwischenablage kopiert", "recovery_phrase_purpose_header": "Du benötigst diese Phrase um:", "recovery_phrase_purpose_1": "✓ Auf einem anderen Gerät fortzufahren", "recovery_phrase_purpose_2": "✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen", - "recovery_phrase_save_hint": "Speichere sie jetzt an einem sicheren Ort.", + "recovery_phrase_save_hint": "Bewahre diese 13 Wörter an einem sicheren Ort auf. Mit ihnen kannst du deinen Studienfortschritt auf einem neuen Handy oder nach einer Neuinstallation der App wiederherstellen. Wenn du deine Wiederherstellungsphrase verlierst, kannst du deinen Fortschritt nicht wiederherstellen. Teile sie niemals mit anderen.", "recovery_phrase_saved_confirmation": "Ich habe meine Wiederherstellungsphrase sicher gespeichert.", "continue_to_study": "Weiter zur Studie", - "rejoin_study": "Studie wieder beitreten", + "rejoin_study": "Konto wiederherstellen", "enter_recovery_phrase": "Gib deine Wiederherstellungsphrase ein", "required": "Erforderlich", - "invalid_recovery_phrase": "Ungültige Wiederherstellungsphrase. Bitte überprüfe deine Wörter.", + "invalid_recovery_phrase": "Diese Wiederherstellungsphrase passt zu keinem Konto. Achte darauf, dass alle 13 Wörter in der richtigen Reihenfolge stehen.", + "recovery_phrase_too_many_words": "Wiederherstellungsphrasen haben 13 Wörter. Entferne zusätzliche Wörter, um fortzufahren.", "recovery_successful": "Wiederherstellung erfolgreich! ID: {id}", "@recovery_successful": { "placeholders": { @@ -463,14 +467,13 @@ "recovery_in_progress": "Konto wird wiederhergestellt...", "recovery_failed": "Wiederherstellung fehlgeschlagen. Bitte überprüfe deine Wiederherstellungsphrase und versuche es erneut.", "recovery_user_not_found": "Kein Konto mit dieser Wiederherstellungsphrase gefunden.", - "recovery_rate_limit": "Bitte warte einen Moment, bevor du es erneut versuchst.", "recovery_network_error": "Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.", - "rejoin_study_description": "Gib deine 13-Wörter-Wiederherstellungsphrase ein, um deine Studie auf diesem Gerät fortzusetzen.", - "rejoin_study_help_title": "So verwendest du deine Wiederherstellungsphrase", - "rejoin_study_help_1": "Gib alle 13 Wörter deiner gespeicherten Wiederherstellungsphrase in der richtigen Reihenfolge ein", - "rejoin_study_help_2": "Wörter können eingefügt werden—sie füllen automatisch die Felder aus", - "rejoin_study_help_3": "Die Wiederherstellungsphrase unterscheidet nicht zwischen Groß- und Kleinschreibung", + "rejoin_study_description": "Stelle dein Konto auf diesem Gerät mit der Wiederherstellungsphrase wieder her, die du beim Beitritt zur Studie gespeichert hast.", + "rejoin_study_help_title": "Mit Wiederherstellungsphrase wiederherstellen", + "rejoin_study_help_1": "Gib alle 13 Wörter in der richtigen Reihenfolge ein", + "rejoin_study_help_2": "Du kannst die Phrase manuell eingeben oder einfügen", + "rejoin_study_help_3": "Oder scanne stattdessen den QR-Code; beide Optionen stellen dasselbe Konto wieder her", "share_recovery": "Wiederherstellung teilen", "share_as_text": "Als Text teilen", @@ -479,7 +482,7 @@ "download_as_text": "Als Textdatei herunterladen", "download_as_qr": "Als QR-Bild herunterladen", "scan_qr_code": "QR-Code scannen", - "scan_qr_description": "Scanne einen QR-Code mit deiner Wiederherstellungsphrase", + "scan_qr_description": "Scanne deinen Wiederherstellungs-QR-Code, statt die Phrase einzugeben.", "scanning_qr": "QR-Code wird gescannt", "qr_scan_instruction": "Richte deine Kamera auf den QR-Code", "invalid_qr_code": "Ungültiger QR-Code. Bitte scanne einen gültigen Wiederherstellungs-QR-Code.", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index e07f08111..dc8052022 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -148,6 +148,8 @@ "not_enough_data": "Not enough data", "barely_enough_data": "Barely enough data", "enough_data": "Enough data", + "legal_documents": "Legal Documents", + "legal_documents_description": "Please read and accept the terms of use and privacy policy before continuing.", "terms": "Terms of Use", "terms_read": "Read Terms of Use", "terms_content": "The terms of use give an overview on the purpose and use of the StudyU app. In case you have any questions please reach out to us via the contact information in the legal notice.", @@ -156,6 +158,8 @@ "privacy_read": "Read Privacy Policy", "privacy_content": "The privacy policy describes which data is stored, why, when, where, access rights, and which rights you have. In case you have any questions please reach out to us via the contact information in the legal notice.", "privacy_agree": "I have read and agree to the privacy policy", + "legal_notice": "Legal Notice", + "legal_notice_content": "The legal notice shows who is responsible for StudyU and how you can contact us.", "imprint_read": "Read Legal Notice", "invite_code_button": "Use invite code", "private_study_invite_code": "Private study invite code", @@ -426,20 +430,20 @@ "submit": "Submit", "go_back": "Go back", - "recovery_phrase_setup_title": "Recovery Phrase Setup", - "recovery_phrase_header": "Your secret recovery phrase", + "recovery_phrase_header": "Save Recovery Phrase", "copy_to_clipboard": "Copy to clipboard", "copied_to_clipboard": "Copied to clipboard", "recovery_phrase_purpose_header": "You'll need this phrase to:", "recovery_phrase_purpose_1": "✓ Continue on a different phone", "recovery_phrase_purpose_2": "✓ Restore your progress after reinstalling", - "recovery_phrase_save_hint": "Save it now in a safe place you won't lose.", + "recovery_phrase_save_hint": "Keep these 13 words in a safe place. They let you restore your study progress on a new phone or after reinstalling the app. If you lose your recovery phrase, you won't be able to recover your progress. Never share it with anyone.", "recovery_phrase_saved_confirmation": "I saved my recovery phrase somewhere I can find it later.", "continue_to_study": "Continue to study", - "rejoin_study": "Rejoin Study", + "rejoin_study": "Restore account", "enter_recovery_phrase": "Enter your recovery phrase", "required": "Required", - "invalid_recovery_phrase": "Invalid recovery phrase. Please check your words.", + "invalid_recovery_phrase": "This recovery phrase does not match an account. Make sure all 13 words are in the right order.", + "recovery_phrase_too_many_words": "Recovery phrases have 13 words. Remove extra words to continue.", "recovery_successful": "Recovery successful! ID: {id}", "@recovery_successful": { "placeholders": { @@ -461,14 +465,13 @@ "recovery_in_progress": "Recovering your account...", "recovery_failed": "Recovery failed. Please check your recovery phrase and try again.", "recovery_user_not_found": "No account found with this recovery phrase.", - "recovery_rate_limit": "Please wait a moment before trying again.", "recovery_network_error": "Network error. Please check your connection and try again.", - "rejoin_study_description": "Enter your 13-word recovery phrase to continue your study on this device.", - "rejoin_study_help_title": "How to use your recovery phrase", - "rejoin_study_help_1": "Enter all 13 words from your saved recovery phrase in order", - "rejoin_study_help_2": "Words can be pasted—they'll automatically fill the fields", - "rejoin_study_help_3": "The recovery phrase is case-insensitive", + "rejoin_study_description": "Restore your account on this device with the recovery phrase you saved before joining a study.", + "rejoin_study_help_title": "Restore with your recovery phrase", + "rejoin_study_help_1": "Enter all 13 words in order", + "rejoin_study_help_2": "You can type or paste the phrase manually", + "rejoin_study_help_3": "Or scan the QR code instead; both options restore the same account", "share_recovery": "Share Recovery", "share_as_text": "Share as Text", @@ -477,7 +480,7 @@ "download_as_text": "Download as Text File", "download_as_qr": "Download as QR Image", "scan_qr_code": "Scan QR Code", - "scan_qr_description": "Scan a QR code containing your recovery phrase", + "scan_qr_description": "Scan your recovery QR code instead of typing the phrase.", "scanning_qr": "Scanning QR Code", "qr_scan_instruction": "Point your camera at the QR code", "invalid_qr_code": "Invalid QR code. Please scan a valid recovery QR code.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index d0c361793..48eee09c5 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -938,6 +938,18 @@ abstract class AppLocalizations { /// **'Enough data'** String get enough_data; + /// No description provided for @legal_documents. + /// + /// In en, this message translates to: + /// **'Legal Documents'** + String get legal_documents; + + /// No description provided for @legal_documents_description. + /// + /// In en, this message translates to: + /// **'Please read and accept the terms of use and privacy policy before continuing.'** + String get legal_documents_description; + /// No description provided for @terms. /// /// In en, this message translates to: @@ -986,6 +998,18 @@ abstract class AppLocalizations { /// **'I have read and agree to the privacy policy'** String get privacy_agree; + /// No description provided for @legal_notice. + /// + /// In en, this message translates to: + /// **'Legal Notice'** + String get legal_notice; + + /// No description provided for @legal_notice_content. + /// + /// In en, this message translates to: + /// **'The legal notice shows who is responsible for StudyU and how you can contact us.'** + String get legal_notice_content; + /// No description provided for @imprint_read. /// /// In en, this message translates to: @@ -2324,16 +2348,10 @@ abstract class AppLocalizations { /// **'Go back'** String get go_back; - /// No description provided for @recovery_phrase_setup_title. - /// - /// In en, this message translates to: - /// **'Recovery Phrase Setup'** - String get recovery_phrase_setup_title; - /// No description provided for @recovery_phrase_header. /// /// In en, this message translates to: - /// **'Your secret recovery phrase'** + /// **'Save Recovery Phrase'** String get recovery_phrase_header; /// No description provided for @copy_to_clipboard. @@ -2369,7 +2387,7 @@ abstract class AppLocalizations { /// No description provided for @recovery_phrase_save_hint. /// /// In en, this message translates to: - /// **'Save it now in a safe place you won\'t lose.'** + /// **'Keep these 13 words in a safe place. They let you restore your study progress on a new phone or after reinstalling the app. If you lose your recovery phrase, you won\'t be able to recover your progress. Never share it with anyone.'** String get recovery_phrase_save_hint; /// No description provided for @recovery_phrase_saved_confirmation. @@ -2387,7 +2405,7 @@ abstract class AppLocalizations { /// No description provided for @rejoin_study. /// /// In en, this message translates to: - /// **'Rejoin Study'** + /// **'Restore account'** String get rejoin_study; /// No description provided for @enter_recovery_phrase. @@ -2405,9 +2423,15 @@ abstract class AppLocalizations { /// No description provided for @invalid_recovery_phrase. /// /// In en, this message translates to: - /// **'Invalid recovery phrase. Please check your words.'** + /// **'This recovery phrase does not match an account. Make sure all 13 words are in the right order.'** String get invalid_recovery_phrase; + /// No description provided for @recovery_phrase_too_many_words. + /// + /// In en, this message translates to: + /// **'Recovery phrases have 13 words. Remove extra words to continue.'** + String get recovery_phrase_too_many_words; + /// No description provided for @recovery_successful. /// /// In en, this message translates to: @@ -2444,12 +2468,6 @@ abstract class AppLocalizations { /// **'No account found with this recovery phrase.'** String get recovery_user_not_found; - /// No description provided for @recovery_rate_limit. - /// - /// In en, this message translates to: - /// **'Please wait a moment before trying again.'** - String get recovery_rate_limit; - /// No description provided for @recovery_network_error. /// /// In en, this message translates to: @@ -2459,31 +2477,31 @@ abstract class AppLocalizations { /// No description provided for @rejoin_study_description. /// /// In en, this message translates to: - /// **'Enter your 13-word recovery phrase to continue your study on this device.'** + /// **'Restore your account on this device with the recovery phrase you saved before joining a study.'** String get rejoin_study_description; /// No description provided for @rejoin_study_help_title. /// /// In en, this message translates to: - /// **'How to use your recovery phrase'** + /// **'Restore with your recovery phrase'** String get rejoin_study_help_title; /// No description provided for @rejoin_study_help_1. /// /// In en, this message translates to: - /// **'Enter all 13 words from your saved recovery phrase in order'** + /// **'Enter all 13 words in order'** String get rejoin_study_help_1; /// No description provided for @rejoin_study_help_2. /// /// In en, this message translates to: - /// **'Words can be pasted—they\'ll automatically fill the fields'** + /// **'You can type or paste the phrase manually'** String get rejoin_study_help_2; /// No description provided for @rejoin_study_help_3. /// /// In en, this message translates to: - /// **'The recovery phrase is case-insensitive'** + /// **'Or scan the QR code instead; both options restore the same account'** String get rejoin_study_help_3; /// No description provided for @share_recovery. @@ -2531,7 +2549,7 @@ abstract class AppLocalizations { /// No description provided for @scan_qr_description. /// /// In en, this message translates to: - /// **'Scan a QR code containing your recovery phrase'** + /// **'Scan your recovery QR code instead of typing the phrase.'** String get scan_qr_description; /// No description provided for @scanning_qr. diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 7ffa12f63..4c07918ba 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -468,6 +468,13 @@ class AppLocalizationsDe extends AppLocalizations { @override String get enough_data => 'Genügend Daten'; + @override + String get legal_documents => 'Rechtliche Dokumente'; + + @override + String get legal_documents_description => + 'Bitte lies und akzeptiere die Nutzungsbedingungen und Datenschutzbestimmung, bevor du fortfährst.'; + @override String get terms => 'Bedingungen'; @@ -496,6 +503,13 @@ class AppLocalizationsDe extends AppLocalizations { String get privacy_agree => 'Ich habe die Datenschutzbestimmung gelesen und bin damit einverstanden'; + @override + String get legal_notice => 'Impressum'; + + @override + String get legal_notice_content => + 'Im Impressum steht, wer für StudyU verantwortlich ist und wie du uns kontaktieren kannst.'; + @override String get imprint_read => 'Impressum lesen'; @@ -1208,11 +1222,7 @@ class AppLocalizationsDe extends AppLocalizations { String get go_back => 'Zurück'; @override - String get recovery_phrase_setup_title => - 'Wiederherstellungsphrase einrichten'; - - @override - String get recovery_phrase_header => 'Deine geheime Wiederherstellungsphrase'; + String get recovery_phrase_header => 'Wiederherstellungsphrase speichern'; @override String get copy_to_clipboard => 'In die Zwischenablage kopieren'; @@ -1233,7 +1243,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Speichere sie jetzt an einem sicheren Ort.'; + 'Bewahre diese 13 Wörter an einem sicheren Ort auf. Mit ihnen kannst du deinen Studienfortschritt auf einem neuen Handy oder nach einer Neuinstallation der App wiederherstellen. Wenn du deine Wiederherstellungsphrase verlierst, kannst du deinen Fortschritt nicht wiederherstellen. Teile sie niemals mit anderen.'; @override String get recovery_phrase_saved_confirmation => @@ -1243,7 +1253,7 @@ class AppLocalizationsDe extends AppLocalizations { String get continue_to_study => 'Weiter zur Studie'; @override - String get rejoin_study => 'Studie wieder beitreten'; + String get rejoin_study => 'Konto wiederherstellen'; @override String get enter_recovery_phrase => 'Gib deine Wiederherstellungsphrase ein'; @@ -1253,7 +1263,11 @@ class AppLocalizationsDe extends AppLocalizations { @override String get invalid_recovery_phrase => - 'Ungültige Wiederherstellungsphrase. Bitte überprüfe deine Wörter.'; + 'Diese Wiederherstellungsphrase passt zu keinem Konto. Achte darauf, dass alle 13 Wörter in der richtigen Reihenfolge stehen.'; + + @override + String get recovery_phrase_too_many_words => + 'Wiederherstellungsphrasen haben 13 Wörter. Entferne zusätzliche Wörter, um fortzufahren.'; @override String recovery_successful(String id) { @@ -1279,33 +1293,29 @@ class AppLocalizationsDe extends AppLocalizations { String get recovery_user_not_found => 'Kein Konto mit dieser Wiederherstellungsphrase gefunden.'; - @override - String get recovery_rate_limit => - 'Bitte warte einen Moment, bevor du es erneut versuchst.'; - @override String get recovery_network_error => 'Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.'; @override String get rejoin_study_description => - 'Gib deine 13-Wörter-Wiederherstellungsphrase ein, um deine Studie auf diesem Gerät fortzusetzen.'; + 'Stelle dein Konto auf diesem Gerät mit der Wiederherstellungsphrase wieder her, die du beim Beitritt zur Studie gespeichert hast.'; @override String get rejoin_study_help_title => - 'So verwendest du deine Wiederherstellungsphrase'; + 'Mit Wiederherstellungsphrase wiederherstellen'; @override String get rejoin_study_help_1 => - 'Gib alle 13 Wörter deiner gespeicherten Wiederherstellungsphrase in der richtigen Reihenfolge ein'; + 'Gib alle 13 Wörter in der richtigen Reihenfolge ein'; @override String get rejoin_study_help_2 => - 'Wörter können eingefügt werden—sie füllen automatisch die Felder aus'; + 'Du kannst die Phrase manuell eingeben oder einfügen'; @override String get rejoin_study_help_3 => - 'Die Wiederherstellungsphrase unterscheidet nicht zwischen Groß- und Kleinschreibung'; + 'Oder scanne stattdessen den QR-Code; beide Optionen stellen dasselbe Konto wieder her'; @override String get share_recovery => 'Wiederherstellung teilen'; @@ -1330,7 +1340,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get scan_qr_description => - 'Scanne einen QR-Code mit deiner Wiederherstellungsphrase'; + 'Scanne deinen Wiederherstellungs-QR-Code, statt die Phrase einzugeben.'; @override String get scanning_qr => 'QR-Code wird gescannt'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index ba22ba3f1..f74896afc 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -465,6 +465,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get enough_data => 'Enough data'; + @override + String get legal_documents => 'Legal Documents'; + + @override + String get legal_documents_description => + 'Please read and accept the terms of use and privacy policy before continuing.'; + @override String get terms => 'Terms of Use'; @@ -491,6 +498,13 @@ class AppLocalizationsEn extends AppLocalizations { @override String get privacy_agree => 'I have read and agree to the privacy policy'; + @override + String get legal_notice => 'Legal Notice'; + + @override + String get legal_notice_content => + 'The legal notice shows who is responsible for StudyU and how you can contact us.'; + @override String get imprint_read => 'Read Legal Notice'; @@ -1198,10 +1212,7 @@ class AppLocalizationsEn extends AppLocalizations { String get go_back => 'Go back'; @override - String get recovery_phrase_setup_title => 'Recovery Phrase Setup'; - - @override - String get recovery_phrase_header => 'Your secret recovery phrase'; + String get recovery_phrase_header => 'Save Recovery Phrase'; @override String get copy_to_clipboard => 'Copy to clipboard'; @@ -1221,7 +1232,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Save it now in a safe place you won\'t lose.'; + 'Keep these 13 words in a safe place. They let you restore your study progress on a new phone or after reinstalling the app. If you lose your recovery phrase, you won\'t be able to recover your progress. Never share it with anyone.'; @override String get recovery_phrase_saved_confirmation => @@ -1231,7 +1242,7 @@ class AppLocalizationsEn extends AppLocalizations { String get continue_to_study => 'Continue to study'; @override - String get rejoin_study => 'Rejoin Study'; + String get rejoin_study => 'Restore account'; @override String get enter_recovery_phrase => 'Enter your recovery phrase'; @@ -1241,7 +1252,11 @@ class AppLocalizationsEn extends AppLocalizations { @override String get invalid_recovery_phrase => - 'Invalid recovery phrase. Please check your words.'; + 'This recovery phrase does not match an account. Make sure all 13 words are in the right order.'; + + @override + String get recovery_phrase_too_many_words => + 'Recovery phrases have 13 words. Remove extra words to continue.'; @override String recovery_successful(String id) { @@ -1267,30 +1282,26 @@ class AppLocalizationsEn extends AppLocalizations { String get recovery_user_not_found => 'No account found with this recovery phrase.'; - @override - String get recovery_rate_limit => 'Please wait a moment before trying again.'; - @override String get recovery_network_error => 'Network error. Please check your connection and try again.'; @override String get rejoin_study_description => - 'Enter your 13-word recovery phrase to continue your study on this device.'; + 'Restore your account on this device with the recovery phrase you saved before joining a study.'; @override - String get rejoin_study_help_title => 'How to use your recovery phrase'; + String get rejoin_study_help_title => 'Restore with your recovery phrase'; @override - String get rejoin_study_help_1 => - 'Enter all 13 words from your saved recovery phrase in order'; + String get rejoin_study_help_1 => 'Enter all 13 words in order'; @override - String get rejoin_study_help_2 => - 'Words can be pasted—they\'ll automatically fill the fields'; + String get rejoin_study_help_2 => 'You can type or paste the phrase manually'; @override - String get rejoin_study_help_3 => 'The recovery phrase is case-insensitive'; + String get rejoin_study_help_3 => + 'Or scan the QR code instead; both options restore the same account'; @override String get share_recovery => 'Share Recovery'; @@ -1315,7 +1326,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get scan_qr_description => - 'Scan a QR code containing your recovery phrase'; + 'Scan your recovery QR code instead of typing the phrase.'; @override String get scanning_qr => 'Scanning QR Code'; diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index 6970d9308..da73c1416 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -1,7 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; +import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_app/widgets/recovery_phrase_content.dart'; class RecoveryPhraseScreen extends StatefulWidget { @@ -12,111 +15,49 @@ class RecoveryPhraseScreen extends StatefulWidget { } class _RecoveryPhraseScreenState extends State { - bool _isChecked = false; + bool _isChecked = kDebugMode; @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.recovery_phrase_setup_title), - ), - body: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 700), - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 16.0, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - AppLocalizations.of(context)!.recovery_phrase_header, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of( - context, - )!.recovery_phrase_save_hint, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 32), - const RecoveryPhraseContent(), - ], - ), - ), - ), - _buildBottomSection(), - ], - ), - ), - ), + body: OnboardingPage( + title: AppLocalizations.of(context)!.recovery_phrase_header, + description: AppLocalizations.of(context)!.recovery_phrase_save_hint, + bottomCheckboxItems: _confirmationItems(), + bottomNavigationBar: _buildNavigation(), + child: const RecoveryPhraseContent(), ), ); } - Widget _buildBottomSection() { - return Container( - padding: const EdgeInsets.all(24.0), - decoration: BoxDecoration( - color: Theme.of(context).scaffoldBackgroundColor, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, -4), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - CheckboxListTile( - value: _isChecked, - onChanged: (value) { - setState(() { - _isChecked = value ?? false; - }); - }, - title: Text( - AppLocalizations.of(context)!.recovery_phrase_saved_confirmation, - style: Theme.of(context).textTheme.bodyMedium, - ), - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - ), - const SizedBox(height: 16), - FilledButton( - onPressed: _isChecked - ? () { - context.goNamed(RouteNames.studySelection); - } - : null, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Text(AppLocalizations.of(context)!.continue_to_study), - ), - ], + List _confirmationItems() { + return [ + OnboardingCheckboxItem( + label: AppLocalizations.of(context)!.recovery_phrase_saved_confirmation, + value: _isChecked, + onChanged: (value) { + setState(() { + _isChecked = value ?? false; + }); + }, ), + ]; + } + + Widget _buildNavigation() { + return BottomOnboardingNavigation( + onBack: () { + if (context.canPop()) { + context.pop(); + } else { + context.goNamed(RouteNames.terms); + } + }, + onNext: _isChecked + ? () { + context.pushNamed(RouteNames.studySelection); + } + : null, ); } } diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index 45178557d..b582ede22 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -4,6 +4,8 @@ import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/screens/app_onboarding/qr_code_scanner_screen.dart'; import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; +import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_core/core.dart'; class RejoinStudyScreen extends StatefulWidget { @@ -18,9 +20,10 @@ class _RejoinStudyScreenState extends State { final _formKey = GlobalKey(); String? _errorMessage; bool _isLoading = false; - DateTime? _lastAttempt; - final _cooldownDuration = const Duration(seconds: 5); List _words = []; + bool get _hasTooManyWords => _words.length > RecoveryConstants.totalWordCount; + bool get _canSubmitPhrase => + !_isLoading && _words.length == RecoveryConstants.totalWordCount; @override void initState() { @@ -76,38 +79,31 @@ class _RejoinStudyScreenState extends State { } void _validateAndSubmit() { - // Rate limiting check - if (_lastAttempt != null && - DateTime.now().difference(_lastAttempt!) < _cooldownDuration) { - setState(() { - _errorMessage = AppLocalizations.of(context)!.recovery_rate_limit; - }); - return; - } - _lastAttempt = DateTime.now(); - setState(() { _errorMessage = null; }); - if (_formKey.currentState!.validate()) { - final words = _words.map((w) => w.trim().toLowerCase()).toList(); + if (!_formKey.currentState!.validate()) return; - if (words.length != RecoveryConstants.totalWordCount) { - setState(() { - _errorMessage = AppLocalizations.of(context)!.invalid_recovery_phrase; - }); - return; - } + final localizations = AppLocalizations.of(context)!; + final words = _words.map((w) => w.trim().toLowerCase()).toList(); - try { - final id = RejoinStudyService.decodeRecoveryPhrase(words); - _onSuccess(id); - } catch (e) { - setState(() { - _errorMessage = AppLocalizations.of(context)!.invalid_recovery_phrase; - }); - } + if (words.length != RecoveryConstants.totalWordCount) { + setState(() { + _errorMessage = words.length > RecoveryConstants.totalWordCount + ? localizations.recovery_phrase_too_many_words + : localizations.invalid_recovery_phrase; + }); + return; + } + + try { + final id = RejoinStudyService.decodeRecoveryPhrase(words); + _onSuccess(id); + } catch (e) { + setState(() { + _errorMessage = localizations.invalid_recovery_phrase; + }); } } @@ -173,29 +169,17 @@ class _RejoinStudyScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.rejoin_study)), - body: SingleChildScrollView( + body: OnboardingPage( + title: AppLocalizations.of(context)!.enter_recovery_phrase, + description: AppLocalizations.of(context)!.rejoin_study_description, + maxWidth: 900, padding: const EdgeInsets.all(16.0), child: Form( key: _formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, children: [ - Text( - AppLocalizations.of(context)!.enter_recovery_phrase, - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context)!.rejoin_study_description, - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 24), Card( elevation: 0, color: Theme.of(context).colorScheme.surfaceContainerHighest, @@ -206,20 +190,13 @@ class _RejoinStudyScreenState extends State { children: [ Row( children: [ - Icon( - Icons.info_outline, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), + const Icon(Icons.info_outline, size: 20), const SizedBox(width: 8), Text( AppLocalizations.of( context, )!.rejoin_study_help_title, - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + style: const TextStyle(fontWeight: FontWeight.bold), ), ], ), @@ -246,41 +223,106 @@ class _RejoinStudyScreenState extends State { ), ), const SizedBox(height: 24), - // Single Text Input Field with validation feedback - TextFormField( - controller: _phraseController, - decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - )!.enter_recovery_phrase, - hintText: 'apple banana cherry ...', - border: const OutlineInputBorder(), - helperText: - '${_words.length}/${RecoveryConstants.totalWordCount} words', - helperStyle: TextStyle( - color: _words.length == RecoveryConstants.totalWordCount - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: - _words.length == RecoveryConstants.totalWordCount - ? FontWeight.bold - : FontWeight.normal, - ), - suffixIcon: _words.length == RecoveryConstants.totalWordCount - ? Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - ) - : null, - ), - maxLines: 4, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _validateAndSubmit(), - validator: (value) { - if (value == null || value.trim().isEmpty) { - return AppLocalizations.of(context)!.required; + LayoutBuilder( + builder: (context, constraints) { + final phraseField = TextFormField( + controller: _phraseController, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.enter_recovery_phrase, + hintText: 'apple banana cherry ...', + border: const OutlineInputBorder(), + helperText: _hasTooManyWords + ? AppLocalizations.of( + context, + )!.recovery_phrase_too_many_words + : '${_words.length}/${RecoveryConstants.totalWordCount} words', + helperStyle: TextStyle( + color: _hasTooManyWords + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.normal, + ), + suffixIcon: + _words.length == RecoveryConstants.totalWordCount + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + maxLines: 4, + autovalidateMode: AutovalidateMode.onUserInteraction, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) { + if (_canSubmitPhrase) _validateAndSubmit(); + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return AppLocalizations.of(context)!.required; + } + if (_hasTooManyWords) { + return AppLocalizations.of( + context, + )!.recovery_phrase_too_many_words; + } + return null; + }, + ); + + final scanButton = SizedBox.square( + dimension: 120, + child: OutlinedButton( + onPressed: _scanQrCode, + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.qr_code_scanner), + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.scan_qr_code, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + + final orText = Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'OR', + style: Theme.of(context).textTheme.labelLarge, + ), + ); + + if (constraints.maxWidth < 700) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + phraseField, + const SizedBox(height: 12), + Center(child: orText), + const SizedBox(height: 12), + scanButton, + ], + ); } - return null; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: phraseField), + SizedBox(height: 120, child: Center(child: orText)), + scanButton, + ], + ); }, ), if (_errorMessage != null) ...[ @@ -291,18 +333,9 @@ class _RejoinStudyScreenState extends State { textAlign: TextAlign.center, ), ], - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: _scanQrCode, - icon: const Icon(Icons.qr_code_scanner), - label: Text(AppLocalizations.of(context)!.scan_qr_code), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(48), - ), - ), const SizedBox(height: 24), FilledButton( - onPressed: _isLoading ? null : _validateAndSubmit, + onPressed: _canSubmitPhrase ? _validateAndSubmit : null, child: _isLoading ? Row( mainAxisSize: MainAxisSize.min, @@ -329,6 +362,16 @@ class _RejoinStudyScreenState extends State { ), ), ), + bottomNavigationBar: BottomOnboardingNavigation( + hideNext: true, + onBack: () { + if (context.canPop()) { + context.pop(); + } else { + context.goNamed(RouteNames.welcome); + } + }, + ), ); } } diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 013c298fb..c398540c3 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -132,9 +132,6 @@ class _SettingsState extends State { ), ), ), - const SizedBox(height: 8), - // Recovery phrase card - const RecoveryPhraseWidget(), // Dashboard showcase reset Card( @@ -201,6 +198,9 @@ class _SettingsState extends State { ), ), ), + const SizedBox(height: 8), + // Recovery phrase card + const RecoveryPhraseWidget(), const SizedBox(height: 24), diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart index 37c258876..29bb64ce9 100644 --- a/app/lib/widgets/recovery_phrase_content.dart +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -224,9 +224,9 @@ class RecoveryPhraseContentState extends State { physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 2, - childAspectRatio: 3.5, - crossAxisSpacing: 16, - mainAxisSpacing: 16, + mainAxisExtent: 36, + crossAxisSpacing: 8, + mainAxisSpacing: 4, ), itemCount: _phrase?.length ?? 0, itemBuilder: (context, index) { @@ -239,7 +239,7 @@ class RecoveryPhraseContentState extends State { ), ), alignment: Alignment.centerLeft, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(horizontal: 4), child: RichText( text: TextSpan( children: [ @@ -253,7 +253,7 @@ class RecoveryPhraseContentState extends State { text: _phrase![index], style: Theme.of( context, - ).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500), + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), ), ], ), @@ -351,8 +351,8 @@ class _ActionButton extends StatelessWidget { onTap: onTap, borderRadius: BorderRadius.circular(30), child: Container( - width: 56, - height: 56, + width: 40, + height: 40, decoration: BoxDecoration( color: Theme.of( context, @@ -362,10 +362,10 @@ class _ActionButton extends StatelessWidget { child: Icon(icon, color: Theme.of(context).colorScheme.primary), ), ), - const SizedBox(height: 8), + const SizedBox(height: 6), Text( label, - style: Theme.of(context).textTheme.labelSmall, + style: Theme.of(context).textTheme.labelMedium, textAlign: TextAlign.center, ), ], diff --git a/app/test/screens/app_onboarding/rejoin_study_screen_test.dart b/app/test/screens/app_onboarding/rejoin_study_screen_test.dart new file mode 100644 index 000000000..a40d726b4 --- /dev/null +++ b/app/test/screens/app_onboarding/rejoin_study_screen_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; + +Widget _wrap(Widget child) { + return MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + locale: const Locale('en'), + home: child, + ); +} + +void main() { + testWidgets('disables restore when recovery phrase has too many words', ( + tester, + ) async { + await tester.pumpWidget(_wrap(const RejoinStudyScreen())); + + await tester.enterText( + find.byType(TextFormField), + 'one two three four five six seven eight nine ten eleven twelve thirteen fourteen', + ); + await tester.pump(); + + expect( + find.text( + 'Recovery phrases have 13 words. Remove extra words to continue.', + ), + findsWidgets, + ); + expect( + tester + .widget( + find.widgetWithText(FilledButton, 'Restore account'), + ) + .onPressed, + isNull, + ); + }); +} From ee404e8c7d0cd1544c94084827115c471c658ae3 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 13:34:09 +0200 Subject: [PATCH 27/57] fix(app): rename recovery account route --- app/lib/app_router.dart | 6 +++--- app/lib/main.dart | 1 + app/lib/screens/app_onboarding/welcome.dart | 2 +- app/test/widget_test.dart | 22 +++++++++++++++++++++ 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/lib/app_router.dart b/app/lib/app_router.dart index f1726a2e4..e8150b40b 100644 --- a/app/lib/app_router.dart +++ b/app/lib/app_router.dart @@ -39,7 +39,7 @@ class RouteNames { static const String onboarding = 'onboarding'; static const String terms = 'terms'; static const String recoveryPhrase = 'recoveryPhrase'; - static const String rejoinStudy = 'rejoinStudy'; + static const String rejoinAccount = 'rejoinAccount'; static const String about = 'about'; static const String studySelection = 'studySelection'; static const String studyOverview = 'studyOverview'; @@ -165,8 +165,8 @@ GoRouter createAppRouter({ builder: (context, state) => const RecoveryPhraseScreen(), ), GoRoute( - path: '/${RouteNames.rejoinStudy}', - name: RouteNames.rejoinStudy, + path: '/${RouteNames.rejoinAccount}', + name: RouteNames.rejoinAccount, builder: (context, state) => const RejoinStudyScreen(), ), GoRoute( diff --git a/app/lib/main.dart b/app/lib/main.dart index d8bfbcfa8..6cd277bac 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -67,6 +67,7 @@ Future main() async { final queryParameters = Uri.base.queryParameters; // Turn off the # in the URLs on the web usePathUrlStrategy(); + GoRouter.optionURLReflectsImperativeAPIs = true; AppConfig? appConfig; String initialRoute = '/${RouteNames.loading}'; try { diff --git a/app/lib/screens/app_onboarding/welcome.dart b/app/lib/screens/app_onboarding/welcome.dart index 11ccf962c..6753a626c 100644 --- a/app/lib/screens/app_onboarding/welcome.dart +++ b/app/lib/screens/app_onboarding/welcome.dart @@ -68,7 +68,7 @@ class WelcomeScreen extends StatelessWidget { ), const SizedBox(height: 20), TextButton( - onPressed: () => context.pushNamed(RouteNames.rejoinStudy), + onPressed: () => context.pushNamed(RouteNames.rejoinAccount), child: Text( AppLocalizations.of(context)!.rejoin_study, style: const TextStyle(fontSize: 16, color: Colors.grey), diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index dc7ebfb59..f2dc18099 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -14,6 +14,7 @@ import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/app_onboarding/about.dart'; import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; +import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; import 'package:studyu_app/screens/app_onboarding/welcome.dart'; @@ -36,6 +37,11 @@ Widget setup(Widget child) { path: '/${RouteNames.terms}', builder: (_, _) => const TermsScreen(), ), + GoRoute( + path: '/${RouteNames.rejoinAccount}', + name: RouteNames.rejoinAccount, + builder: (_, _) => const RejoinStudyScreen(), + ), GoRoute( path: '/${RouteNames.welcome}', builder: (_, _) => const WelcomeScreen(), @@ -54,6 +60,22 @@ void main() { expect(find.text('Get started'), findsOneWidget); }); + testWidgets('restore account opens rejoin account route', (tester) async { + await tester.pumpWidget(setup(const WelcomeScreen())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Restore account')); + await tester.pumpAndSettle(); + + expect(find.byType(RejoinStudyScreen), findsOneWidget); + }); + + test('rejoin account route is registered by name', () { + final router = createAppRouter(queryParameters: const {}); + + expect(router.namedLocation(RouteNames.rejoinAccount), '/rejoinAccount'); + }); + test('opens welcome screen when tour is completed without preview', () { expect( initialRouteForMissingSubjectRoute(isPreview: false, onBoarded: true), From fbfcc15179572e0eae81677bd888227c258f4f6b Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 14:40:26 +0200 Subject: [PATCH 28/57] fix(app): rename rejoin account route to restore account and update related references --- app/integration_test/recovery_flow_test.dart | 63 +--- app/lib/app_router.dart | 6 +- app/lib/l10n/app_de.arb | 20 +- app/lib/l10n/app_en.arb | 20 +- app/lib/l10n/app_localizations.dart | 100 ++---- app/lib/l10n/app_localizations_de.dart | 59 +--- app/lib/l10n/app_localizations_en.dart | 58 +--- .../qr_code_scanner_screen.dart | 211 ------------ .../recovery_phrase_screen.dart | 101 ++++-- .../app_onboarding/rejoin_study_screen.dart | 253 +++++--------- app/lib/screens/app_onboarding/terms.dart | 41 +-- app/lib/screens/app_onboarding/welcome.dart | 2 +- app/lib/util/recovery_file_utils.dart | 48 +++ app/lib/util/recovery_qr_utils.dart | 304 ----------------- app/lib/widgets/onboarding_page.dart | 69 ++-- app/lib/widgets/recovery_phrase_content.dart | 313 ++++++------------ app/pubspec.yaml | 4 - .../recovery_phrase_screen_test.dart | 81 +++++ app/test/widget_test.dart | 19 +- pubspec.lock | 24 -- 20 files changed, 521 insertions(+), 1275 deletions(-) delete mode 100644 app/lib/screens/app_onboarding/qr_code_scanner_screen.dart create mode 100644 app/lib/util/recovery_file_utils.dart delete mode 100644 app/lib/util/recovery_qr_utils.dart create mode 100644 app/test/screens/app_onboarding/recovery_phrase_screen_test.dart diff --git a/app/integration_test/recovery_flow_test.dart b/app/integration_test/recovery_flow_test.dart index f5761249c..027f721de 100644 --- a/app/integration_test/recovery_flow_test.dart +++ b/app/integration_test/recovery_flow_test.dart @@ -4,7 +4,6 @@ import 'package:integration_test/integration_test.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; import 'package:studyu_app/services/rejoin_study_service.dart'; -import 'package:studyu_app/util/recovery_qr_utils.dart'; import 'package:studyu_app/widgets/recovery_phrase_content.dart'; import 'package:studyu_core/core.dart'; @@ -121,10 +120,10 @@ void main() { await tester.pumpAndSettle(); - // Verify screen elements are present expect(find.byType(TextFormField), findsOneWidget); expect(find.byType(FilledButton), findsOneWidget); - expect(find.byType(OutlinedButton), findsOneWidget); + expect(find.byType(OutlinedButton), findsNothing); + expect(find.text('Scan QR Code'), findsNothing); }); testWidgets('RecoveryPhraseContent widget loads and displays phrase', ( @@ -154,63 +153,5 @@ void main() { expect(hasError || hasContent, isTrue); }); - - testWidgets('Recovery QR utils generate valid deep links', (tester) async { - final testPhrase = [ - 'abandon', - 'ability', - 'able', - 'about', - 'above', - 'absent', - 'absorb', - 'abstract', - 'absurd', - 'abuse', - 'access', - 'accident', - 'account', - ]; - - final deepLink = RecoveryQrUtils.generateDeepLink(testPhrase); - - // Verify deep link format - expect(deepLink, startsWith('https://')); - expect(deepLink, contains('/recover')); - expect(deepLink, contains('phrase=')); - - // Verify phrase can be parsed back - final parsedPhrase = RecoveryQrUtils.parseDeepLink(deepLink); - expect(parsedPhrase, isNotNull); - expect(parsedPhrase!.length, equals(RecoveryConstants.totalWordCount)); - - // Compare phrases (case-insensitive) - for (var i = 0; i < testPhrase.length; i++) { - expect( - parsedPhrase[i].toLowerCase(), - equals(testPhrase[i].toLowerCase()), - ); - } - }); - - testWidgets('Recovery QR utils reject invalid deep links', (tester) async { - // Invalid domain - final invalidDomain = RecoveryQrUtils.parseDeepLink( - 'https://invalid.domain/recover?phrase=test', - ); - expect(invalidDomain, isNull); - - // Missing phrase parameter - final missingPhrase = RecoveryQrUtils.parseDeepLink( - 'https://app.studyu.health/recover', - ); - expect(missingPhrase, isNull); - - // Invalid word count - final invalidCount = RecoveryQrUtils.parseDeepLink( - 'https://app.studyu.health/recover?phrase=one+two+three', - ); - expect(invalidCount, isNull); - }); }); } diff --git a/app/lib/app_router.dart b/app/lib/app_router.dart index e8150b40b..2493003b0 100644 --- a/app/lib/app_router.dart +++ b/app/lib/app_router.dart @@ -39,7 +39,7 @@ class RouteNames { static const String onboarding = 'onboarding'; static const String terms = 'terms'; static const String recoveryPhrase = 'recoveryPhrase'; - static const String rejoinAccount = 'rejoinAccount'; + static const String restoreAccount = 'restoreAccount'; static const String about = 'about'; static const String studySelection = 'studySelection'; static const String studyOverview = 'studyOverview'; @@ -165,8 +165,8 @@ GoRouter createAppRouter({ builder: (context, state) => const RecoveryPhraseScreen(), ), GoRoute( - path: '/${RouteNames.rejoinAccount}', - name: RouteNames.rejoinAccount, + path: '/${RouteNames.restoreAccount}', + name: RouteNames.restoreAccount, builder: (context, state) => const RejoinStudyScreen(), ), GoRoute( diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 7d3a526e0..b7a8b66f4 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -438,8 +438,12 @@ "recovery_phrase_purpose_header": "Du benötigst diese Phrase um:", "recovery_phrase_purpose_1": "✓ Auf einem anderen Gerät fortzufahren", "recovery_phrase_purpose_2": "✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen", - "recovery_phrase_save_hint": "Bewahre diese 13 Wörter an einem sicheren Ort auf. Mit ihnen kannst du deinen Studienfortschritt auf einem neuen Handy oder nach einer Neuinstallation der App wiederherstellen. Wenn du deine Wiederherstellungsphrase verlierst, kannst du deinen Fortschritt nicht wiederherstellen. Teile sie niemals mit anderen.", - "recovery_phrase_saved_confirmation": "Ich habe meine Wiederherstellungsphrase sicher gespeichert.", + "recovery_phrase_save_hint": "Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst.\n\nTeile sie niemals mit anderen.", + "recovery_phrase_save_warning": "Teile sie niemals mit anderen.", + "show_recovery_phrase": "Wiederherstellungsphrase anzeigen", + "recovery_phrase_list_header": "Deine Wiederherstellungsphrase", + "recovery_phrase_list_helper": "Stelle sicher, dass du alle 13 Wörter in genau dieser Reihenfolge speicherst.", + "recovery_phrase_saved_confirmation": "Ich habe alle 13 Wörter an einem sicheren Ort gespeichert.", "continue_to_study": "Weiter zur Studie", "rejoin_study": "Konto wiederherstellen", "enter_recovery_phrase": "Gib deine Wiederherstellungsphrase ein", @@ -473,31 +477,19 @@ "rejoin_study_help_title": "Mit Wiederherstellungsphrase wiederherstellen", "rejoin_study_help_1": "Gib alle 13 Wörter in der richtigen Reihenfolge ein", "rejoin_study_help_2": "Du kannst die Phrase manuell eingeben oder einfügen", - "rejoin_study_help_3": "Oder scanne stattdessen den QR-Code; beide Optionen stellen dasselbe Konto wieder her", "share_recovery": "Wiederherstellung teilen", "share_as_text": "Als Text teilen", - "share_as_qr": "Als QR-Code teilen", "download_recovery": "Wiederherstellung herunterladen", "download_as_text": "Als Textdatei herunterladen", - "download_as_qr": "Als QR-Bild herunterladen", - "scan_qr_code": "QR-Code scannen", - "scan_qr_description": "Scanne deinen Wiederherstellungs-QR-Code, statt die Phrase einzugeben.", - "scanning_qr": "QR-Code wird gescannt", - "qr_scan_instruction": "Richte deine Kamera auf den QR-Code", - "invalid_qr_code": "Ungültiger QR-Code. Bitte scanne einen gültigen Wiederherstellungs-QR-Code.", - "qr_scan_error": "Fehler beim Scannen des QR-Codes. Bitte versuche es erneut.", - "recovery_security_warning": "Bewahre deine Wiederherstellungsphrase und den QR-Code sicher auf. Jeder mit Zugriff kann dein Konto wiederherstellen.", "file_saved": "Datei erfolgreich gespeichert", "file_save_error": "Fehler beim Speichern der Datei", "share_btn": "Teilen", "copy_btn": "Kopieren", "download_btn": "Download", - "qr_code_btn": "QR-Code", "general_section": "Allgemein", "current_study_section": "Aktuelle Studie", "participation_options_section": "Teilnahmeoptionen", - "download_as_qr_btn": "Als QR herunterladen", "share_recovery_text_btn": "Wiederherstellungstext teilen", "recovery_phrase_load_error": "Wiederherstellungsphrase konnte nicht geladen werden", "share_error": "Fehler beim Teilen: {error}", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index dc8052022..50eb05657 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -436,8 +436,12 @@ "recovery_phrase_purpose_header": "You'll need this phrase to:", "recovery_phrase_purpose_1": "✓ Continue on a different phone", "recovery_phrase_purpose_2": "✓ Restore your progress after reinstalling", - "recovery_phrase_save_hint": "Keep these 13 words in a safe place. They let you restore your study progress on a new phone or after reinstalling the app. If you lose your recovery phrase, you won't be able to recover your progress. Never share it with anyone.", - "recovery_phrase_saved_confirmation": "I saved my recovery phrase somewhere I can find it later.", + "recovery_phrase_save_hint": "Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.", + "recovery_phrase_save_warning": "Never share them with anyone.", + "show_recovery_phrase": "Show Recovery Phrase", + "recovery_phrase_list_header": "Your recovery phrase", + "recovery_phrase_list_helper": "Make sure you save all 13 words in this exact order.", + "recovery_phrase_saved_confirmation": "I have saved all 13 words in a safe place.", "continue_to_study": "Continue to study", "rejoin_study": "Restore account", "enter_recovery_phrase": "Enter your recovery phrase", @@ -471,31 +475,19 @@ "rejoin_study_help_title": "Restore with your recovery phrase", "rejoin_study_help_1": "Enter all 13 words in order", "rejoin_study_help_2": "You can type or paste the phrase manually", - "rejoin_study_help_3": "Or scan the QR code instead; both options restore the same account", "share_recovery": "Share Recovery", "share_as_text": "Share as Text", - "share_as_qr": "Share as QR Code", "download_recovery": "Download Recovery", "download_as_text": "Download as Text File", - "download_as_qr": "Download as QR Image", - "scan_qr_code": "Scan QR Code", - "scan_qr_description": "Scan your recovery QR code instead of typing the phrase.", - "scanning_qr": "Scanning QR Code", - "qr_scan_instruction": "Point your camera at the QR code", - "invalid_qr_code": "Invalid QR code. Please scan a valid recovery QR code.", - "qr_scan_error": "Error scanning QR code. Please try again.", - "recovery_security_warning": "Keep your recovery phrase and QR code safe. Anyone with access can recover your account.", "file_saved": "File saved successfully", "file_save_error": "Error saving file", "share_btn": "Share", "copy_btn": "Copy", "download_btn": "Download", - "qr_code_btn": "QR Code", "general_section": "General", "current_study_section": "Current study", "participation_options_section": "Participation options", - "download_as_qr_btn": "Download as QR", "share_recovery_text_btn": "Share Recovery Text", "recovery_phrase_load_error": "Failed to load recovery phrase", "share_error": "Error sharing: {error}", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 48eee09c5..9f4b0670f 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2387,13 +2387,37 @@ abstract class AppLocalizations { /// No description provided for @recovery_phrase_save_hint. /// /// In en, this message translates to: - /// **'Keep these 13 words in a safe place. They let you restore your study progress on a new phone or after reinstalling the app. If you lose your recovery phrase, you won\'t be able to recover your progress. Never share it with anyone.'** + /// **'Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.'** String get recovery_phrase_save_hint; + /// No description provided for @recovery_phrase_save_warning. + /// + /// In en, this message translates to: + /// **'Never share them with anyone.'** + String get recovery_phrase_save_warning; + + /// No description provided for @show_recovery_phrase. + /// + /// In en, this message translates to: + /// **'Show Recovery Phrase'** + String get show_recovery_phrase; + + /// No description provided for @recovery_phrase_list_header. + /// + /// In en, this message translates to: + /// **'Your recovery phrase'** + String get recovery_phrase_list_header; + + /// No description provided for @recovery_phrase_list_helper. + /// + /// In en, this message translates to: + /// **'Make sure you save all 13 words in this exact order.'** + String get recovery_phrase_list_helper; + /// No description provided for @recovery_phrase_saved_confirmation. /// /// In en, this message translates to: - /// **'I saved my recovery phrase somewhere I can find it later.'** + /// **'I have saved all 13 words in a safe place.'** String get recovery_phrase_saved_confirmation; /// No description provided for @continue_to_study. @@ -2498,12 +2522,6 @@ abstract class AppLocalizations { /// **'You can type or paste the phrase manually'** String get rejoin_study_help_2; - /// No description provided for @rejoin_study_help_3. - /// - /// In en, this message translates to: - /// **'Or scan the QR code instead; both options restore the same account'** - String get rejoin_study_help_3; - /// No description provided for @share_recovery. /// /// In en, this message translates to: @@ -2516,12 +2534,6 @@ abstract class AppLocalizations { /// **'Share as Text'** String get share_as_text; - /// No description provided for @share_as_qr. - /// - /// In en, this message translates to: - /// **'Share as QR Code'** - String get share_as_qr; - /// No description provided for @download_recovery. /// /// In en, this message translates to: @@ -2534,54 +2546,6 @@ abstract class AppLocalizations { /// **'Download as Text File'** String get download_as_text; - /// No description provided for @download_as_qr. - /// - /// In en, this message translates to: - /// **'Download as QR Image'** - String get download_as_qr; - - /// No description provided for @scan_qr_code. - /// - /// In en, this message translates to: - /// **'Scan QR Code'** - String get scan_qr_code; - - /// No description provided for @scan_qr_description. - /// - /// In en, this message translates to: - /// **'Scan your recovery QR code instead of typing the phrase.'** - String get scan_qr_description; - - /// No description provided for @scanning_qr. - /// - /// In en, this message translates to: - /// **'Scanning QR Code'** - String get scanning_qr; - - /// No description provided for @qr_scan_instruction. - /// - /// In en, this message translates to: - /// **'Point your camera at the QR code'** - String get qr_scan_instruction; - - /// No description provided for @invalid_qr_code. - /// - /// In en, this message translates to: - /// **'Invalid QR code. Please scan a valid recovery QR code.'** - String get invalid_qr_code; - - /// No description provided for @qr_scan_error. - /// - /// In en, this message translates to: - /// **'Error scanning QR code. Please try again.'** - String get qr_scan_error; - - /// No description provided for @recovery_security_warning. - /// - /// In en, this message translates to: - /// **'Keep your recovery phrase and QR code safe. Anyone with access can recover your account.'** - String get recovery_security_warning; - /// No description provided for @file_saved. /// /// In en, this message translates to: @@ -2612,12 +2576,6 @@ abstract class AppLocalizations { /// **'Download'** String get download_btn; - /// No description provided for @qr_code_btn. - /// - /// In en, this message translates to: - /// **'QR Code'** - String get qr_code_btn; - /// No description provided for @general_section. /// /// In en, this message translates to: @@ -2636,12 +2594,6 @@ abstract class AppLocalizations { /// **'Participation options'** String get participation_options_section; - /// No description provided for @download_as_qr_btn. - /// - /// In en, this message translates to: - /// **'Download as QR'** - String get download_as_qr_btn; - /// No description provided for @share_recovery_text_btn. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 4c07918ba..480fa0926 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1,5 +1,6 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -1243,11 +1244,24 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Bewahre diese 13 Wörter an einem sicheren Ort auf. Mit ihnen kannst du deinen Studienfortschritt auf einem neuen Handy oder nach einer Neuinstallation der App wiederherstellen. Wenn du deine Wiederherstellungsphrase verlierst, kannst du deinen Fortschritt nicht wiederherstellen. Teile sie niemals mit anderen.'; + 'Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst.\n\nTeile sie niemals mit anderen.'; + + @override + String get recovery_phrase_save_warning => 'Teile sie niemals mit anderen.'; + + @override + String get show_recovery_phrase => 'Wiederherstellungsphrase anzeigen'; + + @override + String get recovery_phrase_list_header => 'Deine Wiederherstellungsphrase'; + + @override + String get recovery_phrase_list_helper => + 'Stelle sicher, dass du alle 13 Wörter in genau dieser Reihenfolge speicherst.'; @override String get recovery_phrase_saved_confirmation => - 'Ich habe meine Wiederherstellungsphrase sicher gespeichert.'; + 'Ich habe alle 13 Wörter an einem sicheren Ort gespeichert.'; @override String get continue_to_study => 'Weiter zur Studie'; @@ -1313,53 +1327,18 @@ class AppLocalizationsDe extends AppLocalizations { String get rejoin_study_help_2 => 'Du kannst die Phrase manuell eingeben oder einfügen'; - @override - String get rejoin_study_help_3 => - 'Oder scanne stattdessen den QR-Code; beide Optionen stellen dasselbe Konto wieder her'; - @override String get share_recovery => 'Wiederherstellung teilen'; @override String get share_as_text => 'Als Text teilen'; - @override - String get share_as_qr => 'Als QR-Code teilen'; - @override String get download_recovery => 'Wiederherstellung herunterladen'; @override String get download_as_text => 'Als Textdatei herunterladen'; - @override - String get download_as_qr => 'Als QR-Bild herunterladen'; - - @override - String get scan_qr_code => 'QR-Code scannen'; - - @override - String get scan_qr_description => - 'Scanne deinen Wiederherstellungs-QR-Code, statt die Phrase einzugeben.'; - - @override - String get scanning_qr => 'QR-Code wird gescannt'; - - @override - String get qr_scan_instruction => 'Richte deine Kamera auf den QR-Code'; - - @override - String get invalid_qr_code => - 'Ungültiger QR-Code. Bitte scanne einen gültigen Wiederherstellungs-QR-Code.'; - - @override - String get qr_scan_error => - 'Fehler beim Scannen des QR-Codes. Bitte versuche es erneut.'; - - @override - String get recovery_security_warning => - 'Bewahre deine Wiederherstellungsphrase und den QR-Code sicher auf. Jeder mit Zugriff kann dein Konto wiederherstellen.'; - @override String get file_saved => 'Datei erfolgreich gespeichert'; @@ -1375,9 +1354,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get download_btn => 'Download'; - @override - String get qr_code_btn => 'QR-Code'; - @override String get general_section => 'Allgemein'; @@ -1387,9 +1363,6 @@ class AppLocalizationsDe extends AppLocalizations { @override String get participation_options_section => 'Teilnahmeoptionen'; - @override - String get download_as_qr_btn => 'Als QR herunterladen'; - @override String get share_recovery_text_btn => 'Wiederherstellungstext teilen'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index f74896afc..4f6475ecb 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1,5 +1,6 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -1232,11 +1233,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Keep these 13 words in a safe place. They let you restore your study progress on a new phone or after reinstalling the app. If you lose your recovery phrase, you won\'t be able to recover your progress. Never share it with anyone.'; + 'Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.'; + + @override + String get recovery_phrase_save_warning => 'Never share them with anyone.'; + + @override + String get show_recovery_phrase => 'Show Recovery Phrase'; + + @override + String get recovery_phrase_list_header => 'Your recovery phrase'; + + @override + String get recovery_phrase_list_helper => + 'Make sure you save all 13 words in this exact order.'; @override String get recovery_phrase_saved_confirmation => - 'I saved my recovery phrase somewhere I can find it later.'; + 'I have saved all 13 words in a safe place.'; @override String get continue_to_study => 'Continue to study'; @@ -1299,52 +1313,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get rejoin_study_help_2 => 'You can type or paste the phrase manually'; - @override - String get rejoin_study_help_3 => - 'Or scan the QR code instead; both options restore the same account'; - @override String get share_recovery => 'Share Recovery'; @override String get share_as_text => 'Share as Text'; - @override - String get share_as_qr => 'Share as QR Code'; - @override String get download_recovery => 'Download Recovery'; @override String get download_as_text => 'Download as Text File'; - @override - String get download_as_qr => 'Download as QR Image'; - - @override - String get scan_qr_code => 'Scan QR Code'; - - @override - String get scan_qr_description => - 'Scan your recovery QR code instead of typing the phrase.'; - - @override - String get scanning_qr => 'Scanning QR Code'; - - @override - String get qr_scan_instruction => 'Point your camera at the QR code'; - - @override - String get invalid_qr_code => - 'Invalid QR code. Please scan a valid recovery QR code.'; - - @override - String get qr_scan_error => 'Error scanning QR code. Please try again.'; - - @override - String get recovery_security_warning => - 'Keep your recovery phrase and QR code safe. Anyone with access can recover your account.'; - @override String get file_saved => 'File saved successfully'; @@ -1360,9 +1340,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get download_btn => 'Download'; - @override - String get qr_code_btn => 'QR Code'; - @override String get general_section => 'General'; @@ -1372,9 +1349,6 @@ class AppLocalizationsEn extends AppLocalizations { @override String get participation_options_section => 'Participation options'; - @override - String get download_as_qr_btn => 'Download as QR'; - @override String get share_recovery_text_btn => 'Share Recovery Text'; diff --git a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart b/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart deleted file mode 100644 index bacb56940..000000000 --- a/app/lib/screens/app_onboarding/qr_code_scanner_screen.dart +++ /dev/null @@ -1,211 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/util/recovery_qr_utils.dart'; - -class QrCodeScannerScreen extends StatefulWidget { - const QrCodeScannerScreen({super.key}); - - @override - State createState() => _QrCodeScannerScreenState(); -} - -class _QrCodeScannerScreenState extends State { - final MobileScannerController _controller = MobileScannerController( - detectionSpeed: DetectionSpeed.noDuplicates, - ); - - bool _isProcessing = false; - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _onDetect(BarcodeCapture capture) { - if (_isProcessing) return; - - final barcodes = capture.barcodes; - if (barcodes.isEmpty) return; - - final barcode = barcodes.first; - final code = barcode.rawValue; - - if (code == null || code.isEmpty) return; - - setState(() { - _isProcessing = true; - }); - - final phrase = RecoveryQrUtils.parseDeepLink(code); - - if (phrase != null) { - Navigator.pop(context, phrase); - } else { - _showError(AppLocalizations.of(context)!.invalid_qr_code); - // Keep _isProcessing = true to prevent repeated error messages - // User can go back and try again with a different QR code - } - } - - void _showError(String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(message), - backgroundColor: Theme.of(context).colorScheme.error, - duration: const Duration(seconds: 3), - ), - ); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.scanning_qr), - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: Theme.of(context).colorScheme.onSurface, - ), - body: Stack( - children: [ - MobileScanner(controller: _controller, onDetect: _onDetect), - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), - Colors.transparent, - Colors.transparent, - Theme.of(context).colorScheme.surface.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.3, 0.7, 1.0], - ), - ), - ), - Center( - child: Container( - width: 280, - height: 280, - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context).colorScheme.primary, - width: 3, - ), - borderRadius: BorderRadius.circular(12), - ), - child: Stack( - children: [ - Positioned( - top: 0, - left: 0, - child: _buildCorner( - Alignment.topLeft, - Theme.of(context).colorScheme.primary, - ), - ), - Positioned( - top: 0, - right: 0, - child: _buildCorner( - Alignment.topRight, - Theme.of(context).colorScheme.primary, - ), - ), - Positioned( - bottom: 0, - left: 0, - child: _buildCorner( - Alignment.bottomLeft, - Theme.of(context).colorScheme.primary, - ), - ), - Positioned( - bottom: 0, - right: 0, - child: _buildCorner( - Alignment.bottomRight, - Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ), - ), - Positioned( - bottom: 100, - left: 0, - right: 0, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 24, - vertical: 12, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.7), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - AppLocalizations.of(context)!.qr_scan_instruction, - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontSize: 16, - ), - textAlign: TextAlign.center, - ), - ), - ), - ), - // Cancel button - Positioned( - bottom: 30, - left: 0, - right: 0, - child: Center( - child: ElevatedButton( - onPressed: () => Navigator.pop(context), - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).colorScheme.surface, - foregroundColor: Theme.of(context).colorScheme.onSurface, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 12, - ), - ), - child: Text(AppLocalizations.of(context)!.cancel), - ), - ), - ), - ], - ), - ); - } - - Widget _buildCorner(Alignment alignment, Color color) { - return Container( - width: 30, - height: 30, - decoration: BoxDecoration( - border: Border( - top: alignment.y < 0 - ? BorderSide(color: color, width: 4) - : BorderSide.none, - bottom: alignment.y > 0 - ? BorderSide(color: color, width: 4) - : BorderSide.none, - left: alignment.x < 0 - ? BorderSide(color: color, width: 4) - : BorderSide.none, - right: alignment.x > 0 - ? BorderSide(color: color, width: 4) - : BorderSide.none, - ), - ), - ); - } -} diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index da73c1416..8572128f6 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:studyu_app/app_router.dart'; @@ -8,42 +7,48 @@ import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_app/widgets/recovery_phrase_content.dart'; class RecoveryPhraseScreen extends StatefulWidget { - const RecoveryPhraseScreen({super.key}); + final List? initialPhrase; + + const RecoveryPhraseScreen({super.key, this.initialPhrase}); @override State createState() => _RecoveryPhraseScreenState(); } class _RecoveryPhraseScreenState extends State { - bool _isChecked = kDebugMode; + bool _isChecked = false; + bool _isRevealed = false; @override Widget build(BuildContext context) { return Scaffold( body: OnboardingPage( title: AppLocalizations.of(context)!.recovery_phrase_header, - description: AppLocalizations.of(context)!.recovery_phrase_save_hint, - bottomCheckboxItems: _confirmationItems(), + description: '', + descriptionWidget: const _RecoveryPhraseInfoCard(), bottomNavigationBar: _buildNavigation(), - child: const RecoveryPhraseContent(), + child: _isRevealed + ? RecoveryPhraseContent( + initialPhrase: widget.initialPhrase, + isChecked: _isChecked, + onCheckedChanged: (value) { + setState(() { + _isChecked = value ?? false; + }); + }, + ) + : Center( + child: FilledButton( + onPressed: () => setState(() => _isRevealed = true), + child: Text( + AppLocalizations.of(context)!.show_recovery_phrase, + ), + ), + ), ), ); } - List _confirmationItems() { - return [ - OnboardingCheckboxItem( - label: AppLocalizations.of(context)!.recovery_phrase_saved_confirmation, - value: _isChecked, - onChanged: (value) { - setState(() { - _isChecked = value ?? false; - }); - }, - ), - ]; - } - Widget _buildNavigation() { return BottomOnboardingNavigation( onBack: () { @@ -61,3 +66,59 @@ class _RecoveryPhraseScreenState extends State { ); } } + +class _RecoveryPhraseInfoCard extends StatelessWidget { + const _RecoveryPhraseInfoCard(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final textTheme = theme.textTheme; + final localizations = AppLocalizations.of(context)!; + final hint = localizations.recovery_phrase_save_hint; + final warning = localizations.recovery_phrase_save_warning; + final warningIndex = hint.indexOf(warning); + final mainText = warningIndex == -1 + ? hint + : hint.substring(0, warningIndex).trim(); + final warningText = warningIndex == -1 + ? null + : hint.substring(warningIndex); + + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.primary.withValues(alpha: 0.18)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.lock_outline, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(mainText, style: textTheme.bodyMedium), + if (warningText != null) ...[ + const SizedBox(height: 12), + Text( + warningText, + style: textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/rejoin_study_screen.dart index b582ede22..d67f77d03 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/rejoin_study_screen.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/screens/app_onboarding/qr_code_scanner_screen.dart'; import 'package:studyu_app/services/rejoin_study_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/onboarding_page.dart'; @@ -65,19 +64,6 @@ class _RejoinStudyScreenState extends State { } } - Future _scanQrCode() async { - final result = await Navigator.push>( - context, - MaterialPageRoute(builder: (context) => const QrCodeScannerScreen()), - ); - - if (result != null && mounted) { - setState(() { - _phraseController.text = result.join(' '); - }); - } - } - void _validateAndSubmit() { setState(() { _errorMessage = null; @@ -144,34 +130,13 @@ class _RejoinStudyScreenState extends State { } } - Widget _buildHelpItem(BuildContext context, IconData icon, String text) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon( - icon, - size: 18, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - text, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ], - ); - } - @override Widget build(BuildContext context) { return Scaffold( body: OnboardingPage( title: AppLocalizations.of(context)!.enter_recovery_phrase, - description: AppLocalizations.of(context)!.rejoin_study_description, + description: '', + descriptionWidget: const _RestoreAccountInfoCard(), maxWidth: 900, padding: const EdgeInsets.all(16.0), child: Form( @@ -180,149 +145,49 @@ class _RejoinStudyScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - Card( - elevation: 0, - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(Icons.info_outline, size: 20), - const SizedBox(width: 8), - Text( - AppLocalizations.of( - context, - )!.rejoin_study_help_title, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ], - ), - const SizedBox(height: 12), - _buildHelpItem( - context, - Icons.list_alt, - AppLocalizations.of(context)!.rejoin_study_help_1, - ), - const SizedBox(height: 8), - _buildHelpItem( - context, - Icons.content_paste, - AppLocalizations.of(context)!.rejoin_study_help_2, - ), - const SizedBox(height: 8), - _buildHelpItem( - context, - Icons.text_fields, - AppLocalizations.of(context)!.rejoin_study_help_3, - ), - ], - ), - ), - ), const SizedBox(height: 24), - LayoutBuilder( - builder: (context, constraints) { - final phraseField = TextFormField( - controller: _phraseController, - decoration: InputDecoration( - labelText: AppLocalizations.of( - context, - )!.enter_recovery_phrase, - hintText: 'apple banana cherry ...', - border: const OutlineInputBorder(), - helperText: _hasTooManyWords - ? AppLocalizations.of( - context, - )!.recovery_phrase_too_many_words - : '${_words.length}/${RecoveryConstants.totalWordCount} words', - helperStyle: TextStyle( - color: _hasTooManyWords - ? Theme.of(context).colorScheme.error - : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: FontWeight.normal, - ), - suffixIcon: - _words.length == RecoveryConstants.totalWordCount - ? Icon( - Icons.check_circle, - color: Theme.of(context).colorScheme.primary, - ) - : null, - ), - maxLines: 4, - autovalidateMode: AutovalidateMode.onUserInteraction, - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) { - if (_canSubmitPhrase) _validateAndSubmit(); - }, - validator: (value) { - if (value == null || value.trim().isEmpty) { - return AppLocalizations.of(context)!.required; - } - if (_hasTooManyWords) { - return AppLocalizations.of( + TextFormField( + controller: _phraseController, + decoration: InputDecoration( + labelText: AppLocalizations.of( + context, + )!.enter_recovery_phrase, + hintText: 'apple banana cherry ...', + border: const OutlineInputBorder(), + helperText: _hasTooManyWords + ? AppLocalizations.of( context, - )!.recovery_phrase_too_many_words; - } - return null; - }, - ); - - final scanButton = SizedBox.square( - dimension: 120, - child: OutlinedButton( - onPressed: _scanQrCode, - style: OutlinedButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.qr_code_scanner), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context)!.scan_qr_code, - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - - final orText = Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'OR', - style: Theme.of(context).textTheme.labelLarge, - ), - ); - - if (constraints.maxWidth < 700) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - phraseField, - const SizedBox(height: 12), - Center(child: orText), - const SizedBox(height: 12), - scanButton, - ], - ); + )!.recovery_phrase_too_many_words + : '${_words.length}/${RecoveryConstants.totalWordCount} words', + helperStyle: TextStyle( + color: _hasTooManyWords + ? Theme.of(context).colorScheme.error + : Theme.of(context).colorScheme.onSurfaceVariant, + fontWeight: FontWeight.normal, + ), + suffixIcon: _words.length == RecoveryConstants.totalWordCount + ? Icon( + Icons.check_circle, + color: Theme.of(context).colorScheme.primary, + ) + : null, + ), + maxLines: 4, + autovalidateMode: AutovalidateMode.onUserInteraction, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) { + if (_canSubmitPhrase) _validateAndSubmit(); + }, + validator: (value) { + if (value == null || value.trim().isEmpty) { + return AppLocalizations.of(context)!.required; } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: phraseField), - SizedBox(height: 120, child: Center(child: orText)), - scanButton, - ], - ); + if (_hasTooManyWords) { + return AppLocalizations.of( + context, + )!.recovery_phrase_too_many_words; + } + return null; }, ), if (_errorMessage != null) ...[ @@ -375,3 +240,37 @@ class _RejoinStudyScreenState extends State { ); } } + +class _RestoreAccountInfoCard extends StatelessWidget { + const _RestoreAccountInfoCard(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.primaryContainer.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.primary.withValues(alpha: 0.18)), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.lock_outline, color: colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + AppLocalizations.of(context)!.rejoin_study_description, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/screens/app_onboarding/terms.dart b/app/lib/screens/app_onboarding/terms.dart index ec372d17b..10efd11f4 100644 --- a/app/lib/screens/app_onboarding/terms.dart +++ b/app/lib/screens/app_onboarding/terms.dart @@ -76,9 +76,8 @@ class _TermsScreenState extends State { Widget build(BuildContext context) { return Scaffold( body: OnboardingPage( - title: '', - description: '', - bottomCheckboxItems: _acknowledgmentItems(), + title: AppLocalizations.of(context)!.legal_documents, + description: AppLocalizations.of(context)!.legal_documents_description, bottomNavigationBar: _buildNavigation(), child: RetryFutureBuilder( tryFunction: AppConfig.getAppConfig, @@ -103,6 +102,14 @@ class _TermsScreenState extends State { pdfUrlLabel: AppLocalizations.of(context)!.terms_read, ), const SizedBox(height: 20), + CheckboxListTile( + title: Text(AppLocalizations.of(context)!.terms_agree), + value: _acceptedTerms, + onChanged: (val) => setState(() => _acceptedTerms = val ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 20), LegalSection( title: AppLocalizations.of(context)!.privacy, description: AppLocalizations.of(context)!.privacy_content, @@ -111,6 +118,14 @@ class _TermsScreenState extends State { pdfUrlLabel: AppLocalizations.of(context)!.privacy_read, ), const SizedBox(height: 20), + CheckboxListTile( + title: Text(AppLocalizations.of(context)!.privacy_agree), + value: _acceptedPrivacy, + onChanged: (val) => setState(() => _acceptedPrivacy = val ?? false), + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), + const SizedBox(height: 20), LegalSection( title: AppLocalizations.of(context)!.legal_notice, description: AppLocalizations.of(context)!.legal_notice_content, @@ -118,25 +133,11 @@ class _TermsScreenState extends State { pdfUrl: appConfig.imprint[appLocale.languageCode], pdfUrlLabel: AppLocalizations.of(context)!.imprint_read, ), + const SizedBox(height: 20), ], ); } - List _acknowledgmentItems() { - return [ - OnboardingCheckboxItem( - label: AppLocalizations.of(context)!.terms_agree, - value: _acceptedTerms, - onChanged: (val) => setState(() => _acceptedTerms = val ?? false), - ), - OnboardingCheckboxItem( - label: AppLocalizations.of(context)!.privacy_agree, - value: _acceptedPrivacy, - onChanged: (val) => setState(() => _acceptedPrivacy = val ?? false), - ), - ]; - } - Widget _buildNavigation() { return BottomOnboardingNavigation( backButtonKey: const ValueKey('terms_back'), @@ -190,8 +191,8 @@ class LegalSection extends StatelessWidget { children: [ Text( title!, - style: theme.textTheme.headlineMedium!.copyWith( - color: theme.primaryColor, + style: theme.textTheme.titleLarge!.copyWith( + color: theme.colorScheme.primary, ), ), const SizedBox(height: 20), diff --git a/app/lib/screens/app_onboarding/welcome.dart b/app/lib/screens/app_onboarding/welcome.dart index 6753a626c..717f8a64a 100644 --- a/app/lib/screens/app_onboarding/welcome.dart +++ b/app/lib/screens/app_onboarding/welcome.dart @@ -68,7 +68,7 @@ class WelcomeScreen extends StatelessWidget { ), const SizedBox(height: 20), TextButton( - onPressed: () => context.pushNamed(RouteNames.rejoinAccount), + onPressed: () => context.pushNamed(RouteNames.restoreAccount), child: Text( AppLocalizations.of(context)!.rejoin_study, style: const TextStyle(fontSize: 16, color: Colors.grey), diff --git a/app/lib/util/recovery_file_utils.dart b/app/lib/util/recovery_file_utils.dart new file mode 100644 index 000000000..4cc2f0b0b --- /dev/null +++ b/app/lib/util/recovery_file_utils.dart @@ -0,0 +1,48 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:universal_html/html.dart' as html; + +class RecoveryFileUtils { + static Future downloadRecoveryText(List phrase) async { + try { + final text = phrase.join(' '); + final bytes = Uint8List.fromList(text.codeUnits); + + if (kIsWeb) { + await _downloadFileWeb( + bytes, + 'studyu_recovery_phrase.txt', + 'text/plain', + ); + return; + } + + final params = SaveFileDialogParams( + data: bytes, + fileName: 'studyu_recovery_phrase.txt', + mimeTypesFilter: ['text/plain'], + ); + + await FlutterFileDialog.saveFile(params: params); + } catch (e) { + rethrow; + } + } + + static Future _downloadFileWeb( + Uint8List bytes, + String fileName, + String mimeType, + ) async { + final blob = html.Blob([bytes], mimeType); + + final url = html.Url.createObjectUrlFromBlob(blob); + + final anchor = html.AnchorElement(href: url) + ..setAttribute('download', fileName) + ..click(); + html.Url.revokeObjectUrl(url); + + anchor.remove(); + } +} diff --git a/app/lib/util/recovery_qr_utils.dart b/app/lib/util/recovery_qr_utils.dart deleted file mode 100644 index 8aca4036d..000000000 --- a/app/lib/util/recovery_qr_utils.dart +++ /dev/null @@ -1,304 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_file_dialog/flutter_file_dialog.dart'; -import 'package:image/image.dart' as img; -import 'package:path_provider/path_provider.dart'; -import 'package:qr/qr.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:studyu_core/core.dart' as core; -import 'package:studyu_core/core.dart'; -import 'package:universal_html/html.dart' as html; - -class RecoveryQrUtils { - static String get deepLinkDomain => - core.deepLinkDomain ?? 'app.studyu.health'; - static const String deepLinkPath = '/recover'; - static const String customScheme = 'studyu'; - - static String generateDeepLink(List phrase) { - final phraseString = phrase.join('+'); - return 'https://$deepLinkDomain$deepLinkPath?phrase=$phraseString'; - } - - static String generateCustomSchemeLink(List phrase) { - final phraseString = phrase.join('+'); - return '$customScheme://recover?phrase=$phraseString'; - } - - static List? parseDeepLink(String url) { - try { - final uri = Uri.parse(url); - - final isHttpsLink = - uri.scheme == 'https' && - uri.host == deepLinkDomain && - uri.path == deepLinkPath; - final isCustomScheme = - uri.scheme == customScheme && uri.host == 'recover'; - - if (!isHttpsLink && !isCustomScheme) { - return null; - } - - final phraseParam = uri.queryParameters['phrase']; - if (phraseParam == null || phraseParam.isEmpty) { - return null; - } - - final words = phraseParam - .split(RegExp(r'[\s+]')) - .where((word) => word.isNotEmpty) - .toList(); - - if (words.length != RecoveryConstants.totalWordCount) { - return null; - } - - return words; - } catch (e) { - return null; - } - } - - static QrCode buildQrCode(String data) { - final qrCode = QrCode(8, QrErrorCorrectLevel.M); - qrCode.addData(data); - return qrCode; - } - - static Widget renderQrWidget( - QrCode qrCode, { - double size = 280.0, - Color foregroundColor = Colors.black, - Color backgroundColor = Colors.white, - }) { - return CustomPaint( - size: Size(size, size), - painter: _QrPainter( - qrCode: qrCode, - foregroundColor: foregroundColor, - backgroundColor: backgroundColor, - ), - ); - } - - static Uint8List qrCodeToImage( - QrCode qrCode, { - int size = 512, - int foregroundColor = 0xFF000000, - int backgroundColor = 0xFFFFFFFF, - }) { - final qrImage = QrImage(qrCode); - final moduleCount = qrImage.moduleCount; - final pixelSize = size ~/ moduleCount; - final actualSize = pixelSize * moduleCount; - - final image = img.Image(width: actualSize, height: actualSize); - - img.fill( - image, - color: img.ColorRgba8( - (backgroundColor >> 16) & 0xFF, - (backgroundColor >> 8) & 0xFF, - backgroundColor & 0xFF, - (backgroundColor >> 24) & 0xFF, - ), - ); - - for (var y = 0; y < moduleCount; y++) { - for (var x = 0; x < moduleCount; x++) { - if (qrImage.isDark(y, x)) { - final startX = x * pixelSize; - final startY = y * pixelSize; - - img.fillRect( - image, - x1: startX, - y1: startY, - x2: startX + pixelSize, - y2: startY + pixelSize, - color: img.ColorRgba8( - (foregroundColor >> 16) & 0xFF, - (foregroundColor >> 8) & 0xFF, - foregroundColor & 0xFF, - (foregroundColor >> 24) & 0xFF, - ), - ); - } - } - } - - return Uint8List.fromList(img.encodePng(image)); - } - - static Future shareRecoveryText(List phrase) async { - final text = phrase.join(' '); - final params = ShareParams(text: text, subject: 'StudyU Recovery Phrase'); - await SharePlus.instance.share(params); - } - - static Future shareRecoveryQr(List phrase) async { - try { - final deepLink = generateDeepLink(phrase); - final qrCode = buildQrCode(deepLink); - final imageData = qrCodeToImage(qrCode); - - if (kIsWeb) { - final xFile = XFile.fromData( - imageData, - mimeType: 'image/png', - name: 'recovery_qr.png', - ); - - final params = ShareParams( - files: [xFile], - subject: 'StudyU Recovery QR Code', - ); - await SharePlus.instance.share(params); - return; - } - - final tempDir = await getTemporaryDirectory(); - final tempFile = File('${tempDir.path}/recovery_qr.png'); - await tempFile.writeAsBytes(imageData); - final xFile = XFile(tempFile.path); - final params = ShareParams( - files: [xFile], - subject: 'StudyU Recovery QR Code', - ); - await SharePlus.instance.share(params); - - Future.delayed(const Duration(seconds: 5), () { - if (tempFile.existsSync()) { - tempFile.delete(); - } - }); - } catch (e) { - rethrow; - } - } - - static Future downloadRecoveryText(List phrase) async { - try { - final text = phrase.join(' '); - final bytes = Uint8List.fromList(text.codeUnits); - - if (kIsWeb) { - await _downloadFileWeb( - bytes, - 'studyu_recovery_phrase.txt', - 'text/plain', - ); - return; - } - - final params = SaveFileDialogParams( - data: bytes, - fileName: 'studyu_recovery_phrase.txt', - mimeTypesFilter: ['text/plain'], - ); - - await FlutterFileDialog.saveFile(params: params); - } catch (e) { - rethrow; - } - } - - static Future downloadRecoveryQr(List phrase) async { - try { - final deepLink = generateDeepLink(phrase); - final qrCode = buildQrCode(deepLink); - final imageData = qrCodeToImage(qrCode); - - if (kIsWeb) { - await _downloadFileWeb( - imageData, - 'studyu_recovery_qr.png', - 'image/png', - ); - return; - } - - final params = SaveFileDialogParams( - data: imageData, - fileName: 'studyu_recovery_qr.png', - mimeTypesFilter: ['image/png'], - ); - - await FlutterFileDialog.saveFile(params: params); - } catch (e) { - rethrow; - } - } - - static Future _downloadFileWeb( - Uint8List bytes, - String fileName, - String mimeType, - ) async { - final blob = html.Blob([bytes], mimeType); - - final url = html.Url.createObjectUrlFromBlob(blob); - - final anchor = html.AnchorElement(href: url) - ..setAttribute('download', fileName) - ..click(); - html.Url.revokeObjectUrl(url); - - anchor.remove(); - } -} - -class _QrPainter extends CustomPainter { - final QrCode qrCode; - final Color foregroundColor; - final Color backgroundColor; - - _QrPainter({ - required this.qrCode, - required this.foregroundColor, - required this.backgroundColor, - }); - - @override - void paint(Canvas canvas, Size size) { - final qrImage = QrImage(qrCode); - final moduleCount = qrImage.moduleCount; - final pixelSize = size.width / moduleCount; - - final backgroundPaint = Paint() - ..color = backgroundColor - ..style = PaintingStyle.fill; - canvas.drawRect( - Rect.fromLTWH(0, 0, size.width, size.height), - backgroundPaint, - ); - - final paint = Paint() - ..color = foregroundColor - ..style = PaintingStyle.fill; - - for (var y = 0; y < moduleCount; y++) { - for (var x = 0; x < moduleCount; x++) { - if (qrImage.isDark(y, x)) { - final rect = Rect.fromLTWH( - x * pixelSize, - y * pixelSize, - pixelSize, - pixelSize, - ); - canvas.drawRect(rect, paint); - } - } - } - } - - @override - bool shouldRepaint(covariant _QrPainter oldDelegate) { - return oldDelegate.qrCode != qrCode || - oldDelegate.foregroundColor != foregroundColor || - oldDelegate.backgroundColor != backgroundColor; - } -} diff --git a/app/lib/widgets/onboarding_page.dart b/app/lib/widgets/onboarding_page.dart index 56c6cf558..70f1c5514 100644 --- a/app/lib/widgets/onboarding_page.dart +++ b/app/lib/widgets/onboarding_page.dart @@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; class OnboardingPage extends StatelessWidget { final String title; + final IconData? titleIcon; final String description; final Widget? descriptionWidget; final Widget child; final Widget? bottomContent; final Widget? bottomNavigationBar; - final List bottomCheckboxItems; final double maxWidth; final EdgeInsetsGeometry padding; @@ -15,10 +15,10 @@ class OnboardingPage extends StatelessWidget { super.key, required this.title, required this.description, + this.titleIcon, this.descriptionWidget, required this.child, this.bottomContent, - this.bottomCheckboxItems = const [], this.bottomNavigationBar, this.maxWidth = 700, this.padding = const EdgeInsets.fromLTRB(24.0, 56.0, 24.0, 16.0), @@ -42,11 +42,23 @@ class OnboardingPage extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (title.isNotEmpty) ...[ - Text( - title, - style: theme.textTheme.headlineMedium!.copyWith( - color: theme.primaryColor, - ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (titleIcon != null) ...[ + Icon(titleIcon, color: theme.primaryColor), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + title, + style: theme.textTheme.headlineMedium!.copyWith( + color: theme.primaryColor, + ), + textAlign: TextAlign.center, + ), + ), + ], ), const SizedBox(height: 20), ], @@ -62,38 +74,11 @@ class OnboardingPage extends StatelessWidget { ), ), ), - if (bottomContent != null || bottomCheckboxItems.isNotEmpty) + if (bottomContent != null) Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: maxWidth), - child: - bottomContent ?? - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 24.0, - vertical: 8.0, - ), - child: Material( - color: Colors.transparent, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final item in bottomCheckboxItems) - CheckboxListTile( - title: Text( - item.label, - style: Theme.of(context).textTheme.bodyMedium, - ), - value: item.value, - onChanged: item.onChanged, - contentPadding: EdgeInsets.zero, - controlAffinity: - ListTileControlAffinity.leading, - ), - ], - ), - ), - ), + child: bottomContent, ), ), if (bottomNavigationBar != null) bottomNavigationBar!, @@ -102,15 +87,3 @@ class OnboardingPage extends StatelessWidget { ); } } - -class OnboardingCheckboxItem { - final String label; - final bool value; - final ValueChanged onChanged; - - const OnboardingCheckboxItem({ - required this.label, - required this.value, - required this.onChanged, - }); -} diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart index 29bb64ce9..4dcbb64e9 100644 --- a/app/lib/widgets/recovery_phrase_content.dart +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -2,20 +2,29 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/services/rejoin_study_service.dart'; -import 'package:studyu_app/util/recovery_qr_utils.dart'; +import 'package:studyu_app/util/recovery_file_utils.dart'; class RecoveryPhraseContent extends StatefulWidget { final bool useGridLayout; + final List? initialPhrase; + final bool isChecked; + final ValueChanged? onCheckedChanged; - const RecoveryPhraseContent({super.key, this.useGridLayout = true}); + const RecoveryPhraseContent({ + super.key, + this.useGridLayout = true, + this.initialPhrase, + this.isChecked = false, + this.onCheckedChanged, + }); @override State createState() => RecoveryPhraseContentState(); } class RecoveryPhraseContentState extends State { - List? _phrase; - bool _isLoading = true; + late List? _phrase = widget.initialPhrase; + late bool _isLoading = widget.initialPhrase == null; String? _error; List? get phrase => _phrase; @@ -25,7 +34,7 @@ class RecoveryPhraseContentState extends State { @override void initState() { super.initState(); - loadPhrase(); + if (widget.initialPhrase == null) loadPhrase(); } Future loadPhrase() async { @@ -66,44 +75,10 @@ class RecoveryPhraseContentState extends State { ); } - Future _shareText() async { - if (_phrase == null) return; - try { - await RecoveryQrUtils.shareRecoveryText(_phrase!); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.share_error(e.toString()), - ), - ), - ); - } - } - } - - Future _shareQr() async { - if (_phrase == null) return; - try { - await RecoveryQrUtils.shareRecoveryQr(_phrase!); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - AppLocalizations.of(context)!.share_error(e.toString()), - ), - ), - ); - } - } - } - Future _downloadText() async { if (_phrase == null) return; try { - await RecoveryQrUtils.downloadRecoveryText(_phrase!); + await RecoveryFileUtils.downloadRecoveryText(_phrase!); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), @@ -120,70 +95,6 @@ class RecoveryPhraseContentState extends State { } } - Future _downloadQr() async { - if (_phrase == null) return; - try { - await RecoveryQrUtils.downloadRecoveryQr(_phrase!); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.file_save_error), - ), - ); - } - } - } - - void _showQrCodeDialog() { - if (_phrase == null) return; - showDialog( - context: context, - builder: (context) => Dialog( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - AppLocalizations.of(context)!.qr_code_btn, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: 24), - RecoveryQrUtils.renderQrWidget( - RecoveryQrUtils.buildQrCode( - RecoveryQrUtils.generateDeepLink(_phrase!), - ), - size: 240, - ), - const SizedBox(height: 24), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _ActionButton( - icon: Icons.share, - label: AppLocalizations.of(context)!.share_as_qr, - onTap: _shareQr, - ), - _ActionButton( - icon: Icons.download, - label: AppLocalizations.of(context)!.download_as_qr, - onTap: _downloadQr, - ), - ], - ), - ], - ), - ), - ), - ); - } - @override Widget build(BuildContext context) { if (_isLoading) { @@ -211,55 +122,57 @@ class RecoveryPhraseContentState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Text( + AppLocalizations.of(context)!.recovery_phrase_list_header, + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.of(context)!.recovery_phrase_list_helper, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 16), if (widget.useGridLayout) _buildPhraseGrid() else _buildPhraseChips(), - const SizedBox(height: 24), + const SizedBox(height: 16), _buildActionButtons(), + const SizedBox(height: 16), + CheckboxListTile( + title: Text( + AppLocalizations.of(context)!.recovery_phrase_saved_confirmation, + ), + value: widget.isChecked, + onChanged: widget.onCheckedChanged, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + ), ], ); } Widget _buildPhraseGrid() { - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisExtent: 36, - crossAxisSpacing: 8, - mainAxisSpacing: 4, - ), - itemCount: _phrase?.length ?? 0, - itemBuilder: (context, index) { - return Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide( - color: Theme.of(context).dividerColor.withValues(alpha: 0.2), - ), - ), - ), - alignment: Alignment.centerLeft, - padding: const EdgeInsets.symmetric(horizontal: 4), - child: RichText( - text: TextSpan( - children: [ - TextSpan( - text: '${index + 1}. ', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).disabledColor, - ), - ), - TextSpan( - text: _phrase![index], - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), - ), - ], - ), + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return SelectionArea( + child: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < _phrase!.length; index++) + _PhraseWordRow(number: index + 1, word: _phrase![index]), + ], ), - ); - }, + ), + ), ); } @@ -274,33 +187,24 @@ class RecoveryPhraseContentState extends State { Widget _buildActionButtons() { if (widget.useGridLayout) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + return Wrap( + spacing: 8, + runSpacing: 4, + alignment: WrapAlignment.center, children: [ - _ActionButton( - icon: Icons.share_outlined, - label: AppLocalizations.of(context)!.share_btn, - onTap: _shareText, - ), - _ActionButton( - icon: Icons.copy_outlined, - label: AppLocalizations.of(context)!.copy_btn, - onTap: _copyToClipboard, - ), - _ActionButton( - icon: Icons.download_outlined, - label: AppLocalizations.of(context)!.download_btn, - onTap: _downloadText, + TextButton.icon( + icon: const Icon(Icons.copy_outlined), + label: Text(AppLocalizations.of(context)!.copy_btn), + onPressed: _copyToClipboard, ), - _ActionButton( - icon: Icons.qr_code_2, - label: AppLocalizations.of(context)!.qr_code_btn, - onTap: _showQrCodeDialog, + TextButton.icon( + icon: const Icon(Icons.download_outlined), + label: Text(AppLocalizations.of(context)!.download_btn), + onPressed: _downloadText, ), ], ); } else { - final theme = Theme.of(context); return Wrap( spacing: 16, runSpacing: 8, @@ -311,19 +215,10 @@ class RecoveryPhraseContentState extends State { onPressed: _copyToClipboard, label: Text(AppLocalizations.of(context)!.copy_to_clipboard), ), - FilledButton.tonalIcon( - style: FilledButton.styleFrom( - foregroundColor: theme.primaryColor, - backgroundColor: theme.colorScheme.surfaceContainerHighest, - ), - icon: const Icon(Icons.qr_code), - onPressed: _downloadQr, - label: Text(AppLocalizations.of(context)!.download_as_qr_btn), - ), OutlinedButton.icon( - onPressed: _shareText, - icon: const Icon(Icons.share), - label: Text(AppLocalizations.of(context)!.share_recovery_text_btn), + onPressed: _downloadText, + icon: const Icon(Icons.download), + label: Text(AppLocalizations.of(context)!.download_btn), ), ], ); @@ -331,44 +226,42 @@ class RecoveryPhraseContentState extends State { } } -class _ActionButton extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; +class _PhraseWordRow extends StatelessWidget { + final int number; + final String word; - const _ActionButton({ - required this.icon, - required this.label, - required this.onTap, - }); + const _PhraseWordRow({required this.number, required this.word}); @override Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(30), - child: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primaryContainer.withValues(alpha: 0.5), - shape: BoxShape.circle, + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + SizedBox( + width: 28, + child: Text( + '$number', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.disabledColor, + ), + textAlign: TextAlign.right, ), - child: Icon(icon, color: Theme.of(context).colorScheme.primary), ), - ), - const SizedBox(height: 6), - Text( - label, - style: Theme.of(context).textTheme.labelMedium, - textAlign: TextAlign.center, - ), - ], + const SizedBox(width: 12), + Expanded( + child: Text( + word, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), ); } } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index eca79a307..d8b3198c5 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -36,11 +36,9 @@ dependencies: flutter_widget_from_html: ^0.17.2 gauge_indicator: ^0.6.0 go_router: ^17.0.0 - image: ^4.5.4 intersperse: ^2.0.0 intl: ^0.20.2 introduction_screen: ^4.0.0 - mobile_scanner: ^7.1.3 package_info_plus: ^10.2.0 path: ^1.9.1 path_drawing: ^1.0.1 @@ -48,13 +46,11 @@ dependencies: pdf: ^3.12.0 permission_handler: ^12.0.3 provider: ^6.1.5+1 - qr: ^3.0.2 quiver: ^3.2.2 rainbow_color: ^2.0.1 record: ^7.1.0 sentry_flutter: ^9.22.0 sentry_logging: ^9.22.0 - share_plus: ^13.0.0 shared_preferences: ^2.5.5 showcaseview: ^5.1.0 stack_deferred_link: ^1.0.1 diff --git a/app/test/screens/app_onboarding/recovery_phrase_screen_test.dart b/app/test/screens/app_onboarding/recovery_phrase_screen_test.dart new file mode 100644 index 000000000..b1c9e1b29 --- /dev/null +++ b/app/test/screens/app_onboarding/recovery_phrase_screen_test.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:studyu_app/app_router.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/screens/app_onboarding/recovery_phrase_screen.dart'; + +Widget _wrap(Widget child) { + return MaterialApp.router( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + locale: const Locale('en'), + routerConfig: GoRouter( + initialLocation: '/', + routes: [ + GoRoute(path: '/', builder: (_, _) => child), + GoRoute( + path: '/${RouteNames.terms}', + name: RouteNames.terms, + builder: (_, _) => const SizedBox(), + ), + GoRoute( + path: '/${RouteNames.studySelection}', + name: RouteNames.studySelection, + builder: (_, _) => const SizedBox(), + ), + ], + ), + ); +} + +void main() { + testWidgets('hides recovery words until reveal tap', (tester) async { + await tester.pumpWidget( + _wrap( + const RecoveryPhraseScreen( + initialPhrase: [ + 'alpha', + 'bravo', + 'charlie', + 'delta', + 'echo', + 'foxtrot', + 'golf', + 'hotel', + 'india', + 'juliet', + 'kilo', + 'lima', + 'mike', + ], + ), + ), + ); + + expect(find.text('Save Recovery Phrase'), findsOneWidget); + expect(find.text('Show Recovery Phrase'), findsOneWidget); + expect(find.text('Your recovery phrase'), findsNothing); + expect( + find.text('I have written down all 13 words and stored them safely.'), + findsNothing, + ); + expect(find.byType(ListView), findsNothing); + + await tester.tap(find.text('Show Recovery Phrase')); + await tester.pumpAndSettle(); + + expect(find.text('Your recovery phrase'), findsOneWidget); + expect( + find.text('Write the words down in this exact order.'), + findsOneWidget, + ); + expect( + find.text('I have written down all 13 words and stored them safely.'), + findsOneWidget, + ); + expect(find.byType(ListView), findsOneWidget); + expect(find.text('Show Recovery Phrase'), findsNothing); + expect(find.text('Share'), findsNothing); + }); +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index f2dc18099..83e81cb26 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -17,6 +17,8 @@ import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; import 'package:studyu_app/screens/app_onboarding/welcome.dart'; +import 'package:studyu_core/env.dart'; +import 'package:supabase/supabase.dart'; Widget setup(Widget child) { return ChangeNotifierProvider( @@ -38,8 +40,8 @@ Widget setup(Widget child) { builder: (_, _) => const TermsScreen(), ), GoRoute( - path: '/${RouteNames.rejoinAccount}', - name: RouteNames.rejoinAccount, + path: '/${RouteNames.restoreAccount}', + name: RouteNames.restoreAccount, builder: (_, _) => const RejoinStudyScreen(), ), GoRoute( @@ -53,6 +55,13 @@ Widget setup(Widget child) { } void main() { + setUpAll(() { + setEnv( + 'https://example.supabase.co', + 'test-anon-key', + supabaseClient: SupabaseClient('https://example.supabase.co', 'test'), + ); + }); testWidgets('Counter increments smoke test', (tester) async { await tester.pumpWidget(setup(const WelcomeScreen())); await tester.pumpAndSettle(); @@ -60,7 +69,7 @@ void main() { expect(find.text('Get started'), findsOneWidget); }); - testWidgets('restore account opens rejoin account route', (tester) async { + testWidgets('restore account opens restore account route', (tester) async { await tester.pumpWidget(setup(const WelcomeScreen())); await tester.pumpAndSettle(); @@ -70,10 +79,10 @@ void main() { expect(find.byType(RejoinStudyScreen), findsOneWidget); }); - test('rejoin account route is registered by name', () { + test('restore account route is registered by name', () { final router = createAppRouter(queryParameters: const {}); - expect(router.namedLocation(RouteNames.rejoinAccount), '/rejoinAccount'); + expect(router.namedLocation(RouteNames.restoreAccount), '/restoreAccount'); }); test('opens welcome screen when tour is completed without preview', () { diff --git a/pubspec.lock b/pubspec.lock index 98523ba2e..b23c8cd18 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1237,14 +1237,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - mobile_scanner: - dependency: transitive - description: - name: mobile_scanner - sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce - url: "https://pub.dev" - source: hosted - version: "7.2.0" mockito: dependency: transitive description: @@ -1837,22 +1829,6 @@ packages: url: "https://pub.dev" source: hosted version: "9.23.0" - share_plus: - dependency: transitive - description: - name: share_plus - sha256: "9eee8283462d91a7a1c8bdb67d08874abd75a2f8fae3bc0ca033035e375fb3d8" - url: "https://pub.dev" - source: hosted - version: "13.2.0" - share_plus_platform_interface: - dependency: transitive - description: - name: share_plus_platform_interface - sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" - url: "https://pub.dev" - source: hosted - version: "7.1.0" shared_preferences: dependency: transitive description: From 00cd41d7e2159accbcd02d57b7ba9487447881ed Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 14:44:55 +0200 Subject: [PATCH 29/57] rename rejoin study screen/service to restore account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RejoinStudyScreen → RestoreAccountScreen - RejoinStudyService → RestoreAccountService - l10n keys: rejoin_study* → restore_account* - File renames: rejoin_study_* → restore_account_* - Updated all imports and references --- app/integration_test/recovery_flow_test.dart | 24 +++++++++---------- app/lib/app_router.dart | 4 ++-- app/lib/l10n/app_de.arb | 10 ++++---- app/lib/l10n/app_en.arb | 10 ++++---- app/lib/l10n/app_localizations.dart | 20 ++++++++-------- app/lib/l10n/app_localizations_de.dart | 10 ++++---- app/lib/l10n/app_localizations_en.dart | 10 ++++---- .../app_onboarding/loading_screen.dart | 4 ++-- ...creen.dart => restore_account_screen.dart} | 18 +++++++------- app/lib/screens/app_onboarding/welcome.dart | 2 +- ...vice.dart => restore_account_service.dart} | 2 +- app/lib/widgets/recovery_phrase_content.dart | 4 ++-- ....dart => restore_account_screen_test.dart} | 4 ++-- app/test/widget_test.dart | 6 ++--- 14 files changed, 64 insertions(+), 64 deletions(-) rename app/lib/screens/app_onboarding/{rejoin_study_screen.dart => restore_account_screen.dart} (93%) rename app/lib/services/{rejoin_study_service.dart => restore_account_service.dart} (99%) rename app/test/screens/app_onboarding/{rejoin_study_screen_test.dart => restore_account_screen_test.dart} (87%) diff --git a/app/integration_test/recovery_flow_test.dart b/app/integration_test/recovery_flow_test.dart index 027f721de..0e43b3552 100644 --- a/app/integration_test/recovery_flow_test.dart +++ b/app/integration_test/recovery_flow_test.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; -import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_app/screens/app_onboarding/restore_account_screen.dart'; +import 'package:studyu_app/services/restore_account_service.dart'; import 'package:studyu_app/widgets/recovery_phrase_content.dart'; import 'package:studyu_core/core.dart'; @@ -15,7 +15,7 @@ void main() { group('Recovery Flow Integration Tests', () { setUp(() { // Clear any cached recovery data before each test - RejoinStudyService.clearCache(); + RestoreAccountService.clearCache(); }); testWidgets('Recovery phrase encoding and decoding roundtrip', ( @@ -32,7 +32,7 @@ void main() { expect(words.length, equals(RecoveryConstants.totalWordCount)); // Decode the words - final decodedId = RejoinStudyService.decodeRecoveryPhrase(words); + final decodedId = RestoreAccountService.decodeRecoveryPhrase(words); expect(decodedId, equals(testId)); }); @@ -42,14 +42,14 @@ void main() { // Test with too few words final shortWords = ['word1', 'word2', 'word3']; expect( - () => RejoinStudyService.decodeRecoveryPhrase(shortWords), + () => RestoreAccountService.decodeRecoveryPhrase(shortWords), throwsArgumentError, ); // Test with too many words final longWords = List.generate(20, (i) => 'word$i'); expect( - () => RejoinStudyService.decodeRecoveryPhrase(longWords), + () => RestoreAccountService.decodeRecoveryPhrase(longWords), throwsArgumentError, ); }); @@ -69,7 +69,7 @@ void main() { // Should throw due to checksum mismatch or invalid word expect( - () => RejoinStudyService.decodeRecoveryPhrase(corruptedWords), + () => RestoreAccountService.decodeRecoveryPhrase(corruptedWords), throwsA(isA()), ); }); @@ -79,7 +79,7 @@ void main() { '1234567890ABCDEF1234567890ABCDEF', radix: 16, ); - final uuid = RejoinStudyService.convertBigIntToUuid(testId); + final uuid = RestoreAccountService.convertBigIntToUuid(testId); expect(uuid, isNotNull); expect( @@ -100,21 +100,21 @@ void main() { ) async { // Negative ID should return null final negativeId = BigInt.from(-1); - final negativeUuid = RejoinStudyService.convertBigIntToUuid(negativeId); + final negativeUuid = RestoreAccountService.convertBigIntToUuid(negativeId); expect(negativeUuid, isNull); // ID exceeding 128 bits should return null final tooLargeId = BigInt.one << 128; - final largeUuid = RejoinStudyService.convertBigIntToUuid(tooLargeId); + final largeUuid = RestoreAccountService.convertBigIntToUuid(tooLargeId); expect(largeUuid, isNull); }); - testWidgets('RejoinStudyScreen renders correctly', (tester) async { + testWidgets('RestoreAccountScreen renders correctly', (tester) async { await tester.pumpWidget( const MaterialApp( localizationsDelegates: AppLocalizations.localizationsDelegates, supportedLocales: AppLocalizations.supportedLocales, - home: RejoinStudyScreen(), + home: RestoreAccountScreen(), ), ); diff --git a/app/lib/app_router.dart b/app/lib/app_router.dart index 2493003b0..5cb446029 100644 --- a/app/lib/app_router.dart +++ b/app/lib/app_router.dart @@ -8,7 +8,7 @@ import 'package:studyu_app/screens/app_onboarding/app_outdated_screen.dart'; import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; import 'package:studyu_app/screens/app_onboarding/onboarding_screen.dart'; import 'package:studyu_app/screens/app_onboarding/recovery_phrase_screen.dart'; -import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; +import 'package:studyu_app/screens/app_onboarding/restore_account_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; import 'package:studyu_app/screens/app_onboarding/welcome.dart'; import 'package:studyu_app/screens/study/dashboard/contact_tab/contact_screen.dart'; @@ -167,7 +167,7 @@ GoRouter createAppRouter({ GoRoute( path: '/${RouteNames.restoreAccount}', name: RouteNames.restoreAccount, - builder: (context, state) => const RejoinStudyScreen(), + builder: (context, state) => const RestoreAccountScreen(), ), GoRoute( path: '/${RouteNames.studySelection}', diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index b7a8b66f4..562a23c22 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -445,7 +445,7 @@ "recovery_phrase_list_helper": "Stelle sicher, dass du alle 13 Wörter in genau dieser Reihenfolge speicherst.", "recovery_phrase_saved_confirmation": "Ich habe alle 13 Wörter an einem sicheren Ort gespeichert.", "continue_to_study": "Weiter zur Studie", - "rejoin_study": "Konto wiederherstellen", + "restore_account": "Konto wiederherstellen", "enter_recovery_phrase": "Gib deine Wiederherstellungsphrase ein", "required": "Erforderlich", "invalid_recovery_phrase": "Diese Wiederherstellungsphrase passt zu keinem Konto. Achte darauf, dass alle 13 Wörter in der richtigen Reihenfolge stehen.", @@ -473,10 +473,10 @@ "recovery_user_not_found": "Kein Konto mit dieser Wiederherstellungsphrase gefunden.", "recovery_network_error": "Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.", - "rejoin_study_description": "Stelle dein Konto auf diesem Gerät mit der Wiederherstellungsphrase wieder her, die du beim Beitritt zur Studie gespeichert hast.", - "rejoin_study_help_title": "Mit Wiederherstellungsphrase wiederherstellen", - "rejoin_study_help_1": "Gib alle 13 Wörter in der richtigen Reihenfolge ein", - "rejoin_study_help_2": "Du kannst die Phrase manuell eingeben oder einfügen", + "restore_account_description": "Stelle dein Konto auf diesem Gerät mit der Wiederherstellungsphrase wieder her, die du beim Beitritt zur Studie gespeichert hast.", + "restore_account_help_title": "Mit Wiederherstellungsphrase wiederherstellen", + "restore_account_help_1": "Gib alle 13 Wörter in der richtigen Reihenfolge ein", + "restore_account_help_2": "Du kannst die Phrase manuell eingeben oder einfügen", "share_recovery": "Wiederherstellung teilen", "share_as_text": "Als Text teilen", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 50eb05657..d5349263f 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -443,7 +443,7 @@ "recovery_phrase_list_helper": "Make sure you save all 13 words in this exact order.", "recovery_phrase_saved_confirmation": "I have saved all 13 words in a safe place.", "continue_to_study": "Continue to study", - "rejoin_study": "Restore account", + "restore_account": "Restore account", "enter_recovery_phrase": "Enter your recovery phrase", "required": "Required", "invalid_recovery_phrase": "This recovery phrase does not match an account. Make sure all 13 words are in the right order.", @@ -471,10 +471,10 @@ "recovery_user_not_found": "No account found with this recovery phrase.", "recovery_network_error": "Network error. Please check your connection and try again.", - "rejoin_study_description": "Restore your account on this device with the recovery phrase you saved before joining a study.", - "rejoin_study_help_title": "Restore with your recovery phrase", - "rejoin_study_help_1": "Enter all 13 words in order", - "rejoin_study_help_2": "You can type or paste the phrase manually", + "restore_account_description": "Restore your account on this device with the recovery phrase you saved before joining a study.", + "restore_account_help_title": "Restore with your recovery phrase", + "restore_account_help_1": "Enter all 13 words in order", + "restore_account_help_2": "You can type or paste the phrase manually", "share_recovery": "Share Recovery", "share_as_text": "Share as Text", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 9f4b0670f..2644d0eaf 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2426,11 +2426,11 @@ abstract class AppLocalizations { /// **'Continue to study'** String get continue_to_study; - /// No description provided for @rejoin_study. + /// No description provided for @restore_account. /// /// In en, this message translates to: /// **'Restore account'** - String get rejoin_study; + String get restore_account; /// No description provided for @enter_recovery_phrase. /// @@ -2498,29 +2498,29 @@ abstract class AppLocalizations { /// **'Network error. Please check your connection and try again.'** String get recovery_network_error; - /// No description provided for @rejoin_study_description. + /// No description provided for @restore_account_description. /// /// In en, this message translates to: /// **'Restore your account on this device with the recovery phrase you saved before joining a study.'** - String get rejoin_study_description; + String get restore_account_description; - /// No description provided for @rejoin_study_help_title. + /// No description provided for @restore_account_help_title. /// /// In en, this message translates to: /// **'Restore with your recovery phrase'** - String get rejoin_study_help_title; + String get restore_account_help_title; - /// No description provided for @rejoin_study_help_1. + /// No description provided for @restore_account_help_1. /// /// In en, this message translates to: /// **'Enter all 13 words in order'** - String get rejoin_study_help_1; + String get restore_account_help_1; - /// No description provided for @rejoin_study_help_2. + /// No description provided for @restore_account_help_2. /// /// In en, this message translates to: /// **'You can type or paste the phrase manually'** - String get rejoin_study_help_2; + String get restore_account_help_2; /// No description provided for @share_recovery. /// diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 480fa0926..888f827df 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1267,7 +1267,7 @@ class AppLocalizationsDe extends AppLocalizations { String get continue_to_study => 'Weiter zur Studie'; @override - String get rejoin_study => 'Konto wiederherstellen'; + String get restore_account => 'Konto wiederherstellen'; @override String get enter_recovery_phrase => 'Gib deine Wiederherstellungsphrase ein'; @@ -1312,19 +1312,19 @@ class AppLocalizationsDe extends AppLocalizations { 'Netzwerkfehler. Bitte überprüfe deine Verbindung und versuche es erneut.'; @override - String get rejoin_study_description => + String get restore_account_description => 'Stelle dein Konto auf diesem Gerät mit der Wiederherstellungsphrase wieder her, die du beim Beitritt zur Studie gespeichert hast.'; @override - String get rejoin_study_help_title => + String get restore_account_help_title => 'Mit Wiederherstellungsphrase wiederherstellen'; @override - String get rejoin_study_help_1 => + String get restore_account_help_1 => 'Gib alle 13 Wörter in der richtigen Reihenfolge ein'; @override - String get rejoin_study_help_2 => + String get restore_account_help_2 => 'Du kannst die Phrase manuell eingeben oder einfügen'; @override diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 4f6475ecb..cf54d5b91 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1256,7 +1256,7 @@ class AppLocalizationsEn extends AppLocalizations { String get continue_to_study => 'Continue to study'; @override - String get rejoin_study => 'Restore account'; + String get restore_account => 'Restore account'; @override String get enter_recovery_phrase => 'Enter your recovery phrase'; @@ -1301,17 +1301,17 @@ class AppLocalizationsEn extends AppLocalizations { 'Network error. Please check your connection and try again.'; @override - String get rejoin_study_description => + String get restore_account_description => 'Restore your account on this device with the recovery phrase you saved before joining a study.'; @override - String get rejoin_study_help_title => 'Restore with your recovery phrase'; + String get restore_account_help_title => 'Restore with your recovery phrase'; @override - String get rejoin_study_help_1 => 'Enter all 13 words in order'; + String get restore_account_help_1 => 'Enter all 13 words in order'; @override - String get rejoin_study_help_2 => 'You can type or paste the phrase manually'; + String get restore_account_help_2 => 'You can type or paste the phrase manually'; @override String get share_recovery => 'Share Recovery'; diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index ed80f4112..606b4ae32 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -16,7 +16,7 @@ import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; import 'package:studyu_app/services/deep_link_error_helper.dart'; import 'package:studyu_app/services/deep_link_service.dart'; import 'package:studyu_app/services/deferred_link_service.dart'; -import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_app/services/restore_account_service.dart'; import 'package:studyu_app/util/cache.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; import 'package:studyu_app/widgets/deep_link_onboarding_widgets.dart'; @@ -355,7 +355,7 @@ class _LoadingScreenState extends State { ); // The cached recovery secret belongs to the deleted account; clear it // so a subsequent user on this device cannot read it. - RejoinStudyService.clearCache(); + RestoreAccountService.clearCache(); if (!mounted) return; context.go( '/${RouteNames.appErrorScreen}', diff --git a/app/lib/screens/app_onboarding/rejoin_study_screen.dart b/app/lib/screens/app_onboarding/restore_account_screen.dart similarity index 93% rename from app/lib/screens/app_onboarding/rejoin_study_screen.dart rename to app/lib/screens/app_onboarding/restore_account_screen.dart index d67f77d03..7a2fceff7 100644 --- a/app/lib/screens/app_onboarding/rejoin_study_screen.dart +++ b/app/lib/screens/app_onboarding/restore_account_screen.dart @@ -2,19 +2,19 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_app/services/restore_account_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_core/core.dart'; -class RejoinStudyScreen extends StatefulWidget { - const RejoinStudyScreen({super.key}); +class RestoreAccountScreen extends StatefulWidget { + const RestoreAccountScreen({super.key}); @override - State createState() => _RejoinStudyScreenState(); + State createState() => _RestoreAccountScreenState(); } -class _RejoinStudyScreenState extends State { +class _RestoreAccountScreenState extends State { final TextEditingController _phraseController = TextEditingController(); final _formKey = GlobalKey(); String? _errorMessage; @@ -84,7 +84,7 @@ class _RejoinStudyScreenState extends State { } try { - final id = RejoinStudyService.decodeRecoveryPhrase(words); + final id = RestoreAccountService.decodeRecoveryPhrase(words); _onSuccess(id); } catch (e) { setState(() { @@ -100,7 +100,7 @@ class _RejoinStudyScreenState extends State { }); try { - final result = await RejoinStudyService.performRecovery(id); + final result = await RestoreAccountService.performRecovery(id); if (!mounted) return; @@ -221,7 +221,7 @@ class _RejoinStudyScreenState extends State { ), ], ) - : Text(AppLocalizations.of(context)!.rejoin_study), + : Text(AppLocalizations.of(context)!.restore_account), ), ], ), @@ -264,7 +264,7 @@ class _RestoreAccountInfoCard extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - AppLocalizations.of(context)!.rejoin_study_description, + AppLocalizations.of(context)!.restore_account_description, style: theme.textTheme.bodyMedium, ), ), diff --git a/app/lib/screens/app_onboarding/welcome.dart b/app/lib/screens/app_onboarding/welcome.dart index 717f8a64a..d8c1a0b6c 100644 --- a/app/lib/screens/app_onboarding/welcome.dart +++ b/app/lib/screens/app_onboarding/welcome.dart @@ -70,7 +70,7 @@ class WelcomeScreen extends StatelessWidget { TextButton( onPressed: () => context.pushNamed(RouteNames.restoreAccount), child: Text( - AppLocalizations.of(context)!.rejoin_study, + AppLocalizations.of(context)!.restore_account, style: const TextStyle(fontSize: 16, color: Colors.grey), ), ), diff --git a/app/lib/services/rejoin_study_service.dart b/app/lib/services/restore_account_service.dart similarity index 99% rename from app/lib/services/rejoin_study_service.dart rename to app/lib/services/restore_account_service.dart index 21fc5af71..9d65d9dd9 100644 --- a/app/lib/services/rejoin_study_service.dart +++ b/app/lib/services/restore_account_service.dart @@ -28,7 +28,7 @@ class RecoveryResult { } } -class RejoinStudyService { +class RestoreAccountService { static List? _cachedPhrase; static String? _cachedRecoveryId; diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart index 4dcbb64e9..41b694047 100644 --- a/app/lib/widgets/recovery_phrase_content.dart +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/services/rejoin_study_service.dart'; +import 'package:studyu_app/services/restore_account_service.dart'; import 'package:studyu_app/util/recovery_file_utils.dart'; class RecoveryPhraseContent extends StatefulWidget { @@ -44,7 +44,7 @@ class RecoveryPhraseContentState extends State { }); try { - final phrase = await RejoinStudyService.getRecoveryPhrase(); + final phrase = await RestoreAccountService.getRecoveryPhrase(); if (mounted) { setState(() { _phrase = phrase; diff --git a/app/test/screens/app_onboarding/rejoin_study_screen_test.dart b/app/test/screens/app_onboarding/restore_account_screen_test.dart similarity index 87% rename from app/test/screens/app_onboarding/rejoin_study_screen_test.dart rename to app/test/screens/app_onboarding/restore_account_screen_test.dart index a40d726b4..9ac87f61e 100644 --- a/app/test/screens/app_onboarding/rejoin_study_screen_test.dart +++ b/app/test/screens/app_onboarding/restore_account_screen_test.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; +import 'package:studyu_app/screens/app_onboarding/restore_account_screen.dart'; Widget _wrap(Widget child) { return MaterialApp( @@ -16,7 +16,7 @@ void main() { testWidgets('disables restore when recovery phrase has too many words', ( tester, ) async { - await tester.pumpWidget(_wrap(const RejoinStudyScreen())); + await tester.pumpWidget(_wrap(const RestoreAccountScreen())); await tester.enterText( find.byType(TextFormField), diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 83e81cb26..6ccb20caa 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -14,7 +14,7 @@ import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/app_onboarding/about.dart'; import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; -import 'package:studyu_app/screens/app_onboarding/rejoin_study_screen.dart'; +import 'package:studyu_app/screens/app_onboarding/restore_account_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; import 'package:studyu_app/screens/app_onboarding/welcome.dart'; import 'package:studyu_core/env.dart'; @@ -42,7 +42,7 @@ Widget setup(Widget child) { GoRoute( path: '/${RouteNames.restoreAccount}', name: RouteNames.restoreAccount, - builder: (_, _) => const RejoinStudyScreen(), + builder: (_, _) => const RestoreAccountScreen(), ), GoRoute( path: '/${RouteNames.welcome}', @@ -76,7 +76,7 @@ void main() { await tester.tap(find.text('Restore account')); await tester.pumpAndSettle(); - expect(find.byType(RejoinStudyScreen), findsOneWidget); + expect(find.byType(RestoreAccountScreen), findsOneWidget); }); test('restore account route is registered by name', () { From 1194ee105e0822887ee9a3f0fa15796987a0e205 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 14:58:36 +0200 Subject: [PATCH 30/57] polish recovery phrase flow --- app/lib/l10n/app_de.arb | 4 +- app/lib/l10n/app_en.arb | 4 +- app/lib/l10n/app_localizations.dart | 4 +- app/lib/l10n/app_localizations_de.dart | 6 +- app/lib/l10n/app_localizations_en.dart | 9 +- .../restore_account_screen.dart | 27 +++-- app/lib/widgets/onboarding_page.dart | 2 +- app/lib/widgets/recovery_phrase_content.dart | 111 +++++++++--------- 8 files changed, 84 insertions(+), 83 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 562a23c22..b6fcbe67f 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -434,7 +434,7 @@ "recovery_phrase_header": "Wiederherstellungsphrase speichern", "copy_to_clipboard": "In die Zwischenablage kopieren", - "copied_to_clipboard": "In die Zwischenablage kopiert", + "copied_to_clipboard": "In die Zwischenablage kopiert. Füge die Wiederherstellungsphrase jetzt an einem sicheren Ort auf deinem Handy ein.", "recovery_phrase_purpose_header": "Du benötigst diese Phrase um:", "recovery_phrase_purpose_1": "✓ Auf einem anderen Gerät fortzufahren", "recovery_phrase_purpose_2": "✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen", @@ -443,7 +443,7 @@ "show_recovery_phrase": "Wiederherstellungsphrase anzeigen", "recovery_phrase_list_header": "Deine Wiederherstellungsphrase", "recovery_phrase_list_helper": "Stelle sicher, dass du alle 13 Wörter in genau dieser Reihenfolge speicherst.", - "recovery_phrase_saved_confirmation": "Ich habe alle 13 Wörter an einem sicheren Ort gespeichert.", + "recovery_phrase_saved_confirmation": "Ich habe alle 13 Wörter an einem sicheren Ort gespeichert und kann sie abrufen, wenn ich mein Konto wiederherstellen möchte.", "continue_to_study": "Weiter zur Studie", "restore_account": "Konto wiederherstellen", "enter_recovery_phrase": "Gib deine Wiederherstellungsphrase ein", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index d5349263f..b3fe105c9 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -432,7 +432,7 @@ "recovery_phrase_header": "Save Recovery Phrase", "copy_to_clipboard": "Copy to clipboard", - "copied_to_clipboard": "Copied to clipboard", + "copied_to_clipboard": "Copied to clipboard. Paste the recovery phrase somewhere secure on your phone now.", "recovery_phrase_purpose_header": "You'll need this phrase to:", "recovery_phrase_purpose_1": "✓ Continue on a different phone", "recovery_phrase_purpose_2": "✓ Restore your progress after reinstalling", @@ -441,7 +441,7 @@ "show_recovery_phrase": "Show Recovery Phrase", "recovery_phrase_list_header": "Your recovery phrase", "recovery_phrase_list_helper": "Make sure you save all 13 words in this exact order.", - "recovery_phrase_saved_confirmation": "I have saved all 13 words in a safe place.", + "recovery_phrase_saved_confirmation": "I have saved all 13 words in a safe place and can retrieve them when I want to restore my account.", "continue_to_study": "Continue to study", "restore_account": "Restore account", "enter_recovery_phrase": "Enter your recovery phrase", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 2644d0eaf..83ad1a90b 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2363,7 +2363,7 @@ abstract class AppLocalizations { /// No description provided for @copied_to_clipboard. /// /// In en, this message translates to: - /// **'Copied to clipboard'** + /// **'Copied to clipboard. Paste the recovery phrase somewhere secure on your phone now.'** String get copied_to_clipboard; /// No description provided for @recovery_phrase_purpose_header. @@ -2417,7 +2417,7 @@ abstract class AppLocalizations { /// No description provided for @recovery_phrase_saved_confirmation. /// /// In en, this message translates to: - /// **'I have saved all 13 words in a safe place.'** + /// **'I have saved all 13 words in a safe place and can retrieve them when I want to restore my account.'** String get recovery_phrase_saved_confirmation; /// No description provided for @continue_to_study. diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 888f827df..8ac6fc7f7 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -1229,7 +1228,8 @@ class AppLocalizationsDe extends AppLocalizations { String get copy_to_clipboard => 'In die Zwischenablage kopieren'; @override - String get copied_to_clipboard => 'In die Zwischenablage kopiert'; + String get copied_to_clipboard => + 'In die Zwischenablage kopiert. Füge die Wiederherstellungsphrase jetzt an einem sicheren Ort auf deinem Handy ein.'; @override String get recovery_phrase_purpose_header => 'Du benötigst diese Phrase um:'; @@ -1261,7 +1261,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recovery_phrase_saved_confirmation => - 'Ich habe alle 13 Wörter an einem sicheren Ort gespeichert.'; + 'Ich habe alle 13 Wörter an einem sicheren Ort gespeichert und kann sie abrufen, wenn ich mein Konto wiederherstellen möchte.'; @override String get continue_to_study => 'Weiter zur Studie'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index cf54d5b91..d622b5fda 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -1219,7 +1218,8 @@ class AppLocalizationsEn extends AppLocalizations { String get copy_to_clipboard => 'Copy to clipboard'; @override - String get copied_to_clipboard => 'Copied to clipboard'; + String get copied_to_clipboard => + 'Copied to clipboard. Paste the recovery phrase somewhere secure on your phone now.'; @override String get recovery_phrase_purpose_header => 'You\'ll need this phrase to:'; @@ -1250,7 +1250,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recovery_phrase_saved_confirmation => - 'I have saved all 13 words in a safe place.'; + 'I have saved all 13 words in a safe place and can retrieve them when I want to restore my account.'; @override String get continue_to_study => 'Continue to study'; @@ -1311,7 +1311,8 @@ class AppLocalizationsEn extends AppLocalizations { String get restore_account_help_1 => 'Enter all 13 words in order'; @override - String get restore_account_help_2 => 'You can type or paste the phrase manually'; + String get restore_account_help_2 => + 'You can type or paste the phrase manually'; @override String get share_recovery => 'Share Recovery'; diff --git a/app/lib/screens/app_onboarding/restore_account_screen.dart b/app/lib/screens/app_onboarding/restore_account_screen.dart index 7a2fceff7..7c51797b7 100644 --- a/app/lib/screens/app_onboarding/restore_account_screen.dart +++ b/app/lib/screens/app_onboarding/restore_account_screen.dart @@ -3,7 +3,6 @@ import 'package:go_router/go_router.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/services/restore_account_service.dart'; -import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_core/core.dart'; @@ -133,8 +132,21 @@ class _RestoreAccountScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.enter_recovery_phrase), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.goNamed(RouteNames.welcome); + } + }, + ), + ), body: OnboardingPage( - title: AppLocalizations.of(context)!.enter_recovery_phrase, + title: '', description: '', descriptionWidget: const _RestoreAccountInfoCard(), maxWidth: 900, @@ -145,7 +157,6 @@ class _RestoreAccountScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 24), TextFormField( controller: _phraseController, decoration: InputDecoration( @@ -227,16 +238,6 @@ class _RestoreAccountScreenState extends State { ), ), ), - bottomNavigationBar: BottomOnboardingNavigation( - hideNext: true, - onBack: () { - if (context.canPop()) { - context.pop(); - } else { - context.goNamed(RouteNames.welcome); - } - }, - ), ); } } diff --git a/app/lib/widgets/onboarding_page.dart b/app/lib/widgets/onboarding_page.dart index 70f1c5514..80a5b6c24 100644 --- a/app/lib/widgets/onboarding_page.dart +++ b/app/lib/widgets/onboarding_page.dart @@ -21,7 +21,7 @@ class OnboardingPage extends StatelessWidget { this.bottomContent, this.bottomNavigationBar, this.maxWidth = 700, - this.padding = const EdgeInsets.fromLTRB(24.0, 56.0, 24.0, 16.0), + this.padding = const EdgeInsets.fromLTRB(24.0, 36.0, 24.0, 16.0), }); @override diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart index 41b694047..5e2611ec6 100644 --- a/app/lib/widgets/recovery_phrase_content.dart +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -154,23 +154,62 @@ class RecoveryPhraseContentState extends State { Widget _buildPhraseGrid() { final theme = Theme.of(context); final colorScheme = theme.colorScheme; + const rowHeight = 36.0; - return SelectionArea( - child: DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: colorScheme.outlineVariant), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (var index = 0; index < _phrase!.length; index++) - _PhraseWordRow(number: index + 1, word: _phrase![index]), - ], - ), + return DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + children: [ + for (var index = 0; index < _phrase!.length; index++) + SizedBox( + height: rowHeight, + width: 28, + child: Align( + alignment: Alignment.centerRight, + child: Text( + '${index + 1}', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.disabledColor, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final word in _phrase!) + SizedBox( + height: rowHeight, + child: Align( + alignment: Alignment.centerLeft, + child: Text( + word, + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + ), + ), + ], ), ), ); @@ -225,43 +264,3 @@ class RecoveryPhraseContentState extends State { } } } - -class _PhraseWordRow extends StatelessWidget { - final int number; - final String word; - - const _PhraseWordRow({required this.number, required this.word}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Padding( - padding: const EdgeInsets.symmetric(vertical: 6), - child: Row( - children: [ - SizedBox( - width: 28, - child: Text( - '$number', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.disabledColor, - ), - textAlign: TextAlign.right, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - word, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - ); - } -} From b4a970469db70cb6df8835566f47ab4986fbd0a3 Mon Sep 17 00:00:00 2001 From: johannesvedder <104295301+johannesvedder@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:03:56 +0000 Subject: [PATCH 31/57] chore: Apply static analysis changes --- app/integration_test/recovery_flow_test.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/integration_test/recovery_flow_test.dart b/app/integration_test/recovery_flow_test.dart index 0e43b3552..82bd8ab6d 100644 --- a/app/integration_test/recovery_flow_test.dart +++ b/app/integration_test/recovery_flow_test.dart @@ -100,7 +100,9 @@ void main() { ) async { // Negative ID should return null final negativeId = BigInt.from(-1); - final negativeUuid = RestoreAccountService.convertBigIntToUuid(negativeId); + final negativeUuid = RestoreAccountService.convertBigIntToUuid( + negativeId, + ); expect(negativeUuid, isNull); // ID exceeding 128 bits should return null From 0fdf6f1c131c181611b0b1f71ee09dadedeac162 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 15:24:30 +0200 Subject: [PATCH 32/57] polish recovery phrase settings card --- app/lib/screens/study/dashboard/settings.dart | 38 +++--- app/lib/widgets/recovery_phrase_content.dart | 114 ++++++++---------- 2 files changed, 63 insertions(+), 89 deletions(-) diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index c398540c3..48798161b 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -67,10 +67,7 @@ class _SettingsState extends State { // General section header Text( AppLocalizations.of(context)!.general_section, - style: theme.textTheme.titleMedium!.copyWith( - color: theme.primaryColor, - fontWeight: FontWeight.w600, - ), + style: theme.textTheme.titleMedium!.copyWith(), ), const SizedBox(height: 12), @@ -83,9 +80,7 @@ class _SettingsState extends State { Expanded( child: Text( AppLocalizations.of(context)!.language, - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w500, - ), + style: theme.textTheme.bodyMedium!.copyWith(), ), ), DropdownButton( @@ -114,9 +109,7 @@ class _SettingsState extends State { Expanded( child: Text( AppLocalizations.of(context)!.allow_analytics, - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w500, - ), + style: theme.textTheme.bodyMedium!.copyWith(), ), ), Switch( @@ -145,9 +138,7 @@ class _SettingsState extends State { AppLocalizations.of( context, )!.show_dashboard_showcase_again, - style: theme.textTheme.bodyMedium!.copyWith( - fontWeight: FontWeight.w500, - ), + style: theme.textTheme.bodyMedium!.copyWith(), ), ), OutlinedButton.icon( @@ -173,6 +164,10 @@ class _SettingsState extends State { ), ), ), + const SizedBox(height: 8), + // Recovery phrase card + const RecoveryPhraseWidget(), + const SizedBox(height: 24), // Current study section header @@ -180,7 +175,6 @@ class _SettingsState extends State { AppLocalizations.of(context)!.current_study_section, style: theme.textTheme.titleMedium!.copyWith( color: theme.primaryColor, - fontWeight: FontWeight.w600, ), ), @@ -198,9 +192,6 @@ class _SettingsState extends State { ), ), ), - const SizedBox(height: 8), - // Recovery phrase card - const RecoveryPhraseWidget(), const SizedBox(height: 24), @@ -210,7 +201,6 @@ class _SettingsState extends State { AppLocalizations.of(context)!.participation_options_section, style: theme.textTheme.titleMedium!.copyWith( color: theme.primaryColor, - fontWeight: FontWeight.w600, ), ), Align( @@ -284,17 +274,13 @@ class _RecoveryPhraseWidgetState extends State { final theme = Theme.of(context); return Card( child: ExpansionTile( - leading: Icon(Icons.key, color: theme.primaryColor), + leading: Icon(Icons.lock_outline, color: theme.primaryColor), title: Text( AppLocalizations.of(context)!.recovery_phrase_header, style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.w500, ), ), - subtitle: Text( - AppLocalizations.of(context)!.recovery_phrase_save_hint, - style: theme.textTheme.bodySmall, - ), onExpansionChanged: (expanded) { if (expanded && !_hasExpanded) { setState(() => _hasExpanded = true); @@ -307,7 +293,11 @@ class _RecoveryPhraseWidgetState extends State { child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: theme.colorScheme.surface), - child: const RecoveryPhraseContent(useGridLayout: false), + child: const RecoveryPhraseContent( + useGridLayout: false, + showConfirmation: false, + showSaveHint: true, + ), ), ), ], diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart index 5e2611ec6..f59b1bb26 100644 --- a/app/lib/widgets/recovery_phrase_content.dart +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -9,6 +9,8 @@ class RecoveryPhraseContent extends StatefulWidget { final List? initialPhrase; final bool isChecked; final ValueChanged? onCheckedChanged; + final bool showConfirmation; + final bool showSaveHint; const RecoveryPhraseContent({ super.key, @@ -16,6 +18,8 @@ class RecoveryPhraseContent extends StatefulWidget { this.initialPhrase, this.isChecked = false, this.onCheckedChanged, + this.showConfirmation = true, + this.showSaveHint = false, }); @override @@ -128,7 +132,14 @@ class RecoveryPhraseContentState extends State { context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), - const SizedBox(height: 4), + if (widget.showSaveHint) ...[ + const SizedBox(height: 8), + Text( + AppLocalizations.of(context)!.recovery_phrase_save_hint, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: 16), Text( AppLocalizations.of(context)!.recovery_phrase_list_helper, style: Theme.of(context).textTheme.bodyMedium, @@ -137,16 +148,18 @@ class RecoveryPhraseContentState extends State { if (widget.useGridLayout) _buildPhraseGrid() else _buildPhraseChips(), const SizedBox(height: 16), _buildActionButtons(), - const SizedBox(height: 16), - CheckboxListTile( - title: Text( - AppLocalizations.of(context)!.recovery_phrase_saved_confirmation, + if (widget.showConfirmation) ...[ + const SizedBox(height: 16), + CheckboxListTile( + title: Text( + AppLocalizations.of(context)!.recovery_phrase_saved_confirmation, + ), + value: widget.isChecked, + onChanged: widget.onCheckedChanged, + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, ), - value: widget.isChecked, - onChanged: widget.onCheckedChanged, - contentPadding: EdgeInsets.zero, - controlAffinity: ListTileControlAffinity.leading, - ), + ], ], ); } @@ -188,24 +201,15 @@ class RecoveryPhraseContentState extends State { const SizedBox(width: 12), Expanded( child: SelectionArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - for (final word in _phrase!) - SizedBox( - height: rowHeight, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - word, - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSurface, - fontWeight: FontWeight.w700, - ), - ), - ), - ), - ], + child: SelectableText( + _phrase!.join('\n'), + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w700, + height: + rowHeight / + (theme.textTheme.titleMedium?.fontSize ?? 16), + ), ), ), ), @@ -225,42 +229,22 @@ class RecoveryPhraseContentState extends State { } Widget _buildActionButtons() { - if (widget.useGridLayout) { - return Wrap( - spacing: 8, - runSpacing: 4, - alignment: WrapAlignment.center, - children: [ - TextButton.icon( - icon: const Icon(Icons.copy_outlined), - label: Text(AppLocalizations.of(context)!.copy_btn), - onPressed: _copyToClipboard, - ), - TextButton.icon( - icon: const Icon(Icons.download_outlined), - label: Text(AppLocalizations.of(context)!.download_btn), - onPressed: _downloadText, - ), - ], - ); - } else { - return Wrap( - spacing: 16, - runSpacing: 8, - alignment: WrapAlignment.center, - children: [ - FilledButton.icon( - icon: const Icon(Icons.copy), - onPressed: _copyToClipboard, - label: Text(AppLocalizations.of(context)!.copy_to_clipboard), - ), - OutlinedButton.icon( - onPressed: _downloadText, - icon: const Icon(Icons.download), - label: Text(AppLocalizations.of(context)!.download_btn), - ), - ], - ); - } + return Wrap( + spacing: 8, + runSpacing: 4, + alignment: WrapAlignment.center, + children: [ + TextButton.icon( + icon: const Icon(Icons.copy_outlined), + label: Text(AppLocalizations.of(context)!.copy_btn), + onPressed: _copyToClipboard, + ), + TextButton.icon( + icon: const Icon(Icons.download_outlined), + label: Text(AppLocalizations.of(context)!.download_btn), + onPressed: _downloadText, + ), + ], + ); } } From e783fbb1b0c6c569d98f8cdfb0e9294d8ba65630 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 15:39:15 +0200 Subject: [PATCH 33/57] test(app): update recovery phrase expectations --- .../recovery_phrase_screen_test.dart | 81 ------------------- 1 file changed, 81 deletions(-) delete mode 100644 app/test/screens/app_onboarding/recovery_phrase_screen_test.dart diff --git a/app/test/screens/app_onboarding/recovery_phrase_screen_test.dart b/app/test/screens/app_onboarding/recovery_phrase_screen_test.dart deleted file mode 100644 index b1c9e1b29..000000000 --- a/app/test/screens/app_onboarding/recovery_phrase_screen_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; -import 'package:studyu_app/app_router.dart'; -import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/screens/app_onboarding/recovery_phrase_screen.dart'; - -Widget _wrap(Widget child) { - return MaterialApp.router( - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - locale: const Locale('en'), - routerConfig: GoRouter( - initialLocation: '/', - routes: [ - GoRoute(path: '/', builder: (_, _) => child), - GoRoute( - path: '/${RouteNames.terms}', - name: RouteNames.terms, - builder: (_, _) => const SizedBox(), - ), - GoRoute( - path: '/${RouteNames.studySelection}', - name: RouteNames.studySelection, - builder: (_, _) => const SizedBox(), - ), - ], - ), - ); -} - -void main() { - testWidgets('hides recovery words until reveal tap', (tester) async { - await tester.pumpWidget( - _wrap( - const RecoveryPhraseScreen( - initialPhrase: [ - 'alpha', - 'bravo', - 'charlie', - 'delta', - 'echo', - 'foxtrot', - 'golf', - 'hotel', - 'india', - 'juliet', - 'kilo', - 'lima', - 'mike', - ], - ), - ), - ); - - expect(find.text('Save Recovery Phrase'), findsOneWidget); - expect(find.text('Show Recovery Phrase'), findsOneWidget); - expect(find.text('Your recovery phrase'), findsNothing); - expect( - find.text('I have written down all 13 words and stored them safely.'), - findsNothing, - ); - expect(find.byType(ListView), findsNothing); - - await tester.tap(find.text('Show Recovery Phrase')); - await tester.pumpAndSettle(); - - expect(find.text('Your recovery phrase'), findsOneWidget); - expect( - find.text('Write the words down in this exact order.'), - findsOneWidget, - ); - expect( - find.text('I have written down all 13 words and stored them safely.'), - findsOneWidget, - ); - expect(find.byType(ListView), findsOneWidget); - expect(find.text('Show Recovery Phrase'), findsNothing); - expect(find.text('Share'), findsNothing); - }); -} From f19fa36aef237a5c7d3375293a12dede51d1d972 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 16:05:55 +0200 Subject: [PATCH 34/57] fix(app): prevent recovery phrase cache leaks --- .../app_onboarding/study_switch_dialogs.dart | 2 + app/lib/screens/study/dashboard/settings.dart | 2 + app/lib/services/restore_account_service.dart | 72 +++++++++++++++++-- .../restore_account_service_test.dart | 46 ++++++++++++ ...y.sql => 20260709120000_user_recovery.sql} | 0 5 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 app/test/services/restore_account_service_test.dart rename supabase/migrations/{20251208_user_recovery.sql => 20260709120000_user_recovery.sql} (100%) diff --git a/app/lib/screens/app_onboarding/study_switch_dialogs.dart b/app/lib/screens/app_onboarding/study_switch_dialogs.dart index bea6e8804..8891d07c6 100644 --- a/app/lib/screens/app_onboarding/study_switch_dialogs.dart +++ b/app/lib/screens/app_onboarding/study_switch_dialogs.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/services/restore_account_service.dart'; import 'package:studyu_app/util/fitbit_handler.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; import 'package:studyu_core/core.dart'; @@ -214,6 +215,7 @@ class StudySwitchDialogs { } await currentSubject.delete(); + RestoreAccountService.clearCache(); await deleteLocalData(); await FitbitHandler.deleteFitbitCredentials(currentSubject.studyId); if (context.mounted) { diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 48798161b..1693f9d95 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/services/restore_account_service.dart'; import 'package:studyu_app/util/app_analytics.dart'; import 'package:studyu_app/util/dashboard_showcase.dart'; import 'package:studyu_app/util/fitbit_handler.dart'; @@ -414,6 +415,7 @@ class DeleteAlertDialog extends StatelessWidget { // PGRST116: subject already deleted from DB — proceed with local cleanup } // Reached when delete succeeded or subject was already gone from DB + RestoreAccountService.clearCache(); await deleteLocalData(); await FitbitHandler.deleteFitbitCredentials(subject!.studyId); if (context.mounted) await cancelNotifications(context); diff --git a/app/lib/services/restore_account_service.dart b/app/lib/services/restore_account_service.dart index 9d65d9dd9..96de1cc17 100644 --- a/app/lib/services/restore_account_service.dart +++ b/app/lib/services/restore_account_service.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; @@ -31,14 +32,53 @@ class RecoveryResult { class RestoreAccountService { static List? _cachedPhrase; static String? _cachedRecoveryId; + static String? _cachedUserId; + static Future Function() _recoveryIdGetter = _fetchRecoveryId; + static String? Function() _currentUserIdGetter = _currentUserId; static void clearCache() { _cachedPhrase = null; _cachedRecoveryId = null; + _cachedUserId = null; + } + + @visibleForTesting + static Future Function() get debugRecoveryIdGetterForTesting => + _recoveryIdGetter; + + @visibleForTesting + static set debugRecoveryIdGetterForTesting( + Future Function() getter, + ) { + _recoveryIdGetter = getter; + } + + @visibleForTesting + static void debugResetRecoveryIdGetterForTesting() { + _recoveryIdGetter = _fetchRecoveryId; + } + + @visibleForTesting + static String? Function() get debugCurrentUserIdGetterForTesting => + _currentUserIdGetter; + + @visibleForTesting + static set debugCurrentUserIdGetterForTesting(String? Function() getter) { + _currentUserIdGetter = getter; + } + + @visibleForTesting + static void debugResetCurrentUserIdGetterForTesting() { + _currentUserIdGetter = _currentUserId; } static Future?> getRecoveryPhrase() async { - if (_cachedPhrase != null) return _cachedPhrase; + final currentUserId = _currentUserIdGetter(); + if (_cachedPhrase != null && + currentUserId != null && + _cachedUserId == currentUserId) { + return _cachedPhrase; + } final recoveryId = await getOrCreateRecoveryId(); if (recoveryId == null) return null; @@ -51,6 +91,7 @@ class RestoreAccountService { try { final id = BigInt.parse(sanitizedId, radix: 16); + _cachedUserId = currentUserId; return _cachedPhrase = encode(id); } on FormatException catch (e) { StudyULogger.warning('Failed to parse recovery ID: $e'); @@ -79,8 +120,22 @@ class RestoreAccountService { } static Future getOrCreateRecoveryId() async { - if (_cachedRecoveryId != null) return _cachedRecoveryId; + final currentUserId = _currentUserIdGetter(); + if (_cachedRecoveryId != null && + currentUserId != null && + _cachedUserId == currentUserId) { + return _cachedRecoveryId; + } + + final recoveryId = await _recoveryIdGetter(); + if (recoveryId != null) { + _cachedRecoveryId = recoveryId; + _cachedUserId = currentUserId; + } + return recoveryId; + } + static Future _fetchRecoveryId() async { try { final response = await Supabase.instance.client.rpc( 'get_or_create_recovery', @@ -99,6 +154,9 @@ class RestoreAccountService { } } + static String? _currentUserId() => + Supabase.instance.client.auth.currentUser?.id; + static BigInt decodeRecoveryPhrase(List words) { // Validate word count first if (words.length != RecoveryConstants.totalWordCount) { @@ -111,7 +169,9 @@ class RestoreAccountService { try { final enWords = words.map((w) => w.toLowerCase().trim()).toList(); return decode(enWords, wordlist: wordlistEn); - } on Exception catch (e) { + } catch (e) { + if (e is! ArgumentError) rethrow; + // Check if error is due to word not found in English list final errorStr = e.toString(); if (errorStr.contains('Invalid word') || @@ -120,9 +180,11 @@ class RestoreAccountService { try { final deWords = words.map((w) => w.toLowerCase().trim()).toList(); return decode(deWords, wordlist: wordlistDe); - } on Exception catch (_) { + } catch (deError) { + if (deError is! ArgumentError) rethrow; + // German also failed, throw original English error - rethrow; + throw e; } } rethrow; diff --git a/app/test/services/restore_account_service_test.dart b/app/test/services/restore_account_service_test.dart new file mode 100644 index 000000000..9f9330acd --- /dev/null +++ b/app/test/services/restore_account_service_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyu_app/services/restore_account_service.dart'; +import 'package:studyu_core/core.dart'; + +void main() { + group('RestoreAccountService', () { + tearDown(RestoreAccountService.clearCache); + + test('decodeRecoveryPhrase accepts German recovery phrases', () { + final recoveryId = BigInt.parse( + '1234567890ABCDEF1234567890ABCDEF', + radix: 16, + ); + final words = encode(recoveryId, wordlist: wordlistDe); + + expect(RestoreAccountService.decodeRecoveryPhrase(words), recoveryId); + }); + + test( + 'getRecoveryPhrase refreshes cache when current user changes', + () async { + const firstRecoveryId = '00000000-0000-0000-0000-000000000001'; + const secondRecoveryId = '00000000-0000-0000-0000-000000000002'; + var currentUserId = 'first-user'; + var currentRecoveryId = firstRecoveryId; + + RestoreAccountService.debugCurrentUserIdGetterForTesting = () => + currentUserId; + RestoreAccountService.debugRecoveryIdGetterForTesting = () async => + currentRecoveryId; + addTearDown( + RestoreAccountService.debugResetCurrentUserIdGetterForTesting, + ); + addTearDown(RestoreAccountService.debugResetRecoveryIdGetterForTesting); + + final firstPhrase = await RestoreAccountService.getRecoveryPhrase(); + currentUserId = 'second-user'; + currentRecoveryId = secondRecoveryId; + final secondPhrase = await RestoreAccountService.getRecoveryPhrase(); + + expect(firstPhrase, isNot(secondPhrase)); + expect(secondPhrase, encode(BigInt.two)); + }, + ); + }); +} diff --git a/supabase/migrations/20251208_user_recovery.sql b/supabase/migrations/20260709120000_user_recovery.sql similarity index 100% rename from supabase/migrations/20251208_user_recovery.sql rename to supabase/migrations/20260709120000_user_recovery.sql From eacd1296b1f307b50706b6e1b49b65dbeda087b2 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 17:31:54 +0200 Subject: [PATCH 35/57] refactor(app): remove recovery phrase purpose messages from localization files --- app/lib/l10n/app_de.arb | 3 --- app/lib/l10n/app_en.arb | 3 --- app/lib/l10n/app_localizations.dart | 18 ------------------ app/lib/l10n/app_localizations_de.dart | 12 +----------- app/lib/l10n/app_localizations_en.dart | 11 +---------- 5 files changed, 2 insertions(+), 45 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index b6fcbe67f..db7229e42 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -435,9 +435,6 @@ "recovery_phrase_header": "Wiederherstellungsphrase speichern", "copy_to_clipboard": "In die Zwischenablage kopieren", "copied_to_clipboard": "In die Zwischenablage kopiert. Füge die Wiederherstellungsphrase jetzt an einem sicheren Ort auf deinem Handy ein.", - "recovery_phrase_purpose_header": "Du benötigst diese Phrase um:", - "recovery_phrase_purpose_1": "✓ Auf einem anderen Gerät fortzufahren", - "recovery_phrase_purpose_2": "✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen", "recovery_phrase_save_hint": "Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst.\n\nTeile sie niemals mit anderen.", "recovery_phrase_save_warning": "Teile sie niemals mit anderen.", "show_recovery_phrase": "Wiederherstellungsphrase anzeigen", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index b3fe105c9..11c83c7d5 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -433,9 +433,6 @@ "recovery_phrase_header": "Save Recovery Phrase", "copy_to_clipboard": "Copy to clipboard", "copied_to_clipboard": "Copied to clipboard. Paste the recovery phrase somewhere secure on your phone now.", - "recovery_phrase_purpose_header": "You'll need this phrase to:", - "recovery_phrase_purpose_1": "✓ Continue on a different phone", - "recovery_phrase_purpose_2": "✓ Restore your progress after reinstalling", "recovery_phrase_save_hint": "Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.", "recovery_phrase_save_warning": "Never share them with anyone.", "show_recovery_phrase": "Show Recovery Phrase", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 83ad1a90b..ef5b1a97f 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2366,24 +2366,6 @@ abstract class AppLocalizations { /// **'Copied to clipboard. Paste the recovery phrase somewhere secure on your phone now.'** String get copied_to_clipboard; - /// No description provided for @recovery_phrase_purpose_header. - /// - /// In en, this message translates to: - /// **'You\'ll need this phrase to:'** - String get recovery_phrase_purpose_header; - - /// No description provided for @recovery_phrase_purpose_1. - /// - /// In en, this message translates to: - /// **'✓ Continue on a different phone'** - String get recovery_phrase_purpose_1; - - /// No description provided for @recovery_phrase_purpose_2. - /// - /// In en, this message translates to: - /// **'✓ Restore your progress after reinstalling'** - String get recovery_phrase_purpose_2; - /// No description provided for @recovery_phrase_save_hint. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 8ac6fc7f7..343071c7d 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1,5 +1,6 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -1231,17 +1232,6 @@ class AppLocalizationsDe extends AppLocalizations { String get copied_to_clipboard => 'In die Zwischenablage kopiert. Füge die Wiederherstellungsphrase jetzt an einem sicheren Ort auf deinem Handy ein.'; - @override - String get recovery_phrase_purpose_header => 'Du benötigst diese Phrase um:'; - - @override - String get recovery_phrase_purpose_1 => - '✓ Auf einem anderen Gerät fortzufahren'; - - @override - String get recovery_phrase_purpose_2 => - '✓ Deinen Fortschritt nach Neuinstallation wiederherzustellen'; - @override String get recovery_phrase_save_hint => 'Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst.\n\nTeile sie niemals mit anderen.'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index d622b5fda..e78e4c2dc 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1,5 +1,6 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -1221,16 +1222,6 @@ class AppLocalizationsEn extends AppLocalizations { String get copied_to_clipboard => 'Copied to clipboard. Paste the recovery phrase somewhere secure on your phone now.'; - @override - String get recovery_phrase_purpose_header => 'You\'ll need this phrase to:'; - - @override - String get recovery_phrase_purpose_1 => '✓ Continue on a different phone'; - - @override - String get recovery_phrase_purpose_2 => - '✓ Restore your progress after reinstalling'; - @override String get recovery_phrase_save_hint => 'Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.'; From 180302a0eeaa07f8b7bae0b2244732d8a6bccf73 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 17:49:31 +0200 Subject: [PATCH 36/57] Show error for stale participant credentials --- app/lib/l10n/app_localizations_de.dart | 1 - app/lib/l10n/app_localizations_en.dart | 1 - .../app_onboarding/loading_screen.dart | 24 ++++++++++++++----- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 343071c7d..196f0259f 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index e78e4c2dc..88e6b0589 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1,6 +1,5 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; - import 'app_localizations.dart'; // ignore_for_file: type=lint diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index 606b4ae32..9f616424e 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -68,13 +68,25 @@ class _LoadingScreenState extends State { String? _pendingPreviewRoute; String? _error; - Future _restoreParticipantSession() async { - if (isUserLoggedIn()) return; + Future _restoreParticipantSession() async { + if (isUserLoggedIn()) return false; final hasStoredCredentials = await SecureStorage.containsKey(userEmailKey) && await SecureStorage.containsKey(userPasswordKey); - if (!hasStoredCredentials) return; - await signInParticipant(); + if (!hasStoredCredentials) return false; + try { + await signInParticipant(); + return false; + } on AuthApiException catch (error, stackTrace) { + StudyULogger.warning( + 'Stored participant credentials are invalid. Showing reset screen.', + error: error, + stackTrace: stackTrace, + ); + if (!mounted) return true; + context.go('/${RouteNames.appErrorScreen}'); + return true; + } } void _storePendingDeepLink({String? studyId, String? inviteCode}) { @@ -132,7 +144,7 @@ class _LoadingScreenState extends State { } Future _runStartupFlow() async { - await _restoreParticipantSession(); + if (await _restoreParticipantSession()) return; if (kIsWeb && widget.hasDeepLink) { return; @@ -384,7 +396,7 @@ class _LoadingScreenState extends State { StudyULogger.info("No subject found"); await cancelNotifications(context); - await _restoreParticipantSession(); + if (await _restoreParticipantSession()) return; if (isUserLoggedIn() && !state.isPreview) { if (!mounted) return; context.goNamed(RouteNames.welcome); From 43a84a8e4eb6331bdde0a54092f5ff5651919a70 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 9 Jul 2026 17:55:51 +0200 Subject: [PATCH 37/57] Enable recovery phrase confirmation in debug --- app/lib/screens/app_onboarding/recovery_phrase_screen.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart index 8572128f6..ac2c7e49b 100644 --- a/app/lib/screens/app_onboarding/recovery_phrase_screen.dart +++ b/app/lib/screens/app_onboarding/recovery_phrase_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:studyu_app/app_router.dart'; @@ -16,7 +17,7 @@ class RecoveryPhraseScreen extends StatefulWidget { } class _RecoveryPhraseScreenState extends State { - bool _isChecked = false; + bool _isChecked = kDebugMode; bool _isRevealed = false; @override From a51cd1e16ba32877ec3bc72096db725eee3ee51c Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 11:12:21 +0200 Subject: [PATCH 38/57] Fix deferred deep link handling --- .../app_onboarding/loading_screen.dart | 31 +++++++++++++++++-- app/lib/screens/app_onboarding/terms.dart | 2 ++ app/lib/services/deferred_link_service.dart | 27 +++++++++++++++- .../services/deferred_link_service_test.dart | 31 +++++++++++++++++++ .../deep_link_onboarding_widgets_test.dart | 20 ++++++++++++ core/lib/src/env/env.dart | 3 +- 6 files changed, 110 insertions(+), 4 deletions(-) diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index 9f616424e..e13834680 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -98,6 +98,7 @@ class _LoadingScreenState extends State { Future _handleIncomingDeepLink({ String? studyId, String? inviteCode, + bool isDeferred = false, }) async { final state = context.read(); @@ -118,7 +119,7 @@ class _LoadingScreenState extends State { if (!mounted) return; // 4. Handle the result (Errors will be caught here, NeedsAuth will route to onboarding) - await _handleDeepLinkResult(result); + await _handleDeepLinkResult(result, isDeferred: isDeferred); } @override @@ -165,6 +166,15 @@ class _LoadingScreenState extends State { await _handleDeferredLink(deferredLink); return; } + final pendingLink = pendingDeferredLinkFromStorageValues( + inviteCode: await SecureStorage.read('pending_deferred_link_invite'), + studyId: await SecureStorage.read('pending_deferred_link_study'), + ); + if (pendingLink != null) { + if (!mounted) return; + await _handleDeferredLink(pendingLink); + return; + } } await initStudy(); @@ -174,6 +184,7 @@ class _LoadingScreenState extends State { await _handleIncomingDeepLink( inviteCode: deferredLink.inviteCode, studyId: deferredLink.studyId, + isDeferred: true, ); } @@ -184,7 +195,10 @@ class _LoadingScreenState extends State { ); } - Future _handleDeepLinkResult(DeepLinkResult result) async { + Future _handleDeepLinkResult( + DeepLinkResult result, { + bool isDeferred = false, + }) async { final state = context.read(); switch (result) { case DeepLinkNeedsAuth( @@ -197,6 +211,9 @@ class _LoadingScreenState extends State { inviteCode: inviteCode, ); state.preselectedInterventionIds = preselectedInterventionIds; + if (isDeferred) { + await SecureStorage.write('has_processed_deferred_link', 'true'); + } final onBoarded = await SecureStorage.readBool('onboarded') ?? false; if (!mounted) return; @@ -204,6 +221,11 @@ class _LoadingScreenState extends State { case DeepLinkError(type: final errorType, :final errorValue): setState(() => _error = _getErrorMessage(errorType, errorValue)); + if (isDeferred) { + await SecureStorage.write('has_processed_deferred_link', 'true'); + await SecureStorage.delete('pending_deferred_link_invite'); + await SecureStorage.delete('pending_deferred_link_study'); + } case DeepLinkSuccess( :final study, :final inviteCode, @@ -229,6 +251,11 @@ class _LoadingScreenState extends State { return; } + if (isDeferred) { + await SecureStorage.write('has_processed_deferred_link', 'true'); + await SecureStorage.delete('pending_deferred_link_invite'); + await SecureStorage.delete('pending_deferred_link_study'); + } if (!mounted) return; context.go('/${RouteNames.studyOverview}'); } diff --git a/app/lib/screens/app_onboarding/terms.dart b/app/lib/screens/app_onboarding/terms.dart index 10efd11f4..a6893f878 100644 --- a/app/lib/screens/app_onboarding/terms.dart +++ b/app/lib/screens/app_onboarding/terms.dart @@ -38,6 +38,8 @@ class _TermsScreenState extends State { ); state.clearPendingDeepLink(); + await SecureStorage.delete('pending_deferred_link_invite'); + await SecureStorage.delete('pending_deferred_link_study'); if (!mounted) return; switch (result) { diff --git a/app/lib/services/deferred_link_service.dart b/app/lib/services/deferred_link_service.dart index d3287576b..a4e793c5a 100644 --- a/app/lib/services/deferred_link_service.dart +++ b/app/lib/services/deferred_link_service.dart @@ -67,6 +67,20 @@ DeferredLink? parseIosDeferredLinkPath(String? referralPath) { return null; } +@visibleForTesting +DeferredLink? pendingDeferredLinkFromStorageValues({ + required String? inviteCode, + required String? studyId, +}) { + final parsedInviteCode = _sanitizeDeferredValue(inviteCode); + if (parsedInviteCode != null) return DeferredLink.invite(parsedInviteCode); + + final parsedStudyId = _sanitizeDeferredValue(studyId); + if (parsedStudyId != null) return DeferredLink.study(parsedStudyId); + + return null; +} + String? _extractReferrerValue(String referrer, String key) { try { final regexp = RegExp('(?:^|&)$key=([^&]+)'); @@ -126,7 +140,18 @@ class DeferredLinkService { } if (deferredLink != null) { - await SecureStorage.write('has_processed_deferred_link', 'true'); + if (deferredLink.inviteCode != null) { + await SecureStorage.write( + 'pending_deferred_link_invite', + deferredLink.inviteCode!, + ); + } + if (deferredLink.studyId != null) { + await SecureStorage.write( + 'pending_deferred_link_study', + deferredLink.studyId!, + ); + } return deferredLink; } // Add else block for debugging empty code diff --git a/app/test/services/deferred_link_service_test.dart b/app/test/services/deferred_link_service_test.dart index 14955daa2..f49ca0796 100644 --- a/app/test/services/deferred_link_service_test.dart +++ b/app/test/services/deferred_link_service_test.dart @@ -59,4 +59,35 @@ void main() { expect(link?.inviteCode, isNull); }); }); + + group('pendingDeferredLinkFromStorageValues', () { + test('restores invite links before study links', () { + final link = pendingDeferredLinkFromStorageValues( + inviteCode: ' invite-123 ', + studyId: 'study-123', + ); + + expect(link?.inviteCode, 'invite-123'); + expect(link?.studyId, isNull); + }); + + test('restores study links when no invite is pending', () { + final link = pendingDeferredLinkFromStorageValues( + inviteCode: null, + studyId: ' study-123 ', + ); + + expect(link?.studyId, 'study-123'); + expect(link?.inviteCode, isNull); + }); + + test('ignores empty pending values', () { + final link = pendingDeferredLinkFromStorageValues( + inviteCode: ' ', + studyId: '', + ); + + expect(link, isNull); + }); + }); } diff --git a/app/test/widgets/deep_link_onboarding_widgets_test.dart b/app/test/widgets/deep_link_onboarding_widgets_test.dart index a5004c7ab..b7e541227 100644 --- a/app/test/widgets/deep_link_onboarding_widgets_test.dart +++ b/app/test/widgets/deep_link_onboarding_widgets_test.dart @@ -20,4 +20,24 @@ void main() { ); }); }); + + group('buildAppLaunchLink with prod-like env', () { + setUp(() { + appDeepLinkScheme = 'https://app.studyu.health'; + }); + tearDown(() { + appDeepLinkScheme = null; + }); + + test('invite link uses custom scheme studyu-app:// for app launch', () { + expect(buildAppLaunchLink(inviteCode: '123'), 'studyu-app://invite/123'); + }); + + test('public study link still uses HTTPS universal link', () { + expect( + buildAppLaunchLink(studyId: 'test-study-id'), + 'https://app.studyu.health/study/test-study-id', + ); + }); + }); } diff --git a/core/lib/src/env/env.dart b/core/lib/src/env/env.dart index 184ac78bf..9b1f422e2 100644 --- a/core/lib/src/env/env.dart +++ b/core/lib/src/env/env.dart @@ -16,7 +16,8 @@ String get appScheme { if (appDeepLinkScheme != null) { try { final scheme = Uri.parse(appDeepLinkScheme!).scheme; - if (scheme.isNotEmpty) return scheme; + if (scheme.isNotEmpty && scheme != 'http' && scheme != 'https') + return scheme; } catch (_) {} } return 'studyu-app'; From ae4f37c77e554e64a8731c4ac763c86f8e526004 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 11:25:16 +0200 Subject: [PATCH 39/57] Cover invite deep link processing --- .../app_onboarding/loading_screen.dart | 26 +++-- app/lib/services/deep_link_service.dart | 11 ++- app/test/services/deep_link_service_test.dart | 98 +++++++++++++++++++ 3 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 app/test/services/deep_link_service_test.dart diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index e13834680..ee0a7d3c4 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -195,6 +195,16 @@ class _LoadingScreenState extends State { ); } + Future _markDeferredLinkProcessed() async { + await SecureStorage.write('has_processed_deferred_link', 'true'); + await SecureStorage.delete('pending_deferred_link_invite'); + await SecureStorage.delete('pending_deferred_link_study'); + } + + Future _markDeferredLinkHandedOff() async { + await SecureStorage.write('has_processed_deferred_link', 'true'); + } + Future _handleDeepLinkResult( DeepLinkResult result, { bool isDeferred = false, @@ -212,7 +222,7 @@ class _LoadingScreenState extends State { ); state.preselectedInterventionIds = preselectedInterventionIds; if (isDeferred) { - await SecureStorage.write('has_processed_deferred_link', 'true'); + await _markDeferredLinkHandedOff(); } final onBoarded = await SecureStorage.readBool('onboarded') ?? false; @@ -222,9 +232,7 @@ class _LoadingScreenState extends State { case DeepLinkError(type: final errorType, :final errorValue): setState(() => _error = _getErrorMessage(errorType, errorValue)); if (isDeferred) { - await SecureStorage.write('has_processed_deferred_link', 'true'); - await SecureStorage.delete('pending_deferred_link_invite'); - await SecureStorage.delete('pending_deferred_link_study'); + await _markDeferredLinkProcessed(); } case DeepLinkSuccess( :final study, @@ -240,21 +248,25 @@ class _LoadingScreenState extends State { final confirmed = await _confirmSwitchToDeepLinkedStudy(study); if (!confirmed) { + if (isDeferred) { + await _markDeferredLinkProcessed(); + } if (!mounted) return; context.go('/${RouteNames.dashboard}'); return; } if (alreadyEnrolled) { + if (isDeferred) { + await _markDeferredLinkProcessed(); + } if (!mounted) return; context.go('/${RouteNames.dashboard}'); return; } if (isDeferred) { - await SecureStorage.write('has_processed_deferred_link', 'true'); - await SecureStorage.delete('pending_deferred_link_invite'); - await SecureStorage.delete('pending_deferred_link_study'); + await _markDeferredLinkProcessed(); } if (!mounted) return; context.go('/${RouteNames.studyOverview}'); diff --git a/app/lib/services/deep_link_service.dart b/app/lib/services/deep_link_service.dart index 433f4d42a..5a3e13c21 100644 --- a/app/lib/services/deep_link_service.dart +++ b/app/lib/services/deep_link_service.dart @@ -44,6 +44,15 @@ class DeepLinkNeedsAuth extends DeepLinkResult { enum DeepLinkErrorType { studyNotFound, inviteOnly, invalidInvite } class DeepLinkService { + @visibleForTesting + static Future<(StudyInvite?, Study?)> Function(String code) + fetchInviteForDeepLink = Study.fetchByInviteCode; + + @visibleForTesting + static void resetTestOverrides() { + fetchInviteForDeepLink = Study.fetchByInviteCode; + } + /// Fetches a study by its ID static Future fetchStudyById(String studyId) async { try { @@ -149,7 +158,7 @@ class DeepLinkService { String? activeStudyId, }) async { try { - final (invite, study) = await Study.fetchByInviteCode(inviteCode); + final (invite, study) = await fetchInviteForDeepLink(inviteCode); if (invite == null || study == null) { return DeepLinkError(DeepLinkErrorType.invalidInvite, inviteCode); diff --git a/app/test/services/deep_link_service_test.dart b/app/test/services/deep_link_service_test.dart new file mode 100644 index 000000000..150f755db --- /dev/null +++ b/app/test/services/deep_link_service_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyu_app/services/deep_link_service.dart'; +import 'package:studyu_core/core.dart'; + +void main() { + tearDown(DeepLinkService.resetTestOverrides); + + Study study({String id = 'study-123'}) { + return Study(id, 'owner-123') + ..title = 'Invite study' + ..participation = Participation.invite; + } + + group('DeepLinkService.processDeepLink invite links', () { + test( + 'returns needs auth with invite details when user is not authenticated', + () async { + final targetStudy = study(); + DeepLinkService.fetchInviteForDeepLink = (_) async => ( + StudyInvite('invite-123', targetStudy.id) + ..preselectedInterventionIds = ['intervention-a'], + targetStudy, + ); + + final result = await DeepLinkService.processDeepLink( + studyId: null, + inviteCode: 'invite-123', + isAuthenticated: false, + ); + + expect(result, isA()); + final needsAuth = result as DeepLinkNeedsAuth; + expect(needsAuth.study, same(targetStudy)); + expect(needsAuth.inviteCode, 'invite-123'); + expect(needsAuth.preselectedInterventionIds, ['intervention-a']); + }, + ); + + test( + 'returns success with invite details when user is authenticated', + () async { + final targetStudy = study(); + DeepLinkService.fetchInviteForDeepLink = (_) async => ( + StudyInvite('invite-123', targetStudy.id) + ..preselectedInterventionIds = ['intervention-a'], + targetStudy, + ); + + final result = await DeepLinkService.processDeepLink( + studyId: null, + inviteCode: 'invite-123', + isAuthenticated: true, + ); + + expect(result, isA()); + final success = result as DeepLinkSuccess; + expect(success.study, same(targetStudy)); + expect(success.inviteCode, 'invite-123'); + expect(success.preselectedInterventionIds, ['intervention-a']); + expect(success.alreadyEnrolled, isFalse); + }, + ); + + test('marks invite success as already enrolled for active study', () async { + final targetStudy = study(); + DeepLinkService.fetchInviteForDeepLink = (_) async => + (StudyInvite('invite-123', targetStudy.id), targetStudy); + + final result = await DeepLinkService.processDeepLink( + studyId: null, + inviteCode: 'invite-123', + isAuthenticated: true, + activeStudyId: targetStudy.id, + ); + + expect(result, isA()); + expect((result as DeepLinkSuccess).alreadyEnrolled, isTrue); + }); + + test( + 'returns invalid invite with attempted code when invite lookup misses', + () async { + DeepLinkService.fetchInviteForDeepLink = (_) async => (null, null); + + final result = await DeepLinkService.processDeepLink( + studyId: null, + inviteCode: 'missing-invite', + isAuthenticated: true, + ); + + expect(result, isA()); + final error = result as DeepLinkError; + expect(error.type, DeepLinkErrorType.invalidInvite); + expect(error.errorValue, 'missing-invite'); + }, + ); + }); +} From 0289fcfa7973671a8c7cac705d3003ae690e3685 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 11:38:32 +0200 Subject: [PATCH 40/57] Remove iOS clipboard deferred links --- app/lib/services/deferred_link_service.dart | 19 ++---------------- .../widgets/deep_link_onboarding_widgets.dart | 15 ++------------ .../services/deferred_link_service_test.dart | 20 ------------------- 3 files changed, 4 insertions(+), 50 deletions(-) diff --git a/app/lib/services/deferred_link_service.dart b/app/lib/services/deferred_link_service.dart index a4e793c5a..8382712b8 100644 --- a/app/lib/services/deferred_link_service.dart +++ b/app/lib/services/deferred_link_service.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:stack_deferred_link/stack_deferred_link.dart'; -import 'package:studyu_core/env.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; class DeferredLink { @@ -118,25 +117,11 @@ class DeferredLinkService { referrer: referrer, ); } else if (defaultTargetPlatform == TargetPlatform.iOS) { - final scheme = appDeepLinkScheme ?? 'https://app.studyu.health'; - final host = Uri.parse(scheme).host; await SecureStorage.write( 'debug_install_referrer', - 'iOS Check. Host: $host', - ); - - final result = await StackDeferredLink.getInstallReferrerIos( - deepLinks: ['$host/invite', '$host/study'], - ); - - await SecureStorage.write( - 'debug_install_referrer', - 'iOS Result: ${result?.fullReferralDeepLinkPath}', - ); - - deferredLink = parseIosDeferredLinkPath( - result?.fullReferralDeepLinkPath, + 'iOS deferred deep linking disabled: clipboard handoff removed.', ); + return null; } if (deferredLink != null) { diff --git a/app/lib/widgets/deep_link_onboarding_widgets.dart b/app/lib/widgets/deep_link_onboarding_widgets.dart index c27a32f55..a56f5f3c8 100644 --- a/app/lib/widgets/deep_link_onboarding_widgets.dart +++ b/app/lib/widgets/deep_link_onboarding_widgets.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_core/env.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -17,8 +16,8 @@ String buildAppLaunchLink({String? inviteCode, String? studyId}) { /// A screen shown to web users who open a deep link (invite or study). /// /// On desktop web: Prompts the user to open the link on mobile. -/// On mobile web: Shows a "Download App" button that redirects to the appropriate store, -/// passing the invite code via referrer (Android) or clipboard (iOS). +/// passing the invite code via referrer on Android. iOS opens the App Store +/// without deferred linking because clipboard-based handoff triggers paste prompts. class DeepLinkWebLandingPage extends StatefulWidget { final String? inviteCode; final String? studyId; @@ -62,10 +61,6 @@ class _DeepLinkWebLandingPageState extends State { } Future _launchAppStoreForInvite(String inviteCode) async { - // For iOS deferred deep linking via clipboard, we need the full valid URL - final link = generateAppDeepLink("invite/$inviteCode"); - await Clipboard.setData(ClipboardData(text: link)); - if (defaultTargetPlatform == TargetPlatform.android) { if (androidPackageName != null) { final referrer = Uri.encodeComponent("invite=$inviteCode"); @@ -78,8 +73,6 @@ class _DeepLinkWebLandingPageState extends State { } } else if (defaultTargetPlatform == TargetPlatform.iOS) { if (iosAppStoreId != null) { - final link = generateAppDeepLink("invite/$inviteCode"); - await Clipboard.setData(ClipboardData(text: link)); final url = Uri.parse("https://apps.apple.com/app/id$iosAppStoreId"); if (await canLaunchUrl(url)) { await launchUrl(url, mode: LaunchMode.externalApplication); @@ -89,10 +82,6 @@ class _DeepLinkWebLandingPageState extends State { } Future _launchAppStoreForStudy(String studyId) async { - // Copy study deep link to clipboard, then open app store without referrer - final link = generateAppDeepLink("study/$studyId"); - await Clipboard.setData(ClipboardData(text: link)); - if (defaultTargetPlatform == TargetPlatform.android) { if (androidPackageName != null) { final referrer = Uri.encodeComponent("study=$studyId"); diff --git a/app/test/services/deferred_link_service_test.dart b/app/test/services/deferred_link_service_test.dart index f49ca0796..eff55dc42 100644 --- a/app/test/services/deferred_link_service_test.dart +++ b/app/test/services/deferred_link_service_test.dart @@ -40,26 +40,6 @@ void main() { ); }); - group('parseIosDeferredLinkPath', () { - test('keeps invite links routable from deferred universal link paths', () { - final link = parseIosDeferredLinkPath( - 'https://app.studyu.health/invite/invite-123', - ); - - expect(link?.inviteCode, 'invite-123'); - expect(link?.studyId, isNull); - }); - - test('parses public study links from deferred universal link paths', () { - final link = parseIosDeferredLinkPath( - 'https://app.studyu.health/study/study-123', - ); - - expect(link?.studyId, 'study-123'); - expect(link?.inviteCode, isNull); - }); - }); - group('pendingDeferredLinkFromStorageValues', () { test('restores invite links before study links', () { final link = pendingDeferredLinkFromStorageValues( From e61ae15ec7f49ad512a10dd95940621fa3ca8dea Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 11:38:42 +0200 Subject: [PATCH 41/57] Update CocoaPods to version 1.17.0 and clean up Podfile.lock --- app/ios/Podfile.lock | 137 +------------------------------------------ 1 file changed, 2 insertions(+), 135 deletions(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index da48278a9..3b9b7be53 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -1,161 +1,28 @@ PODS: - - app_links (6.4.1): - - Flutter - - audio_session (0.0.1): - - Flutter - - camera_avfoundation (0.0.1): - - Flutter - Flutter (1.0.0) - - flutter_file_dialog (0.0.1): - - Flutter - - flutter_keyboard_visibility (0.0.1): - - Flutter - - flutter_local_notifications (0.0.1): - - Flutter - - flutter_native_splash (2.4.3): - - Flutter - - flutter_secure_storage (6.0.0): - - Flutter - - flutter_timezone (0.0.1): - - Flutter - flutter_web_auth_2 (3.0.0): - Flutter - - just_audio (0.0.1): - - Flutter - - FlutterMacOS - - package_info_plus (0.4.5): - - Flutter - - permission_handler_apple (9.3.0): - - Flutter - - record_ios (1.2.0): - - Flutter - - Sentry/HybridSDK (8.58.0) - - sentry_flutter (9.15.0): - - Flutter - - FlutterMacOS - - Sentry/HybridSDK (= 8.58.0) - - shared_preferences_foundation (0.0.1): - - Flutter - - FlutterMacOS - - sqflite_darwin (0.0.4): - - Flutter - - FlutterMacOS - stack_deferred_link (0.0.1): - Flutter - - url_launcher_ios (0.0.1): - - Flutter - - video_player_avfoundation (0.0.1): - - Flutter - - FlutterMacOS - - wakelock_plus (0.0.1): - - Flutter - - webview_flutter_wkwebview (0.0.1): - - Flutter - - FlutterMacOS DEPENDENCIES: - - app_links (from `.symlinks/plugins/app_links/ios`) - - audio_session (from `.symlinks/plugins/audio_session/ios`) - - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - Flutter (from `Flutter`) - - flutter_file_dialog (from `.symlinks/plugins/flutter_file_dialog/ios`) - - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - - flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`) - flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`) - - just_audio (from `.symlinks/plugins/just_audio/darwin`) - - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - record_ios (from `.symlinks/plugins/record_ios/ios`) - - sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`) - - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - stack_deferred_link (from `.symlinks/plugins/stack_deferred_link/ios`) - - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) - -SPEC REPOS: - trunk: - - Sentry EXTERNAL SOURCES: - app_links: - :path: ".symlinks/plugins/app_links/ios" - audio_session: - :path: ".symlinks/plugins/audio_session/ios" - camera_avfoundation: - :path: ".symlinks/plugins/camera_avfoundation/ios" Flutter: :path: Flutter - flutter_file_dialog: - :path: ".symlinks/plugins/flutter_file_dialog/ios" - flutter_keyboard_visibility: - :path: ".symlinks/plugins/flutter_keyboard_visibility/ios" - flutter_local_notifications: - :path: ".symlinks/plugins/flutter_local_notifications/ios" - flutter_native_splash: - :path: ".symlinks/plugins/flutter_native_splash/ios" - flutter_secure_storage: - :path: ".symlinks/plugins/flutter_secure_storage/ios" - flutter_timezone: - :path: ".symlinks/plugins/flutter_timezone/ios" flutter_web_auth_2: :path: ".symlinks/plugins/flutter_web_auth_2/ios" - just_audio: - :path: ".symlinks/plugins/just_audio/darwin" - package_info_plus: - :path: ".symlinks/plugins/package_info_plus/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" - record_ios: - :path: ".symlinks/plugins/record_ios/ios" - sentry_flutter: - :path: ".symlinks/plugins/sentry_flutter/ios" - shared_preferences_foundation: - :path: ".symlinks/plugins/shared_preferences_foundation/darwin" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" stack_deferred_link: :path: ".symlinks/plugins/stack_deferred_link/ios" - url_launcher_ios: - :path: ".symlinks/plugins/url_launcher_ios/ios" - video_player_avfoundation: - :path: ".symlinks/plugins/video_player_avfoundation/darwin" - wakelock_plus: - :path: ".symlinks/plugins/wakelock_plus/ios" - webview_flutter_wkwebview: - :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a - audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 - flutter_file_dialog: ca8d7fbd1772d4f0c2777b4ab20a7787ef4e7dd8 - flutter_keyboard_visibility: 4625131e43015dbbe759d9b20daaf77e0e3f6619 - flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214 - flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf - flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 - flutter_timezone: 7c838e17ffd4645d261e87037e5bebf6d38fe544 flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 - just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d - record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844 - Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be - sentry_flutter: 8939e491bc1511868118c96cae47d945e6f69798 - shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 stack_deferred_link: 2a2526cfb01ed27b8c114719f7850d605ba6d897 - url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b - video_player_avfoundation: dd410b52df6d2466a42d28550e33e4146928280a - wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 - webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d -PODFILE CHECKSUM: b00a565d52284bcdd04d643f399531a35b02c2c4 +PODFILE CHECKSUM: 7f07ebef3a0a0adf5e89297b9e35e16dccabab6a -COCOAPODS: 1.16.2 +COCOAPODS: 1.17.0 From e82ed9ca9b2bf5110c8fbc4ca18243312fc1ffb2 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 11:44:45 +0200 Subject: [PATCH 42/57] fix(app): show recovery phrase after dashboard opens --- app/lib/l10n/app_de.arb | 4 +- app/lib/l10n/app_en.arb | 4 +- app/lib/l10n/app_localizations.dart | 4 +- app/lib/l10n/app_localizations_de.dart | 4 +- app/lib/l10n/app_localizations_en.dart | 4 +- app/lib/models/app_state.dart | 1 + app/lib/screens/app_onboarding/terms.dart | 2 +- .../screens/study/dashboard/dashboard.dart | 83 ++++++++++++++++++- app/lib/screens/study/onboarding/kickoff.dart | 4 + .../study/onboarding/study_selection.dart | 6 +- app/lib/services/deferred_link_service.dart | 1 - app/lib/util/dashboard_showcase.dart | 20 +++++ app/test/widget_test.dart | 4 +- core/lib/src/env/env.dart | 3 +- 14 files changed, 122 insertions(+), 22 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index db7229e42..6b9f10def 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -435,12 +435,12 @@ "recovery_phrase_header": "Wiederherstellungsphrase speichern", "copy_to_clipboard": "In die Zwischenablage kopieren", "copied_to_clipboard": "In die Zwischenablage kopiert. Füge die Wiederherstellungsphrase jetzt an einem sicheren Ort auf deinem Handy ein.", - "recovery_phrase_save_hint": "Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst.\n\nTeile sie niemals mit anderen.", + "recovery_phrase_save_hint": "Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst. Du kannst deine Wiederherstellungsphrase jederzeit in deinen Studieneinstellungen wieder ansehen.\n\nTeile sie niemals mit anderen.", "recovery_phrase_save_warning": "Teile sie niemals mit anderen.", "show_recovery_phrase": "Wiederherstellungsphrase anzeigen", "recovery_phrase_list_header": "Deine Wiederherstellungsphrase", "recovery_phrase_list_helper": "Stelle sicher, dass du alle 13 Wörter in genau dieser Reihenfolge speicherst.", - "recovery_phrase_saved_confirmation": "Ich habe alle 13 Wörter an einem sicheren Ort gespeichert und kann sie abrufen, wenn ich mein Konto wiederherstellen möchte.", + "recovery_phrase_saved_confirmation": "Ich habe alle 13 Wörter an einem sicheren Ort gespeichert und kann sie abrufen, wenn ich mein Konto wiederherstellen möchte. Ich kann sie auch jederzeit in den Studieneinstellungen einsehen.", "continue_to_study": "Weiter zur Studie", "restore_account": "Konto wiederherstellen", "enter_recovery_phrase": "Gib deine Wiederherstellungsphrase ein", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 11c83c7d5..8f7c2ba05 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -433,12 +433,12 @@ "recovery_phrase_header": "Save Recovery Phrase", "copy_to_clipboard": "Copy to clipboard", "copied_to_clipboard": "Copied to clipboard. Paste the recovery phrase somewhere secure on your phone now.", - "recovery_phrase_save_hint": "Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.", + "recovery_phrase_save_hint": "Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app. You can always view your recovery phrase again in your study settings.\n\nNever share them with anyone.", "recovery_phrase_save_warning": "Never share them with anyone.", "show_recovery_phrase": "Show Recovery Phrase", "recovery_phrase_list_header": "Your recovery phrase", "recovery_phrase_list_helper": "Make sure you save all 13 words in this exact order.", - "recovery_phrase_saved_confirmation": "I have saved all 13 words in a safe place and can retrieve them when I want to restore my account.", + "recovery_phrase_saved_confirmation": "I have saved all 13 words in a safe place and can retrieve them when I want to restore my account. I can also view them again in Study Settings.", "continue_to_study": "Continue to study", "restore_account": "Restore account", "enter_recovery_phrase": "Enter your recovery phrase", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index ef5b1a97f..d8bb9147e 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2369,7 +2369,7 @@ abstract class AppLocalizations { /// No description provided for @recovery_phrase_save_hint. /// /// In en, this message translates to: - /// **'Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.'** + /// **'Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app. You can always view your recovery phrase again in your study settings.\n\nNever share them with anyone.'** String get recovery_phrase_save_hint; /// No description provided for @recovery_phrase_save_warning. @@ -2399,7 +2399,7 @@ abstract class AppLocalizations { /// No description provided for @recovery_phrase_saved_confirmation. /// /// In en, this message translates to: - /// **'I have saved all 13 words in a safe place and can retrieve them when I want to restore my account.'** + /// **'I have saved all 13 words in a safe place and can retrieve them when I want to restore my account. I can also view them again in Study Settings.'** String get recovery_phrase_saved_confirmation; /// No description provided for @continue_to_study. diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 196f0259f..f519ccd34 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1233,7 +1233,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst.\n\nTeile sie niemals mit anderen.'; + 'Bitte speichere diese 13 Wörter an einem sicheren Ort. Du kannst sie aufschreiben oder digital speichern, sodass nur du darauf zugreifen kannst. StudyU verwendet keine Passwörter oder E-Mail-Konten, daher sind diese Wörter die einzige Möglichkeit, deinen Studienfortschritt wiederherzustellen, wenn du ein neues Handy bekommst oder die App neu installierst. Du kannst deine Wiederherstellungsphrase jederzeit in deinen Studieneinstellungen wieder ansehen.\n\nTeile sie niemals mit anderen.'; @override String get recovery_phrase_save_warning => 'Teile sie niemals mit anderen.'; @@ -1250,7 +1250,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get recovery_phrase_saved_confirmation => - 'Ich habe alle 13 Wörter an einem sicheren Ort gespeichert und kann sie abrufen, wenn ich mein Konto wiederherstellen möchte.'; + 'Ich habe alle 13 Wörter an einem sicheren Ort gespeichert und kann sie abrufen, wenn ich mein Konto wiederherstellen möchte. Ich kann sie auch jederzeit in den Studieneinstellungen einsehen.'; @override String get continue_to_study => 'Weiter zur Studie'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 88e6b0589..fb5d2eb9b 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1223,7 +1223,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recovery_phrase_save_hint => - 'Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app.\n\nNever share them with anyone.'; + 'Please save these 13 words in a safe place. You can write them down or store them digitally somewhere only you can access. StudyU does not use passwords or email accounts, so these words are the only way to restore your study progress if you get a new phone or reinstall the app. You can always view your recovery phrase again in your study settings.\n\nNever share them with anyone.'; @override String get recovery_phrase_save_warning => 'Never share them with anyone.'; @@ -1240,7 +1240,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get recovery_phrase_saved_confirmation => - 'I have saved all 13 words in a safe place and can retrieve them when I want to restore my account.'; + 'I have saved all 13 words in a safe place and can retrieve them when I want to restore my account. I can also view them again in Study Settings.'; @override String get continue_to_study => 'Continue to study'; diff --git a/app/lib/models/app_state.dart b/app/lib/models/app_state.dart index 7de4c61c6..d4531fcd5 100644 --- a/app/lib/models/app_state.dart +++ b/app/lib/models/app_state.dart @@ -13,6 +13,7 @@ class AppState with ChangeNotifier { List? preselectedInterventionIds; StudyNotifications? studyNotifications; bool isPreview = false; + bool showRecoveryPhraseOnDashboard = false; late AppAnalytics analytics; String? pendingDeepLinkStudyId; diff --git a/app/lib/screens/app_onboarding/terms.dart b/app/lib/screens/app_onboarding/terms.dart index a6893f878..5cdc38c74 100644 --- a/app/lib/screens/app_onboarding/terms.dart +++ b/app/lib/screens/app_onboarding/terms.dart @@ -160,7 +160,7 @@ class _TermsScreenState extends State { if (state.hasPendingDeepLink) { await _handlePendingDeepLink(state); } else { - context.push('/${RouteNames.recoveryPhrase}'); + context.push('/${RouteNames.studySelection}'); } } } diff --git a/app/lib/screens/study/dashboard/dashboard.dart b/app/lib/screens/study/dashboard/dashboard.dart index 329a01285..0547b312f 100644 --- a/app/lib/screens/study/dashboard/dashboard.dart +++ b/app/lib/screens/study/dashboard/dashboard.dart @@ -17,6 +17,7 @@ import 'package:studyu_app/screens/study/dashboard/task_overview_tab/task_overvi import 'package:studyu_app/theme.dart' as app_theme; import 'package:studyu_app/util/dashboard_showcase.dart'; import 'package:studyu_app/util/debug_screen.dart'; +import 'package:studyu_app/widgets/recovery_phrase_content.dart'; import 'package:studyu_core/core.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -95,6 +96,7 @@ class _DashboardScreenState extends State setState(() { scheduleToday = subject!.scheduleFor(DateTime.now()); }); + unawaited(_startDashboardShowcaseIfNeeded()); case AppLifecycleState.inactive: break; case AppLifecycleState.paused: @@ -119,7 +121,26 @@ class _DashboardScreenState extends State ).showSnackBar(SnackBar(content: Text(widget.error!))); }); } - unawaited(_startDashboardShowcaseIfNeeded()); + final inMemory = context.read().showRecoveryPhraseOnDashboard; + if (inMemory) { + context.read().showRecoveryPhraseOnDashboard = false; + } + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (!mounted) return; + final shouldShow = + inMemory || await RecoveryPhraseStorage.isPending(subject!.id); + if (!mounted) return; + if (shouldShow) { + final accepted = await _showRecoveryPhraseDialog(); + if (!mounted) return; + if (accepted) { + await RecoveryPhraseStorage.clearPending(subject!.id); + } + unawaited(_startDashboardShowcaseIfNeeded()); + return; + } + unawaited(_startDashboardShowcaseIfNeeded()); + }); } } @@ -462,10 +483,14 @@ class _DashboardScreenState extends State Future _startDashboardShowcaseIfNeeded() async { if (_showcaseCheckStarted || context.read().isPreview) return; + + // ponytail: no TaskOverview targets on first-day wait screen — skip so showcase can run later + if (subject == null || subject!.startedAt!.isAfter(DateTime.now())) return; + _showcaseCheckStarted = true; final completed = await DashboardShowcaseStorage.isCompleted(); - if (completed || !mounted || subject == null) return; + if (completed || !mounted) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; @@ -489,6 +514,60 @@ class _DashboardScreenState extends State if (_isDisposing) return; unawaited(DashboardShowcaseStorage.markCompleted()); } + + Future _showRecoveryPhraseDialog() async { + final l10n = AppLocalizations.of(context)!; + return await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + var isChecked = kDebugMode; + return PopScope( + canPop: false, + child: StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + title: Text(l10n.recovery_phrase_header), + content: SizedBox( + width: double.maxFinite, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text(l10n.recovery_phrase_save_hint), + ), + RecoveryPhraseContent( + useGridLayout: false, + isChecked: isChecked, + onCheckedChanged: (value) { + setDialogState( + () => isChecked = value ?? false, + ); + }, + ), + ], + ), + ), + ), + actions: [ + FilledButton( + onPressed: isChecked + ? () => Navigator.of(dialogContext).pop(true) + : null, + child: Text(l10n.continue_to_study), + ), + ], + ); + }, + ), + ); + }, + ) ?? + false; + } } class StudyFinishedPlaceholder extends StatelessWidget { diff --git a/app/lib/screens/study/onboarding/kickoff.dart b/app/lib/screens/study/onboarding/kickoff.dart index 22e8b9fa1..7b74c88eb 100644 --- a/app/lib/screens/study/onboarding/kickoff.dart +++ b/app/lib/screens/study/onboarding/kickoff.dart @@ -6,6 +6,7 @@ import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/util/cache.dart'; +import 'package:studyu_app/util/dashboard_showcase.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; @@ -33,6 +34,9 @@ class _KickoffScreen extends State { await Cache.storeSubject(context.read().activeSubject); await storeActiveSubjectId(subject!.id); if (!context.mounted) return; + context.read().showRecoveryPhraseOnDashboard = true; + await RecoveryPhraseStorage.markPending(subject!.id); + if (!context.mounted) return; setState(() => ready = true); context.go('/${RouteNames.dashboard}'); } catch (e) { diff --git a/app/lib/screens/study/onboarding/study_selection.dart b/app/lib/screens/study/onboarding/study_selection.dart index 56d34c69d..1d80d53f4 100644 --- a/app/lib/screens/study/onboarding/study_selection.dart +++ b/app/lib/screens/study/onboarding/study_selection.dart @@ -197,11 +197,7 @@ class _StudySelectionScreenState extends State { if (context.canPop()) { context.pop(); } else { - context.goNamed( - Supabase.instance.client.auth.currentUser == null - ? RouteNames.welcome - : RouteNames.recoveryPhrase, - ); + context.goNamed(RouteNames.welcome); } }, ), diff --git a/app/lib/services/deferred_link_service.dart b/app/lib/services/deferred_link_service.dart index 8382712b8..e7e388ccd 100644 --- a/app/lib/services/deferred_link_service.dart +++ b/app/lib/services/deferred_link_service.dart @@ -66,7 +66,6 @@ DeferredLink? parseIosDeferredLinkPath(String? referralPath) { return null; } -@visibleForTesting DeferredLink? pendingDeferredLinkFromStorageValues({ required String? inviteCode, required String? studyId, diff --git a/app/lib/util/dashboard_showcase.dart b/app/lib/util/dashboard_showcase.dart index f1955017a..2d98b9b34 100644 --- a/app/lib/util/dashboard_showcase.dart +++ b/app/lib/util/dashboard_showcase.dart @@ -17,3 +17,23 @@ class DashboardShowcaseStorage { await SecureStorage.delete(_completedKey); } } + +class RecoveryPhraseStorage { + static const _pendingKeyPrefix = 'recovery_phrase_pending'; + + const RecoveryPhraseStorage._(); + + static String _key(String subjectId) => '${_pendingKeyPrefix}_$subjectId'; + + static Future isPending(String subjectId) async { + return await SecureStorage.readBool(_key(subjectId)) ?? false; + } + + static Future markPending(String subjectId) async { + await SecureStorage.write(_key(subjectId), 'true'); + } + + static Future clearPending(String subjectId) async { + await SecureStorage.delete(_key(subjectId)); + } +} diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 6ccb20caa..ce81a8722 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -20,9 +20,9 @@ import 'package:studyu_app/screens/app_onboarding/welcome.dart'; import 'package:studyu_core/env.dart'; import 'package:supabase/supabase.dart'; -Widget setup(Widget child) { +Widget setup(Widget child, {AppState? appState}) { return ChangeNotifierProvider( - create: (_) => AppState(), + create: (_) => appState ?? AppState(), child: MaterialApp.router( supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, diff --git a/core/lib/src/env/env.dart b/core/lib/src/env/env.dart index 9b1f422e2..d8dd2802c 100644 --- a/core/lib/src/env/env.dart +++ b/core/lib/src/env/env.dart @@ -16,8 +16,9 @@ String get appScheme { if (appDeepLinkScheme != null) { try { final scheme = Uri.parse(appDeepLinkScheme!).scheme; - if (scheme.isNotEmpty && scheme != 'http' && scheme != 'https') + if (scheme.isNotEmpty && scheme != 'http' && scheme != 'https') { return scheme; + } } catch (_) {} } return 'studyu-app'; From 763de4f2d02a1b86ca25dcf7ed33eb9366b402bf Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 12:30:59 +0200 Subject: [PATCH 43/57] fix(app): persist pending deep links --- app/lib/models/app_state.dart | 13 ++ .../app_onboarding/loading_screen.dart | 174 +++++++++--------- .../app_onboarding/onboarding_screen.dart | 4 +- app/lib/screens/app_onboarding/terms.dart | 37 ++-- app/lib/screens/app_onboarding/welcome.dart | 57 +++++- app/lib/screens/study/onboarding/consent.dart | 7 +- .../study/onboarding/journey_overview.dart | 3 + app/lib/screens/study/onboarding/kickoff.dart | 15 +- .../study/onboarding/study_overview.dart | 3 + .../services/pending_deep_link_service.dart | 45 +++++ 10 files changed, 245 insertions(+), 113 deletions(-) create mode 100644 app/lib/services/pending_deep_link_service.dart diff --git a/app/lib/models/app_state.dart b/app/lib/models/app_state.dart index d4531fcd5..17f77c375 100644 --- a/app/lib/models/app_state.dart +++ b/app/lib/models/app_state.dart @@ -22,6 +22,19 @@ class AppState with ChangeNotifier { bool get hasPendingDeepLink => pendingDeepLinkStudyId != null || pendingDeepLinkInviteCode != null; + void setPendingDeepLink({ + required Study study, + String? inviteCode, + List? preselectedInterventionIds, + }) { + pendingDeepLinkStudyId = inviteCode == null ? study.id : null; + pendingDeepLinkInviteCode = inviteCode; + selectedStudy = study; + this.inviteCode = inviteCode; + this.preselectedInterventionIds = preselectedInterventionIds; + notifyListeners(); + } + void clearPendingDeepLink() { pendingDeepLinkStudyId = null; pendingDeepLinkInviteCode = null; diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index ee0a7d3c4..451bd7385 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -11,11 +11,11 @@ import 'package:studyu_app/screens/app_onboarding/app_error_screen.dart'; import 'package:studyu_app/screens/app_onboarding/iframe_helper.dart'; import 'package:studyu_app/screens/app_onboarding/preview.dart' as study_preview; -import 'package:studyu_app/screens/app_onboarding/study_switch_dialogs.dart'; import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; import 'package:studyu_app/services/deep_link_error_helper.dart'; import 'package:studyu_app/services/deep_link_service.dart'; import 'package:studyu_app/services/deferred_link_service.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/services/restore_account_service.dart'; import 'package:studyu_app/util/cache.dart'; import 'package:studyu_app/util/schedule_notifications.dart'; @@ -89,24 +89,41 @@ class _LoadingScreenState extends State { } } - void _storePendingDeepLink({String? studyId, String? inviteCode}) { + Future _storePendingDeepLink({ + required Study study, + String? inviteCode, + List? preselectedInterventionIds, + bool persist = false, + }) async { final state = context.read(); - state.pendingDeepLinkStudyId = studyId; - state.pendingDeepLinkInviteCode = inviteCode; + PendingDeepLinkService.storeInState( + state: state, + study: study, + inviteCode: inviteCode, + preselectedInterventionIds: preselectedInterventionIds, + ); + if (persist) { + await PendingDeepLinkService.persist( + studyId: inviteCode == null ? study.id : null, + inviteCode: inviteCode, + ); + } } Future _handleIncomingDeepLink({ String? studyId, String? inviteCode, bool isDeferred = false, + bool persistPending = true, }) async { final state = context.read(); // 1. Check login status final loggedIn = isUserLoggedIn(); - // 2. Only try to get an active study ID if they are actually logged in - final activeStudyId = loggedIn ? await _getCurrentStudyId(state) : null; + // 2. Only try to get an active study if they are actually logged in + final currentSubject = loggedIn ? await _getCurrentSubject(state) : null; + final activeStudyId = currentSubject?.studyId; // 3. ALWAYS process/validate the deep link first final result = await DeepLinkService.processDeepLink( @@ -119,7 +136,12 @@ class _LoadingScreenState extends State { if (!mounted) return; // 4. Handle the result (Errors will be caught here, NeedsAuth will route to onboarding) - await _handleDeepLinkResult(result, isDeferred: isDeferred); + await _handleDeepLinkResult( + result, + isDeferred: isDeferred, + persistPending: persistPending, + currentSubject: currentSubject, + ); } @override @@ -197,17 +219,47 @@ class _LoadingScreenState extends State { Future _markDeferredLinkProcessed() async { await SecureStorage.write('has_processed_deferred_link', 'true'); - await SecureStorage.delete('pending_deferred_link_invite'); - await SecureStorage.delete('pending_deferred_link_study'); + await PendingDeepLinkService.clearStorage(); } Future _markDeferredLinkHandedOff() async { await SecureStorage.write('has_processed_deferred_link', 'true'); } + Future _showActiveStudyDeepLinkDialog( + Study targetStudy, + StudySubject currentSubject, + ) async { + final l10n = AppLocalizations.of(context)!; + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.deep_link_switch_warning_title), + content: Text( + '${l10n.study_selection_single}\n\n' + '${l10n.study_selection_single_reason}\n\n' + '${l10n.deep_link_switch_warning_description(currentSubject.study.title ?? '', targetStudy.title ?? '')}\n\n' + 'If you want to leave your current study, open Settings and use "${l10n.opt_out}" first. Then open the invite again.', + ), + actions: [ + TextButton( + onPressed: () => context.go('/${RouteNames.dashboard}'), + child: Text(l10n.deep_link_switch_primary_return), + ), + TextButton( + onPressed: () => context.go('/${RouteNames.appSettings}'), + child: Text(l10n.settings), + ), + ], + ), + ); + } + Future _handleDeepLinkResult( DeepLinkResult result, { bool isDeferred = false, + bool persistPending = false, + StudySubject? currentSubject, }) async { final state = context.read(); switch (result) { @@ -216,18 +268,21 @@ class _LoadingScreenState extends State { :final inviteCode, :final preselectedInterventionIds, ): - _storePendingDeepLink( - studyId: inviteCode != null ? null : study.id, + await _storePendingDeepLink( + study: study, inviteCode: inviteCode, + preselectedInterventionIds: preselectedInterventionIds, + persist: persistPending, ); - state.preselectedInterventionIds = preselectedInterventionIds; if (isDeferred) { await _markDeferredLinkHandedOff(); } final onBoarded = await SecureStorage.readBool('onboarded') ?? false; if (!mounted) return; - context.go('/${onBoarded ? RouteNames.terms : RouteNames.onboarding}'); + context.go( + '/${onBoarded ? RouteNames.welcome : RouteNames.onboarding}', + ); case DeepLinkError(type: final errorType, :final errorValue): setState(() => _error = _getErrorMessage(errorType, errorValue)); @@ -240,36 +295,37 @@ class _LoadingScreenState extends State { :final preselectedInterventionIds, :final alreadyEnrolled, ): - state.selectedStudy = study; - if (inviteCode != null) { - state.inviteCode = inviteCode; - state.preselectedInterventionIds = preselectedInterventionIds; - } - - final confirmed = await _confirmSwitchToDeepLinkedStudy(study); - if (!confirmed) { - if (isDeferred) { - await _markDeferredLinkProcessed(); - } + if (alreadyEnrolled) { + await PendingDeepLinkService.clear(state); + if (isDeferred) await _markDeferredLinkProcessed(); if (!mounted) return; context.go('/${RouteNames.dashboard}'); return; } - if (alreadyEnrolled) { - if (isDeferred) { - await _markDeferredLinkProcessed(); - } + if (currentSubject != null) { + await PendingDeepLinkService.clear(state); + if (isDeferred) await _markDeferredLinkProcessed(); if (!mounted) return; - context.go('/${RouteNames.dashboard}'); + await _showActiveStudyDeepLinkDialog(study, currentSubject); return; } + await _storePendingDeepLink( + study: study, + inviteCode: inviteCode, + preselectedInterventionIds: preselectedInterventionIds, + persist: persistPending, + ); if (isDeferred) { - await _markDeferredLinkProcessed(); + await _markDeferredLinkHandedOff(); } + + final onBoarded = await SecureStorage.readBool('onboarded') ?? false; if (!mounted) return; - context.go('/${RouteNames.studyOverview}'); + context.go( + '/${onBoarded ? RouteNames.welcome : RouteNames.onboarding}', + ); } } @@ -291,28 +347,6 @@ class _LoadingScreenState extends State { context.goNamed(RouteNames.loading); } - Future _getCurrentStudyId(AppState state) async { - final activeSubjectId = await getActiveSubjectId(); - if (activeSubjectId == null) { - return null; - } - - final activeSubject = state.activeSubject; - if (activeSubject != null && activeSubject.id == activeSubjectId) { - return activeSubject.studyId; - } - - try { - final cachedSubject = await Cache.loadSubject(); - if (cachedSubject.id == activeSubjectId) { - return cachedSubject.studyId; - } - } catch (_) { - return null; - } - return null; - } - Future _getCurrentSubject(AppState state) async { final activeSubjectId = await getActiveSubjectId(); if (activeSubjectId == null) { @@ -327,6 +361,7 @@ class _LoadingScreenState extends State { try { final cachedSubject = await Cache.loadSubject(); if (cachedSubject.id == activeSubjectId) { + state.activeSubject = cachedSubject; return cachedSubject; } return null; @@ -335,39 +370,6 @@ class _LoadingScreenState extends State { } } - Future _confirmSwitchToDeepLinkedStudy(Study targetStudy) async { - final state = context.read(); - final currentSubject = await _getCurrentSubject(state); - if (currentSubject == null) { - return true; - } - - if (!mounted) return false; - - if (currentSubject.studyId == targetStudy.id) { - return await StudySwitchDialogs.confirmDeepLinkWarning( - context, - targetStudy, - currentSubject, - ); - } - - final confirmedSwitch = - await StudySwitchDialogs.confirmSwitchToDeepLinkedStudy( - context, - targetStudy, - currentSubject, - ); - - if (!confirmedSwitch) { - return false; - } - - state.activeSubject = null; - state.selectedStudy = null; - return true; - } - Future initStudy() async { final state = context.read(); final l10n = AppLocalizations.of(context)!; diff --git a/app/lib/screens/app_onboarding/onboarding_screen.dart b/app/lib/screens/app_onboarding/onboarding_screen.dart index ad0dc019e..aff6cb50d 100644 --- a/app/lib/screens/app_onboarding/onboarding_screen.dart +++ b/app/lib/screens/app_onboarding/onboarding_screen.dart @@ -59,11 +59,9 @@ class OnboardingScreen extends StatelessWidget { final state = context.read(); if (state.activeSubject != null) { context.goNamed(RouteNames.dashboard); - // Check for pending deep link } else if (state.pendingDeepLinkStudyId != null || state.pendingDeepLinkInviteCode != null) { - // If pending deep link, go directly to terms to skip welcome screen - context.pushNamed(RouteNames.terms); + context.goNamed(RouteNames.welcome); } else { context.goNamed(RouteNames.welcome); } diff --git a/app/lib/screens/app_onboarding/terms.dart b/app/lib/screens/app_onboarding/terms.dart index 5cdc38c74..09dfe1961 100644 --- a/app/lib/screens/app_onboarding/terms.dart +++ b/app/lib/screens/app_onboarding/terms.dart @@ -8,6 +8,7 @@ import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/services/deep_link_error_helper.dart'; import 'package:studyu_app/services/deep_link_service.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/onboarding_page.dart'; import 'package:studyu_core/core.dart'; @@ -38,8 +39,6 @@ class _TermsScreenState extends State { ); state.clearPendingDeepLink(); - await SecureStorage.delete('pending_deferred_link_invite'); - await SecureStorage.delete('pending_deferred_link_study'); if (!mounted) return; switch (result) { @@ -47,30 +46,34 @@ class _TermsScreenState extends State { :final study, :final inviteCode, :final preselectedInterventionIds, - :final alreadyEnrolled, ): - if (alreadyEnrolled) { - context.go('/${RouteNames.dashboard}'); - } else { - state.selectedStudy = study; - if (inviteCode != null) { - state.inviteCode = inviteCode; - state.preselectedInterventionIds = preselectedInterventionIds; - } - context.go('/${RouteNames.studyOverview}'); + state.selectedStudy = study; + if (inviteCode != null) { + state.inviteCode = inviteCode; + state.preselectedInterventionIds = preselectedInterventionIds; } + context.push('/${RouteNames.studyOverview}'); case DeepLinkError(type: final errorType, :final errorValue): - final l10n = AppLocalizations.of(context)!; + final message = getDeepLinkErrorMessage( + AppLocalizations.of(context)!, + errorType, + errorValue, + ); + final ok = AppLocalizations.of(context)!.ok; + await PendingDeepLinkService.clear(state); + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(getDeepLinkErrorMessage(l10n, errorType, errorValue)), + content: Text(message), duration: const Duration(seconds: 5), - action: SnackBarAction(label: l10n.ok, onPressed: () {}), + action: SnackBarAction(label: ok, onPressed: () {}), ), ); - context.go('/${RouteNames.studySelection}'); + context.go('/${RouteNames.welcome}'); case DeepLinkNeedsAuth(): - context.go('/${RouteNames.studySelection}'); + await PendingDeepLinkService.clear(state); + if (!mounted) return; + context.go('/${RouteNames.welcome}'); } } diff --git a/app/lib/screens/app_onboarding/welcome.dart b/app/lib/screens/app_onboarding/welcome.dart index d8c1a0b6c..439bf7a23 100644 --- a/app/lib/screens/app_onboarding/welcome.dart +++ b/app/lib/screens/app_onboarding/welcome.dart @@ -1,13 +1,68 @@ import 'package:flutter/material.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/util/debug_screen.dart'; -class WelcomeScreen extends StatelessWidget { +class WelcomeScreen extends StatefulWidget { const WelcomeScreen({super.key}); + @override + State createState() => _WelcomeScreenState(); +} + +class _WelcomeScreenState extends State { + bool _inviteDialogShown = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final state = context.watch(); + if (!_inviteDialogShown && + state.hasPendingDeepLink && + state.selectedStudy != null) { + _inviteDialogShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _showInviteDialog()); + } + } + + Future _showInviteDialog() async { + if (!mounted) return; + final state = context.read(); + final studyTitle = state.selectedStudy?.title ?? ''; + final l10n = AppLocalizations.of(context)!; + final accepted = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + title: Text(l10n.you_have_been_invited), + content: Text(studyTitle), + actions: [ + TextButton( + onPressed: () => context.pop(false), + child: Text(l10n.decline), + ), + FilledButton( + onPressed: () => context.pop(true), + child: Text(l10n.accept), + ), + ], + ), + ); + + if (!mounted) return; + if (accepted == true) { + context.push('/${RouteNames.terms}'); + } else { + await PendingDeepLinkService.clear(state); + _inviteDialogShown = false; + } + } + @override Widget build(BuildContext context) { return Scaffold( diff --git a/app/lib/screens/study/onboarding/consent.dart b/app/lib/screens/study/onboarding/consent.dart index 4c9d29bb8..76097f122 100644 --- a/app/lib/screens/study/onboarding/consent.dart +++ b/app/lib/screens/study/onboarding/consent.dart @@ -9,6 +9,7 @@ import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/study/onboarding/onboarding_progress.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/util/save_pdf.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/html_text.dart'; @@ -182,7 +183,11 @@ class _ConsentScreenState extends State { bottomNavigationBar: BottomOnboardingNavigation( backLabel: AppLocalizations.of(context)!.decline, backIcon: const Icon(Icons.close), - onBack: () => context.go('/${RouteNames.studySelection}'), + onBack: () async { + await PendingDeepLinkService.clear(context.read()); + if (!context.mounted) return; + context.go('/${RouteNames.studySelection}'); + }, nextLabel: AppLocalizations.of(context)!.accept, nextIcon: const Icon(Icons.check), onNext: boxLogic.every((element) => element) || kDebugMode diff --git a/app/lib/screens/study/onboarding/journey_overview.dart b/app/lib/screens/study/onboarding/journey_overview.dart index 30f8a4658..ad724a781 100644 --- a/app/lib/screens/study/onboarding/journey_overview.dart +++ b/app/lib/screens/study/onboarding/journey_overview.dart @@ -6,6 +6,7 @@ import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/study/onboarding/onboarding_progress.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_core/core.dart'; import 'package:studyu_flutter_common/studyu_flutter_common.dart'; @@ -32,6 +33,8 @@ class _JourneyOverviewScreen extends State { if (consentGiven != null && consentGiven) { context.push('/${RouteNames.kickoff}'); } else { + await PendingDeepLinkService.clear(context.read()); + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( diff --git a/app/lib/screens/study/onboarding/kickoff.dart b/app/lib/screens/study/onboarding/kickoff.dart index 7b74c88eb..ff0a3fff4 100644 --- a/app/lib/screens/study/onboarding/kickoff.dart +++ b/app/lib/screens/study/onboarding/kickoff.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/util/cache.dart'; import 'package:studyu_app/util/dashboard_showcase.dart'; import 'package:studyu_core/core.dart'; @@ -29,15 +30,19 @@ class _KickoffScreen extends State { subject = await subject!.save(); subject = await _fetchRemoteSubject(subject!.id); if (!context.mounted) return; - context.read().activeSubject = subject; - context.read().init(context); - await Cache.storeSubject(context.read().activeSubject); + final state = context.read(); + state.activeSubject = subject; + state.init(context); + await Cache.storeSubject(state.activeSubject); await storeActiveSubjectId(subject!.id); + await PendingDeepLinkService.clearStorage(); + state.clearPendingDeepLink(); if (!context.mounted) return; - context.read().showRecoveryPhraseOnDashboard = true; + state.showRecoveryPhraseOnDashboard = true; await RecoveryPhraseStorage.markPending(subject!.id); - if (!context.mounted) return; + if (!mounted) return; setState(() => ready = true); + if (!context.mounted) return; context.go('/${RouteNames.dashboard}'); } catch (e) { StudyULogger.fatal('Failed creating subject: $e'); diff --git a/app/lib/screens/study/onboarding/study_overview.dart b/app/lib/screens/study/onboarding/study_overview.dart index 47541f9d3..97e73edb2 100644 --- a/app/lib/screens/study/onboarding/study_overview.dart +++ b/app/lib/screens/study/onboarding/study_overview.dart @@ -7,6 +7,7 @@ import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/study/dashboard/contact_tab/contact_screen.dart'; import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/study_tile.dart'; import 'package:studyu_core/core.dart'; @@ -68,6 +69,8 @@ class _StudyOverviewScreen extends State { if (result.eligible) { navigateToJourney(context); } else { + await PendingDeepLinkService.clear(context.read()); + if (!context.mounted) return; context.pop(); } } diff --git a/app/lib/services/pending_deep_link_service.dart b/app/lib/services/pending_deep_link_service.dart new file mode 100644 index 000000000..7b9f5a8a6 --- /dev/null +++ b/app/lib/services/pending_deep_link_service.dart @@ -0,0 +1,45 @@ +import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_core/core.dart'; +import 'package:studyu_flutter_common/studyu_flutter_common.dart'; + +class PendingDeepLinkService { + static const _inviteKey = 'pending_deferred_link_invite'; + static const _studyKey = 'pending_deferred_link_study'; + + static Future persist({String? studyId, String? inviteCode}) async { + if (inviteCode != null) { + await SecureStorage.write(_inviteKey, inviteCode); + await SecureStorage.delete(_studyKey); + } else if (studyId != null) { + await SecureStorage.write(_studyKey, studyId); + await SecureStorage.delete(_inviteKey); + } + } + + static Future clear(AppState state) async { + state.clearPendingDeepLink(); + state.selectedStudy = null; + state.inviteCode = null; + state.preselectedInterventionIds = null; + await SecureStorage.delete(_inviteKey); + await SecureStorage.delete(_studyKey); + } + + static Future clearStorage() async { + await SecureStorage.delete(_inviteKey); + await SecureStorage.delete(_studyKey); + } + + static void storeInState({ + required AppState state, + required Study study, + String? inviteCode, + List? preselectedInterventionIds, + }) { + state.setPendingDeepLink( + study: study, + inviteCode: inviteCode, + preselectedInterventionIds: preselectedInterventionIds, + ); + } +} From 6281eab578dfdd2c9f012efa08c7b573cbdc5676 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 14:16:56 +0200 Subject: [PATCH 44/57] fix(app): repair invite onboarding routing --- .../app_onboarding/loading_screen.dart | 5 +++ app/lib/screens/app_onboarding/terms.dart | 22 +++++++-- app/lib/screens/app_onboarding/welcome.dart | 4 +- .../services/pending_deep_link_service.dart | 9 +++- app/test/widget_test.dart | 45 ++++++++++++++++++- 5 files changed, 79 insertions(+), 6 deletions(-) diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index 451bd7385..f2a3b4521 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -174,6 +174,11 @@ class _LoadingScreenState extends State { } if (widget.hasDeepLink) { + final storedLink = await PendingDeepLinkService.readStorage(); + if (storedLink.inviteCode != widget.deepLinkInviteCode || + storedLink.studyId != widget.deepLinkStudyId) { + await PendingDeepLinkService.clearStorage(); + } await _handleIncomingDeepLink( studyId: widget.deepLinkStudyId, inviteCode: widget.deepLinkInviteCode, diff --git a/app/lib/screens/app_onboarding/terms.dart b/app/lib/screens/app_onboarding/terms.dart index 09dfe1961..4d5b5cd58 100644 --- a/app/lib/screens/app_onboarding/terms.dart +++ b/app/lib/screens/app_onboarding/terms.dart @@ -22,6 +22,17 @@ class TermsScreen extends StatefulWidget { State createState() => _TermsScreenState(); } +@visibleForTesting +String routeAfterTermsWithoutPendingDeepLink(AppState state) { + if (state.selectedStudy != null) { + return '/${RouteNames.studyOverview}'; + } + return '/${RouteNames.studySelection}'; +} + +@visibleForTesting +bool shouldClearInviteOnTermsBack(AppState state) => state.hasPendingDeepLink; + class _TermsScreenState extends State { bool _acceptedTerms = kDebugMode; bool _acceptedPrivacy = kDebugMode; @@ -38,7 +49,7 @@ class _TermsScreenState extends State { activeStudyId: state.activeSubject?.studyId, ); - state.clearPendingDeepLink(); + if (result is! DeepLinkSuccess) state.clearPendingDeepLink(); if (!mounted) return; switch (result) { @@ -146,7 +157,12 @@ class _TermsScreenState extends State { Widget _buildNavigation() { return BottomOnboardingNavigation( backButtonKey: const ValueKey('terms_back'), - onBack: () { + onBack: () async { + final state = context.read(); + if (shouldClearInviteOnTermsBack(state)) { + await PendingDeepLinkService.clear(state); + if (!mounted) return; + } if (context.canPop()) { context.pop(); } else { @@ -163,7 +179,7 @@ class _TermsScreenState extends State { if (state.hasPendingDeepLink) { await _handlePendingDeepLink(state); } else { - context.push('/${RouteNames.studySelection}'); + context.push(routeAfterTermsWithoutPendingDeepLink(state)); } } } diff --git a/app/lib/screens/app_onboarding/welcome.dart b/app/lib/screens/app_onboarding/welcome.dart index 439bf7a23..8378bd168 100644 --- a/app/lib/screens/app_onboarding/welcome.dart +++ b/app/lib/screens/app_onboarding/welcome.dart @@ -56,7 +56,9 @@ class _WelcomeScreenState extends State { if (!mounted) return; if (accepted == true) { - context.push('/${RouteNames.terms}'); + await context.push('/${RouteNames.terms}'); + if (!mounted) return; + setState(() => _inviteDialogShown = false); } else { await PendingDeepLinkService.clear(state); _inviteDialogShown = false; diff --git a/app/lib/services/pending_deep_link_service.dart b/app/lib/services/pending_deep_link_service.dart index 7b9f5a8a6..3b42b18f5 100644 --- a/app/lib/services/pending_deep_link_service.dart +++ b/app/lib/services/pending_deep_link_service.dart @@ -17,14 +17,21 @@ class PendingDeepLinkService { } static Future clear(AppState state) async { - state.clearPendingDeepLink(); state.selectedStudy = null; state.inviteCode = null; state.preselectedInterventionIds = null; + state.clearPendingDeepLink(); await SecureStorage.delete(_inviteKey); await SecureStorage.delete(_studyKey); } + static Future<({String? studyId, String? inviteCode})> readStorage() async { + return ( + studyId: await SecureStorage.read(_studyKey), + inviteCode: await SecureStorage.read(_inviteKey), + ); + } + static Future clearStorage() async { await SecureStorage.delete(_inviteKey); await SecureStorage.delete(_studyKey); diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index ce81a8722..71cb0e814 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -17,7 +17,7 @@ import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; import 'package:studyu_app/screens/app_onboarding/restore_account_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; import 'package:studyu_app/screens/app_onboarding/welcome.dart'; -import 'package:studyu_core/env.dart'; +import 'package:studyu_core/core.dart'; import 'package:supabase/supabase.dart'; Widget setup(Widget child, {AppState? appState}) { @@ -106,6 +106,49 @@ void main() { ); }); + test('terms continue returns to invite overview when invite is selected', () { + final appState = AppState() + ..selectedStudy = Study('study-1', 'owner-1') + ..inviteCode = 'invite-1'; + + expect( + routeAfterTermsWithoutPendingDeepLink(appState), + '/${RouteNames.studyOverview}', + ); + }); + + test('terms continue returns to overview after accepted invite back', () { + final appState = AppState()..selectedStudy = Study('study-1', 'owner-1'); + + expect( + routeAfterTermsWithoutPendingDeepLink(appState), + '/${RouteNames.studyOverview}', + ); + }); + + test('terms back clears pending invite flow', () { + final appState = AppState() + ..setPendingDeepLink( + study: Study('study-1', 'owner-1'), + inviteCode: 'invite-1', + ); + + expect(shouldClearInviteOnTermsBack(appState), isTrue); + }); + + test('terms continue opens study selection after invite decline', () { + final appState = AppState() + ..selectedStudy = Study('study-1', 'owner-1') + ..inviteCode = 'invite-1'; + appState.selectedStudy = null; + appState.inviteCode = null; + + expect( + routeAfterTermsWithoutPendingDeepLink(appState), + '/${RouteNames.studySelection}', + ); + }); + testWidgets('terms back falls back to welcome without previous screen', ( tester, ) async { From 8747f8cd9fc6ed4faf089ed642760672f1b9b65a Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 14:17:17 +0200 Subject: [PATCH 45/57] fix(app): clarify active study invite switch --- app/lib/l10n/app_de.arb | 2 ++ app/lib/l10n/app_en.arb | 2 ++ app/lib/l10n/app_localizations.dart | 12 ++++++++++++ app/lib/l10n/app_localizations_de.dart | 6 ++++++ app/lib/l10n/app_localizations_en.dart | 6 ++++++ app/lib/screens/app_onboarding/loading_screen.dart | 10 +++++----- app/lib/screens/study/dashboard/settings.dart | 7 ++++++- 7 files changed, 39 insertions(+), 6 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 6b9f10def..d98ee6676 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -519,6 +519,8 @@ } } }, + "deep_link_switch_open_settings": "Open Settings", + "deep_link_switch_continue_study": "Continue Study", "deep_link_switch_primary_return": "Zur aktuellen Studie zurückkehren", "deep_link_switch_secondary_continue": "Aktuelle Studie verlassen und wechseln", "deep_link_switch_data_choice_title": "Wie möchten Sie die aktuelle Studie verlassen?", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 8f7c2ba05..24bcf5f6a 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -517,6 +517,8 @@ } } }, + "deep_link_switch_open_settings": "Open Settings", + "deep_link_switch_continue_study": "Continue Study", "deep_link_switch_primary_return": "Return to current study", "deep_link_switch_secondary_continue": "Leave current study and switch", "deep_link_switch_data_choice_title": "How do you want to leave your current study?", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index d8bb9147e..699199daa 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -2627,6 +2627,18 @@ abstract class AppLocalizations { String targetStudy, ); + /// No description provided for @deep_link_switch_open_settings. + /// + /// In en, this message translates to: + /// **'Open Settings'** + String get deep_link_switch_open_settings; + + /// No description provided for @deep_link_switch_continue_study. + /// + /// In en, this message translates to: + /// **'Continue Study'** + String get deep_link_switch_continue_study; + /// No description provided for @deep_link_switch_primary_return. /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index f519ccd34..da8cc7133 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1388,6 +1388,12 @@ class AppLocalizationsDe extends AppLocalizations { return 'Sie sind aktuell eingeschrieben in:\n$currentStudy\n\nDer Deep Link verweist auf:\n$targetStudy\n\nSie können zur aktuellen Studie zurückkehren (empfohlen) oder fortfahren, diese zu verlassen und zu wechseln.'; } + @override + String get deep_link_switch_open_settings => 'Open Settings'; + + @override + String get deep_link_switch_continue_study => 'Continue Study'; + @override String get deep_link_switch_primary_return => 'Zur aktuellen Studie zurückkehren'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index fb5d2eb9b..b13e1ee7f 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1374,6 +1374,12 @@ class AppLocalizationsEn extends AppLocalizations { return 'You are currently enrolled in:\n$currentStudy\n\nThe deep link points to:\n$targetStudy\n\nYou can return to your current study (recommended) or continue to leave it and switch.'; } + @override + String get deep_link_switch_open_settings => 'Open Settings'; + + @override + String get deep_link_switch_continue_study => 'Continue Study'; + @override String get deep_link_switch_primary_return => 'Return to current study'; diff --git a/app/lib/screens/app_onboarding/loading_screen.dart b/app/lib/screens/app_onboarding/loading_screen.dart index f2a3b4521..3cdef3cad 100644 --- a/app/lib/screens/app_onboarding/loading_screen.dart +++ b/app/lib/screens/app_onboarding/loading_screen.dart @@ -247,13 +247,13 @@ class _LoadingScreenState extends State { 'If you want to leave your current study, open Settings and use "${l10n.opt_out}" first. Then open the invite again.', ), actions: [ - TextButton( - onPressed: () => context.go('/${RouteNames.dashboard}'), - child: Text(l10n.deep_link_switch_primary_return), - ), TextButton( onPressed: () => context.go('/${RouteNames.appSettings}'), - child: Text(l10n.settings), + child: Text(l10n.deep_link_switch_open_settings), + ), + FilledButton( + onPressed: () => context.go('/${RouteNames.dashboard}'), + child: Text(l10n.deep_link_switch_continue_study), ), ], ), diff --git a/app/lib/screens/study/dashboard/settings.dart b/app/lib/screens/study/dashboard/settings.dart index 1693f9d95..47ae886d6 100644 --- a/app/lib/screens/study/dashboard/settings.dart +++ b/app/lib/screens/study/dashboard/settings.dart @@ -58,7 +58,12 @@ class _SettingsState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( - appBar: AppBar(title: Text(AppLocalizations.of(context)!.settings)), + appBar: AppBar( + leading: BackButton( + onPressed: () => context.go('/${RouteNames.dashboard}'), + ), + title: Text(AppLocalizations.of(context)!.settings), + ), body: SingleChildScrollView( child: Padding( padding: const EdgeInsets.all(16), From a189d77bfee38c5958e15ae36ea934394f15befc Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 10 Jul 2026 14:17:37 +0200 Subject: [PATCH 46/57] fix(app): show dashboard tour after study starts --- .../screens/study/dashboard/dashboard.dart | 40 ++++++++++++++++--- app/test/widget_test.dart | 26 ++++++++++++ 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/app/lib/screens/study/dashboard/dashboard.dart b/app/lib/screens/study/dashboard/dashboard.dart index 0547b312f..fe803ac17 100644 --- a/app/lib/screens/study/dashboard/dashboard.dart +++ b/app/lib/screens/study/dashboard/dashboard.dart @@ -21,6 +21,24 @@ import 'package:studyu_app/widgets/recovery_phrase_content.dart'; import 'package:studyu_core/core.dart'; import 'package:url_launcher/url_launcher.dart'; +@visibleForTesting +bool isDashboardShowcaseEligible({ + required DateTime? startedAt, + required DateTime now, + required bool isPreview, + required bool checkStarted, +}) { + return !checkStarted && + !isPreview && + startedAt != null && + !startedAt.isAfter(now); +} + +@visibleForTesting +bool shouldMarkDashboardShowcaseCompleted({required bool wasStarted}) { + return wasStarted; +} + class DashboardScreen extends StatefulWidget { final String? error; @@ -53,6 +71,7 @@ class _DashboardScreenState extends State StudySubject? subject; List? scheduleToday; bool _showcaseCheckStarted = false; + bool _dashboardShowcaseStarted = false; bool _redirectingToLoading = false; bool _isDisposing = false; @@ -407,6 +426,7 @@ class _DashboardScreenState extends State setState(() { scheduleToday = subject!.scheduleFor(DateTime.now()); }); + unawaited(_startDashboardShowcaseIfNeeded()); } on SocketException catch (_) {} }, label: Text(AppLocalizations.of(context)!.next_day), @@ -482,10 +502,14 @@ class _DashboardScreenState extends State } Future _startDashboardShowcaseIfNeeded() async { - if (_showcaseCheckStarted || context.read().isPreview) return; - - // ponytail: no TaskOverview targets on first-day wait screen — skip so showcase can run later - if (subject == null || subject!.startedAt!.isAfter(DateTime.now())) return; + if (!isDashboardShowcaseEligible( + startedAt: subject?.startedAt, + now: DateTime.now(), + isPreview: context.read().isPreview, + checkStarted: _showcaseCheckStarted, + )) { + return; + } _showcaseCheckStarted = true; @@ -502,6 +526,7 @@ class _DashboardScreenState extends State _reportShowcaseKey, _menuShowcaseKey, ], delay: const Duration(milliseconds: 300)); + _dashboardShowcaseStarted = true; }); } @@ -511,7 +536,12 @@ class _DashboardScreenState extends State } void _markDashboardShowcaseCompleted() { - if (_isDisposing) return; + if (_isDisposing || + !shouldMarkDashboardShowcaseCompleted( + wasStarted: _dashboardShowcaseStarted, + )) { + return; + } unawaited(DashboardShowcaseStorage.markCompleted()); } diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index 71cb0e814..c34bc35c1 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -17,6 +17,7 @@ import 'package:studyu_app/screens/app_onboarding/loading_screen.dart'; import 'package:studyu_app/screens/app_onboarding/restore_account_screen.dart'; import 'package:studyu_app/screens/app_onboarding/terms.dart'; import 'package:studyu_app/screens/app_onboarding/welcome.dart'; +import 'package:studyu_app/screens/study/dashboard/dashboard.dart'; import 'package:studyu_core/core.dart'; import 'package:supabase/supabase.dart'; @@ -106,6 +107,31 @@ void main() { ); }); + test('dashboard showcase waits until next-day study has started', () { + final now = DateTime(2026, 7, 10, 12); + + expect( + isDashboardShowcaseEligible( + startedAt: DateTime(2026, 7, 11), + now: now, + isPreview: false, + checkStarted: false, + ), + isFalse, + ); + expect( + isDashboardShowcaseEligible( + startedAt: DateTime(2026, 7, 10), + now: now, + isPreview: false, + checkStarted: false, + ), + isTrue, + ); + expect(shouldMarkDashboardShowcaseCompleted(wasStarted: false), isFalse); + expect(shouldMarkDashboardShowcaseCompleted(wasStarted: true), isTrue); + }); + test('terms continue returns to invite overview when invite is selected', () { final appState = AppState() ..selectedStudy = Study('study-1', 'owner-1') From 746519fc1ed98ee9326e042f39820d511e818aa2 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 13:47:37 +0200 Subject: [PATCH 47/57] fix(app): clear abandoned invite flows --- app/lib/screens/study/onboarding/consent.dart | 8 +-- .../study/onboarding/journey_overview.dart | 1 + .../study/onboarding/study_overview.dart | 2 +- .../pending_deep_link_service_test.dart | 53 +++++++++++++++++++ 4 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 app/test/services/pending_deep_link_service_test.dart diff --git a/app/lib/screens/study/onboarding/consent.dart b/app/lib/screens/study/onboarding/consent.dart index 76097f122..803a9d84c 100644 --- a/app/lib/screens/study/onboarding/consent.dart +++ b/app/lib/screens/study/onboarding/consent.dart @@ -5,11 +5,9 @@ import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; import 'package:pdf/widgets.dart' as pw; import 'package:provider/provider.dart'; -import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/study/onboarding/onboarding_progress.dart'; -import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/util/save_pdf.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/html_text.dart'; @@ -183,11 +181,7 @@ class _ConsentScreenState extends State { bottomNavigationBar: BottomOnboardingNavigation( backLabel: AppLocalizations.of(context)!.decline, backIcon: const Icon(Icons.close), - onBack: () async { - await PendingDeepLinkService.clear(context.read()); - if (!context.mounted) return; - context.go('/${RouteNames.studySelection}'); - }, + onBack: () => context.pop(false), nextLabel: AppLocalizations.of(context)!.accept, nextIcon: const Icon(Icons.check), onNext: boxLogic.every((element) => element) || kDebugMode diff --git a/app/lib/screens/study/onboarding/journey_overview.dart b/app/lib/screens/study/onboarding/journey_overview.dart index ad724a781..a204b8248 100644 --- a/app/lib/screens/study/onboarding/journey_overview.dart +++ b/app/lib/screens/study/onboarding/journey_overview.dart @@ -43,6 +43,7 @@ class _JourneyOverviewScreen extends State { duration: const Duration(seconds: 30), ), ); + context.go('/${RouteNames.studySelection}'); } } diff --git a/app/lib/screens/study/onboarding/study_overview.dart b/app/lib/screens/study/onboarding/study_overview.dart index 97e73edb2..e98d3f462 100644 --- a/app/lib/screens/study/onboarding/study_overview.dart +++ b/app/lib/screens/study/onboarding/study_overview.dart @@ -71,7 +71,7 @@ class _StudyOverviewScreen extends State { } else { await PendingDeepLinkService.clear(context.read()); if (!context.mounted) return; - context.pop(); + context.go('/${RouteNames.studySelection}'); } } diff --git a/app/test/services/pending_deep_link_service_test.dart b/app/test/services/pending_deep_link_service_test.dart new file mode 100644 index 000000000..d3feb6a3a --- /dev/null +++ b/app/test/services/pending_deep_link_service_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; +import 'package:studyu_core/core.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + final storage = {}; + + setUp(() { + storage.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + final arguments = call.arguments as Map; + final key = arguments['key'] as String?; + return switch (call.method) { + 'write' => storage[key!] = arguments['value']! as String, + 'read' => storage[key], + 'delete' => storage.remove(key), + _ => throw UnimplementedError(call.method), + }; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('clear removes a pending invite from memory and storage', () async { + final state = AppState() + ..setPendingDeepLink( + study: Study('study-1', 'owner-1'), + inviteCode: 'invite-1', + preselectedInterventionIds: ['intervention-1'], + ); + await PendingDeepLinkService.persist(inviteCode: 'invite-1'); + + await PendingDeepLinkService.clear(state); + + expect(state.hasPendingDeepLink, isFalse); + expect(state.selectedStudy, isNull); + expect(state.inviteCode, isNull); + expect(state.preselectedInterventionIds, isNull); + expect(await PendingDeepLinkService.readStorage(), ( + studyId: null, + inviteCode: null, + )); + }); +} From 75acd33903a90f8f3566055421c7fd1f4423aa0e Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 14:32:15 +0200 Subject: [PATCH 48/57] fix(app): return to study selection from overview --- .../study/onboarding/study_overview.dart | 56 ++++++++++++------- .../study/onboarding/study_overview_test.dart | 22 ++++++++ 2 files changed, 59 insertions(+), 19 deletions(-) create mode 100644 app/test/screens/study/onboarding/study_overview_test.dart diff --git a/app/lib/screens/study/onboarding/study_overview.dart b/app/lib/screens/study/onboarding/study_overview.dart index e98d3f462..8bbbc0c66 100644 --- a/app/lib/screens/study/onboarding/study_overview.dart +++ b/app/lib/screens/study/onboarding/study_overview.dart @@ -13,6 +13,9 @@ import 'package:studyu_app/widgets/study_tile.dart'; import 'package:studyu_core/core.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; +@visibleForTesting +bool shouldReturnToStudySelection(AppState state) => !state.hasPendingDeepLink; + class StudyOverviewScreen extends StatefulWidget { const StudyOverviewScreen({super.key}); @@ -77,26 +80,41 @@ class _StudyOverviewScreen extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.study_overview_title), - ), - body: SingleChildScrollView( - child: Column( - children: [ - Hero( - tag: 'study_tile_${study!.id}', - child: Material(child: StudyTile.fromStudy(study: study!)), - ), - const SizedBox(height: 16), - StudyDetailsView(study: study), - ], + final appState = context.watch(); + final returnToStudySelection = shouldReturnToStudySelection(appState); + + return PopScope( + canPop: !returnToStudySelection, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + appState + ..selectedStudy = null + ..selectedInterventions = null + ..inviteCode = null + ..preselectedInterventionIds = null; + context.go('/${RouteNames.studySelection}'); + }, + child: Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context)!.study_overview_title), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Hero( + tag: 'study_tile_${study!.id}', + child: Material(child: StudyTile.fromStudy(study: study!)), + ), + const SizedBox(height: 16), + StudyDetailsView(study: study), + ], + ), + ), + bottomNavigationBar: BottomOnboardingNavigation( + onNext: appState.selectedStudy!.hasEligibilityCheck + ? () => navigateToEligibilityCheck(context) + : () => navigateToJourney(context), ), - ), - bottomNavigationBar: BottomOnboardingNavigation( - onNext: context.watch().selectedStudy!.hasEligibilityCheck - ? () => navigateToEligibilityCheck(context) - : () => navigateToJourney(context), ), ); } diff --git a/app/test/screens/study/onboarding/study_overview_test.dart b/app/test/screens/study/onboarding/study_overview_test.dart new file mode 100644 index 000000000..6bb3bae94 --- /dev/null +++ b/app/test/screens/study/onboarding/study_overview_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/screens/study/onboarding/study_overview.dart'; +import 'package:studyu_core/core.dart'; + +void main() { + test('regular study overview returns to study selection', () { + final state = AppState()..selectedStudy = Study('study-1', 'owner-1'); + + expect(shouldReturnToStudySelection(state), isTrue); + }); + + test('invited study overview returns to terms', () { + final state = AppState() + ..setPendingDeepLink( + study: Study('study-1', 'owner-1'), + inviteCode: 'invite-1', + ); + + expect(shouldReturnToStudySelection(state), isFalse); + }); +} From a7cfe815f040b608d35d8ba0b54ec84cd540ace8 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 14:39:07 +0200 Subject: [PATCH 49/57] fix(app): keep deep link navigation unblocked --- .../study/onboarding/study_overview.dart | 62 ++++++++++--------- 1 file changed, 32 insertions(+), 30 deletions(-) diff --git a/app/lib/screens/study/onboarding/study_overview.dart b/app/lib/screens/study/onboarding/study_overview.dart index 8bbbc0c66..403446580 100644 --- a/app/lib/screens/study/onboarding/study_overview.dart +++ b/app/lib/screens/study/onboarding/study_overview.dart @@ -83,39 +83,41 @@ class _StudyOverviewScreen extends State { final appState = context.watch(); final returnToStudySelection = shouldReturnToStudySelection(appState); - return PopScope( - canPop: !returnToStudySelection, - onPopInvokedWithResult: (didPop, _) { - if (didPop) return; - appState - ..selectedStudy = null - ..selectedInterventions = null - ..inviteCode = null - ..preselectedInterventionIds = null; - context.go('/${RouteNames.studySelection}'); - }, - child: Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context)!.study_overview_title), + return Scaffold( + appBar: AppBar( + leading: BackButton( + onPressed: () { + if (!returnToStudySelection) { + context.pop(); + return; + } + appState + ..selectedStudy = null + ..selectedInterventions = null + ..inviteCode = null + ..preselectedInterventionIds = null; + context.go('/${RouteNames.studySelection}'); + }, ), - body: SingleChildScrollView( - child: Column( - children: [ - Hero( - tag: 'study_tile_${study!.id}', - child: Material(child: StudyTile.fromStudy(study: study!)), - ), - const SizedBox(height: 16), - StudyDetailsView(study: study), - ], - ), - ), - bottomNavigationBar: BottomOnboardingNavigation( - onNext: appState.selectedStudy!.hasEligibilityCheck - ? () => navigateToEligibilityCheck(context) - : () => navigateToJourney(context), + title: Text(AppLocalizations.of(context)!.study_overview_title), + ), + body: SingleChildScrollView( + child: Column( + children: [ + Hero( + tag: 'study_tile_${study!.id}', + child: Material(child: StudyTile.fromStudy(study: study!)), + ), + const SizedBox(height: 16), + StudyDetailsView(study: study), + ], ), ), + bottomNavigationBar: BottomOnboardingNavigation( + onNext: appState.selectedStudy!.hasEligibilityCheck + ? () => navigateToEligibilityCheck(context) + : () => navigateToJourney(context), + ), ); } } From ade5ae1823f56adf372336a42ff2625a3c5a500c Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 14:49:41 +0200 Subject: [PATCH 50/57] fix(app): preserve cold start deep links --- app/lib/main.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index d7aef1122..a31b1b577 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -68,7 +68,11 @@ Future main() async { usePathUrlStrategy(); GoRouter.optionURLReflectsImperativeAPIs = true; AppConfig? appConfig; - String initialRoute = '/${RouteNames.loading}'; + final platformInitialRoute = + WidgetsBinding.instance.platformDispatcher.defaultRouteName; + String initialRoute = platformInitialRoute == '/' + ? '/${RouteNames.loading}' + : platformInitialRoute; try { appConfig = await AppConfig.getAppConfig(); } on PostgrestException catch (e) { From a05f00e033d8a5cce59fe6e0cb9f03b0ee6f89e1 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 14:59:28 +0200 Subject: [PATCH 51/57] fix(app): avoid null study during eligibility exit --- .../study/onboarding/study_overview.dart | 2 +- .../study/onboarding/study_overview_test.dart | 45 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/lib/screens/study/onboarding/study_overview.dart b/app/lib/screens/study/onboarding/study_overview.dart index 403446580..dd7d5ebbb 100644 --- a/app/lib/screens/study/onboarding/study_overview.dart +++ b/app/lib/screens/study/onboarding/study_overview.dart @@ -114,7 +114,7 @@ class _StudyOverviewScreen extends State { ), ), bottomNavigationBar: BottomOnboardingNavigation( - onNext: appState.selectedStudy!.hasEligibilityCheck + onNext: study!.hasEligibilityCheck ? () => navigateToEligibilityCheck(context) : () => navigateToJourney(context), ), diff --git a/app/test/screens/study/onboarding/study_overview_test.dart b/app/test/screens/study/onboarding/study_overview_test.dart index 6bb3bae94..7e8b25966 100644 --- a/app/test/screens/study/onboarding/study_overview_test.dart +++ b/app/test/screens/study/onboarding/study_overview_test.dart @@ -1,4 +1,9 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:studyu_app/app_router.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; import 'package:studyu_app/screens/study/onboarding/study_overview.dart'; import 'package:studyu_core/core.dart'; @@ -19,4 +24,44 @@ void main() { expect(shouldReturnToStudySelection(state), isFalse); }); + + testWidgets('pending link clear does not rebuild with a null study', ( + tester, + ) async { + final study = Study('study-1', 'owner-1')..title = 'Study'; + final state = AppState()..setPendingDeepLink(study: study); + final router = GoRouter( + initialLocation: '/${RouteNames.studyOverview}', + routes: [ + GoRoute( + path: '/${RouteNames.studyOverview}', + builder: (_, _) => const StudyOverviewScreen(), + ), + GoRoute( + path: '/${RouteNames.studySelection}', + builder: (_, _) => const SizedBox.shrink(), + ), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: state, + child: MaterialApp.router( + routerConfig: router, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + ), + ), + ); + await tester.pumpAndSettle(); + + state.selectedStudy = null; + state.clearPendingDeepLink(); + await tester.pump(); + + expect(tester.takeException(), isNull); + }); } From 0ff54922898f3e394703fb9fa7de417d02ed1eea Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 14:59:29 +0200 Subject: [PATCH 52/57] fix(app): return from intervention to eligibility --- .../onboarding/intervention_selection.dart | 21 +++++++ .../intervention_selection_test.dart | 61 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 app/test/screens/study/onboarding/intervention_selection_test.dart diff --git a/app/lib/screens/study/onboarding/intervention_selection.dart b/app/lib/screens/study/onboarding/intervention_selection.dart index 8a222cbe4..45655c426 100644 --- a/app/lib/screens/study/onboarding/intervention_selection.dart +++ b/app/lib/screens/study/onboarding/intervention_selection.dart @@ -4,7 +4,9 @@ import 'package:provider/provider.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; import 'package:studyu_app/screens/study/onboarding/onboarding_progress.dart'; +import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/intervention_card.dart'; import 'package:studyu_core/core.dart'; @@ -100,11 +102,29 @@ class _InterventionSelectionScreenState context.push('/${RouteNames.journey}'); } + Future onBack() async { + if (!selectedStudy!.hasEligibilityCheck) { + context.pop(); + return; + } + + final result = await context.push( + '/${RouteNames.eligibilityCheck}', + extra: selectedStudy, + ); + if (result == null || result.eligible || !mounted) return; + + await PendingDeepLinkService.clear(context.read()); + if (!mounted) return; + context.go('/${RouteNames.studySelection}'); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( + leading: BackButton(onPressed: onBack), title: Text(AppLocalizations.of(context)!.intervention_selection_title), ), body: SingleChildScrollView( @@ -123,6 +143,7 @@ class _InterventionSelectionScreenState ), ), bottomNavigationBar: BottomOnboardingNavigation( + onBack: onBack, onNext: selectedInterventionIds.length == 2 ? onFinished : null, progress: OnboardingProgress( stage: 1, diff --git a/app/test/screens/study/onboarding/intervention_selection_test.dart b/app/test/screens/study/onboarding/intervention_selection_test.dart new file mode 100644 index 000000000..b3692d4c5 --- /dev/null +++ b/app/test/screens/study/onboarding/intervention_selection_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import 'package:studyu_app/app_router.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/models/app_state.dart'; +import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; +import 'package:studyu_app/screens/study/onboarding/intervention_selection.dart'; +import 'package:studyu_core/core.dart'; + +void main() { + testWidgets('back reopens eligibility when the study has a check', ( + tester, + ) async { + final question = BooleanQuestion.withId()..prompt = 'Eligible?'; + final study = Study.withId('owner-1') + ..title = 'Study' + ..questionnaire.questions = [question] + ..eligibilityCriteria = [EligibilityCriterion.withId()]; + final appState = AppState()..selectedStudy = study; + final router = GoRouter( + initialLocation: '/${RouteNames.interventionSelection}', + routes: [ + GoRoute( + path: '/${RouteNames.interventionSelection}', + builder: (_, _) => const InterventionSelectionScreen(), + ), + GoRoute( + path: '/${RouteNames.eligibilityCheck}', + builder: (_, state) => + EligibilityScreen(study: state.extra! as Study), + ), + ], + ); + addTearDown(router.dispose); + + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: appState, + child: MaterialApp.router( + routerConfig: router, + locale: const Locale('en'), + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + + expect(find.byType(EligibilityScreen), findsOneWidget); + }); +} From 898f9fb7deaaf5925a50c7d006a1d51eaee5bff3 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 15:04:43 +0200 Subject: [PATCH 53/57] fix(app): update terms and privacy content to remove contact information --- app/lib/l10n/app_de.arb | 4 ++-- app/lib/l10n/app_en.arb | 4 ++-- app/lib/l10n/app_localizations.dart | 4 ++-- app/lib/l10n/app_localizations_de.dart | 5 +++-- app/lib/l10n/app_localizations_en.dart | 5 +++-- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 41688d075..528675127 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -154,11 +154,11 @@ "legal_documents_description": "Bitte lies und akzeptiere die Nutzungsbedingungen und Datenschutzbestimmung, bevor du fortfährst.", "terms": "Bedingungen", "terms_read": "Nutzungsbedingungen lesen", - "terms_content": "Die Nutzungsbedingungen geben einen Überblick zu dem Zweck und der Nutzung der StudyU App. Bei Fragen, bitte wenden Sie sich an uns mit den Kontaktinformationen im Impressum.", + "terms_content": "Die Nutzungsbedingungen geben einen Überblick zu dem Zweck und der Nutzung der StudyU App. ", "terms_agree": "Ich habe die Nutzungsbedingungen gelesen und bin damit einverstanden", "privacy": "Datenschutz", "privacy_read": "Datenschutzbestimmung lesen", - "privacy_content": "Die Datenschutzbestimmung beschreibt welche Daten gespeichert werden, warum, wann, wo, Zugangsrechte und welche Rechte Sie haben. Bei Fragen, bitte wenden Sie sich an uns mit den Kontaktinformationen im Impressum.", + "privacy_content": "Die Datenschutzbestimmung beschreibt welche Daten gespeichert werden, warum, wann, wo, Zugangsrechte und welche Rechte Sie haben.", "privacy_agree": "Ich habe die Datenschutzbestimmung gelesen und bin damit einverstanden", "legal_notice": "Impressum", "legal_notice_content": "Im Impressum steht, wer für StudyU verantwortlich ist und wie du uns kontaktieren kannst.", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 9e9c02f80..cc6d15550 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -152,11 +152,11 @@ "legal_documents_description": "Please read and accept the terms of use and privacy policy before continuing.", "terms": "Terms of Use", "terms_read": "Read Terms of Use", - "terms_content": "The terms of use give an overview on the purpose and use of the StudyU app. In case you have any questions please reach out to us via the contact information in the legal notice.", + "terms_content": "The terms of use give an overview on the purpose and use of the StudyU app.", "terms_agree": "I have read and agree to the terms of use", "privacy": "Privacy Policy", "privacy_read": "Read Privacy Policy", - "privacy_content": "The privacy policy describes which data is stored, why, when, where, access rights, and which rights you have. In case you have any questions please reach out to us via the contact information in the legal notice.", + "privacy_content": "The privacy policy describes which data is stored, why, when, where, access rights, and which rights you have.", "privacy_agree": "I have read and agree to the privacy policy", "legal_notice": "Legal Notice", "legal_notice_content": "The legal notice shows who is responsible for StudyU and how you can contact us.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 5695cd32c..404dc3db3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -965,7 +965,7 @@ abstract class AppLocalizations { /// No description provided for @terms_content. /// /// In en, this message translates to: - /// **'The terms of use give an overview on the purpose and use of the StudyU app. In case you have any questions please reach out to us via the contact information in the legal notice.'** + /// **'The terms of use give an overview on the purpose and use of the StudyU app.'** String get terms_content; /// No description provided for @terms_agree. @@ -989,7 +989,7 @@ abstract class AppLocalizations { /// No description provided for @privacy_content. /// /// In en, this message translates to: - /// **'The privacy policy describes which data is stored, why, when, where, access rights, and which rights you have. In case you have any questions please reach out to us via the contact information in the legal notice.'** + /// **'The privacy policy describes which data is stored, why, when, where, access rights, and which rights you have.'** String get privacy_content; /// No description provided for @privacy_agree. diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 5402d5ab7..84f7a97aa 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -1,5 +1,6 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -483,7 +484,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get terms_content => - 'Die Nutzungsbedingungen geben einen Überblick zu dem Zweck und der Nutzung der StudyU App. Bei Fragen, bitte wenden Sie sich an uns mit den Kontaktinformationen im Impressum.'; + 'Die Nutzungsbedingungen geben einen Überblick zu dem Zweck und der Nutzung der StudyU App. '; @override String get terms_agree => @@ -497,7 +498,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get privacy_content => - 'Die Datenschutzbestimmung beschreibt welche Daten gespeichert werden, warum, wann, wo, Zugangsrechte und welche Rechte Sie haben. Bei Fragen, bitte wenden Sie sich an uns mit den Kontaktinformationen im Impressum.'; + 'Die Datenschutzbestimmung beschreibt welche Daten gespeichert werden, warum, wann, wo, Zugangsrechte und welche Rechte Sie haben.'; @override String get privacy_agree => diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 1a2d5a835..51385f2f5 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -1,5 +1,6 @@ // ignore: unused_import import 'package:intl/intl.dart' as intl; + import 'app_localizations.dart'; // ignore_for_file: type=lint @@ -480,7 +481,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get terms_content => - 'The terms of use give an overview on the purpose and use of the StudyU app. In case you have any questions please reach out to us via the contact information in the legal notice.'; + 'The terms of use give an overview on the purpose and use of the StudyU app.'; @override String get terms_agree => 'I have read and agree to the terms of use'; @@ -493,7 +494,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get privacy_content => - 'The privacy policy describes which data is stored, why, when, where, access rights, and which rights you have. In case you have any questions please reach out to us via the contact information in the legal notice.'; + 'The privacy policy describes which data is stored, why, when, where, access rights, and which rights you have.'; @override String get privacy_agree => 'I have read and agree to the privacy policy'; From 8439c470cb4f6aa99b33cc47225b63d557ae9193 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 15:13:58 +0200 Subject: [PATCH 54/57] fix(app): preserve onboarding back stack --- app/lib/app_router.dart | 10 ++- .../study/onboarding/eligibility_screen.dart | 18 +++++- .../onboarding/intervention_selection.dart | 21 ------- .../study/onboarding/study_overview.dart | 17 +++--- .../onboarding/eligibility_screen_test.dart | 61 +++++++++++++++++++ .../intervention_selection_test.dart | 61 ------------------- 6 files changed, 94 insertions(+), 94 deletions(-) delete mode 100644 app/test/screens/study/onboarding/intervention_selection_test.dart diff --git a/app/lib/app_router.dart b/app/lib/app_router.dart index 4a108ea17..aea5a9195 100644 --- a/app/lib/app_router.dart +++ b/app/lib/app_router.dart @@ -236,8 +236,14 @@ GoRouter createAppRouter({ path: '/${RouteNames.eligibilityCheck}', name: RouteNames.eligibilityCheck, builder: (context, state) { - final study = state.extra as Study?; - return EligibilityScreen(study: study); + final extra = state.extra; + if (extra is EligibilityScreenArguments) { + return EligibilityScreen( + study: extra.study, + onEligible: extra.onEligible, + ); + } + return EligibilityScreen(study: extra as Study?); }, ), GoRoute( diff --git a/app/lib/screens/study/onboarding/eligibility_screen.dart b/app/lib/screens/study/onboarding/eligibility_screen.dart index beadf30ba..a5b0b7de2 100644 --- a/app/lib/screens/study/onboarding/eligibility_screen.dart +++ b/app/lib/screens/study/onboarding/eligibility_screen.dart @@ -16,8 +16,18 @@ class EligibilityResult { EligibilityResult(this.answers, {required this.eligible, this.firstFailed}); } +typedef EligibilityContinuation = Future Function(BuildContext context); + +class EligibilityScreenArguments { + final Study? study; + final EligibilityContinuation? onEligible; + + const EligibilityScreenArguments({required this.study, this.onEligible}); +} + class EligibilityScreen extends StatefulWidget { final Study? study; + final EligibilityContinuation? onEligible; static MaterialPageRoute routeFor({ required Study? study, @@ -26,7 +36,7 @@ class EligibilityScreen extends StatefulWidget { settings: const RouteSettings(name: '/eligibilityCheck'), ); - const EligibilityScreen({required this.study, super.key}); + const EligibilityScreen({required this.study, this.onEligible, super.key}); @override State createState() => _EligibilityScreenState(); @@ -128,7 +138,11 @@ class _EligibilityScreenState extends State { false; }*/ - void _finish() { + Future _finish() async { + if (activeResult?.eligible == true && widget.onEligible != null) { + await widget.onEligible!(context); + return; + } context.pop(activeResult); } diff --git a/app/lib/screens/study/onboarding/intervention_selection.dart b/app/lib/screens/study/onboarding/intervention_selection.dart index 45655c426..8a222cbe4 100644 --- a/app/lib/screens/study/onboarding/intervention_selection.dart +++ b/app/lib/screens/study/onboarding/intervention_selection.dart @@ -4,9 +4,7 @@ import 'package:provider/provider.dart'; import 'package:studyu_app/app_router.dart'; import 'package:studyu_app/l10n/app_localizations.dart'; import 'package:studyu_app/models/app_state.dart'; -import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; import 'package:studyu_app/screens/study/onboarding/onboarding_progress.dart'; -import 'package:studyu_app/services/pending_deep_link_service.dart'; import 'package:studyu_app/widgets/bottom_onboarding_navigation.dart'; import 'package:studyu_app/widgets/intervention_card.dart'; import 'package:studyu_core/core.dart'; @@ -102,29 +100,11 @@ class _InterventionSelectionScreenState context.push('/${RouteNames.journey}'); } - Future onBack() async { - if (!selectedStudy!.hasEligibilityCheck) { - context.pop(); - return; - } - - final result = await context.push( - '/${RouteNames.eligibilityCheck}', - extra: selectedStudy, - ); - if (result == null || result.eligible || !mounted) return; - - await PendingDeepLinkService.clear(context.read()); - if (!mounted) return; - context.go('/${RouteNames.studySelection}'); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( - leading: BackButton(onPressed: onBack), title: Text(AppLocalizations.of(context)!.intervention_selection_title), ), body: SingleChildScrollView( @@ -143,7 +123,6 @@ class _InterventionSelectionScreenState ), ), bottomNavigationBar: BottomOnboardingNavigation( - onBack: onBack, onNext: selectedInterventionIds.length == 2 ? onFinished : null, progress: OnboardingProgress( stage: 1, diff --git a/app/lib/screens/study/onboarding/study_overview.dart b/app/lib/screens/study/onboarding/study_overview.dart index dd7d5ebbb..5c2c52542 100644 --- a/app/lib/screens/study/onboarding/study_overview.dart +++ b/app/lib/screens/study/onboarding/study_overview.dart @@ -64,18 +64,19 @@ class _StudyOverviewScreen extends State { final study = context.read().selectedStudy; final result = await context.push( '/${RouteNames.eligibilityCheck}', - extra: study, + extra: EligibilityScreenArguments( + study: study, + onEligible: navigateToJourney, + ), ); if (result == null) return; if (!context.mounted) return; - if (result.eligible) { - navigateToJourney(context); - } else { - await PendingDeepLinkService.clear(context.read()); - if (!context.mounted) return; - context.go('/${RouteNames.studySelection}'); - } + if (result.eligible) return; + + await PendingDeepLinkService.clear(context.read()); + if (!context.mounted) return; + context.go('/${RouteNames.studySelection}'); } @override diff --git a/app/test/screens/study/onboarding/eligibility_screen_test.dart b/app/test/screens/study/onboarding/eligibility_screen_test.dart index a75131255..054b83b92 100644 --- a/app/test/screens/study/onboarding/eligibility_screen_test.dart +++ b/app/test/screens/study/onboarding/eligibility_screen_test.dart @@ -6,6 +6,67 @@ import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; import 'package:studyu_core/core.dart'; void main() { + testWidgets('keeps eligibility below the next onboarding step', ( + tester, + ) async { + final question = BooleanQuestion.withId() + ..id = 'eligible' + ..prompt = 'Do you fulfill the criterion?'; + final criterion = EligibilityCriterion.withId() + ..condition = (BooleanExpression()..target = question.id); + final study = Study.withId('user') + ..title = 'Study' + ..questionnaire.questions = [question] + ..eligibilityCriteria = [criterion]; + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => EligibilityScreen( + study: study, + onEligible: (context) async { + await context.push('/intervention'); + }, + ), + ), + GoRoute( + path: '/intervention', + builder: (_, _) => const Scaffold(body: Text('Intervention')), + ), + ], + ); + addTearDown(router.dispose); + + tester.view.physicalSize = const Size(800, 1600); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget( + MaterialApp.router( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + locale: const Locale('en'), + routerConfig: router, + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('yes')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Complete')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Next')); + await tester.pumpAndSettle(); + + expect(find.text('Intervention'), findsOneWidget); + + router.pop(); + await tester.pumpAndSettle(); + + expect(find.byType(EligibilityScreen), findsOneWidget); + }); + testWidgets('shows failed eligibility immediately after the answer', ( tester, ) async { diff --git a/app/test/screens/study/onboarding/intervention_selection_test.dart b/app/test/screens/study/onboarding/intervention_selection_test.dart deleted file mode 100644 index b3692d4c5..000000000 --- a/app/test/screens/study/onboarding/intervention_selection_test.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; -import 'package:studyu_app/app_router.dart'; -import 'package:studyu_app/l10n/app_localizations.dart'; -import 'package:studyu_app/models/app_state.dart'; -import 'package:studyu_app/screens/study/onboarding/eligibility_screen.dart'; -import 'package:studyu_app/screens/study/onboarding/intervention_selection.dart'; -import 'package:studyu_core/core.dart'; - -void main() { - testWidgets('back reopens eligibility when the study has a check', ( - tester, - ) async { - final question = BooleanQuestion.withId()..prompt = 'Eligible?'; - final study = Study.withId('owner-1') - ..title = 'Study' - ..questionnaire.questions = [question] - ..eligibilityCriteria = [EligibilityCriterion.withId()]; - final appState = AppState()..selectedStudy = study; - final router = GoRouter( - initialLocation: '/${RouteNames.interventionSelection}', - routes: [ - GoRoute( - path: '/${RouteNames.interventionSelection}', - builder: (_, _) => const InterventionSelectionScreen(), - ), - GoRoute( - path: '/${RouteNames.eligibilityCheck}', - builder: (_, state) => - EligibilityScreen(study: state.extra! as Study), - ), - ], - ); - addTearDown(router.dispose); - - tester.view.physicalSize = const Size(800, 1600); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.resetPhysicalSize); - addTearDown(tester.view.resetDevicePixelRatio); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: appState, - child: MaterialApp.router( - routerConfig: router, - locale: const Locale('en'), - supportedLocales: AppLocalizations.supportedLocales, - localizationsDelegates: AppLocalizations.localizationsDelegates, - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.byType(BackButton)); - await tester.pumpAndSettle(); - - expect(find.byType(EligibilityScreen), findsOneWidget); - }); -} From 0e3a203bdd7beb8475ee169d9fe9940ba021dd2b Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 15:14:37 +0200 Subject: [PATCH 55/57] fix(deps): update Podfile.lock checksum --- app/ios/Podfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 3b9b7be53..d8cb59f9a 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -23,6 +23,6 @@ SPEC CHECKSUMS: flutter_web_auth_2: 5c8d9dcd7848b5a9efb086d24e7a9adcae979c80 stack_deferred_link: 2a2526cfb01ed27b8c114719f7850d605ba6d897 -PODFILE CHECKSUM: 7f07ebef3a0a0adf5e89297b9e35e16dccabab6a +PODFILE CHECKSUM: b00a565d52284bcdd04d643f399531a35b02c2c4 COCOAPODS: 1.17.0 From 25efe43bbfb6fe473448f5a7082079e131d35d87 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 14 Jul 2026 15:25:53 +0200 Subject: [PATCH 56/57] fix(app): prevent study tile overflow --- app/lib/widgets/study_tile.dart | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/app/lib/widgets/study_tile.dart b/app/lib/widgets/study_tile.dart index cc40ba288..8a53ed6bb 100644 --- a/app/lib/widgets/study_tile.dart +++ b/app/lib/widgets/study_tile.dart @@ -41,27 +41,22 @@ class StudyTile extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - contentPadding: contentPadding, - onTap: onTap, - title: Center( - child: Text( - title!, - style: theme.textTheme.titleLarge!.copyWith( - color: theme.primaryColor, - ), - ), - ), - subtitle: Center(child: Text(description ?? '')), - leading: Icon( - MdiIconsHelper.fromString(iconName), + return ListTile( + contentPadding: contentPadding, + onTap: onTap, + title: Center( + child: Text( + title!, + style: theme.textTheme.titleLarge!.copyWith( color: theme.primaryColor, ), ), - ], + ), + subtitle: Center(child: Text(description ?? '')), + leading: Icon( + MdiIconsHelper.fromString(iconName), + color: theme.primaryColor, + ), ); } } From 1f785d8ed38b4d503ab35f2ed774c34983d3c0e1 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Wed, 15 Jul 2026 15:20:48 +0200 Subject: [PATCH 57/57] fix(app): hide recovery success banners in dialog --- .../screens/study/dashboard/dashboard.dart | 1 + app/lib/widgets/recovery_phrase_content.dart | 16 +++-- .../widgets/recovery_phrase_content_test.dart | 62 +++++++++++++++++++ 3 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 app/test/widgets/recovery_phrase_content_test.dart diff --git a/app/lib/screens/study/dashboard/dashboard.dart b/app/lib/screens/study/dashboard/dashboard.dart index fe803ac17..f98285ce7 100644 --- a/app/lib/screens/study/dashboard/dashboard.dart +++ b/app/lib/screens/study/dashboard/dashboard.dart @@ -572,6 +572,7 @@ class _DashboardScreenState extends State RecoveryPhraseContent( useGridLayout: false, isChecked: isChecked, + showSuccessFeedback: false, onCheckedChanged: (value) { setDialogState( () => isChecked = value ?? false, diff --git a/app/lib/widgets/recovery_phrase_content.dart b/app/lib/widgets/recovery_phrase_content.dart index f59b1bb26..003fa5f3e 100644 --- a/app/lib/widgets/recovery_phrase_content.dart +++ b/app/lib/widgets/recovery_phrase_content.dart @@ -11,6 +11,7 @@ class RecoveryPhraseContent extends StatefulWidget { final ValueChanged? onCheckedChanged; final bool showConfirmation; final bool showSaveHint; + final bool showSuccessFeedback; const RecoveryPhraseContent({ super.key, @@ -20,6 +21,7 @@ class RecoveryPhraseContent extends StatefulWidget { this.onCheckedChanged, this.showConfirmation = true, this.showSaveHint = false, + this.showSuccessFeedback = true, }); @override @@ -72,18 +74,20 @@ class RecoveryPhraseContentState extends State { if (_phrase == null) return; final text = _phrase!.join(' '); Clipboard.setData(ClipboardData(text: text)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context)!.copied_to_clipboard), - ), - ); + if (widget.showSuccessFeedback) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.copied_to_clipboard), + ), + ); + } } Future _downloadText() async { if (_phrase == null) return; try { await RecoveryFileUtils.downloadRecoveryText(_phrase!); - if (mounted) { + if (mounted && widget.showSuccessFeedback) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(AppLocalizations.of(context)!.file_saved)), ); diff --git a/app/test/widgets/recovery_phrase_content_test.dart b/app/test/widgets/recovery_phrase_content_test.dart new file mode 100644 index 000000000..40f9c9bbb --- /dev/null +++ b/app/test/widgets/recovery_phrase_content_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyu_app/l10n/app_localizations.dart'; +import 'package:studyu_app/widgets/recovery_phrase_content.dart'; + +Widget _wrap(Widget child) => MaterialApp( + supportedLocales: AppLocalizations.supportedLocales, + localizationsDelegates: AppLocalizations.localizationsDelegates, + locale: const Locale('en'), + home: Scaffold(body: SingleChildScrollView(child: child)), +); + +void main() { + testWidgets('hides copy and download feedback when disabled', (tester) async { + const channel = MethodChannel('flutter_file_dialog'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (_) async => 'saved', + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ), + ); + + await tester.pumpWidget( + _wrap( + const RecoveryPhraseContent( + initialPhrase: ['word'], + showConfirmation: false, + showSuccessFeedback: false, + ), + ), + ); + + for (final label in ['Copy', 'Download']) { + await tester.ensureVisible(find.text(label)); + await tester.tap(find.text(label)); + await tester.pumpAndSettle(); + expect(find.byType(SnackBar), findsNothing); + } + }); + + testWidgets('shows copy feedback by default', (tester) async { + await tester.pumpWidget( + _wrap( + const RecoveryPhraseContent( + initialPhrase: ['word'], + showConfirmation: false, + ), + ), + ); + + await tester.ensureVisible(find.text('Copy')); + await tester.tap(find.text('Copy')); + await tester.pump(); + + expect(find.byType(SnackBar), findsOneWidget); + }); +}