diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3f10f2ca..86c5f38d 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -85,7 +85,7 @@ "lastName": "Last name", "gender": "Gender", "email": "Email", - "phoneNumber": "phoneNumber", + "phoneNumber": "Phone number", "firstAddresssLine": "First address line", "secondAddressLine": "Second address line", "zipCode": "Zip code", @@ -209,5 +209,16 @@ "notSpecified": "Not specified", "male": "Male", "female": "Female", - "other": "Other" + "other": "Other", + "csvImport": "CSV import", + "importPasswords": "Import passwords", + "importPaymentCards": "Import payment cards", + "importNotes": "Import notes", + "importIDCards": "Import ID cards", + "importIdentities": "Import identities", + "noCSVDataFound": "No CSV data found", + "csvImportMessage1":"Select CSV field values to match the entry variables", + "csvImportMessage2": "This template will be used for all CSV entries", + "@csvImportMessage1": { "description": "csvImportMessage1 and csvImportMessage2 make up a single message used in the CSV import entries screen" }, + "@csvImportMessage2": { "description": "csvImportMessage1 and csvImportMessage2 make up a single message used in the CSV import entries screen" } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index e4d35f08..d73bba3d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,8 @@ import 'screens/change_password_screen.dart'; import 'screens/change_username_screen.dart'; import 'screens/confirm_restore_screen.dart'; import 'screens/credentials_screen.dart'; +import 'screens/csv_import_screen.dart'; +import 'screens/csv_import_entries_screen.dart'; import 'screens/remove_account_screen.dart'; import 'screens/setup_screen.dart'; import 'screens/security_screen.dart'; @@ -120,6 +122,9 @@ class Passy extends StatelessWidget { const ConfirmRestoreScreen(), ConnectScreen.routeName: (context) => const ConnectScreen(), CredentialsScreen.routeName: (context) => const CredentialsScreen(), + CSVImportScreen.routeName: (context) => const CSVImportScreen(), + CSVImportEntriesScreen.routeName: (context) => + const CSVImportEntriesScreen(), EditCustomFieldScreen.routeName: (context) => const EditCustomFieldScreen(), EditIDCardScreen.routeName: (context) => const EditIDCardScreen(), diff --git a/lib/passy_flutter/common/common.dart b/lib/passy_flutter/common/common.dart index a7fdce48..3b4d6d3a 100644 --- a/lib/passy_flutter/common/common.dart +++ b/lib/passy_flutter/common/common.dart @@ -58,12 +58,14 @@ String dateToString(DateTime date) { DateTime stringToDate(String value) { if (value == '') return DateTime.now(); List _dateSplit = value.split('/'); - if (_dateSplit.length == 3) return DateTime.now(); - return DateTime( - int.parse(_dateSplit[2]), - int.parse(_dateSplit[1]), - int.parse(_dateSplit[0]), - ); + if (_dateSplit.length < 3) return DateTime.now(); + int? yy = int.tryParse(_dateSplit[2]); + if (yy == null) return DateTime.now(); + int? mm = int.tryParse(_dateSplit[1]); + if (mm == null) return DateTime.now(); + int? dd = int.tryParse(_dateSplit[0]); + if (dd == null) return DateTime.now(); + return DateTime(yy, mm, dd); } Future showPassyDatePicker( diff --git a/lib/screens/csv_import_entries_screen.dart b/lib/screens/csv_import_entries_screen.dart new file mode 100644 index 00000000..cba30ca2 --- /dev/null +++ b/lib/screens/csv_import_entries_screen.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart'; +import 'package:passy/common/common.dart'; +import 'package:passy/passy_data/entry_type.dart'; +import 'package:passy/passy_data/passy_entry.dart'; +import 'package:passy/passy_flutter/passy_flutter.dart'; +import 'common.dart'; +import 'csv_import_screen.dart'; +import 'log_screen.dart'; + +class CSVImportEntriesScreenArguments { + Widget title; + EntryType entryType; + Map entryJson; + List> entries; + + CSVImportEntriesScreenArguments({ + required this.title, + required this.entryType, + required this.entryJson, + required this.entries, + }); +} + +class CSVImportEntriesScreen extends StatefulWidget { + const CSVImportEntriesScreen({Key? key}) : super(key: key); + + static const routeName = '${CSVImportScreen.routeName}/entries'; + + @override + State createState() => _CSVImportEntriesScreen(); +} + +class _CSVImportEntriesScreen extends State { + @override + Widget build(BuildContext context) { + CSVImportEntriesScreenArguments args = ModalRoute.of(context)! + .settings + .arguments as CSVImportEntriesScreenArguments; + Map jsonToCSV = {}; + for (MapEntry entryJsonEntry in args.entryJson.entries) { + dynamic entryJsonValue = entryJsonEntry.value; + if (entryJsonValue is! String) continue; + String entryJsonKey = entryJsonEntry.key; + if (entryJsonKey == 'iconName') continue; + if (entryJsonKey == 'key') continue; + jsonToCSV[entryJsonKey] = -1; + } + List entryJsonKeys = jsonToCSV.keys.toList(); + List> items = [ + const DropdownMenuItem( + child: Text('Empty'), + value: -1, + ), + ]; + if (args.entries.isNotEmpty) { + List entry = args.entries.first; + for (int i = 0; i != entry.length; i++) { + dynamic entryValue = entry[i]; + if (entryValue is! String) continue; + items.add(DropdownMenuItem( + child: Text(entryValue), + value: i, + )); + } + } + return Scaffold( + appBar: AppBar( + leading: IconButton( + padding: PassyTheme.appBarButtonPadding, + splashRadius: PassyTheme.appBarButtonSplashRadius, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: () => Navigator.pop(context), + ), + title: args.title, + centerTitle: true, + ), + body: ListView(children: [ + PassyPadding(Text( + '${localizations.csvImportMessage1}.\n\n${localizations.csvImportMessage2}.', + textAlign: TextAlign.center, + )), + ListView.builder( + shrinkWrap: true, + itemBuilder: (context, index) { + String entryJsonKey = entryJsonKeys[index]; + String name = entryJsonKey; + switch (entryJsonKey) { + case 'additionalInfo': + name = localizations.additionalInfo; + break; + case 'nickname': + name = localizations.nickname; + break; + case 'username': + name = localizations.username; + break; + case 'email': + name = localizations.email; + break; + case 'password': + name = localizations.password; + break; + case 'website': + name = localizations.website; + break; + case 'cardNumber': + name = localizations.cardNumber; + break; + case 'cardholderName': + name = localizations.cardHolderName; + break; + case 'cvv': + name = 'CVV'; + break; + case 'exp': + name = localizations.expirationDate; + break; + case 'title': + name = localizations.title; + break; + case 'note': + name = localizations.note; + break; + case 'type': + name = localizations.type; + break; + case 'idNumber': + name = localizations.idNumber; + break; + case 'name': + name = localizations.name; + break; + case 'issDate': + name = localizations.dateOfIssue; + break; + case 'expDate': + name = localizations.expirationDate; + break; + case 'firstName': + name = localizations.firstName; + break; + case 'middleName': + name = localizations.middleName; + break; + case 'lastName': + name = localizations.lastName; + break; + case 'gender': + name = localizations.gender; + break; + case 'number': + name = localizations.phoneNumber; + break; + case 'firstAddressLine': + name = localizations.firstAddresssLine; + break; + case 'secondAddressLine': + name = localizations.secondAddressLine; + break; + case 'zipCode': + name = localizations.zipCode; + break; + case 'city': + name = localizations.city; + break; + case 'country': + name = localizations.country; + break; + } + return PassyPadding(DropdownButtonFormField( + value: -1, + items: items, + onChanged: (value) { + if (value == null) return; + jsonToCSV[entryJsonKey] = value; + }, + decoration: InputDecoration(labelText: name), + )); + }, + itemCount: entryJsonKeys.length, + ), + PassyPadding(ThreeWidgetButton( + left: const Padding( + padding: EdgeInsets.only(right: 30), + child: Icon(Icons.download_for_offline_outlined), + ), + right: const Icon(Icons.arrow_forward_ios_rounded), + center: Text(localizations.import), + onPressed: () async { + List result = []; + DateTime _now = DateTime.now().toUtc(); + int i = 0; + for (List entry in args.entries) { + Map jsonResult = { + 'key': '${_now.toIso8601String()}-import-$i', + }; + for (MapEntry jsonToCSVEntry + in jsonToCSV.entries) { + int index = jsonToCSVEntry.value; + if (index == -1) { + jsonResult[jsonToCSVEntry.key] = ''; + continue; + } + if (index >= entry.length) { + jsonResult[jsonToCSVEntry.key] = ''; + continue; + } + dynamic entryValue = entry[index]; + if (entryValue is! String) { + jsonResult[jsonToCSVEntry.key] = ''; + continue; + } + jsonResult[jsonToCSVEntry.key] = entryValue; + } + PassyEntry entryDecoded; + try { + entryDecoded = + PassyEntry.fromJson(args.entryType)(jsonResult); + } catch (e, s) { + showSnackBar( + context, + message: localizations.couldNotImportAccount, + icon: const Icon(Icons.download_for_offline_outlined, + color: PassyTheme.darkContentColor), + action: SnackBarAction( + label: localizations.details, + onPressed: () => Navigator.pushNamed( + context, LogScreen.routeName, + arguments: e.toString() + '\n' + s.toString()), + ), + ); + return; + } + result.add(entryDecoded); + i++; + } + Future Function(PassyEntry) setEntry = + data.loadedAccount!.setEntry(args.entryType); + for (PassyEntry entry in result) { + await setEntry(entry); + } + Navigator.pop(context); + })), + ])); + } +} diff --git a/lib/screens/csv_import_screen.dart b/lib/screens/csv_import_screen.dart new file mode 100644 index 00000000..d691c485 --- /dev/null +++ b/lib/screens/csv_import_screen.dart @@ -0,0 +1,210 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:passy/common/common.dart'; +import 'package:passy/passy_data/common.dart'; +import 'package:passy/passy_data/entry_type.dart'; +import 'package:passy/passy_data/id_card.dart'; +import 'package:passy/passy_data/identity.dart'; +import 'package:passy/passy_data/note.dart'; +import 'package:passy/passy_data/password.dart'; +import 'package:passy/passy_data/payment_card.dart'; +import 'package:passy/passy_flutter/passy_flutter.dart'; +import 'package:passy/screens/csv_import_entries_screen.dart'; +import 'common.dart'; +import 'import_screen.dart'; +import 'log_screen.dart'; +import 'main_screen.dart'; + +class CSVImportScreen extends StatefulWidget { + const CSVImportScreen({Key? key}) : super(key: key); + + static const routeName = '${ImportScreen.routeName}/csv'; + + @override + State createState() => _CSVImportScreen(); +} + +class _CSVImportScreen extends State { + void _onImportPressed( + String title, EntryType entryType, Map entryJson) async { + MainScreen.shouldLockScreen = false; + FilePickerResult? fileResult; + try { + fileResult = await FilePicker.platform.pickFiles( + dialogTitle: localizations.csvImport, + type: FileType.any, + lockParentWindow: true, + ); + } catch (e, s) { + showSnackBar( + context, + message: localizations.couldNotImportAccount, + icon: const Icon(Icons.download_for_offline_outlined, + color: PassyTheme.darkContentColor), + action: SnackBarAction( + label: localizations.details, + onPressed: () => Navigator.pushNamed(context, LogScreen.routeName, + arguments: e.toString() + '\n' + s.toString()), + ), + ); + return; + } + MainScreen.shouldLockScreen = false; + if (fileResult == null) return; + if (fileResult.files.isEmpty) return; + String? filePath = fileResult.files[0].path; + if (filePath == null) return; + File file = File(filePath); + List fileData; + try { + fileData = (await file.readAsString()).split('\n'); + } catch (e, s) { + showSnackBar( + context, + message: localizations.couldNotImportAccount, + icon: const Icon(Icons.download_for_offline_outlined, + color: PassyTheme.darkContentColor), + action: SnackBarAction( + label: localizations.details, + onPressed: () => Navigator.pushNamed(context, LogScreen.routeName, + arguments: e.toString() + '\n' + s.toString()), + ), + ); + return; + } + List> fileDataDecoded = []; + try { + for (String line in fileData) { + List lineDecoded = csvDecode(line); + List lineDecodedString = []; + for (dynamic value in lineDecoded) { + if (value.length > 1) { + if (value[0] == '"') { + if (value[value.length - 1] == '"') { + value = value.substring(1, value.length - 1); + } + } + } + lineDecodedString.add(value); + } + fileDataDecoded.add(lineDecodedString); + } + } catch (e, s) { + showSnackBar( + context, + message: localizations.couldNotImportAccount, + icon: const Icon(Icons.download_for_offline_outlined, + color: PassyTheme.darkContentColor), + action: SnackBarAction( + label: localizations.details, + onPressed: () => Navigator.pushNamed(context, LogScreen.routeName, + arguments: e.toString() + '\n' + s.toString()), + ), + ); + return; + } + if (fileDataDecoded.isEmpty) { + showSnackBar( + context, + message: localizations.noCSVDataFound, + icon: const Icon(Icons.download_for_offline_outlined, + color: PassyTheme.darkContentColor), + ); + return; + } + Navigator.pushNamed( + context, + CSVImportEntriesScreen.routeName, + arguments: CSVImportEntriesScreenArguments( + title: Text(title), + entryType: entryType, + entryJson: entryJson, + entries: fileDataDecoded), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + padding: PassyTheme.appBarButtonPadding, + splashRadius: PassyTheme.appBarButtonSplashRadius, + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: () => Navigator.pop(context), + ), + title: Text(localizations.import), + centerTitle: true, + ), + body: ListView(children: [ + PassyPadding(ThreeWidgetButton( + center: Text(localizations.importPasswords), + left: const Padding( + padding: EdgeInsets.only(right: 30), + child: Icon(Icons.password_rounded), + ), + right: const Icon(Icons.arrow_forward_ios_rounded), + onPressed: () => _onImportPressed( + localizations.importPasswords, + EntryType.password, + Password().toJson(), + ), + )), + PassyPadding(ThreeWidgetButton( + center: Text(localizations.importPaymentCards), + left: const Padding( + padding: EdgeInsets.only(right: 30), + child: Icon(Icons.payment_rounded), + ), + right: const Icon(Icons.arrow_forward_ios_rounded), + onPressed: () => _onImportPressed( + localizations.importPaymentCards, + EntryType.paymentCard, + PaymentCard().toJson(), + ), + )), + PassyPadding(ThreeWidgetButton( + center: Text(localizations.importNotes), + left: const Padding( + padding: EdgeInsets.only(right: 30), + child: Icon(Icons.note_rounded), + ), + right: const Icon(Icons.arrow_forward_ios_rounded), + onPressed: () => _onImportPressed( + localizations.importNotes, + EntryType.note, + Note().toJson(), + ), + )), + PassyPadding(ThreeWidgetButton( + center: Text(localizations.importIDCards), + left: const Padding( + padding: EdgeInsets.only(right: 30), + child: Icon(Icons.perm_identity_rounded), + ), + right: const Icon(Icons.arrow_forward_ios_rounded), + onPressed: () => _onImportPressed( + localizations.importIDCards, + EntryType.idCard, + IDCard().toJson(), + ), + )), + PassyPadding(ThreeWidgetButton( + center: Text(localizations.importIdentities), + left: const Padding( + padding: EdgeInsets.only(right: 30), + child: Icon(Icons.people_outline_rounded), + ), + right: const Icon(Icons.arrow_forward_ios_rounded), + onPressed: () => _onImportPressed( + localizations.importIdentities, + EntryType.identity, + Identity().toJson(), + ), + )), + ]), + ); + } +} diff --git a/lib/screens/edit_payment_card_screen.dart b/lib/screens/edit_payment_card_screen.dart index da3cf554..50508af1 100644 --- a/lib/screens/edit_payment_card_screen.dart +++ b/lib/screens/edit_payment_card_screen.dart @@ -4,7 +4,6 @@ import 'package:passy/common/common.dart'; import 'package:passy/passy_data/custom_field.dart'; import 'package:passy/passy_data/loaded_account.dart'; import 'package:passy/passy_data/payment_card.dart'; -import 'package:passy/passy_flutter/common/common.dart'; import 'package:passy/passy_flutter/passy_flutter.dart'; import 'edit_custom_field_screen.dart'; diff --git a/lib/screens/import_screen.dart b/lib/screens/import_screen.dart index eb80d306..5802254d 100644 --- a/lib/screens/import_screen.dart +++ b/lib/screens/import_screen.dart @@ -5,6 +5,7 @@ import 'package:passy/common/assets.dart'; import 'package:passy/common/common.dart'; import 'package:passy/passy_flutter/passy_flutter.dart'; import 'package:passy/screens/confirm_import_screen.dart'; +import 'package:passy/screens/csv_import_screen.dart'; import 'package:passy/screens/main_screen.dart'; import 'export_and_import_screen.dart'; @@ -68,6 +69,17 @@ class _ImportScreen extends State { right: const Icon(Icons.arrow_forward_ios_rounded), onPressed: _onPassyImportPressed, )), + PassyPadding( + ThreeWidgetButton( + center: Text(localizations.csvImport), + left: const Padding( + padding: EdgeInsets.only(right: 30), + child: Icon(Icons.download_for_offline_outlined), + ), + right: const Icon(Icons.arrow_forward_ios_rounded), + onPressed: () => + Navigator.pushNamed(context, CSVImportScreen.routeName)), + ), ]), ); }