diff --git a/.gitignore b/.gitignore index b53b954f..e8a9cfc1 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,7 @@ app.*.map.json .vscode/settings.json ios/build test.dart -android/app/.cxx \ No newline at end of file +android/app/.cxx +.claude +CLAUDE.md +tasks/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0c47cc..d8940301 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### **1.4.5** (2025-07-16) + +- Allow import and export of unfinshed ROAST group data in YAML format + ### **1.4.4** (2025-06-25) - UI Improvements for ROAST participant onboarding diff --git a/assets/translations/da.json b/assets/translations/da.json index 4f99299c..5006a979 100644 --- a/assets/translations/da.json +++ b/assets/translations/da.json @@ -693,5 +693,19 @@ "roast_hd_index_empty_error": "HD-indekset må ikke være tomt", "roast_hd_index_invalid_error": "Indtast venligst et gyldigt nummer", "cancel": "Ophæve", - "confirm": "Bekræft" + "confirm": "Bekræft", + "roast_setup_qr_invalid_format": "Ugyldigt format for QR-kode", + "roast_setup_share_participant_qr_title": "Del som QR-kode", + "roast_setup_share_participant_qr_description": "Andre kan scanne denne QR-kode for at tilføje dig som deltager", + "roast_setup_share_participant_qr_share": "Del QR-data", + "roast_setup_group_member_manual_entry_hint": "Eller indtast deltageroplysninger manuelt:", + "roast_setup_share_participant_details_title": "Del Deltageroplysninger", + "roast_setup_share_participant_name_title": "Navn", + "roast_setup_share_participant_identifier_title": "Identifikator", + "roast_setup_share_participant_identifier_description": "Unik identifikator genereret ud fra dit navn", + "roast_setup_share_participant_pubkey_title": "Offentlig Nøgle", + "roast_setup_scan_qr_participant": "Scan QR-koden For Deltageren", + "roast_setup_qr_scan_title": "Scan Deltagerens QR-kode", + "roast_setup_participant_data_loaded_from_qr": "Deltagerdata indlæst fra QR-kode", + "roast_setup_share_participant_pubkey_description": "Din kryptografiske offentlige nøgle (hexadecimalt format)" } diff --git a/assets/translations/en.json b/assets/translations/en.json index 15ff2802..fb5b3dfb 100644 --- a/assets/translations/en.json +++ b/assets/translations/en.json @@ -707,5 +707,33 @@ "roast_setup_share_participant_identifier_title": "Identifier", "roast_setup_share_participant_identifier_description": "Unique identifier generated from your name", "roast_setup_share_participant_pubkey_title": "Public Key", - "roast_setup_share_participant_pubkey_description": "Your cryptographic public key (hex format)" + "roast_setup_share_participant_pubkey_description": "Your cryptographic public key (hex format)", + "roast_import_button": "Import Group Configuration", + "roast_import_importing": "Importing...", + "roast_import_success": "Successfully imported %count% participants", + "roast_import_error": "Failed to import configuration", + "roast_export_button": "Export Group Configuration", + "roast_export_exporting": "Exporting...", + "roast_export_success": "Group configuration exported successfully", + "roast_export_error": "Failed to export configuration", + "roast_import_config_loaded": "Configuration loaded from import", + "roast_import_participant_count": "%count% participants imported", + "roast_import_validation_status": "Import Validation Status", + "roast_import_validation_min_participants": "Participants: %count% (minimum %min% required)", + "roast_import_validation_includes_you": "✓ Your identity is included in the group", + "roast_import_validation_add_yourself": "ⓘ Add yourself to the group if needed", + "roast_import_validation_ready": "✓ Group is ready for ROAST setup", + "roast_import_validation_not_ready": "⚠ Group needs more participants", + "roast_import_confirm_title": "Import Group Configuration", + "roast_import_confirm_description": "This will replace your current group setup with the imported configuration. Any existing participants will be removed.", + "roast_import_confirm_button": "Import", + "roast_export_confirm_title": "Export Group Configuration", + "roast_export_confirm_description": "Export your current group configuration to share with other participants.", + "roast_export_confirm_button": "Export", + "roast_export_preview_title": "Export Preview", + "roast_export_preview_participants": "Participants", + "roast_export_preview_filename": "Filename", + "roast_wallet_show_key_info": "Show Key Information", + "roast_wallet_show_key_info_title": "Your HD Key Index", + "roast_add_participant_error": "Error adding participant" } diff --git a/assets/translations/is.json b/assets/translations/is.json index 4c6d7d11..771ab0f1 100644 --- a/assets/translations/is.json +++ b/assets/translations/is.json @@ -693,5 +693,19 @@ "roast_hd_index_empty_error": "HD vísitalan má ekki vera tóm", "roast_hd_index_range_error": "Vísitala verður að vera meiri en eða jöfn 0", "confirm": "Staðfesta", - "cancel": "Hætta Við" + "cancel": "Hætta Við", + "roast_setup_qr_scan_title": "Skannaðu QR Kóða Þátttakandans", + "roast_setup_qr_invalid_format": "Ógilt snið QR kóða", + "roast_setup_share_participant_qr_title": "Deila sem QR kóða", + "roast_setup_share_participant_qr_share": "Deila QR Gögnum", + "roast_setup_participant_data_loaded_from_qr": "Þátttakandagögn sótt úr QR kóða", + "roast_setup_group_member_manual_entry_hint": "Eða sláðu inn upplýsingar um þátttakanda handvirkt:", + "roast_setup_share_participant_details_title": "Deila Upplýsingum Um Þátttakanda", + "roast_setup_share_participant_name_title": "Nafn", + "roast_setup_share_participant_identifier_title": "Auðkenni", + "roast_setup_scan_qr_participant": "Skannaðu QR Kóða Fyrir Þátttakanda", + "roast_setup_share_participant_qr_description": "Aðrir geta skannað þennan QR kóða til að bæta þér við sem þátttakanda", + "roast_setup_share_participant_identifier_description": "Einkvæmt auðkenni búið til úr nafni þínu", + "roast_setup_share_participant_pubkey_title": "Opinber Lykill", + "roast_setup_share_participant_pubkey_description": "Dulkóðunarlykillinn þinn (sextándakerfisform)" } diff --git a/assets/translations/nb_NO.json b/assets/translations/nb_NO.json index 654dfcab..5415dce9 100644 --- a/assets/translations/nb_NO.json +++ b/assets/translations/nb_NO.json @@ -693,5 +693,19 @@ "roast_hd_index_invalid_error": "Vennligst skriv inn et gyldig nummer", "roast_hd_index_range_error": "Indeksen må være større enn eller lik 0", "confirm": "Bekreft", - "cancel": "Avbryt" + "cancel": "Avbryt", + "roast_setup_qr_scan_title": "Skann Deltakerens QR-kode", + "roast_setup_qr_invalid_format": "Ugyldig format for QR-kode", + "roast_setup_share_participant_qr_title": "Del som QR-kode", + "roast_setup_share_participant_qr_share": "Del QR-data", + "roast_setup_participant_data_loaded_from_qr": "Deltakerdata lastet inn fra QR-kode", + "roast_setup_group_member_manual_entry_hint": "Eller skriv inn detaljer for deltaker manuelt:", + "roast_setup_share_participant_details_title": "Del Detaljer For Deltaker", + "roast_setup_share_participant_name_title": "Navn", + "roast_setup_share_participant_identifier_title": "Identifikator", + "roast_setup_share_participant_identifier_description": "Unik identifikator generert fra navnet ditt", + "roast_setup_share_participant_pubkey_title": "Offentlig Nøkkel", + "roast_setup_scan_qr_participant": "Skann QR-kode For Deltaker", + "roast_setup_share_participant_qr_description": "Andre kan skanne denne QR-koden for å legge deg til som deltaker", + "roast_setup_share_participant_pubkey_description": "Din kryptografiske offentlige nøkkel (heksadesimalt format)" } diff --git a/assets/translations/sv.json b/assets/translations/sv.json index f5ba88cb..bb2201b9 100644 --- a/assets/translations/sv.json +++ b/assets/translations/sv.json @@ -693,5 +693,19 @@ "roast_hd_index_empty_error": "HD-indexet kan inte vara tomt", "roast_hd_index_invalid_error": "Var vänlig ange ett giltigt nummer", "confirm": "Bekräfta", - "cancel": "Avboka" + "cancel": "Avboka", + "roast_setup_qr_scan_title": "Skanna Deltagarens QR-kod", + "roast_setup_share_participant_qr_title": "Dela som QR-kod", + "roast_setup_share_participant_qr_description": "Andra kan skanna den här QR-koden för att lägga till dig som deltagare", + "roast_setup_share_participant_qr_share": "Dela QR-data", + "roast_setup_group_member_manual_entry_hint": "Eller ange deltagaruppgifter manuellt:", + "roast_setup_share_participant_details_title": "Dela Deltagaruppgifter", + "roast_setup_share_participant_name_title": "Namn", + "roast_setup_share_participant_identifier_title": "Identifierare", + "roast_setup_share_participant_identifier_description": "Unik identifierare genererad från ditt namn", + "roast_setup_share_participant_pubkey_title": "Offentlig Nyckel", + "roast_setup_scan_qr_participant": "Skanna QR-koden För Deltagaren", + "roast_setup_qr_invalid_format": "Ogiltigt format för QR-kod", + "roast_setup_participant_data_loaded_from_qr": "Deltagardata laddad från QR-kod", + "roast_setup_share_participant_pubkey_description": "Din kryptografiska offentliga nyckel (hexadecimalt format)" } diff --git a/lib/exceptions/roast_config_exceptions.dart b/lib/exceptions/roast_config_exceptions.dart new file mode 100644 index 00000000..eb77ff12 --- /dev/null +++ b/lib/exceptions/roast_config_exceptions.dart @@ -0,0 +1,69 @@ +/// Custom exceptions for ROAST group configuration export/import operations +library roast_config_exceptions; + +/// Base exception class for ROAST configuration operations +abstract class ROASTConfigException implements Exception { + final String message; + final String? details; + + const ROASTConfigException(this.message, {this.details}); + + @override + String toString() { + if (details != null) { + return 'ROASTConfigException: $message\nDetails: $details'; + } + return 'ROASTConfigException: $message'; + } +} + +/// Exception thrown when YAML format is invalid or cannot be parsed +class InvalidYAMLFormatException extends ROASTConfigException { + const InvalidYAMLFormatException( + super.message, { + super.details, + }); +} + +/// Exception thrown when file operations fail +class FileOperationException extends ROASTConfigException { + final String operation; + final String? filePath; + + const FileOperationException( + this.operation, + super.message, { + this.filePath, + super.details, + }); + + @override + String toString() { + String result = 'FileOperationException: $message\nOperation: $operation'; + if (filePath != null) { + result += '\nFile: $filePath'; + } + return result; + } +} + +/// Exception thrown when configuration validation fails +class ConfigValidationException extends ROASTConfigException { + final List validationErrors; + + const ConfigValidationException( + super.message, + this.validationErrors, { + super.details, + }); + + @override + String toString() { + String result = 'ConfigValidationException: $message'; + if (validationErrors.isNotEmpty) { + result += + '\nValidation errors:\n${validationErrors.map((e) => ' - $e').join('\n')}'; + } + return result; + } +} diff --git a/lib/screens/wallet/roast/roast_wallet_home.dart b/lib/screens/wallet/roast/roast_wallet_home.dart index 1bd40798..eb84f2aa 100644 --- a/lib/screens/wallet/roast/roast_wallet_home.dart +++ b/lib/screens/wallet/roast/roast_wallet_home.dart @@ -366,44 +366,59 @@ class _ROASTWalletHomeScreenState extends State { ), ), ), + if (_roastWallet.isCompleted) + PopupMenuItem( + value: 'export_roast_group', + child: ListTile( + leading: Icon( + Icons.share, + color: Theme.of(context).colorScheme.secondary, + ), + title: Text( + AppLocalizations.instance.translate( + 'roast_wallet_share_group_config', + ), + ), + ), + ), PopupMenuItem( - value: 'export_roast_group', + value: 'change_server_url', child: ListTile( leading: Icon( - Icons.share, + Icons.dns, color: Theme.of(context).colorScheme.secondary, ), title: Text( AppLocalizations.instance.translate( - 'roast_wallet_share_group_config', + 'roast_landing_configured_edit_server_url_title', ), ), ), ), PopupMenuItem( - value: 'change_server_url', + value: 'delete_roast_group', child: ListTile( leading: Icon( - Icons.dns, + Icons.delete_forever, color: Theme.of(context).colorScheme.secondary, ), title: Text( AppLocalizations.instance.translate( - 'roast_landing_configured_edit_server_url_title', + 'delete_wallet', ), ), ), ), PopupMenuItem( - value: 'delete_roast_group', + value: 'show_key_info', child: ListTile( leading: Icon( - Icons.delete_forever, + Icons.key, color: Theme.of(context).colorScheme.secondary, ), title: Text( AppLocalizations.instance.translate( - 'delete_wallet', + 'roast_wallet_show_key_info', ), ), ), @@ -455,7 +470,23 @@ class _ROASTWalletHomeScreenState extends State { case 'change_server_url': _serverURLEditDialog(); break; - default: + case 'show_key_info': + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text( + AppLocalizations.instance + .translate('roast_wallet_show_key_info_title'), + ), + content: Text(_coinWallet.walletIndex.toString()), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(AppLocalizations.instance.translate('close')), + ), + ], + ), + ); } } diff --git a/lib/tools/roast/roast_group_export_config.dart b/lib/tools/roast/roast_group_export_config.dart new file mode 100644 index 00000000..49680a61 --- /dev/null +++ b/lib/tools/roast/roast_group_export_config.dart @@ -0,0 +1,607 @@ +import 'dart:io'; +import 'package:coinlib_flutter/coinlib_flutter.dart'; +import 'package:noosphere_roast_client/noosphere_roast_client.dart'; +import 'package:peercoin/tools/logger_wrapper.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:peercoin/models/hive/roast_wallet.dart'; +import 'package:peercoin/tools/roast_config_utils.dart'; +import 'package:peercoin/exceptions/roast_config_exceptions.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:yaml/yaml.dart'; + +/// Simple data classes for YAML serialization of ROAST group configuration +/// Works with existing ROASTWallet and Map _participants + +/// Configuration model for exporting/importing unfinalized ROAST group data +class ROASTGroupExportConfig { + final String created; + final String serverUrl; + final String groupId; + final List participants; + + ROASTGroupExportConfig({ + required this.created, + required this.serverUrl, + required this.groupId, + required this.participants, + }); + + /// Convert to YAML-compatible map structure + Map toYamlMap() { + return { + 'metadata': { + 'created': created, + 'server_url': serverUrl, + 'group_id': groupId, + 'participant_count': participants.length, + }, + 'participants': participants.map((p) => p.toYamlMap()).toList(), + }; + } + + /// Create from YAML-compatible map structure + factory ROASTGroupExportConfig.fromYamlMap(Map yamlMap) { + final metadata = yamlMap['metadata'] as Map; + final participantsList = yamlMap['participants'] as List; + + return ROASTGroupExportConfig( + created: metadata['created']?.toString() ?? '', + serverUrl: metadata['server_url']?.toString() ?? '', + groupId: metadata['group_id']?.toString() ?? '', + participants: participantsList + .map( + (p) => ROASTParticipantExportConfig.fromYamlMap( + p as Map, + ), + ) + .toList(), + ); + } + + /// Create from ROASTWallet and temporary participants map + factory ROASTGroupExportConfig.fromROASTWallet( + String serverUrl, + String groupId, + Map participants, + Map participantNames, + ) { + final now = DateTime.now().toIso8601String(); + + final participantConfigs = participants.entries.map((entry) { + final identifier = entry.key; + final publicKey = entry.value; + final name = participantNames[identifier.toString()] ?? ''; + + return ROASTParticipantExportConfig( + name: name, + identifier: identifier.toString(), + publicKey: publicKey.hex, + ); + }).toList(); + + return ROASTGroupExportConfig( + created: now, + serverUrl: serverUrl, + groupId: groupId, + participants: participantConfigs, + ); + } + + /// Convert to participants map for importing into ROASTWallet + Map toParticipantsMap() { + final Map participantsMap = {}; + + for (final participant in participants) { + try { + final identifier = Identifier.fromHex(participant.identifier); + final publicKey = ECCompressedPublicKey.fromHex(participant.publicKey); + participantsMap[identifier] = publicKey; + } catch (e) { + // Skip invalid participants - will be handled by validation + continue; + } + } + + return participantsMap; + } + + /// Convert to participant names map for importing into ROASTWallet + Map toParticipantNamesMap() { + final Map namesMap = {}; + + for (final participant in participants) { + if (participant.name.isNotEmpty) { + namesMap[participant.identifier] = participant.name; + } + } + + return namesMap; + } + + /// Export a ROAST group configuration to YAML file + static Future exportGroupConfiguration( + ROASTWallet roastWallet, + Map participants, + ) async { + if (participants.isEmpty) { + throw const ConfigValidationException( + 'Cannot export empty group', + ['At least one participant is required for export'], + ); + } + try { + final exportConfig = ROASTGroupExportConfig.fromROASTWallet( + roastWallet.serverUrl, + roastWallet.groupId, + participants, + roastWallet.participantNames, + ); + final yamlString = _convertToYamlString(exportConfig); + final filePath = await ROASTConfigUtils.getTempExportFilePath(); + await _writeYamlFile(filePath, yamlString); + return filePath; + } catch (e) { + if (e is ROASTConfigException) rethrow; + throw FileOperationException( + 'export', + 'Failed to export group configuration: [${e.toString()}]', + ); + } + } + + /// Share the exported YAML file using the device's share functionality + static Future shareExportedFile(String filePath) async { + try { + if (!await ROASTConfigUtils.fileExists(filePath)) { + throw const FileOperationException( + 'share', + 'Export file not found', + ); + } + final filename = ROASTConfigUtils.extractFilename(filePath); + await Share.shareXFiles( + [XFile(filePath)], + text: 'ROAST Group Configuration', + subject: 'Share ROAST Group Configuration - $filename', + ); + await ROASTConfigUtils.deleteFile(filePath); + } catch (e) { + if (e is ROASTConfigException) rethrow; + throw FileOperationException( + 'share', + 'Failed to share export file: ${e.toString()}', + filePath: filePath, + ); + } + } + + /// Get export preview for UI display + static Map getExportPreview( + ROASTWallet roastWallet, + Map participants, + ) { + return { + 'serverUrl': roastWallet.serverUrl, + 'groupId': roastWallet.groupId, + 'participantCount': participants.length, + 'participants': participants.entries.map((entry) { + final identifier = entry.key; + final name = + roastWallet.participantNames[identifier.toString()] ?? 'Unknown'; + return { + 'name': name, + 'identifier': identifier.toString(), + }; + }).toList(), + 'filename': ROASTConfigUtils.generateExportFilename(), + }; + } + + static String _convertToYamlString(ROASTGroupExportConfig config) { + final yamlMap = config.toYamlMap(); + final buffer = StringBuffer(); + buffer.writeln('# ROAST Group Configuration'); + buffer.writeln('# Generated: ${config.created}'); + buffer.writeln(''); + buffer.writeln('metadata:'); + final metadata = yamlMap['metadata'] as Map; + metadata.forEach((key, value) { + buffer.writeln(' $key: ${_formatYamlValue(value)}'); + }); + buffer.writeln(''); + buffer.writeln('participants:'); + final participants = yamlMap['participants'] as List; + for (final participant in participants) { + final participantMap = participant as Map; + buffer.writeln(' - name: ${_formatYamlValue(participantMap['name'])}'); + buffer.writeln( + ' identifier: ${_formatYamlValue(participantMap['identifier'])}', + ); + buffer.writeln( + ' public_key: ${_formatYamlValue(participantMap['public_key'])}', + ); + if (participant != participants.last) { + buffer.writeln(''); + } + } + return buffer.toString(); + } + + static String _formatYamlValue(dynamic value) { + if (value == null) return '~'; // YAML null + + // For non-string values, just return as-is + if (value is! String) { + return value.toString(); + } + + // For empty strings + if (value.isEmpty) return '""'; + + // For strings that don't need quoting, return as-is + // This regex matches strings that are safe to use unquoted in YAML + // Including ISO date strings (with colons and dashes) + if (RegExp(r'^[a-zA-Z0-9._/:+-]+$').hasMatch(value)) { + return value; + } + + // For everything else, use double quotes and escape properly + // Escape backslashes and double quotes + final escaped = value + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\n', '\\n') + .replaceAll('\r', '\\r') + .replaceAll('\t', '\\t'); + + return '"$escaped"'; + } + + static Future _writeYamlFile( + String filePath, + String yamlContent, + ) async { + try { + final file = File(filePath); + await file.writeAsString(yamlContent); + } catch (e) { + throw FileOperationException( + 'write', + 'Failed to write YAML file: ${e.toString()}', + filePath: filePath, + ); + } + } + + /// Import a ROAST group configuration from YAML file + static Future importGroupConfiguration() async { + try { + final filePath = await _selectYamlFile(); + if (filePath == null) { + throw const FileOperationException( + 'select', + 'No file selected for import', + ); + } + final yamlContent = await _readYamlFile(filePath); + final parsedYaml = await _parseYamlContent(yamlContent); + final exportConfig = ROASTGroupExportConfig.fromYamlMap(parsedYaml); + await _validateConfiguration(exportConfig); + final participantsMap = exportConfig.toParticipantsMap(); + final participantNamesMap = exportConfig.toParticipantNamesMap(); + return ROASTGroupImportResult( + serverUrl: exportConfig.serverUrl, + groupId: exportConfig.groupId, + participants: participantsMap, + participantNames: participantNamesMap, + participantCount: exportConfig.participants.length, + created: exportConfig.created, + ); + } catch (e) { + LoggerWrapper.logError( + 'ROASTGroupExportConfig', + 'importGroupConfiguration', + 'Error importing group configuration: ${e.toString()}', + ); + if (e is ROASTConfigException) rethrow; + throw FileOperationException( + 'import', + 'Failed to import group configuration: ${e.toString()}', + ); + } + } + + static Future _selectYamlFile() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['yaml', 'yml'], + allowMultiple: false, + ); + if (result != null && result.files.isNotEmpty) { + final file = result.files.first; + if (file.path != null) { + return file.path!; + } + } + return null; + } catch (e) { + throw FileOperationException( + 'select', + 'Failed to select file: ${e.toString()}', + ); + } + } + + static Future _readYamlFile(String filePath) async { + try { + if (!ROASTConfigUtils.hasYamlExtension(filePath)) { + throw const InvalidYAMLFormatException( + 'Invalid file type. Please select a YAML configuration file (.yaml or .yml).', + ); + } + final file = File(filePath); + if (!await file.exists()) { + throw FileOperationException( + 'read', + 'Configuration file not found. Please check the file path and try again.', + filePath: filePath, + ); + } + final fileStat = await file.stat(); + if (fileStat.size > 10 * 1024 * 1024) { + throw const FileOperationException( + 'read', + 'Configuration file is too large. Maximum size is 10MB.', + ); + } + if (fileStat.size == 0) { + throw const InvalidYAMLFormatException( + 'Configuration file is empty. Please select a valid YAML file.', + ); + } + final content = await file.readAsString(); + if (content.trim().isEmpty) { + throw const InvalidYAMLFormatException( + 'Configuration file contains no data. Please select a valid YAML file.', + ); + } + // Clean any invisible control characters that might have been inserted + // This removes all control characters except newline, carriage return, and tab + final cleanedContent = content.replaceAll( + RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]'), + '', + ); + return cleanedContent; + } catch (e) { + if (e is ROASTConfigException) rethrow; + if (e.toString().contains('Permission denied')) { + throw FileOperationException( + 'read', + 'Permission denied. Please check file permissions and try again.', + filePath: filePath, + ); + } + if (e.toString().contains('No such file')) { + throw FileOperationException( + 'read', + 'File not found. Please check the file path and try again.', + filePath: filePath, + ); + } + throw FileOperationException( + 'read', + 'Failed to read configuration file: ${e.toString()}', + filePath: filePath, + ); + } + } + + static Future> _parseYamlContent( + String yamlContent, + ) async { + try { + if (yamlContent.trim().isEmpty) { + throw const InvalidYAMLFormatException( + 'The configuration file is empty or contains only whitespace.', + ); + } + final yamlDoc = loadYaml(yamlContent); + if (yamlDoc == null) { + throw const InvalidYAMLFormatException( + 'The configuration file contains no valid YAML data.', + ); + } + if (yamlDoc is! Map) { + throw const InvalidYAMLFormatException( + 'Invalid YAML structure. Expected a configuration object at root level.', + ); + } + return _convertYamlToMap(yamlDoc); + } catch (e) { + LoggerWrapper.logError( + 'ROASTGroupExportConfig', + '_parseYamlContent', + 'Error parsing YAML content: ${e.toString()}', + ); + + if (e is YamlException) { + final String specificError = _parseYamlError(e); + LoggerWrapper.logError( + 'ROASTGroupExportConfig', + '_parseYamlContent', + 'YAML parsing error: $specificError', + ); + throw InvalidYAMLFormatException( + 'Invalid YAML format: $specificError', + details: e.message, + ); + } + if (e is ROASTConfigException) rethrow; + throw InvalidYAMLFormatException( + 'Failed to parse YAML content: ${e.toString()}', + ); + } + } + + /// Recursively convert YAML objects to regular Dart collections + static Map _convertYamlToMap(dynamic yamlObj) { + if (yamlObj is Map) { + final result = {}; + yamlObj.forEach((key, value) { + result[key.toString()] = _convertYamlValue(value); + }); + return result; + } + throw ArgumentError('Expected Map but got ${yamlObj.runtimeType}'); + } + + static dynamic _convertYamlValue(dynamic value) { + if (value is Map) { + final result = {}; + value.forEach((key, val) { + result[key.toString()] = _convertYamlValue(val); + }); + return result; + } else if (value is List) { + return value.map(_convertYamlValue).toList(); + } else { + return value; + } + } + + static String _parseYamlError(YamlException e) { + final message = e.message.toLowerCase(); + if (message.contains('unexpected character')) { + return 'Unexpected character found. Please check for special characters or formatting issues.'; + } + if (message.contains('expected') && message.contains('found')) { + return 'YAML syntax error. Please check indentation and structure.'; + } + if (message.contains('duplicate key')) { + return 'Duplicate key found. Each field name must be unique.'; + } + if (message.contains('invalid')) { + return 'Invalid YAML syntax. Please check the file format.'; + } + if (message.contains('indent')) { + return 'Incorrect indentation. Please use consistent spacing.'; + } + return 'YAML parsing error. Please check the file format and structure.'; + } + + static Future _validateConfiguration( + ROASTGroupExportConfig config, + ) async { + final List validationErrors = []; + if (config.groupId.isEmpty || config.participants.isEmpty) { + throw const ConfigValidationException( + 'Invalid configuration file', + [ + 'The configuration file is missing required information. Please use a valid exported configuration file.', + ], + ); + } + final Set seenIdentifiers = {}; + final Set seenNames = {}; + for (int i = 0; i < config.participants.length; i++) { + final participant = config.participants[i]; + if (seenIdentifiers.contains(participant.identifier)) { + throw const ConfigValidationException( + 'Invalid configuration file', + [ + 'The configuration file contains duplicate participants. Please use a valid exported configuration file.', + ], + ); + } + seenIdentifiers.add(participant.identifier); + if (participant.name.isNotEmpty) { + if (seenNames.contains(participant.name)) { + throw const ConfigValidationException( + 'Invalid configuration file', + [ + 'The configuration file contains duplicate participants. Please use a valid exported configuration file.', + ], + ); + } + seenNames.add(participant.name); + } + try { + Identifier.fromHex(participant.identifier); + ECCompressedPublicKey.fromHex(participant.publicKey); + } catch (e) { + throw const ConfigValidationException( + 'Invalid configuration file', + [ + 'The configuration file contains invalid participant data. Please use a valid exported configuration file.', + ], + ); + } + } + if (validationErrors.isNotEmpty) { + throw ConfigValidationException( + 'Configuration validation failed', + validationErrors, + ); + } + } +} + +/// Individual participant configuration for YAML export/import +class ROASTParticipantExportConfig { + final String name; + final String identifier; + final String publicKey; + + ROASTParticipantExportConfig({ + required this.name, + required this.identifier, + required this.publicKey, + }); + + /// Convert to YAML-compatible map structure + Map toYamlMap() { + return { + 'name': name, + 'identifier': identifier, + 'public_key': publicKey, + }; + } + + /// Create from YAML-compatible map structure + factory ROASTParticipantExportConfig.fromYamlMap( + Map yamlMap, + ) { + return ROASTParticipantExportConfig( + name: yamlMap['name']?.toString() ?? '', + identifier: yamlMap['identifier']?.toString() ?? '', + publicKey: yamlMap['public_key']?.toString() ?? '', + ); + } +} + +/// Result class for import operations +class ROASTGroupImportResult { + final String serverUrl; + final String groupId; + final Map participants; + final Map participantNames; + final int participantCount; + final String created; + + ROASTGroupImportResult({ + required this.serverUrl, + required this.groupId, + required this.participants, + required this.participantNames, + required this.participantCount, + required this.created, + }); + + void applyToROASTWallet(ROASTWallet roastWallet) { + roastWallet.serverUrl = serverUrl; + roastWallet.groupId = groupId; + roastWallet.participantNames = participantNames; + // Note: participants map is applied separately in the UI layer + } +} diff --git a/lib/tools/roast_config_utils.dart b/lib/tools/roast_config_utils.dart new file mode 100644 index 00000000..59bd1c6b --- /dev/null +++ b/lib/tools/roast_config_utils.dart @@ -0,0 +1,93 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +/// Utility functions for ROAST configuration export/import operations +class ROASTConfigUtils { + /// Generate a descriptive filename for ROAST group configuration export + /// Format: roast-group-[timestamp].yaml + static String generateExportFilename() { + final now = DateTime.now(); + final timestamp = now.toIso8601String().replaceAll(':', '-').split('.')[0]; + return 'roast-group-$timestamp.yaml'; + } + + /// Format timestamp for YAML metadata + /// Returns ISO 8601 formatted string + static String formatTimestamp(DateTime dateTime) { + return dateTime.toIso8601String(); + } + + /// Format current timestamp for YAML metadata + /// Returns ISO 8601 formatted string for current time + static String formatCurrentTimestamp() { + return formatTimestamp(DateTime.now()); + } + + /// Get the temporary directory path for storing export files + /// Returns path where temporary files can be stored before sharing + static Future getTempDirectoryPath() async { + final tempDir = await getTemporaryDirectory(); + return tempDir.path; + } + + /// Get the full file path for a temporary export file + /// Combines temp directory with generated filename + static Future getTempExportFilePath() async { + await cleanupOldTempFiles(); + final tempPath = await getTempDirectoryPath(); + final filename = generateExportFilename(); + return '$tempPath/$filename'; + } + + /// Check if a file exists at the given path + static Future fileExists(String filePath) async { + final file = File(filePath); + return file.exists(); + } + + /// Delete a file at the given path + /// Used for cleanup after sharing export files + static Future deleteFile(String filePath) async { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + } + } + + /// Validate that a file path has .yaml extension + static bool hasYamlExtension(String filePath) { + return filePath.toLowerCase().endsWith('.yaml') || + filePath.toLowerCase().endsWith('.yml'); + } + + /// Extract filename from full file path + static String extractFilename(String filePath) { + return filePath.split('/').last; + } + + /// Clean up temporary files older than specified duration + /// Helps prevent temp directory from filling up with old export files + static Future cleanupOldTempFiles({Duration maxAge = const Duration(hours: 24)}) async { + try { + final tempPath = await getTempDirectoryPath(); + final tempDir = Directory(tempPath); + + if (await tempDir.exists()) { + final files = tempDir.listSync() + .whereType() + .where((file) => file.path.contains('roast-group-')); + + final cutoffTime = DateTime.now().subtract(maxAge); + + for (final file in files) { + final stat = await file.stat(); + if (stat.modified.isBefore(cutoffTime)) { + await file.delete(); + } + } + } + } catch (e) { + // Ignore cleanup errors - not critical for functionality + } + } +} \ No newline at end of file diff --git a/lib/widgets/wallet/roast_group/setup_landing.dart b/lib/widgets/wallet/roast_group/setup_landing.dart index d63fe1f9..f2729ff3 100644 --- a/lib/widgets/wallet/roast_group/setup_landing.dart +++ b/lib/widgets/wallet/roast_group/setup_landing.dart @@ -1,10 +1,14 @@ +import 'package:coinlib_flutter/coinlib_flutter.dart'; import 'package:flutter/material.dart'; +import 'package:noosphere_roast_client/noosphere_roast_client.dart'; import 'package:peercoin/models/hive/coin_wallet.dart'; import 'package:peercoin/models/hive/roast_wallet.dart'; +import 'package:peercoin/tools/roast/roast_group_export_config.dart'; import 'package:peercoin/tools/app_localizations.dart'; import 'package:peercoin/widgets/buttons.dart'; import 'package:peercoin/widgets/service_container.dart'; import 'package:peercoin/widgets/wallet/roast_group/setup_participants.dart'; +import 'package:peercoin/exceptions/roast_config_exceptions.dart'; class ROASTGroupSetupLanding extends StatefulWidget { final ROASTWallet roastWallet; @@ -57,6 +61,98 @@ class _ROASTGroupSetupLandingState extends State { } } + bool _isImporting = false; + Map? _importedParticipants; + + Future _importConfiguration() async { + if (_isImporting) return; + + // Show confirmation dialog + final bool? shouldImport = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text( + AppLocalizations.instance.translate('roast_import_confirm_title'), + ), + content: Text( + AppLocalizations.instance + .translate('roast_import_confirm_description'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.instance.translate('cancel')), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + AppLocalizations.instance + .translate('roast_import_confirm_button'), + ), + ), + ], + ); + }, + ); + + if (shouldImport != true) return; + + setState(() { + _isImporting = true; + }); + + try { + final result = await ROASTGroupExportConfig.importGroupConfiguration(); + + // Apply imported configuration to the wallet + result.applyToROASTWallet(widget.roastWallet); + + // Store imported participants data for the participants screen + _importedParticipants = result.participants; + + // Update the UI controllers + _groupIdController.text = result.groupId; + _nameController.text = widget.roastWallet.ourName; + + // Show success message and proceed to participants screen + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.instance + .translate('roast_import_success') + .replaceAll('%count%', result.participantCount.toString()), + ), + backgroundColor: Colors.green, + ), + ); + _changeStep(ROASTSetupStep.pubkey); + } + } catch (e) { + if (mounted) { + String errorMessage = + AppLocalizations.instance.translate('roast_import_error'); + if (e is ROASTConfigException) { + errorMessage = e.message; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isImporting = false; + }); + } + } + } + @override Widget build(BuildContext context) { if (_step == ROASTSetupStep.pubkey) { @@ -64,6 +160,7 @@ class _ROASTGroupSetupLandingState extends State { roastWallet: widget.roastWallet, coinWallet: widget.coinWallet, changeStep: _changeStep, + importedParticipants: _importedParticipants, ); } @@ -180,6 +277,18 @@ class _ROASTGroupSetupLandingState extends State { .translate('roast_setup_landing_create_group'), action: () => _save(), ), + const SizedBox( + height: 10, + ), + PeerButton( + text: _isImporting + ? AppLocalizations.instance + .translate('roast_import_importing') + : AppLocalizations.instance + .translate('roast_import_button'), + disabled: _isImporting, + action: () => _importConfiguration(), + ), ], ), ), diff --git a/lib/widgets/wallet/roast_group/setup_participants.dart b/lib/widgets/wallet/roast_group/setup_participants.dart index 43442757..b42959f7 100644 --- a/lib/widgets/wallet/roast_group/setup_participants.dart +++ b/lib/widgets/wallet/roast_group/setup_participants.dart @@ -14,16 +14,20 @@ import 'package:peercoin/widgets/wallet/roast_group/setup_landing.dart'; import 'package:peercoin/widgets/wallet/roast_group/setup_participants_finger_print_bottom_sheet.dart'; import 'package:peercoin/widgets/wallet/roast_group/setup_participants_share_pubkey_bottom_sheet.dart'; import 'package:peercoin/widgets/wallet/roast_group/setup_pubkey_remove_participant_bottom_sheet.dart'; +import 'package:peercoin/tools/roast/roast_group_export_config.dart'; +import 'package:peercoin/exceptions/roast_config_exceptions.dart'; class ROASTGroupSetupParticipants extends StatefulWidget { final Function changeStep; final ROASTWallet roastWallet; final CoinWallet coinWallet; + final Map? importedParticipants; const ROASTGroupSetupParticipants({ required this.changeStep, required this.roastWallet, required this.coinWallet, + this.importedParticipants, super.key, }); @@ -35,6 +39,7 @@ class ROASTGroupSetupParticipants extends StatefulWidget { class _ROASTGroupSetupParticipantsState extends State { bool _initial = true; + bool _isExporting = false; final Map _participants = {}; @override @@ -44,6 +49,9 @@ class _ROASTGroupSetupParticipantsState // finalized group _participants .addAll(widget.roastWallet.clientConfig!.group.participants); + } else if (widget.importedParticipants != null) { + // imported group configuration + _participants.addAll(widget.importedParticipants!); } else { // add self to uncompleted group final id = Identifier.fromSeed(widget.roastWallet.ourName); @@ -95,19 +103,33 @@ class _ROASTGroupSetupParticipantsState 'participants': _participants, }, ); - if (res.runtimeType != ParticpantNavigatorPopDTO) { - return; - } - final dto = res as ParticpantNavigatorPopDTO; - LoggerWrapper.logInfo( - 'ROASTGroupSetupParticipants', - '_addParticipant', - 'participant added', - ); - setState(() { - _participants[dto.identifier] = dto.key; - }); + try { + final dto = res as ParticpantNavigatorPopDTO; + LoggerWrapper.logInfo( + 'ROASTGroupSetupParticipants', + '_addParticipant', + 'participant added', + ); + setState(() { + _participants[dto.identifier] = dto.key; + }); + } catch (e) { + LoggerWrapper.logError( + 'ROASTGroupSetupParticipants', + '_addParticipant', + 'Error adding participant: $e', + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.instance.translate('roast_add_participant_error'), + ), + backgroundColor: Colors.red, + ), + ); + } } void _removeParticipant(String participantPubKey) { @@ -190,6 +212,226 @@ class _ROASTGroupSetupParticipantsState ); } + Widget _buildImportValidationStatus() { + if (widget.importedParticipants == null) return const SizedBox.shrink(); + + final participantCount = _participants.length; + final isValidForROAST = participantCount >= 2; + final hasOurself = _participants.keys.any( + (id) => + widget.roastWallet.participantNames[id.toString()] == + widget.roastWallet.ourName, + ); + + return Container( + margin: const EdgeInsets.only(top: 10), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.blue.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.info_outline, + color: Colors.blue, + size: 16, + ), + const SizedBox(width: 8), + Text( + AppLocalizations.instance + .translate('roast_import_validation_status'), + style: TextStyle( + fontSize: 12, + color: Colors.blue[700], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 8), + _buildValidationItem( + icon: participantCount >= 2 ? Icons.check_circle : Icons.warning, + color: participantCount >= 2 ? Colors.green : Colors.orange, + text: AppLocalizations.instance + .translate('roast_import_validation_min_participants') + .replaceAll('%count%', participantCount.toString()) + .replaceAll('%min%', '2'), + ), + const SizedBox(height: 4), + _buildValidationItem( + icon: hasOurself ? Icons.check_circle : Icons.info, + color: hasOurself ? Colors.green : Colors.blue, + text: hasOurself + ? AppLocalizations.instance + .translate('roast_import_validation_includes_you') + : AppLocalizations.instance + .translate('roast_import_validation_add_yourself'), + ), + const SizedBox(height: 4), + _buildValidationItem( + icon: isValidForROAST ? Icons.check_circle : Icons.warning, + color: isValidForROAST ? Colors.green : Colors.orange, + text: isValidForROAST + ? AppLocalizations.instance + .translate('roast_import_validation_ready') + : AppLocalizations.instance + .translate('roast_import_validation_not_ready'), + ), + ], + ), + ); + } + + Widget _buildValidationItem({ + required IconData icon, + required Color color, + required String text, + }) { + return Row( + children: [ + Icon( + icon, + color: color, + size: 14, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 11, + color: color, + ), + ), + ), + ], + ); + } + + Future _exportConfiguration() async { + if (_isExporting) return; + + // Show confirmation dialog with export preview + final bool? shouldExport = await showDialog( + context: context, + builder: (BuildContext context) { + final preview = ROASTGroupExportConfig.getExportPreview( + widget.roastWallet, + _participants, + ); + + return AlertDialog( + title: Text( + AppLocalizations.instance.translate('roast_export_confirm_title'), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.instance + .translate('roast_export_confirm_description'), + ), + const SizedBox(height: 12), + Text( + AppLocalizations.instance + .translate('roast_export_preview_title'), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + '${AppLocalizations.instance.translate('roast_export_preview_participants')}: ${preview['participantCount']}', + ), + Text( + '${AppLocalizations.instance.translate('roast_export_preview_filename')}: ${preview['filename']}', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(AppLocalizations.instance.translate('cancel')), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text( + AppLocalizations.instance + .translate('roast_export_confirm_button'), + ), + ), + ], + ); + }, + ); + + if (shouldExport != true) return; + + setState(() { + _isExporting = true; + }); + + try { + // Export the configuration + final filePath = await ROASTGroupExportConfig.exportGroupConfiguration( + widget.roastWallet, + _participants, + ); + // Share the exported file + await ROASTGroupExportConfig.shareExportedFile(filePath); + + if (mounted) { + final successMessage = + AppLocalizations.instance.translate('roast_export_success'); + LoggerWrapper.logInfo( + 'ROASTGroupSetupParticipants', + '_exportConfiguration', + 'Showing success snackbar with message: "$successMessage"', + ); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + successMessage, + ), + ), + ); + } + } catch (e) { + if (mounted) { + String errorMessage = + AppLocalizations.instance.translate('roast_export_error'); + if (e is ROASTConfigException) { + LoggerWrapper.logError( + 'ROASTGroupSetupParticipants', + '_exportConfiguration', + 'ROASTConfigException: ${e.message}', + ); + errorMessage = e.message; + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(errorMessage), + backgroundColor: Colors.red, + ), + ); + } + } finally { + if (mounted) { + setState(() { + _isExporting = false; + }); + } + } + } + @override Widget build(BuildContext context) { return Column( @@ -241,6 +483,66 @@ class _ROASTGroupSetupParticipantsState color: Theme.of(context).colorScheme.secondary, ), ), + if (widget.importedParticipants != null) ...[ + const SizedBox( + height: 10, + ), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.green.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.check_circle, + color: Colors.green, + size: 16, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + AppLocalizations.instance.translate( + 'roast_import_config_loaded', + ), + style: TextStyle( + fontSize: 12, + color: Colors.green[700], + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + AppLocalizations.instance + .translate( + 'roast_import_participant_count', + ) + .replaceAll( + '%count%', + _participants.length.toString(), + ), + style: TextStyle( + fontSize: 11, + color: Colors.green[600], + ), + ), + ], + ), + ), + ], + ), + ), + ], + _buildImportValidationStatus(), const SizedBox( height: 20, ), @@ -307,6 +609,18 @@ class _ROASTGroupSetupParticipantsState ), action: () => _addParticipant(), ), + const SizedBox( + height: 10, + ), + PeerButton( + text: _isExporting + ? AppLocalizations.instance + .translate('roast_export_exporting') + : AppLocalizations.instance + .translate('roast_export_button'), + disabled: _participants.length < 2 || _isExporting, + action: () => _exportConfiguration(), + ), const SizedBox( height: 20, ), diff --git a/pubspec.yaml b/pubspec.yaml index 8d834ef8..3b414d06 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: peercoin description: A new Peercoin wallet. -version: 1.4.4+153 +version: 1.4.5+154 environment: sdk: '>=3.2.0 <4.0.0'