What changed, and why it matters
This commit adds a new address-book feature to the Skylight Wallet app. Users can now save named contacts with Monero addresses, pick them when sending funds, and see the contact name on the confirmation screen. The data is stored on the device using the same shared-preferences mechanism already used for other app settings. There is no indication in the commit that this is a security fix or that it addresses any reported vulnerability.
Treat as a routine feature commit. If reviewing for security, verify that SharedPreferences-backed contact storage meets the project's threat model (contacts are plaintext on device), that address validation covers all intended address types, and that contact names cannot be used to spoof transaction details in the confirmation UI.
Security signals we found
New local data storage class (ContactModel) persists contacts in SharedPreferences as JSON string list
Address validation is performed before saving a contact
Contact name is shown on transaction confirmation screen, which may reduce address-mismatch risk for end users
No input sanitization beyond address validity check and trim() is visible in the diff
No migration or encryption added for stored contacts
Evidence from the diff
The diff introduces a ContactModel backed by SharedPreferences, new AddressBookScreen and contact-picker UI, and integration into the send/confirm flows. Addresses are validated with monero.Wallet_addressValid before save. Contact names are displayed on ConfirmSendScreen via a new optional destinationContactName argument. Navigation bar indices are shifted to insert the address book between wallet home and settings. No cryptographic changes, permission changes, or network changes are present.
Changed components
lib/models/contact_model.dartlib/screens/address_book.dartlib/screens/send.dartlib/screens/confirm_send.dartlib/services/shared_preferences_service.dartlib/widgets/wallet_navigation_bar.dartlib/main.dartlib/l10n/*Inspect captured patch +1153 / −54
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index e9da14e..282d9bf 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -114,5 +114,32 @@
"secretKeysPublicViewKey": "Public View Key",
"scanQrTitle": "Scan QR Code",
"confirmSendTitle": "Confirm Send",
- "confirmSendDescription": "Transactions are irreversible, so make sure that these details match exactly."
+ "confirmSendDescription": "Transactions are irreversible, so make sure that these details match exactly.",
+ "addressBookTitle": "Address Book",
+ "addressBookAddContact": "Add Contact",
+ "addressBookEditContact": "Edit Contact",
+ "addressBookDeleteContact": "Delete Contact",
+ "addressBookDeleteContactConfirmation": "Are you sure you want to delete \"{contactName}\"?",
+ "@addressBookDeleteContactConfirmation": {
+ "placeholders": {
+ "contactName": {
+ "type": "String"
+ }
+ }
+ },
+ "addressBookDelete": "Delete",
+ "addressBookSearchHint": "Search contacts...",
+ "addressBookNoContacts": "No contacts yet",
+ "addressBookNoContactsDescription": "Add your first contact by tapping the + button",
+ "addressBookNoSearchResults": "No contacts found",
+ "addressBookCopyAddress": "Copy Address",
+ "addressBookEdit": "Edit",
+ "addressBookContactName": "Contact Name",
+ "addressBookUpdate": "Update",
+ "addressBookSave": "Save",
+ "addressBookAddressCopied": "Address copied to clipboard",
+ "sendSelectFromAddressBook": "Select from Address Book",
+ "sendSelectedContact": "Selected contact",
+ "sendClearSelectedContact": "Clear selected contact",
+ "sendEditAddress": "Edit Address"
}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 5d7635d..77512d3 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -787,6 +787,126 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Transactions are irreversible, so make sure that these details match exactly.'**
String get confirmSendDescription;
+
+ /// No description provided for @addressBookTitle.
+ ///
+ /// In en, this message translates to:
+ /// **'Address Book'**
+ String get addressBookTitle;
+
+ /// No description provided for @addressBookAddContact.
+ ///
+ /// In en, this message translates to:
+ /// **'Add Contact'**
+ String get addressBookAddContact;
+
+ /// No description provided for @addressBookEditContact.
+ ///
+ /// In en, this message translates to:
+ /// **'Edit Contact'**
+ String get addressBookEditContact;
+
+ /// No description provided for @addressBookDeleteContact.
+ ///
+ /// In en, this message translates to:
+ /// **'Delete Contact'**
+ String get addressBookDeleteContact;
+
+ /// No description provided for @addressBookDeleteContactConfirmation.
+ ///
+ /// In en, this message translates to:
+ /// **'Are you sure you want to delete \"{contactName}\"?'**
+ String addressBookDeleteContactConfirmation(String contactName);
+
+ /// No description provided for @addressBookDelete.
+ ///
+ /// In en, this message translates to:
+ /// **'Delete'**
+ String get addressBookDelete;
+
+ /// No description provided for @addressBookSearchHint.
+ ///
+ /// In en, this message translates to:
+ /// **'Search contacts...'**
+ String get addressBookSearchHint;
+
+ /// No description provided for @addressBookNoContacts.
+ ///
+ /// In en, this message translates to:
+ /// **'No contacts yet'**
+ String get addressBookNoContacts;
+
+ /// No description provided for @addressBookNoContactsDescription.
+ ///
+ /// In en, this message translates to:
+ /// **'Add your first contact by tapping the + button'**
+ String get addressBookNoContactsDescription;
+
+ /// No description provided for @addressBookNoSearchResults.
+ ///
+ /// In en, this message translates to:
+ /// **'No contacts found'**
+ String get addressBookNoSearchResults;
+
+ /// No description provided for @addressBookCopyAddress.
+ ///
+ /// In en, this message translates to:
+ /// **'Copy Address'**
+ String get addressBookCopyAddress;
+
+ /// No description provided for @addressBookEdit.
+ ///
+ /// In en, this message translates to:
+ /// **'Edit'**
+ String get addressBookEdit;
+
+ /// No description provided for @addressBookContactName.
+ ///
+ /// In en, this message translates to:
+ /// **'Contact Name'**
+ String get addressBookContactName;
+
+ /// No description provided for @addressBookUpdate.
+ ///
+ /// In en, this message translates to:
+ /// **'Update'**
+ String get addressBookUpdate;
+
+ /// No description provided for @addressBookSave.
+ ///
+ /// In en, this message translates to:
+ /// **'Save'**
+ String get addressBookSave;
+
+ /// No description provided for @addressBookAddressCopied.
+ ///
+ /// In en, this message translates to:
+ /// **'Address copied to clipboard'**
+ String get addressBookAddressCopied;
+
+ /// No description provided for @sendSelectFromAddressBook.
+ ///
+ /// In en, this message translates to:
+ /// **'Select from Address Book'**
+ String get sendSelectFromAddressBook;
+
+ /// No description provided for @sendSelectedContact.
+ ///
+ /// In en, this message translates to:
+ /// **'Selected contact'**
+ String get sendSelectedContact;
+
+ /// No description provided for @sendClearSelectedContact.
+ ///
+ /// In en, this message translates to:
+ /// **'Clear selected contact'**
+ String get sendClearSelectedContact;
+
+ /// No description provided for @sendEditAddress.
+ ///
+ /// In en, this message translates to:
+ /// **'Edit Address'**
+ String get sendEditAddress;
}
class _AppLocalizationsDelegate
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 9410a36..84a4801 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -369,4 +369,67 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get confirmSendDescription =>
'Transactions are irreversible, so make sure that these details match exactly.';
+
+ @override
+ String get addressBookTitle => 'Address Book';
+
+ @override
+ String get addressBookAddContact => 'Add Contact';
+
+ @override
+ String get addressBookEditContact => 'Edit Contact';
+
+ @override
+ String get addressBookDeleteContact => 'Delete Contact';
+
+ @override
+ String addressBookDeleteContactConfirmation(String contactName) {
+ return 'Are you sure you want to delete \"$contactName\"?';
+ }
+
+ @override
+ String get addressBookDelete => 'Delete';
+
+ @override
+ String get addressBookSearchHint => 'Search contacts...';
+
+ @override
+ String get addressBookNoContacts => 'No contacts yet';
+
+ @override
+ String get addressBookNoContactsDescription =>
+ 'Add your first contact by tapping the + button';
+
+ @override
+ String get addressBookNoSearchResults => 'No contacts found';
+
+ @override
+ String get addressBookCopyAddress => 'Copy Address';
+
+ @override
+ String get addressBookEdit => 'Edit';
+
+ @override
+ String get addressBookContactName => 'Contact Name';
+
+ @override
+ String get addressBookUpdate => 'Update';
+
+ @override
+ String get addressBookSave => 'Save';
+
+ @override
+ String get addressBookAddressCopied => 'Address copied to clipboard';
+
+ @override
+ String get sendSelectFromAddressBook => 'Select from Address Book';
+
+ @override
+ String get sendSelectedContact => 'Selected contact';
+
+ @override
+ String get sendClearSelectedContact => 'Clear selected contact';
+
+ @override
+ String get sendEditAddress => 'Edit Address';
}
diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart
index 96d34c2..2e12556 100644
--- a/lib/l10n/app_localizations_pt.dart
+++ b/lib/l10n/app_localizations_pt.dart
@@ -364,9 +364,73 @@ class AppLocalizationsPt extends AppLocalizations {
String get scanQrTitle => 'Escanear QR Code';
@override
- String get confirmSendTitle => 'Confirm Send';
+ String get confirmSendTitle => 'Confirmar Envio';
@override
String get confirmSendDescription =>
- 'Transactions are irreversible, so make sure that these details match exactly.';
+ 'As transações são irreversíveis, então certifique-se de que estes detalhes correspondem exatamente.';
+
+ @override
+ String get addressBookTitle => 'Lista de Contatos';
+
+ @override
+ String get addressBookAddContact => 'Adicionar Contato';
+
+ @override
+ String get addressBookEditContact => 'Editar Contato';
+
+ @override
+ String get addressBookDeleteContact => 'Excluir Contato';
+
+ @override
+ String addressBookDeleteContactConfirmation(String contactName) {
+ return 'Tem certeza que deseja excluir \"$contactName\"?';
+ }
+
+ @override
+ String get addressBookDelete => 'Excluir';
+
+ @override
+ String get addressBookSearchHint => 'Pesquisar contatos...';
+
+ @override
+ String get addressBookNoContacts => 'Nenhum contato ainda';
+
+ @override
+ String get addressBookNoContactsDescription =>
+ 'Adicione seu primeiro contato tocando no botão +';
+
+ @override
+ String get addressBookNoSearchResults => 'Nenhum contato encontrado';
+
+ @override
+ String get addressBookCopyAddress => 'Copiar Endereço';
+
+ @override
+ String get addressBookEdit => 'Editar';
+
+ @override
+ String get addressBookContactName => 'Nome do Contato';
+
+ @override
+ String get addressBookUpdate => 'Atualizar';
+
+ @override
+ String get addressBookSave => 'Salvar';
+
+ @override
+ String get addressBookAddressCopied =>
+ 'Endereço copiado para a área de transferência';
+
+ @override
+ String get sendSelectFromAddressBook => 'Selecionar da Lista de Contatos';
+
+ @override
+ String get sendSelectedContact => 'Contato selecionado';
+
+ @override
+ String get sendClearSelectedContact => 'Limpar contato selecionado';
+
+ @override
+ String get sendEditAddress => 'Editar Endereço';
}
diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb
index 3b35b00..027049b 100644
--- a/lib/l10n/app_pt.arb
+++ b/lib/l10n/app_pt.arb
@@ -113,6 +113,33 @@
"secretKeysSecretSpendKey": "Chave Privada de Gasto",
"secretKeysPublicViewKey": "Chave Pública de Visualização",
"scanQrTitle": "Escanear QR Code",
- "confirmSendTitle": "Confirm Send",
- "confirmSendDescription": "Transactions are irreversible, so make sure that these details match exactly."
+ "confirmSendTitle": "Confirmar Envio",
+ "confirmSendDescription": "As transações são irreversíveis, então certifique-se de que estes detalhes correspondem exatamente.",
+ "addressBookTitle": "Lista de Contatos",
+ "addressBookAddContact": "Adicionar Contato",
+ "addressBookEditContact": "Editar Contato",
+ "addressBookDeleteContact": "Excluir Contato",
+ "addressBookDeleteContactConfirmation": "Tem certeza que deseja excluir \"{contactName}\"?",
+ "@addressBookDeleteContactConfirmation": {
+ "placeholders": {
+ "contactName": {
+ "type": "String"
+ }
+ }
+ },
+ "addressBookDelete": "Excluir",
+ "addressBookSearchHint": "Pesquisar contatos...",
+ "addressBookNoContacts": "Nenhum contato ainda",
+ "addressBookNoContactsDescription": "Adicione seu primeiro contato tocando no botão +",
+ "addressBookNoSearchResults": "Nenhum contato encontrado",
+ "addressBookCopyAddress": "Copiar Endereço",
+ "addressBookEdit": "Editar",
+ "addressBookContactName": "Nome do Contato",
+ "addressBookUpdate": "Atualizar",
+ "addressBookSave": "Salvar",
+ "addressBookAddressCopied": "Endereço copiado para a área de transferência",
+ "sendSelectFromAddressBook": "Selecionar da Lista de Contatos",
+ "sendSelectedContact": "Contato selecionado",
+ "sendClearSelectedContact": "Limpar contato selecionado",
+ "sendEditAddress": "Editar Endereço"
}
diff --git a/lib/main.dart b/lib/main.dart
index 9357539..fe8817d 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -9,6 +9,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:skylight_wallet/models/fiat_rate_model.dart';
+import 'package:skylight_wallet/models/contact_model.dart';
import 'package:skylight_wallet/screens/confirm_send.dart';
import 'package:skylight_wallet/screens/lws_details.dart';
import 'package:skylight_wallet/screens/lws_keys.dart';
@@ -30,6 +31,7 @@ import 'package:skylight_wallet/screens/restore_wallet.dart';
import 'package:skylight_wallet/screens/restore_warning.dart';
import 'package:skylight_wallet/screens/wallet_home.dart';
import 'package:skylight_wallet/screens/welcome.dart';
+import 'package:skylight_wallet/screens/address_book.dart';
import 'package:skylight_wallet/util/logging.dart';
void main() async {
@@ -63,6 +65,7 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider(create: (context) => LanguageModel()),
ChangeNotifierProvider(create: (context) => ThemeModel()),
ChangeNotifierProvider(create: (context) => FiatRateModel()),
+ ChangeNotifierProvider(create: (context) => ContactModel()),
],
child: Consumer2<LanguageModel, ThemeModel>(
builder: (context, languageProvider, themeProvider, child) {
@@ -147,6 +150,7 @@ class MyApp extends StatelessWidget {
'/scan_qr': (context) => ScanQrScreen(),
'/receive': (context) => ReceiveScreen(),
'/tx_details': (context) => TxDetailsScreen(),
+ '/address_book': (context) => AddressBookScreen(),
'/terms_of_service': (context) => TermsOfService(),
'/privacy_policy': (context) => PrivacyPolicy(),
},
diff --git a/lib/models/contact_model.dart b/lib/models/contact_model.dart
new file mode 100644
index 0000000..7c493d5
--- /dev/null
+++ b/lib/models/contact_model.dart
@@ -0,0 +1,116 @@
+import 'dart:convert';
+import 'package:flutter/foundation.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+import 'package:skylight_wallet/services/shared_preferences_service.dart';
+
+class Contact {
+ final String id;
+ final String name;
+ final String address;
+
+ Contact({required this.id, required this.name, required this.address});
+
+ Map<String, dynamic> toJson() => {'id': id, 'name': name, 'address': address};
+
+ factory Contact.fromJson(Map<String, dynamic> json) => Contact(
+ id: json['id'] as String,
+ name: json['name'] as String,
+ address: json['address'] as String,
+ );
+
+ Contact copyWith({String? id, String? name, String? address}) {
+ return Contact(
+ id: id ?? this.id,
+ name: name ?? this.name,
+ address: address ?? this.address,
+ );
+ }
+}
+
+class ContactModel with ChangeNotifier {
+ List<Contact> _contacts = [];
+
+ List<Contact> get contacts => List.unmodifiable(_contacts);
+
+ ContactModel() {
+ _loadContacts();
+ }
+
+ Future<void> _loadContacts() async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ final contactsJson =
+ prefs.getStringList(SharedPreferencesKeys.contacts) ?? [];
+
+ _contacts = contactsJson
+ .map(
+ (jsonString) => Contact.fromJson(
+ json.decode(jsonString) as Map<String, dynamic>,
+ ),
+ )
+ .toList();
+
+ notifyListeners();
+ } catch (e) {
+ debugPrint('Error loading contacts: $e');
+ }
+ }
+
+ Future<void> _saveContacts() async {
+ try {
+ final prefs = await SharedPreferences.getInstance();
+ final contactsJson = _contacts
+ .map((contact) => json.encode(contact.toJson()))
+ .toList();
+
+ await prefs.setStringList(SharedPreferencesKeys.contacts, contactsJson);
+ } catch (e) {
+ debugPrint('Error saving contacts: $e');
+ }
+ }
+
+ Future<void> addContact(String name, String address) async {
+ final id = DateTime.now().millisecondsSinceEpoch.toString();
+ final contact = Contact(id: id, name: name.trim(), address: address.trim());
+
+ _contacts.add(contact);
+ await _saveContacts();
+ notifyListeners();
+ }
+
+ Future<void> updateContact(String id, String name, String address) async {
+ final index = _contacts.indexWhere((contact) => contact.id == id);
+ if (index != -1) {
+ _contacts[index] = _contacts[index].copyWith(
+ name: name.trim(),
+ address: address.trim(),
+ );
+ await _saveContacts();
+ notifyListeners();
+ }
+ }
+
+ Future<void> deleteContact(String id) async {
+ _contacts.removeWhere((contact) => contact.id == id);
+ await _saveContacts();
+ notifyListeners();
+ }
+
+ Contact? getContactById(String id) {
+ try {
+ return _contacts.firstWhere((contact) => contact.id == id);
+ } catch (e) {
+ return null;
+ }
+ }
+
+ List<Contact> searchContacts(String query) {
+ if (query.isEmpty) return _contacts;
+
+ final lowercaseQuery = query.toLowerCase();
+ return _contacts.where((contact) {
+ return contact.name.toLowerCase().contains(lowercaseQuery) ||
+ contact.address.toLowerCase().contains(lowercaseQuery);
+ }).toList();
+ }
+}
diff --git a/lib/screens/address_book.dart b/lib/screens/address_book.dart
new file mode 100644
index 0000000..fdc6fde
--- /dev/null
+++ b/lib/screens/address_book.dart
@@ -0,0 +1,426 @@
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:provider/provider.dart';
+import 'package:monero/monero.dart' as monero;
+import 'package:skylight_wallet/l10n/app_localizations.dart';
+import 'package:skylight_wallet/models/contact_model.dart';
+import 'package:skylight_wallet/widgets/wallet_navigation_bar.dart';
+
+class AddressBookScreen extends StatefulWidget {
+ const AddressBookScreen({super.key});
+
+ @override
+ State<AddressBookScreen> createState() => _AddressBookScreenState();
+}
+
+class _AddressBookScreenState extends State<AddressBookScreen> {
+ final TextEditingController _searchController = TextEditingController();
+ String _searchQuery = '';
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ void _onSearchChanged(String query) {
+ setState(() {
+ _searchQuery = query;
+ });
+ }
+
+ void _showAddContactDialog() {
+ showDialog(context: context, builder: (context) => _ContactDialog());
+ }
+
+ void _showEditContactDialog(Contact contact) {
+ showDialog(
+ context: context,
+ builder: (context) => _ContactDialog(contact: contact),
+ );
+ }
+
+ void _showDeleteContactDialog(Contact contact) {
+ final i18n = AppLocalizations.of(context)!;
+
+ showDialog(
+ context: context,
+ builder: (context) => AlertDialog(
+ title: Text(i18n.addressBookDeleteContact),
+ content: Text(i18n.addressBookDeleteContactConfirmation(contact.name)),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: Text(i18n.cancel),
+ ),
+ FilledButton(
+ onPressed: () {
+ Provider.of<ContactModel>(
+ context,
+ listen: false,
+ ).deleteContact(contact.id);
+ Navigator.of(context).pop();
+ },
+ child: Text(i18n.addressBookDelete),
+ ),
+ ],
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final i18n = AppLocalizations.of(context)!;
+
+ return Scaffold(
+ appBar: AppBar(
+ title: Text(i18n.addressBookTitle),
+ actions: [
+ IconButton(
+ onPressed: _showAddContactDialog,
+ icon: Icon(Icons.add),
+ tooltip: i18n.addressBookAddContact,
+ ),
+ ],
+ ),
+ bottomNavigationBar: WalletNavigationBar(selectedIndex: 1),
+ body: Column(
+ children: [
+ Padding(
+ padding: EdgeInsets.all(16.0),
+ child: TextField(
+ controller: _searchController,
+ onChanged: _onSearchChanged,
+ decoration: InputDecoration(
+ hintText: i18n.addressBookSearchHint,
+ prefixIcon: Icon(Icons.search),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: Consumer<ContactModel>(
+ builder: (context, contactModel, child) {
+ final filteredContacts = contactModel.searchContacts(
+ _searchQuery,
+ );
+
+ if (filteredContacts.isEmpty) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(
+ Icons.contacts_outlined,
+ size: 64,
+ color: Theme.of(context).colorScheme.outline,
+ ),
+ SizedBox(height: 16),
+ Text(
+ _searchQuery.isEmpty
+ ? i18n.addressBookNoContacts
+ : i18n.addressBookNoSearchResults,
+ style: Theme.of(context).textTheme.bodyLarge
+ ?.copyWith(
+ color: Theme.of(context).colorScheme.outline,
+ ),
+ ),
+ if (_searchQuery.isEmpty) ...[
+ SizedBox(height: 8),
+ Text(
+ i18n.addressBookNoContactsDescription,
+ style: Theme.of(context).textTheme.bodyMedium
+ ?.copyWith(
+ color: Theme.of(context).colorScheme.outline,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ ],
+ ],
+ ),
+ );
+ }
+
+ return ListView.builder(
+ itemCount: filteredContacts.length,
+ itemBuilder: (context, index) {
+ final contact = filteredContacts[index];
+ return _ContactListItem(
+ contact: contact,
+ onEdit: () => _showEditContactDialog(contact),
+ onDelete: () => _showDeleteContactDialog(contact),
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _ContactListItem extends StatelessWidget {
+ final Contact contact;
+ final VoidCallback onEdit;
+ final VoidCallback onDelete;
+
+ const _ContactListItem({
+ required this.contact,
+ required this.onEdit,
+ required this.onDelete,
+ });
+
+ void _copyAddressToClipboard(BuildContext context) {
+ final i18n = AppLocalizations.of(context)!;
+
+ Clipboard.setData(ClipboardData(text: contact.address));
+
+ ScaffoldMessenger.of(
+ context,
+ ).showSnackBar(SnackBar(content: Text(i18n.addressBookAddressCopied)));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final i18n = AppLocalizations.of(context)!;
+
+ return Card(
+ margin: EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
+ child: ListTile(
+ leading: CircleAvatar(
+ backgroundColor: Theme.of(context).colorScheme.primary,
+ child: Text(
+ contact.name.isNotEmpty ? contact.name[0].toUpperCase() : '?',
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onPrimary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ title: Text(
+ contact.name,
+ style: TextStyle(fontWeight: FontWeight.w500),
+ ),
+ subtitle: Text(
+ contact.address,
+ style: TextStyle(fontFamily: 'monospace', fontSize: 12),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ trailing: PopupMenuButton<String>(
+ onSelected: (value) {
+ switch (value) {
+ case 'copy':
+ _copyAddressToClipboard(context);
+ break;
+ case 'edit':
+ onEdit();
+ break;
+ case 'delete':
+ onDelete();
+ break;
+ }
+ },
+ itemBuilder: (context) => [
+ PopupMenuItem(
+ value: 'copy',
+ child: Row(
+ children: [
+ Icon(Icons.copy),
+ SizedBox(width: 8),
+ Text(i18n.addressBookCopyAddress),
+ ],
+ ),
+ ),
+ PopupMenuItem(
+ value: 'edit',
+ child: Row(
+ children: [
+ Icon(Icons.edit),
+ SizedBox(width: 8),
+ Text(i18n.addressBookEdit),
+ ],
+ ),
+ ),
+ PopupMenuItem(
+ value: 'delete',
+ child: Row(
+ children: [
+ Icon(Icons.delete, color: Colors.red),
+ SizedBox(width: 8),
+ Text(
+ i18n.addressBookDelete,
+ style: TextStyle(color: Colors.red),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ onTap: () => _copyAddressToClipboard(context),
+ ),
+ );
+ }
+}
+
+class _ContactDialog extends StatefulWidget {
+ final Contact? contact;
+
+ const _ContactDialog({this.contact});
+
+ @override
+ State<_ContactDialog> createState() => _ContactDialogState();
+}
+
+class _ContactDialogState extends State<_ContactDialog> {
+ final _formKey = GlobalKey<FormState>();
+ final _nameController = TextEditingController();
+ final _addressController = TextEditingController();
+ bool _isLoading = false;
+
+ @override
+ void initState() {
+ super.initState();
+ if (widget.contact != null) {
+ _nameController.text = widget.contact!.name;
+ _addressController.text = widget.contact!.address;
+ }
+ }
+
+ @override
+ void dispose() {
+ _nameController.dispose();
+ _addressController.dispose();
+ super.dispose();
+ }
+
+ String? _validateName(String? value) {
+ final i18n = AppLocalizations.of(context)!;
+ if (value == null || value.trim().isEmpty) {
+ return i18n.fieldEmptyError;
+ }
+ return null;
+ }
+
+ String? _validateAddress(String? value) {
+ final i18n = AppLocalizations.of(context)!;
+
+ if (value == null || value.trim().isEmpty) {
+ return i18n.fieldEmptyError;
+ }
+
+ // ignore: deprecated_member_use
+ if (!monero.Wallet_addressValid(value.trim(), 0)) {
+ return i18n.sendInvalidAddressError;
+ }
+ return null;
+ }
+
+ Future<void> _saveContact() async {
+ final i18n = AppLocalizations.of(context)!;
+ if (!_formKey.currentState!.validate()) return;
+
+ setState(() {
+ _isLoading = true;
+ });
+
+ try {
+ final contactModel = Provider.of<ContactModel>(context, listen: false);
+
+ if (widget.contact == null) {
+ await contactModel.addContact(
+ _nameController.text.trim(),
+ _addressController.text.trim(),
+ );
+ } else {
+ await contactModel.updateContact(
+ widget.contact!.id,
+ _nameController.text.trim(),
+ _addressController.text.trim(),
+ );
+ }
+
+ if (mounted) {
+ Navigator.of(context).pop();
+ }
+ } catch (e) {
+ if (mounted) {
+ ScaffoldMessenger.of(context).showSnackBar(
+ SnackBar(
+ content: Text(i18n.unknownError),
+ backgroundColor: Colors.red,
+ ),
+ );
+ }
+ } finally {
+ if (mounted) {
+ setState(() {
+ _isLoading = false;
+ });
+ }
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final i18n = AppLocalizations.of(context)!;
+ final isEditing = widget.contact != null;
+
+ return AlertDialog(
+ title: Text(
+ isEditing ? i18n.addressBookEditContact : i18n.addressBookAddContact,
+ ),
+ content: Form(
+ key: _formKey,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ TextFormField(
+ controller: _nameController,
+ decoration: InputDecoration(
+ labelText: i18n.addressBookContactName,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ ),
+ ),
+ validator: _validateName,
+ textCapitalization: TextCapitalization.words,
+ ),
+ SizedBox(height: 16),
+ TextFormField(
+ controller: _addressController,
+ decoration: InputDecoration(
+ labelText: i18n.address,
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ ),
+ ),
+ validator: _validateAddress,
+ maxLines: 3,
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
+ child: Text(i18n.cancel),
+ ),
+ FilledButton(
+ onPressed: _isLoading ? null : _saveContact,
+ child: _isLoading
+ ? SizedBox(
+ width: 16,
+ height: 16,
+ child: CircularProgressIndicator(strokeWidth: 2),
+ )
+ : Text(isEditing ? i18n.addressBookUpdate : i18n.addressBookSave),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/screens/confirm_send.dart b/lib/screens/confirm_send.dart
index 69cd0dc..03adbeb 100644
--- a/lib/screens/confirm_send.dart
+++ b/lib/screens/confirm_send.dart
@@ -10,13 +10,15 @@ import 'package:provider/provider.dart';
class ConfirmSendScreenArgs {
MoneroPendingTransaction tx;
- String? destinationOpenAlias;
String destinationAddress;
+ String? destinationOpenAlias;
+ String? destinationContactName;
ConfirmSendScreenArgs({
required this.tx,
required this.destinationAddress,
this.destinationOpenAlias,
+ this.destinationContactName,
});
}
@@ -35,6 +37,7 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
String? _destinationOpenAlias;
String _destinationAddress = '';
List<String> _destinationAddressSliced = [];
+ String? _destinationContactName;
@override
void initState() {
@@ -63,6 +66,7 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
_destinationOpenAlias = args.destinationOpenAlias;
_destinationAddress = args.destinationAddress;
_destinationAddressSliced = _sliceAddress(args.destinationAddress);
+ _destinationContactName = args.destinationContactName;
});
}
@@ -227,31 +231,40 @@ class _ConfirmSendScreenState extends State<ConfirmSendScreen> {
),
Expanded(
- child: Container(
- margin: EdgeInsetsGeometry.only(left: 40),
- child: Wrap(
- spacing: 4,
- alignment: WrapAlignment.end,
- children: _destinationAddressSliced
- .asMap()
- .entries
- .map(
- (item) => Text(
- item.value,
- style: TextStyle(
- fontFamily: 'monospace',
- // highlight start and end slices of address
- fontWeight:
- item.key == 0 ||
- item.key ==
- _destinationAddressSliced.length - 1
- ? FontWeight.w700
- : FontWeight.w300,
- ),
- ),
- )
- .toList(),
- ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Container(
+ margin: EdgeInsetsGeometry.only(left: 40),
+ child: Wrap(
+ spacing: 4,
+ alignment: WrapAlignment.end,
+ children: _destinationAddressSliced
+ .asMap()
+ .entries
+ .map(
+ (item) => Text(
+ item.value,
+ style: TextStyle(
+ fontFamily: 'monospace',
+ // highlight start and end slices of address
+ fontWeight:
+ item.key == 0 ||
+ item.key ==
+ _destinationAddressSliced
+ .length -
+ 1
+ ? FontWeight.w700
+ : FontWeight.w300,
+ ),
+ ),
+ )
+ .toList(),
+ ),
+ ),
+ if (_destinationContactName is String)
+ Text('($_destinationContactName)'),
+ ],
),
),
],
diff --git a/lib/screens/send.dart b/lib/screens/send.dart
index acc28ba..89918d7 100644
--- a/lib/screens/send.dart
+++ b/lib/screens/send.dart
@@ -6,6 +6,7 @@ import 'package:skylight_wallet/l10n/app_localizations.dart';
import 'package:skylight_wallet/screens/confirm_send.dart';
import 'package:skylight_wallet/widgets/monero_amount.dart';
import 'package:skylight_wallet/models/wallet_model.dart';
+import 'package:skylight_wallet/models/contact_model.dart';
class SendScreenArgs {
String destinationAddress;
@@ -26,12 +27,14 @@ class _SendScreenState extends State<SendScreen> {
final _destinationAddressController = TextEditingController(text: '');
final _amountController = TextEditingController(text: '');
bool _isSweepAll = false;
+ Contact? _selectedContact;
String _destinationAddressError = '';
String _amountError = '';
@override
void dispose() {
+ _destinationAddressController.removeListener(_onAddressChanged);
_destinationAddressController.dispose();
_amountController.dispose();
super.dispose();
@@ -42,6 +45,17 @@ class _SendScreenState extends State<SendScreen> {
super.didChangeDependencies();
_loadFormFromArgs();
+ _destinationAddressController.addListener(_onAddressChanged);
+ }
+
+ void _onAddressChanged() {
+ // Clear selected contact if user manually types in address field
+ if (_selectedContact != null &&
+ _destinationAddressController.text != _selectedContact!.address) {
+ setState(() {
+ _selectedContact = null;
+ });
+ }
}
void _loadFormFromArgs() {
@@ -63,6 +77,29 @@ class _SendScreenState extends State<SendScreen> {
}
}
+ void _showContactPicker() {
+ showDialog(
+ context: context,
+ builder: (context) => _ContactPickerDialog(
+ onContactSelected: (contact) {
+ setState(() {
+ _selectedContact = contact;
+ _destinationAddressController.text = contact.address;
+ });
+ Navigator.of(context).pop();
+ },
+ ),
+ );
+ }
+
+ void _clearSelectedContact() {
+ _destinationAddressController.text = '';
+
+ setState(() {
+ _selectedContact = null;
+ });
+ }
+
Future<void> _send() async {
final amount = double.parse(_amountController.text);
@@ -132,6 +169,7 @@ class _SendScreenState extends State<SendScreen> {
tx: tx,
destinationAddress: destinationAddress,
destinationOpenAlias: destinationOpenAlias,
+ destinationContactName: _selectedContact?.name,
),
);
}
@@ -199,32 +237,107 @@ class _SendScreenState extends State<SendScreen> {
mainAxisAlignment: MainAxisAlignment.center,
spacing: 20,
children: [
- TextField(
- controller: _destinationAddressController,
- maxLines: null,
- decoration: InputDecoration(
- labelText: i18n.address,
- border: OutlineInputBorder(),
- errorText: _destinationAddressError != ''
- ? _destinationAddressError
- : null,
- suffixIcon: Row(
- spacing: 0,
- mainAxisSize: MainAxisSize.min,
- children: [
- IconButton(
- onPressed: _pasteAddressFromClipboard,
- icon: Icon(Icons.paste),
+ if (_selectedContact == null)
+ TextField(
+ controller: _destinationAddressController,
+ maxLines: null,
+ decoration: InputDecoration(
+ labelText: i18n.address,
+ border: OutlineInputBorder(),
+ errorText: _destinationAddressError != ''
+ ? _destinationAddressError
+ : null,
+ suffixIcon: Container(
+ margin: EdgeInsets.only(right: 14),
+ child: Row(
+ spacing: 16,
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ GestureDetector(
+ onTap: _pasteAddressFromClipboard,
+ child: Icon(Icons.paste),
+ ),
+ GestureDetector(
+ onTap: () =>
+ Navigator.pushNamed(context, '/scan_qr'),
+ child: Icon(Icons.qr_code),
+ ),
+ GestureDetector(
+ onTap: _showContactPicker,
+ child: Icon(Icons.contacts_outlined),
+ ),
+ ],
),
- IconButton(
- onPressed: () =>
- Navigator.pushNamed(context, '/scan_qr'),
- icon: Icon(Icons.qr_code),
+ ),
+ ),
+ ),
+ if (_selectedContact != null)
+ Container(
+ width: double.infinity,
+ padding: EdgeInsets.all(12),
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.primaryContainer,
+ borderRadius: BorderRadius.circular(8),
+ border: Border.all(
+ color: Theme.of(
+ context,
+ ).colorScheme.outline.withValues(alpha: 0.2),
+ ),
+ ),
+ child: Column(
+ children: [
+ Row(
+ children: [
+ CircleAvatar(
+ radius: 16,
+ backgroundColor: Theme.of(
+ context,
+ ).colorScheme.primary,
+ child: Text(
+ _selectedContact!.name.isNotEmpty
+ ? _selectedContact!.name[0].toUpperCase()
+ : '?',
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onPrimary,
+ fontWeight: FontWeight.bold,
+ fontSize: 14,
+ ),
+ ),
+ ),
+ SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ _selectedContact!.name,
+ style: TextStyle(
+ fontWeight: FontWeight.w500,
+ fontSize: 16,
+ ),
+ ),
+ Text(
+ i18n.sendSelectedContact,
+ style: TextStyle(
+ fontSize: 12,
+ color: Theme.of(
+ context,
+ ).colorScheme.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ IconButton(
+ onPressed: _clearSelectedContact,
+ icon: Icon(Icons.close, size: 20),
+ tooltip: i18n.sendClearSelectedContact,
+ ),
+ ],
),
],
),
),
- ),
Column(
spacing: 10,
children: [
@@ -312,3 +425,123 @@ class _SendScreenState extends State<SendScreen> {
);
}
}
+
+class _ContactPickerDialog extends StatefulWidget {
+ final Function(Contact) onContactSelected;
+
+ const _ContactPickerDialog({required this.onContactSelected});
+
+ @override
+ State<_ContactPickerDialog> createState() => _ContactPickerDialogState();
+}
+
+class _ContactPickerDialogState extends State<_ContactPickerDialog> {
+ final TextEditingController _searchController = TextEditingController();
+ String _searchQuery = '';
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ void _onSearchChanged(String query) {
+ setState(() {
+ _searchQuery = query;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final i18n = AppLocalizations.of(context)!;
+
+ return AlertDialog(
+ title: Text(i18n.sendSelectFromAddressBook),
+ content: SizedBox(
+ width: double.maxFinite,
+ height: 400,
+ child: Column(
+ children: [
+ TextField(
+ controller: _searchController,
+ onChanged: _onSearchChanged,
+ decoration: InputDecoration(
+ hintText: i18n.addressBookSearchHint,
+ prefixIcon: Icon(Icons.search),
+ border: OutlineInputBorder(
+ borderRadius: BorderRadius.circular(8.0),
+ ),
+ ),
+ ),
+ SizedBox(height: 16),
+ Expanded(
+ child: Consumer<ContactModel>(
+ builder: (context, contactModel, child) {
+ final filteredContacts = contactModel.searchContacts(
+ _searchQuery,
+ );
+
+ if (filteredContacts.isEmpty) {
+ return Center(
+ child: Text(
+ _searchQuery.isEmpty
+ ? i18n.addressBookNoContacts
+ : i18n.addressBookNoSearchResults,
+ style: Theme.of(context).textTheme.bodyLarge?.copyWith(
+ color: Theme.of(context).colorScheme.outline,
+ ),
+ ),
+ );
+ }
+
+ return ListView.builder(
+ itemCount: filteredContacts.length,
+ itemBuilder: (context, index) {
+ final contact = filteredContacts[index];
+ return ListTile(
+ leading: CircleAvatar(
+ backgroundColor: Theme.of(
+ context,
+ ).colorScheme.primary,
+ child: Text(
+ contact.name.isNotEmpty
+ ? contact.name[0].toUpperCase()
+ : '?',
+ style: TextStyle(
+ color: Theme.of(context).colorScheme.onPrimary,
+ fontWeight: FontWeight.bold,
+ ),
+ ),
+ ),
+ title: Text(
+ contact.name,
+ style: TextStyle(fontWeight: FontWeight.w500),
+ ),
+ subtitle: Text(
+ contact.address,
+ style: TextStyle(
+ fontFamily: 'monospace',
+ fontSize: 12,
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ onTap: () => widget.onContactSelected(contact),
+ );
+ },
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(context).pop(),
+ child: Text(i18n.cancel),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/screens/settings.dart b/lib/screens/settings.dart
index 7232df1..847ac6d 100644
--- a/lib/screens/settings.dart
+++ b/lib/screens/settings.dart
@@ -291,7 +291,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
final theme = context.watch<ThemeModel>();
return Scaffold(
- bottomNavigationBar: WalletNavigationBar(selectedIndex: 1),
+ bottomNavigationBar: WalletNavigationBar(selectedIndex: 2),
appBar: AppBar(title: Text(i18n.settingsTitle)),
body: Padding(
padding: EdgeInsets.all(20),
diff --git a/lib/services/shared_preferences_service.dart b/lib/services/shared_preferences_service.dart
index 4509a27..e0b571f 100644
--- a/lib/services/shared_preferences_service.dart
+++ b/lib/services/shared_preferences_service.dart
@@ -16,6 +16,7 @@ class SharedPreferencesKeys {
static const String walletRestoreHeight = 'walletRestoreHeight';
static const String txHistoryCount = 'txHistoryCount';
static const String pendingOutgoingTxs = 'pendingOutgoingTxs';
+ static const String contacts = 'contacts';
}
class SharedPreferencesService {
diff --git a/lib/widgets/wallet_navigation_bar.dart b/lib/widgets/wallet_navigation_bar.dart
index 95fdcbf..3f29ed8 100644
--- a/lib/widgets/wallet_navigation_bar.dart
+++ b/lib/widgets/wallet_navigation_bar.dart
@@ -14,13 +14,18 @@ class WalletNavigationBar extends StatelessWidget {
selectedIndex: selectedIndex,
onDestinationSelected: (index) => {
if (index == 0) {Navigator.pushNamed(context, '/wallet_home')},
- if (index == 1) {Navigator.pushNamed(context, '/settings')},
+ if (index == 1) {Navigator.pushNamed(context, '/address_book')},
+ if (index == 2) {Navigator.pushNamed(context, '/settings')},
},
destinations: [
NavigationDestination(
icon: Icon(Icons.wallet),
label: i18n.navigationBarWallet,
),
+ NavigationDestination(
+ icon: Icon(Icons.contacts),
+ label: i18n.addressBookTitle,
+ ),
NavigationDestination(
icon: Icon(Icons.settings),
label: i18n.navigationBarSettings,
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.