diff --git a/melos.yaml b/melos.yaml index 0677f0fa5..b68c86461 100644 --- a/melos.yaml +++ b/melos.yaml @@ -11,6 +11,7 @@ packages: - packages/rust_verifier/rust_builder - packages/nip07_event_signer - packages/sembast_cache_manager + - packages/ndk_flutter scripts: analyze: diff --git a/packages/ndk_flutter/.gitignore b/packages/ndk_flutter/.gitignore new file mode 100644 index 000000000..dd5eb9895 --- /dev/null +++ b/packages/ndk_flutter/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +/build/ +/coverage/ diff --git a/packages/ndk_flutter/.metadata b/packages/ndk_flutter/.metadata new file mode 100644 index 000000000..b1755416d --- /dev/null +++ b/packages/ndk_flutter/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "20f82749394e68bcfbbeee96bad384abaae09c13" + channel: "stable" + +project_type: package diff --git a/packages/ndk_flutter/CHANGELOG.md b/packages/ndk_flutter/CHANGELOG.md new file mode 100644 index 000000000..aa723b10b --- /dev/null +++ b/packages/ndk_flutter/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- basic widgets and methods diff --git a/packages/ndk_flutter/LICENSE b/packages/ndk_flutter/LICENSE new file mode 100644 index 000000000..fc064c908 --- /dev/null +++ b/packages/ndk_flutter/LICENSE @@ -0,0 +1,7 @@ +Copyright 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/packages/ndk_flutter/README.md b/packages/ndk_flutter/README.md new file mode 100644 index 000000000..7a2b314f8 --- /dev/null +++ b/packages/ndk_flutter/README.md @@ -0,0 +1,63 @@ +This package helps you to easily produce Nostr apps by providing generics Widgets and functions. + +## Features + +- Nostr widgets +- Login persistence + +## Getting started + +### Add dependencies + +```bash +flutter pub add ndk +flutter pub add ndk_flutter +``` + +### Add internationalization + +Follow the [Official documentation](https://docs.flutter.dev/ui/accessibility-and-internationalization/internationalization) + +```dart +import 'package:ndk_flutter/l10n/app_localizations.dart' as ndk_flutter; + +MaterialApp( + localizationsDelegates: [ + ndk_flutter.AppLocalizations.delegate, // add this line + ], +); +``` + +## Usage + +By default, the logged user is used for user widgets, you can overwrite it by providing a pubkey as parameter. + +```dart +import 'package:nostr_widgets/nostr_widgets.dart'; + +// available widgets +NBanner(ndk); +NPicture(ndk); +NName(ndk); +NUserProfile(ndk); +NLogin(ndk); +NSwitchAccount(ndk); + +final ndkFlutter = NdkFlutter(ndk: ndk) + +// this method read the saved state from secure storage and add the signers in ndk +// typicaly called before runApp +ndkFlutter.restoreAccountsState(); + +// call this every time the auth state change +ndkFlutter.saveAccountsState(); +``` + +## TODO + +- [ ] NUserProfile optionnal show nsec and copy +- [ ] NUserProfile show the letter in the Picture and make it as big as possible + +## Need more Widgets + +Open an Issue diff --git a/packages/ndk_flutter/analysis_options.yaml b/packages/ndk_flutter/analysis_options.yaml new file mode 100644 index 000000000..a5744c1cf --- /dev/null +++ b/packages/ndk_flutter/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/ndk_flutter/lib/l10n/app_en.arb b/packages/ndk_flutter/lib/l10n/app_en.arb new file mode 100644 index 000000000..b3bc91814 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_en.arb @@ -0,0 +1,117 @@ +{ + "@@locale": "en", + "createAccount": "Create your account", + "@createAccount": { + "description": "Button text for creating a new account" + }, + "newHere": "Are you new here?", + "@newHere": { + "description": "Question asking if the user is new to the platform" + }, + "nostrAddress": "Nostr Address", + "@nostrAddress": { + "description": "Label for nostr address input field" + }, + "publicKey": "Public Key", + "@publicKey": { + "description": "Label for public key input field" + }, + "privateKey": "Private Key (insecure)", + "@privateKey": { + "description": "Label for private key input field" + }, + "browserExtension": "Browser extension", + "@browserExtension": { + "description": "Label for browser extension login section" + }, + "connect": "Connect", + "@connect": { + "description": "Button text to connect with browser extension" + }, + "install": "Install", + "@install": { + "description": "Button text to install browser extension" + }, + "logout": "Logout", + "@logout": { + "description": "Button text to logout from the application" + }, + "nostrAddressHint": "name@example.com", + "@nostrAddressHint": { + "description": "Placeholder text for nostr address input field" + }, + "invalidAddress": "Invalid Address", + "@invalidAddress": { + "description": "Error message for invalid nostr address" + }, + "unableToConnect": "Unable to connect", + "@unableToConnect": { + "description": "Error message when unable to connect to nostr address" + }, + "publicKeyHint": "npub1...", + "@publicKeyHint": { + "description": "Placeholder text for public key input field" + }, + "privateKeyHint": "nsec1...", + "@privateKeyHint": { + "description": "Placeholder text for private key input field" + }, + "newToNostr": "New to Nostr?", + "@newToNostr": { + "description": "Question asking if the user is new to Nostr" + }, + "getStarted": "Get Started", + "@getStarted": { + "description": "Button text to get started with Nostr" + }, + "bunker": "Bunker", + "@bunker": { + "description": "Label for bunker login section" + }, + "bunkerAuthentication": "Bunker Authentication", + "@bunkerAuthentication": { + "description": "Title for bunker authentication toast" + }, + "tapToOpen": "Tap to open: {url}", + "@tapToOpen": { + "description": "Description for bunker authentication toast", + "placeholders": { + "url": { + "type": "String", + "example": "https://example.com/auth" + } + } + }, + "showNostrConnectQrcode": "Show nostr connect qrcode", + "@showNostrConnectQrcode": { + "description": "Button text to show nostr connect QR code" + }, + "loginWithAmber": "Login with amber", + "@loginWithAmber": { + "description": "Button text to login with Amber" + }, + "nostrConnectUrl": "Nostr connect URL", + "@nostrConnectUrl": { + "description": "Title for nostr connect URL dialog" + }, + "copy": "Copy", + "@copy": { + "description": "Button text to copy to clipboard" + }, + "addAccount": "Add account", + "@addAccount": { + "description": "Button text to add a new account" + }, + "readOnly": "Read-only", + "@readOnly": { + "description": "Label for read-only account type" + }, + "nsec": "Nsec", + "@nsec": { + "description": "Label for nsec (private key) account type" + }, + "extension": "Extension", + "@extension": { + "description": "Label for browser extension account type" + } +} \ No newline at end of file diff --git a/packages/ndk_flutter/lib/l10n/app_es.arb b/packages/ndk_flutter/lib/l10n/app_es.arb new file mode 100644 index 000000000..47b765a06 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_es.arb @@ -0,0 +1,30 @@ +{ + "@@locale": "es", + "createAccount": "Crear tu cuenta", + "newHere": "¿Eres nuevo aquí?", + "nostrAddress": "Dirección Nostr", + "publicKey": "Clave pública", + "privateKey": "Clave privada (inseguro)", + "browserExtension": "Extensión del navegador", + "connect": "Conectar", + "install": "Instalar", + "logout": "Cerrar sesión", + "nostrAddressHint": "nombre@ejemplo.com", + "invalidAddress": "Dirección inválida", + "unableToConnect": "No se puede conectar", + "publicKeyHint": "npub1...", + "privateKeyHint": "nsec1...", + "newToNostr": "¿Nuevo en Nostr?", + "getStarted": "Comenzar", + "bunker": "Bunker", + "bunkerAuthentication": "Autenticación Bunker", + "tapToOpen": "Toca para abrir: {url}", + "showNostrConnectQrcode": "Mostrar código QR de nostr connect", + "loginWithAmber": "Iniciar sesión con Amber", + "nostrConnectUrl": "URL de conexión Nostr", + "copy": "Copiar", + "addAccount": "Añadir cuenta", + "readOnly": "Solo lectura", + "nsec": "Nsec", + "extension": "Extensión" +} \ No newline at end of file diff --git a/packages/ndk_flutter/lib/l10n/app_fr.arb b/packages/ndk_flutter/lib/l10n/app_fr.arb new file mode 100644 index 000000000..6d9268415 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_fr.arb @@ -0,0 +1,30 @@ +{ + "@@locale": "fr", + "createAccount": "Créer votre compte", + "newHere": "Êtes-vous nouveau ici?", + "nostrAddress": "Adresse Nostr", + "publicKey": "Clé publique", + "privateKey": "Clé privée (non sécurisé)", + "browserExtension": "Extension de navigateur", + "connect": "Se connecter", + "install": "Installer", + "logout": "Se déconnecter", + "nostrAddressHint": "nom@exemple.com", + "invalidAddress": "Adresse invalide", + "unableToConnect": "Impossible de se connecter", + "publicKeyHint": "npub1...", + "privateKeyHint": "nsec1...", + "newToNostr": "Nouveau sur Nostr?", + "getStarted": "Commencer", + "bunker": "Bunker", + "bunkerAuthentication": "Authentification Bunker", + "tapToOpen": "Appuyez pour ouvrir: {url}", + "showNostrConnectQrcode": "Afficher le code QR nostr connect", + "loginWithAmber": "Se connecter avec Amber", + "nostrConnectUrl": "URL de connexion Nostr", + "copy": "Copier", + "addAccount": "Ajouter un compte", + "readOnly": "Lecture seule", + "nsec": "Nsec", + "extension": "Extension" +} \ No newline at end of file diff --git a/packages/ndk_flutter/lib/l10n/app_ja.arb b/packages/ndk_flutter/lib/l10n/app_ja.arb new file mode 100644 index 000000000..cd806f440 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_ja.arb @@ -0,0 +1,30 @@ +{ + "@@locale": "ja", + "createAccount": "アカウントを作成", + "newHere": "初めてですか?", + "nostrAddress": "Nostrアドレス", + "publicKey": "公開鍵", + "privateKey": "秘密鍵(安全ではありません)", + "browserExtension": "ブラウザ拡張機能", + "connect": "接続", + "install": "インストール", + "logout": "ログアウト", + "nostrAddressHint": "name@example.com", + "invalidAddress": "無効なアドレス", + "unableToConnect": "接続できません", + "publicKeyHint": "npub1...", + "privateKeyHint": "nsec1...", + "newToNostr": "Nostrは初めてですか?", + "getStarted": "始める", + "bunker": "バンカー", + "bunkerAuthentication": "バンカー認証", + "tapToOpen": "タップして開く:{url}", + "showNostrConnectQrcode": "nostr connect QRコードを表示", + "loginWithAmber": "Amberでログイン", + "nostrConnectUrl": "Nostr接続URL", + "copy": "コピー", + "addAccount": "アカウントを追加", + "readOnly": "読み取り専用", + "nsec": "Nsec", + "extension": "拡張機能" +} \ No newline at end of file diff --git a/packages/ndk_flutter/lib/l10n/app_localizations.dart b/packages/ndk_flutter/lib/l10n/app_localizations.dart new file mode 100644 index 000000000..f4f557fd3 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:intl/intl.dart' as intl; + +import 'app_localizations_en.dart'; +import 'app_localizations_es.dart'; +import 'app_localizations_fr.dart'; +import 'app_localizations_ja.dart'; +import 'app_localizations_ru.dart'; +import 'app_localizations_zh.dart'; + +// ignore_for_file: type=lint + +/// Callers can lookup localized strings with an instance of AppLocalizations +/// returned by `AppLocalizations.of(context)`. +/// +/// Applications need to include `AppLocalizations.delegate()` in their app's +/// `localizationDelegates` list, and the locales they support in the app's +/// `supportedLocales` list. For example: +/// +/// ```dart +/// import 'l10n/app_localizations.dart'; +/// +/// return MaterialApp( +/// localizationsDelegates: AppLocalizations.localizationsDelegates, +/// supportedLocales: AppLocalizations.supportedLocales, +/// home: MyApplicationHome(), +/// ); +/// ``` +/// +/// ## Update pubspec.yaml +/// +/// Please make sure to update your pubspec.yaml to include the following +/// packages: +/// +/// ```yaml +/// dependencies: +/// # Internationalization support. +/// flutter_localizations: +/// sdk: flutter +/// intl: any # Use the pinned version from flutter_localizations +/// +/// # Rest of dependencies +/// ``` +/// +/// ## iOS Applications +/// +/// iOS applications define key application metadata, including supported +/// locales, in an Info.plist file that is built into the application bundle. +/// To configure the locales supported by your app, you’ll need to edit this +/// file. +/// +/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file. +/// Then, in the Project Navigator, open the Info.plist file under the Runner +/// project’s Runner folder. +/// +/// Next, select the Information Property List item, select Add Item from the +/// Editor menu, then select Localizations from the pop-up menu. +/// +/// Select and expand the newly-created Localizations item then, for each +/// locale your application supports, add a new item and select the locale +/// you wish to add from the pop-up menu in the Value field. This list should +/// be consistent with the languages listed in the AppLocalizations.supportedLocales +/// property. +abstract class AppLocalizations { + AppLocalizations(String locale) + : localeName = intl.Intl.canonicalizedLocale(locale.toString()); + + final String localeName; + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + /// A list of this localizations delegate along with the default localizations + /// delegates. + /// + /// Returns a list of localizations delegates containing this delegate along with + /// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate, + /// and GlobalWidgetsLocalizations.delegate. + /// + /// Additional delegates can be added by appending to this list in + /// MaterialApp. This list does not have to be used at all if a custom list + /// of delegates is preferred or required. + static const List> localizationsDelegates = + >[ + delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ]; + + /// A list of this localizations delegate's supported locales. + static const List supportedLocales = [ + Locale('en'), + Locale('es'), + Locale('fr'), + Locale('ja'), + Locale('ru'), + Locale('zh'), + ]; + + /// Button text for creating a new account + /// + /// In en, this message translates to: + /// **'Create your account'** + String get createAccount; + + /// Question asking if the user is new to the platform + /// + /// In en, this message translates to: + /// **'Are you new here?'** + String get newHere; + + /// Label for nostr address input field + /// + /// In en, this message translates to: + /// **'Nostr Address'** + String get nostrAddress; + + /// Label for public key input field + /// + /// In en, this message translates to: + /// **'Public Key'** + String get publicKey; + + /// Label for private key input field + /// + /// In en, this message translates to: + /// **'Private Key (insecure)'** + String get privateKey; + + /// Label for browser extension login section + /// + /// In en, this message translates to: + /// **'Browser extension'** + String get browserExtension; + + /// Button text to connect with browser extension + /// + /// In en, this message translates to: + /// **'Connect'** + String get connect; + + /// Button text to install browser extension + /// + /// In en, this message translates to: + /// **'Install'** + String get install; + + /// Button text to logout from the application + /// + /// In en, this message translates to: + /// **'Logout'** + String get logout; + + /// Placeholder text for nostr address input field + /// + /// In en, this message translates to: + /// **'name@example.com'** + String get nostrAddressHint; + + /// Error message for invalid nostr address + /// + /// In en, this message translates to: + /// **'Invalid Address'** + String get invalidAddress; + + /// Error message when unable to connect to nostr address + /// + /// In en, this message translates to: + /// **'Unable to connect'** + String get unableToConnect; + + /// Placeholder text for public key input field + /// + /// In en, this message translates to: + /// **'npub1...'** + String get publicKeyHint; + + /// Placeholder text for private key input field + /// + /// In en, this message translates to: + /// **'nsec1...'** + String get privateKeyHint; + + /// Question asking if the user is new to Nostr + /// + /// In en, this message translates to: + /// **'New to Nostr?'** + String get newToNostr; + + /// Button text to get started with Nostr + /// + /// In en, this message translates to: + /// **'Get Started'** + String get getStarted; + + /// Label for bunker login section + /// + /// In en, this message translates to: + /// **'Bunker'** + String get bunker; + + /// Title for bunker authentication toast + /// + /// In en, this message translates to: + /// **'Bunker Authentication'** + String get bunkerAuthentication; + + /// Description for bunker authentication toast + /// + /// In en, this message translates to: + /// **'Tap to open: {url}'** + String tapToOpen(String url); + + /// Button text to show nostr connect QR code + /// + /// In en, this message translates to: + /// **'Show nostr connect qrcode'** + String get showNostrConnectQrcode; + + /// Button text to login with Amber + /// + /// In en, this message translates to: + /// **'Login with amber'** + String get loginWithAmber; + + /// Title for nostr connect URL dialog + /// + /// In en, this message translates to: + /// **'Nostr connect URL'** + String get nostrConnectUrl; + + /// Button text to copy to clipboard + /// + /// In en, this message translates to: + /// **'Copy'** + String get copy; + + /// Button text to add a new account + /// + /// In en, this message translates to: + /// **'Add account'** + String get addAccount; + + /// Label for read-only account type + /// + /// In en, this message translates to: + /// **'Read-only'** + String get readOnly; + + /// Label for nsec (private key) account type + /// + /// In en, this message translates to: + /// **'Nsec'** + String get nsec; + + /// Label for browser extension account type + /// + /// In en, this message translates to: + /// **'Extension'** + String get extension; +} + +class _AppLocalizationsDelegate + extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + Future load(Locale locale) { + return SynchronousFuture(lookupAppLocalizations(locale)); + } + + @override + bool isSupported(Locale locale) => [ + 'en', + 'es', + 'fr', + 'ja', + 'ru', + 'zh', + ].contains(locale.languageCode); + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} + +AppLocalizations lookupAppLocalizations(Locale locale) { + // Lookup logic when only language code is specified. + switch (locale.languageCode) { + case 'en': + return AppLocalizationsEn(); + case 'es': + return AppLocalizationsEs(); + case 'fr': + return AppLocalizationsFr(); + case 'ja': + return AppLocalizationsJa(); + case 'ru': + return AppLocalizationsRu(); + case 'zh': + return AppLocalizationsZh(); + } + + throw FlutterError( + 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' + 'an issue with the localizations generation tool. Please file an issue ' + 'on GitHub with a reproducible sample app and the gen-l10n configuration ' + 'that was used.', + ); +} diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_en.dart b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart new file mode 100644 index 000000000..7caa376da --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations_en.dart @@ -0,0 +1,93 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for English (`en`). +class AppLocalizationsEn extends AppLocalizations { + AppLocalizationsEn([String locale = 'en']) : super(locale); + + @override + String get createAccount => 'Create your account'; + + @override + String get newHere => 'Are you new here?'; + + @override + String get nostrAddress => 'Nostr Address'; + + @override + String get publicKey => 'Public Key'; + + @override + String get privateKey => 'Private Key (insecure)'; + + @override + String get browserExtension => 'Browser extension'; + + @override + String get connect => 'Connect'; + + @override + String get install => 'Install'; + + @override + String get logout => 'Logout'; + + @override + String get nostrAddressHint => 'name@example.com'; + + @override + String get invalidAddress => 'Invalid Address'; + + @override + String get unableToConnect => 'Unable to connect'; + + @override + String get publicKeyHint => 'npub1...'; + + @override + String get privateKeyHint => 'nsec1...'; + + @override + String get newToNostr => 'New to Nostr?'; + + @override + String get getStarted => 'Get Started'; + + @override + String get bunker => 'Bunker'; + + @override + String get bunkerAuthentication => 'Bunker Authentication'; + + @override + String tapToOpen(String url) { + return 'Tap to open: $url'; + } + + @override + String get showNostrConnectQrcode => 'Show nostr connect qrcode'; + + @override + String get loginWithAmber => 'Login with amber'; + + @override + String get nostrConnectUrl => 'Nostr connect URL'; + + @override + String get copy => 'Copy'; + + @override + String get addAccount => 'Add account'; + + @override + String get readOnly => 'Read-only'; + + @override + String get nsec => 'Nsec'; + + @override + String get extension => 'Extension'; +} diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_es.dart b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart new file mode 100644 index 000000000..0329f43dd --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations_es.dart @@ -0,0 +1,93 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Spanish Castilian (`es`). +class AppLocalizationsEs extends AppLocalizations { + AppLocalizationsEs([String locale = 'es']) : super(locale); + + @override + String get createAccount => 'Crear tu cuenta'; + + @override + String get newHere => '¿Eres nuevo aquí?'; + + @override + String get nostrAddress => 'Dirección Nostr'; + + @override + String get publicKey => 'Clave pública'; + + @override + String get privateKey => 'Clave privada (inseguro)'; + + @override + String get browserExtension => 'Extensión del navegador'; + + @override + String get connect => 'Conectar'; + + @override + String get install => 'Instalar'; + + @override + String get logout => 'Cerrar sesión'; + + @override + String get nostrAddressHint => 'nombre@ejemplo.com'; + + @override + String get invalidAddress => 'Dirección inválida'; + + @override + String get unableToConnect => 'No se puede conectar'; + + @override + String get publicKeyHint => 'npub1...'; + + @override + String get privateKeyHint => 'nsec1...'; + + @override + String get newToNostr => '¿Nuevo en Nostr?'; + + @override + String get getStarted => 'Comenzar'; + + @override + String get bunker => 'Bunker'; + + @override + String get bunkerAuthentication => 'Autenticación Bunker'; + + @override + String tapToOpen(String url) { + return 'Toca para abrir: $url'; + } + + @override + String get showNostrConnectQrcode => 'Mostrar código QR de nostr connect'; + + @override + String get loginWithAmber => 'Iniciar sesión con Amber'; + + @override + String get nostrConnectUrl => 'URL de conexión Nostr'; + + @override + String get copy => 'Copiar'; + + @override + String get addAccount => 'Añadir cuenta'; + + @override + String get readOnly => 'Solo lectura'; + + @override + String get nsec => 'Nsec'; + + @override + String get extension => 'Extensión'; +} diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart new file mode 100644 index 000000000..2d2bc3e13 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations_fr.dart @@ -0,0 +1,93 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for French (`fr`). +class AppLocalizationsFr extends AppLocalizations { + AppLocalizationsFr([String locale = 'fr']) : super(locale); + + @override + String get createAccount => 'Créer votre compte'; + + @override + String get newHere => 'Êtes-vous nouveau ici?'; + + @override + String get nostrAddress => 'Adresse Nostr'; + + @override + String get publicKey => 'Clé publique'; + + @override + String get privateKey => 'Clé privée (non sécurisé)'; + + @override + String get browserExtension => 'Extension de navigateur'; + + @override + String get connect => 'Se connecter'; + + @override + String get install => 'Installer'; + + @override + String get logout => 'Se déconnecter'; + + @override + String get nostrAddressHint => 'nom@exemple.com'; + + @override + String get invalidAddress => 'Adresse invalide'; + + @override + String get unableToConnect => 'Impossible de se connecter'; + + @override + String get publicKeyHint => 'npub1...'; + + @override + String get privateKeyHint => 'nsec1...'; + + @override + String get newToNostr => 'Nouveau sur Nostr?'; + + @override + String get getStarted => 'Commencer'; + + @override + String get bunker => 'Bunker'; + + @override + String get bunkerAuthentication => 'Authentification Bunker'; + + @override + String tapToOpen(String url) { + return 'Appuyez pour ouvrir: $url'; + } + + @override + String get showNostrConnectQrcode => 'Afficher le code QR nostr connect'; + + @override + String get loginWithAmber => 'Se connecter avec Amber'; + + @override + String get nostrConnectUrl => 'URL de connexion Nostr'; + + @override + String get copy => 'Copier'; + + @override + String get addAccount => 'Ajouter un compte'; + + @override + String get readOnly => 'Lecture seule'; + + @override + String get nsec => 'Nsec'; + + @override + String get extension => 'Extension'; +} diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart new file mode 100644 index 000000000..e01fe6080 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ja.dart @@ -0,0 +1,93 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Japanese (`ja`). +class AppLocalizationsJa extends AppLocalizations { + AppLocalizationsJa([String locale = 'ja']) : super(locale); + + @override + String get createAccount => 'アカウントを作成'; + + @override + String get newHere => '初めてですか?'; + + @override + String get nostrAddress => 'Nostrアドレス'; + + @override + String get publicKey => '公開鍵'; + + @override + String get privateKey => '秘密鍵(安全ではありません)'; + + @override + String get browserExtension => 'ブラウザ拡張機能'; + + @override + String get connect => '接続'; + + @override + String get install => 'インストール'; + + @override + String get logout => 'ログアウト'; + + @override + String get nostrAddressHint => 'name@example.com'; + + @override + String get invalidAddress => '無効なアドレス'; + + @override + String get unableToConnect => '接続できません'; + + @override + String get publicKeyHint => 'npub1...'; + + @override + String get privateKeyHint => 'nsec1...'; + + @override + String get newToNostr => 'Nostrは初めてですか?'; + + @override + String get getStarted => '始める'; + + @override + String get bunker => 'バンカー'; + + @override + String get bunkerAuthentication => 'バンカー認証'; + + @override + String tapToOpen(String url) { + return 'タップして開く:$url'; + } + + @override + String get showNostrConnectQrcode => 'nostr connect QRコードを表示'; + + @override + String get loginWithAmber => 'Amberでログイン'; + + @override + String get nostrConnectUrl => 'Nostr接続URL'; + + @override + String get copy => 'コピー'; + + @override + String get addAccount => 'アカウントを追加'; + + @override + String get readOnly => '読み取り専用'; + + @override + String get nsec => 'Nsec'; + + @override + String get extension => '拡張機能'; +} diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart new file mode 100644 index 000000000..3e4ef79f5 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations_ru.dart @@ -0,0 +1,93 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Russian (`ru`). +class AppLocalizationsRu extends AppLocalizations { + AppLocalizationsRu([String locale = 'ru']) : super(locale); + + @override + String get createAccount => 'Создать аккаунт'; + + @override + String get newHere => 'Вы здесь новенький?'; + + @override + String get nostrAddress => 'Адрес Nostr'; + + @override + String get publicKey => 'Публичный ключ'; + + @override + String get privateKey => 'Приватный ключ (небезопасно)'; + + @override + String get browserExtension => 'Расширение браузера'; + + @override + String get connect => 'Подключить'; + + @override + String get install => 'Установить'; + + @override + String get logout => 'Выйти'; + + @override + String get nostrAddressHint => 'имя@пример.com'; + + @override + String get invalidAddress => 'Неверный адрес'; + + @override + String get unableToConnect => 'Не удается подключиться'; + + @override + String get publicKeyHint => 'npub1...'; + + @override + String get privateKeyHint => 'nsec1...'; + + @override + String get newToNostr => 'Новичок в Nostr?'; + + @override + String get getStarted => 'Начать'; + + @override + String get bunker => 'Бункер'; + + @override + String get bunkerAuthentication => 'Аутентификация Bunker'; + + @override + String tapToOpen(String url) { + return 'Нажмите, чтобы открыть: $url'; + } + + @override + String get showNostrConnectQrcode => 'Показать QR-код nostr connect'; + + @override + String get loginWithAmber => 'Войти через Amber'; + + @override + String get nostrConnectUrl => 'URL подключения Nostr'; + + @override + String get copy => 'Копировать'; + + @override + String get addAccount => 'Добавить аккаунт'; + + @override + String get readOnly => 'Только чтение'; + + @override + String get nsec => 'Nsec'; + + @override + String get extension => 'Расширение'; +} diff --git a/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart new file mode 100644 index 000000000..db04e794b --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_localizations_zh.dart @@ -0,0 +1,93 @@ +// ignore: unused_import +import 'package:intl/intl.dart' as intl; +import 'app_localizations.dart'; + +// ignore_for_file: type=lint + +/// The translations for Chinese (`zh`). +class AppLocalizationsZh extends AppLocalizations { + AppLocalizationsZh([String locale = 'zh']) : super(locale); + + @override + String get createAccount => '创建账户'; + + @override + String get newHere => '您是新用户吗?'; + + @override + String get nostrAddress => 'Nostr 地址'; + + @override + String get publicKey => '公钥'; + + @override + String get privateKey => '私钥(不安全)'; + + @override + String get browserExtension => '浏览器扩展'; + + @override + String get connect => '连接'; + + @override + String get install => '安装'; + + @override + String get logout => '退出登录'; + + @override + String get nostrAddressHint => 'name@example.com'; + + @override + String get invalidAddress => '无效地址'; + + @override + String get unableToConnect => '无法连接'; + + @override + String get publicKeyHint => 'npub1...'; + + @override + String get privateKeyHint => 'nsec1...'; + + @override + String get newToNostr => '初次使用 Nostr?'; + + @override + String get getStarted => '开始使用'; + + @override + String get bunker => 'Bunker'; + + @override + String get bunkerAuthentication => 'Bunker 身份验证'; + + @override + String tapToOpen(String url) { + return '点击打开:$url'; + } + + @override + String get showNostrConnectQrcode => '显示 nostr connect 二维码'; + + @override + String get loginWithAmber => '使用 Amber 登录'; + + @override + String get nostrConnectUrl => 'Nostr 连接 URL'; + + @override + String get copy => '复制'; + + @override + String get addAccount => '添加账户'; + + @override + String get readOnly => '只读'; + + @override + String get nsec => 'Nsec'; + + @override + String get extension => '扩展'; +} diff --git a/packages/ndk_flutter/lib/l10n/app_ru.arb b/packages/ndk_flutter/lib/l10n/app_ru.arb new file mode 100644 index 000000000..9022aa01f --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_ru.arb @@ -0,0 +1,30 @@ +{ + "@@locale": "ru", + "createAccount": "Создать аккаунт", + "newHere": "Вы здесь новенький?", + "nostrAddress": "Адрес Nostr", + "publicKey": "Публичный ключ", + "privateKey": "Приватный ключ (небезопасно)", + "browserExtension": "Расширение браузера", + "connect": "Подключить", + "install": "Установить", + "logout": "Выйти", + "nostrAddressHint": "имя@пример.com", + "invalidAddress": "Неверный адрес", + "unableToConnect": "Не удается подключиться", + "publicKeyHint": "npub1...", + "privateKeyHint": "nsec1...", + "newToNostr": "Новичок в Nostr?", + "getStarted": "Начать", + "bunker": "Бункер", + "bunkerAuthentication": "Аутентификация Bunker", + "tapToOpen": "Нажмите, чтобы открыть: {url}", + "showNostrConnectQrcode": "Показать QR-код nostr connect", + "loginWithAmber": "Войти через Amber", + "nostrConnectUrl": "URL подключения Nostr", + "copy": "Копировать", + "addAccount": "Добавить аккаунт", + "readOnly": "Только чтение", + "nsec": "Nsec", + "extension": "Расширение" +} \ No newline at end of file diff --git a/packages/ndk_flutter/lib/l10n/app_zh.arb b/packages/ndk_flutter/lib/l10n/app_zh.arb new file mode 100644 index 000000000..20e0e8ae0 --- /dev/null +++ b/packages/ndk_flutter/lib/l10n/app_zh.arb @@ -0,0 +1,30 @@ +{ + "@@locale": "zh", + "createAccount": "创建账户", + "newHere": "您是新用户吗?", + "nostrAddress": "Nostr 地址", + "publicKey": "公钥", + "privateKey": "私钥(不安全)", + "browserExtension": "浏览器扩展", + "connect": "连接", + "install": "安装", + "logout": "退出登录", + "nostrAddressHint": "name@example.com", + "invalidAddress": "无效地址", + "unableToConnect": "无法连接", + "publicKeyHint": "npub1...", + "privateKeyHint": "nsec1...", + "newToNostr": "初次使用 Nostr?", + "getStarted": "开始使用", + "bunker": "Bunker", + "bunkerAuthentication": "Bunker 身份验证", + "tapToOpen": "点击打开:{url}", + "showNostrConnectQrcode": "显示 nostr connect 二维码", + "loginWithAmber": "使用 Amber 登录", + "nostrConnectUrl": "Nostr 连接 URL", + "copy": "复制", + "addAccount": "添加账户", + "readOnly": "只读", + "nsec": "Nsec", + "extension": "扩展" +} \ No newline at end of file diff --git a/packages/ndk_flutter/lib/main/config.dart b/packages/ndk_flutter/lib/main/config.dart new file mode 100644 index 000000000..ea6f898fd --- /dev/null +++ b/packages/ndk_flutter/lib/main/config.dart @@ -0,0 +1,5 @@ +import 'package:flutter/foundation.dart'; + +const accountsKey = kDebugMode + ? 'dev_ndk_flutter_accounts' + : 'ndk_flutter_accounts'; diff --git a/packages/ndk_flutter/lib/main/ndk_flutter.dart b/packages/ndk_flutter/lib/main/ndk_flutter.dart new file mode 100644 index 000000000..b3be9e7eb --- /dev/null +++ b/packages/ndk_flutter/lib/main/ndk_flutter.dart @@ -0,0 +1,224 @@ +import 'dart:convert'; + +import 'package:amberflutter/amberflutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:ndk/data_layer/repositories/signers/nip46_event_signer.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk_amber/ndk_amber.dart'; +import 'package:ndk_flutter/main/config.dart'; +import 'package:ndk_flutter/models/accounts.dart'; +import 'package:ndk_flutter/models/nip_05_result.dart'; +import 'package:nip07_event_signer/nip07_event_signer.dart'; +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +class NdkFlutter { + static Color getColorFromPubkey(String pubkey) { + if (pubkey.isEmpty) return const Color(0xFF808080); + + // Hash the pubkey using SHA-256 for better distribution + final bytes = utf8.encode(pubkey); + final digest = sha256.convert(bytes); + + // Use first 3 bytes of hash for RGB values + final hashBytes = digest.bytes; + final r = hashBytes[0]; + final g = hashBytes[1]; + final b = hashBytes[2]; + + // Create color from RGB values + final color = Color.fromARGB(255, r, g, b); + + // Convert to HSL to ensure good visibility + final hslColor = HSLColor.fromColor(color); + + // Adjust saturation and lightness for better visibility + // Keep hue from hash but ensure readable colors + final adjustedHslColor = hslColor.withSaturation(0.6).withLightness(0.45); + + return adjustedHslColor.toColor(); + } + + static Future fetchNip05(String nip05) async { + try { + final parts = nip05.split('@'); + if (parts.length != 2) { + return Nip05Result( + error: 'Invalid NIP-05 format. Expected format: name@domain.com', + ); + } + + final name = parts[0]; + final domain = parts[1]; + + final uri = Uri.https(domain, '/.well-known/nostr.json', {"name": name}); + final response = await http.get(uri); + + if (response.statusCode != 200) { + return Nip05Result( + error: 'Failed to fetch NIP-05 data: ${response.statusCode}', + ); + } + + final json = jsonDecode(response.body) as Map; + final names = json['names'] as Map?; + final relays = json['relays'] as Map?; + + if (names == null || !names.containsKey(name)) { + return Nip05Result(error: 'Name not found in NIP-05 data'); + } + + final pubkey = names[name] as String; + final userRelays = relays?[pubkey] as List?; + + return Nip05Result(pubkey: pubkey, relays: userRelays?.cast()); + } catch (e) { + return Nip05Result(error: 'Error fetching NIP-05: $e'); + } + } + + final Ndk ndk; + + NdkFlutter({required this.ndk}); + + Future saveAccountsState() async { + final accounts = NostrWidgetsAccounts(accounts: []); + + for (var account in ndk.accounts.accounts.values) { + if (account.signer is Nip07EventSigner) { + accounts.accounts.add( + NostrAccount(kind: AccountKinds.nip07, pubkey: account.pubkey), + ); + continue; + } + + if (account.signer is AmberEventSigner) { + accounts.accounts.add( + NostrAccount(kind: AccountKinds.amber, pubkey: account.pubkey), + ); + continue; + } + + if (account.signer is Nip46EventSigner) { + final signer = account.signer as Nip46EventSigner; + accounts.accounts.add( + NostrAccount( + kind: AccountKinds.bunker, + pubkey: account.pubkey, + signerSeed: jsonEncode(signer.connection), + ), + ); + continue; + } + + if (account.type == AccountType.privateKey) { + final signer = account.signer as Bip340EventSigner; + if (signer.privateKey == null) continue; + accounts.accounts.add( + NostrAccount( + kind: AccountKinds.privkey, + pubkey: account.pubkey, + signerSeed: signer.privateKey!, + ), + ); + continue; + } + + if (account.type == AccountType.publicKey) { + accounts.accounts.add( + NostrAccount(kind: AccountKinds.pubkey, pubkey: account.pubkey), + ); + continue; + } + } + + accounts.loggedAccount = ndk.accounts.getPublicKey(); + + final storage = FlutterSecureStorage(); + await storage.write(key: accountsKey, value: jsonEncode(accounts)); + } + + Future restoreAccountsState() async { + final storage = FlutterSecureStorage(); + + final storedAccounts = await storage.read(key: accountsKey); + + if (storedAccounts == null) return; + + final accounts = NostrWidgetsAccounts.fromJson(jsonDecode(storedAccounts)); + + for (var account in accounts.accounts) { + if (account.kind == AccountKinds.nip07) { + final signer = Nip07EventSigner(); + ndk.accounts.addAccount( + pubkey: account.pubkey, + type: AccountType.externalSigner, + signer: signer, + ); + continue; + } + + if (account.kind == AccountKinds.amber) { + final amber = Amberflutter(); + final amberFlutterDS = AmberFlutterDS(amber); + + ndk.accounts.addAccount( + pubkey: account.pubkey, + type: AccountType.externalSigner, + signer: AmberEventSigner( + publicKey: account.pubkey, + amberFlutterDS: amberFlutterDS, + ), + ); + continue; + } + + if (account.kind == AccountKinds.bunker) { + final signer = Nip46EventSigner( + connection: BunkerConnection.fromJson( + jsonDecode(account.signerSeed!), + ), + requests: ndk.requests, + broadcast: ndk.broadcast, + ); + ndk.accounts.addAccount( + pubkey: account.pubkey, + type: AccountType.externalSigner, + signer: signer, + ); + continue; + } + + if (account.kind == AccountKinds.pubkey) { + ndk.accounts.addAccount( + pubkey: account.pubkey, + type: AccountType.publicKey, + signer: Bip340EventSigner( + privateKey: null, + publicKey: account.pubkey, + ), + ); + continue; + } + + if (account.kind == AccountKinds.privkey) { + final pubkey = Bip340.getPublicKey(account.signerSeed!); + ndk.accounts.addAccount( + pubkey: pubkey, + type: AccountType.privateKey, + signer: Bip340EventSigner( + privateKey: account.signerSeed!, + publicKey: pubkey, + ), + ); + continue; + } + } + + if (accounts.loggedAccount == null) return; + if (!ndk.accounts.hasAccount(accounts.loggedAccount!)) return; + ndk.accounts.switchAccount(pubkey: accounts.loggedAccount!); + } +} diff --git a/packages/ndk_flutter/lib/models/accounts.dart b/packages/ndk_flutter/lib/models/accounts.dart new file mode 100644 index 000000000..edd7d0cc0 --- /dev/null +++ b/packages/ndk_flutter/lib/models/accounts.dart @@ -0,0 +1,52 @@ +class NostrWidgetsAccounts { + String? loggedAccount; + List accounts; + + NostrWidgetsAccounts({this.loggedAccount, required this.accounts}); + + Map toJson() { + return { + 'loggedAccount': loggedAccount, + 'accounts': accounts.map((account) => account.toJson()).toList(), + }; + } + + factory NostrWidgetsAccounts.fromJson(Map json) { + return NostrWidgetsAccounts( + loggedAccount: json['loggedAccount'], + accounts: + (json['accounts'] as List?) + ?.map((accountJson) => NostrAccount.fromJson(accountJson)) + .toList() ?? + [], + ); + } +} + +enum AccountKinds { nip07, amber, bunker, pubkey, privkey } + +class NostrAccount { + AccountKinds kind; + String pubkey; + String? signerSeed; + + NostrAccount({required this.kind, required this.pubkey, this.signerSeed}); + + Map toJson() { + return { + 'kind': kind.toString().split('.').last, + 'pubkey': pubkey, + 'signerSeed': signerSeed, + }; + } + + factory NostrAccount.fromJson(Map json) { + return NostrAccount( + kind: AccountKinds.values.firstWhere( + (e) => e.toString().split('.').last == json['kind'], + ), + pubkey: json['pubkey'], + signerSeed: json['signerSeed'], + ); + } +} diff --git a/packages/ndk_flutter/lib/models/models.dart b/packages/ndk_flutter/lib/models/models.dart new file mode 100644 index 000000000..01657d16f --- /dev/null +++ b/packages/ndk_flutter/lib/models/models.dart @@ -0,0 +1,2 @@ +export 'accounts.dart'; +export 'nip_05_result.dart'; diff --git a/packages/ndk_flutter/lib/models/nip_05_result.dart b/packages/ndk_flutter/lib/models/nip_05_result.dart new file mode 100644 index 000000000..b5a869bf8 --- /dev/null +++ b/packages/ndk_flutter/lib/models/nip_05_result.dart @@ -0,0 +1,7 @@ +class Nip05Result { + final String? pubkey; + final List? relays; + final String? error; + + Nip05Result({this.pubkey, this.relays, this.error}); +} diff --git a/packages/ndk_flutter/lib/ndk_flutter.dart b/packages/ndk_flutter/lib/ndk_flutter.dart new file mode 100644 index 000000000..900b68aa8 --- /dev/null +++ b/packages/ndk_flutter/lib/ndk_flutter.dart @@ -0,0 +1,2 @@ +export 'main/ndk_flutter.dart'; +export 'widgets/widgets.dart'; diff --git a/packages/ndk_flutter/lib/widgets/banner/n_banner.dart b/packages/ndk_flutter/lib/widgets/banner/n_banner.dart new file mode 100644 index 000000000..d04a57d5a --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/banner/n_banner.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; + +class NBanner extends StatelessWidget { + final Ndk ndk; + final String? pubkey; + final Metadata? metadata; + + String? get _pubkey => + metadata?.pubKey ?? pubkey ?? ndk.accounts.getPublicKey(); + + const NBanner({super.key, required this.ndk, this.pubkey, this.metadata}); + + @override + Widget build(BuildContext context) { + // If metadata is provided, use it directly + if (metadata != null) { + return _buildBannerFromMetadata(context, metadata); + } + + // Otherwise, load metadata + return FutureBuilder( + future: ndk.metadata.loadMetadata(_pubkey!), + builder: (context, snapshot) { + return _buildBannerContent(context, snapshot); + }, + ); + } + + Widget _buildBannerFromMetadata(BuildContext context, Metadata? metadata) { + final banner = metadata?.banner; + if (banner == null) { + return _buildDefaultBanner(context); + } + return _buildImageBanner(context, banner); + } + + Widget _buildBannerContent( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildDefaultBanner(context); + } + + return _buildBannerFromMetadata(context, snapshot.data); + } + + Widget _buildDefaultBanner(BuildContext context) { + final colorScheme = ColorScheme.fromSeed( + seedColor: NdkFlutter.getColorFromPubkey(_pubkey!), + brightness: Theme.of(context).brightness, + ); + + return Container( + height: 10, + width: 10, + color: colorScheme.primaryContainer, + ); + } + + Widget _buildImageBanner(BuildContext context, String bannerUrl) { + return Image.network( + bannerUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + + return ColoredBox( + color: NdkFlutter.getColorFromPubkey(_pubkey!).withValues(alpha: 0.3), + ); + }, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultBanner(context); + }, + ); + } +} diff --git a/packages/ndk_flutter/lib/widgets/login/login_controller.dart b/packages/ndk_flutter/lib/widgets/login/login_controller.dart new file mode 100644 index 000000000..1d25f5f91 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/login/login_controller.dart @@ -0,0 +1,194 @@ +import 'package:amberflutter/amberflutter.dart'; +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_amber/ndk_amber.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; +import 'package:ndk_flutter/widgets/login/nostr_connect_dialog_view.dart'; +import 'package:nip19/nip19.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart'; +import 'package:toastification/toastification.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class LoginController extends ChangeNotifier { + Ndk ndk; + void Function()? onLoggedIn; + + final nip05FieldController = TextEditingController(); + bool _isFetchingNip05 = false; + bool get isFetchingNip05 => _isFetchingNip05; + set isFetchingNip05(bool value) { + _isFetchingNip05 = value; + notifyListeners(); + } + + int _nip05LoginError = 0; + int get nip05LoginError => _nip05LoginError; + set nip05LoginError(int value) { + _nip05LoginError = value; + notifyListeners(); + } + + final bunkerFieldController = TextEditingController(); + bool _isBunkerLoading = false; + bool get isBunkerLoading => _isBunkerLoading; + set isBunkerLoading(bool value) { + _isBunkerLoading = value; + notifyListeners(); + } + + NostrConnect? nostrConnect; + bool isNostrConnectDialogOpen = false; + List challengeToasts = []; + + bool _isWaitingForAmber = false; + bool get isWaitingForAmber => _isWaitingForAmber; + set isWaitingForAmber(bool value) { + _isWaitingForAmber = value; + notifyListeners(); + } + + bool get isValidBunkerUrl { + final bunkerText = bunkerFieldController.text.trim(); + + try { + final uri = Uri.parse(bunkerText); + + // Check if scheme is bunker + if (uri.scheme != 'bunker') return false; + + // Check if host (pubkey) is valid hex (64 characters) + if (uri.host.length != 64) return false; + if (!RegExp(r'^[a-fA-F0-9]+$').hasMatch(uri.host)) return false; + + // Check if at least one relay parameter exists + if (!uri.queryParameters.containsKey('relay')) return false; + + return true; + } catch (e) { + return false; + } + } + + LoginController({required this.ndk, this.onLoggedIn, this.nostrConnect}); + + Future loginWithBunkerUrl(BuildContext context) async { + isBunkerLoading = true; + + try { + final bunkerConnection = await ndk.accounts.loginWithBunkerUrl( + bunkerUrl: bunkerFieldController.text.trim(), + bunkers: ndk.bunkers, + authCallback: (challenge) => showBunkerAuthToast(challenge, context), + ); + + isBunkerLoading = false; + + if (bunkerConnection == null) return; + + await loggedIn(); + } catch (e) { + // + } + } + + Future loginWithAmber() async { + isWaitingForAmber = true; + + final amber = Amberflutter(); + + final isAmberInstalled = await amber.isAppInstalled(); + + if (!isAmberInstalled) { + launchUrl(Uri.parse('https://github.com/greenart7c3/Amber')); + return; + } + + final amberFlutterDS = AmberFlutterDS(amber); + + final amberResponse = await amber.getPublicKey(); + + final npub = amberResponse['signature']; + final pubkey = Nip19.npubToHex(npub); + + final amberSigner = AmberEventSigner( + publicKey: pubkey, + amberFlutterDS: amberFlutterDS, + ); + + ndk.accounts.loginExternalSigner(signer: amberSigner); + + isWaitingForAmber = false; + + await loggedIn(); + } + + Future loggedIn() async { + await NdkFlutter(ndk: ndk).saveAccountsState(); + + for (var toast in challengeToasts) { + toastification.dismiss(toast); + } + challengeToasts.clear(); + + if (onLoggedIn != null) onLoggedIn!(); + } + + void showNostrConnectQrcode(BuildContext context) async { + if (nostrConnect == null) return; + + openNostrConnectDialog(context); + + try { + final bunkerSettings = await ndk.accounts.loginWithNostrConnect( + nostrConnect: nostrConnect!, + bunkers: ndk.bunkers, + // authCallback: (challenge) => showBunkerAuthToast(challenge), + ); + + if (isNostrConnectDialogOpen) { + Navigator.of(context).pop(); + isNostrConnectDialogOpen = false; + } + + if (bunkerSettings == null) return; + + await loggedIn(); + } catch (e) { + if (isNostrConnectDialogOpen) { + Navigator.of(context).pop(); + isNostrConnectDialogOpen = false; + } + } + } + + void openNostrConnectDialog(BuildContext context) async { + if (nostrConnect == null) return; + + isNostrConnectDialogOpen = true; + await showDialog( + context: context, + builder: (_) => NostrConnectDialogView( + nostrConnectURL: nostrConnect!.nostrConnectURL, + ), + ); + isNostrConnectDialogOpen = false; + } + + void showBunkerAuthToast(String challenge, BuildContext context) { + final newToast = toastification.show( + context: context, + title: Text(AppLocalizations.of(context)!.bunkerAuthentication), + description: Text(AppLocalizations.of(context)!.tapToOpen(challenge)), + alignment: Alignment.bottomRight, + type: ToastificationType.info, + style: ToastificationStyle.flat, + showProgressBar: true, + closeOnClick: false, + callbacks: ToastificationCallbacks( + onTap: (toastItem) => launchUrl(Uri.parse(challenge)), + ), + ); + + challengeToasts.add(newToast); + } +} diff --git a/packages/ndk_flutter/lib/widgets/login/n_login.dart b/packages/ndk_flutter/lib/widgets/login/n_login.dart new file mode 100644 index 000000000..ff34f7b97 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/login/n_login.dart @@ -0,0 +1,368 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk/shared/nips/nip01/bip340.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; +import 'package:ndk_flutter/widgets/login/login_controller.dart'; +import 'package:nip07_event_signer/nip07_event_signer.dart'; +import 'package:nip19/nip19.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class NLogin extends StatefulWidget { + final Ndk ndk; + final void Function()? onLoggedIn; + final bool enableAccountCreation; + final bool enableNip05Login; + final bool enableNpubLogin; + final bool enableNsecLogin; + final bool enableNip07Login; + final bool enableBunkerLogin; + final bool enableAmberLogin; + final bool enablePubkeyLogin; + final NostrConnect? nostrConnect; + final String? nsecLabelText; + final String getStartedUrl; + + bool get enableNostrConnectLogin => nostrConnect != null; + + const NLogin({ + super.key, + required this.ndk, + this.onLoggedIn, + this.enableAccountCreation = true, + this.enableNip05Login = true, + this.enableNpubLogin = true, + this.enableNsecLogin = true, + this.enableNip07Login = true, + this.enableBunkerLogin = true, + this.enableAmberLogin = true, + this.enablePubkeyLogin = true, + this.nostrConnect, + this.nsecLabelText, + this.getStartedUrl = 'https://nstart.me/', + }); + + @override + State createState() => _NLoginState(); +} + +class _NLoginState extends State { + late LoginController controller; + + @override + void initState() { + super.initState(); + controller = LoginController( + ndk: widget.ndk, + onLoggedIn: widget.onLoggedIn, + nostrConnect: widget.nostrConnect, + ); + controller.addListener(_updateUI); + } + + @override + void dispose() { + controller.removeListener(_updateUI); + controller.dispose(); + super.dispose(); + } + + void _updateUI() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + const double bottomPadding = 16; + + final createAccountView = Padding( + padding: EdgeInsetsGeometry.only(bottom: bottomPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.newToNostr, + style: Theme.of(context).textTheme.labelLarge, + ), + SizedBox(height: 8), + FilledButton( + onPressed: () async { + await launchUrl(Uri.parse(widget.getStartedUrl)); + }, + child: Text(AppLocalizations.of(context)!.getStarted), + ), + ], + ), + ); + + final nip05View = Padding( + padding: EdgeInsetsGeometry.only(bottom: bottomPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.nostrAddress, + style: Theme.of(context).textTheme.labelLarge, + ), + TextField( + controller: controller.nip05FieldController, + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.nostrAddressHint, + suffixIcon: !controller.isFetchingNip05 + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + onPressed: () => loginWithNip05( + controller.nip05FieldController.text, + ), + icon: Icon(Icons.arrow_forward), + ), + ) + : Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator(), + ), + ), + errorText: [ + null, + AppLocalizations.of(context)!.invalidAddress, + AppLocalizations.of(context)!.unableToConnect, + ][controller.nip05LoginError], + ), + onChanged: nip05Change, + onSubmitted: loginWithNip05, + ), + ], + ), + ); + + final npubView = Padding( + padding: EdgeInsetsGeometry.only(bottom: bottomPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.publicKey, + style: Theme.of(context).textTheme.labelLarge, + ), + TextField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.publicKeyHint, + ), + onChanged: loginWithNpub, + ), + ], + ), + ); + + final nsecView = Padding( + padding: EdgeInsetsGeometry.only(bottom: bottomPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.nsecLabelText ?? AppLocalizations.of(context)!.privateKey, + style: Theme.of(context).textTheme.labelLarge, + ), + TextField( + decoration: InputDecoration( + hintText: AppLocalizations.of(context)!.privateKeyHint, + ), + onChanged: loginWithNsec, + ), + ], + ), + ); + + final nip07View = Padding( + padding: EdgeInsetsGeometry.only(bottom: bottomPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.browserExtension, + style: Theme.of(context).textTheme.labelLarge, + ), + SizedBox(height: 8), + FilledButton.icon( + onPressed: loginWithNip07, + label: Text( + Nip07EventSigner().canSign() + ? AppLocalizations.of(context)!.connect + : AppLocalizations.of(context)!.install, + ), + icon: Icon(Icons.extension_outlined), + ), + ], + ), + ); + + final bunkerView = Padding( + padding: EdgeInsetsGeometry.only(bottom: bottomPadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + AppLocalizations.of(context)!.bunker, + style: Theme.of(context).textTheme.labelLarge, + ), + if (widget.enableBunkerLogin) + TextField( + controller: controller.bunkerFieldController, + decoration: InputDecoration(hintText: "bunker://"), + onChanged: (_) => setState(() {}), + ), + if (widget.enableBunkerLogin) + Padding( + padding: const EdgeInsets.only(top: 8), + child: FilledButton( + onPressed: + controller.isValidBunkerUrl && !controller.isBunkerLoading + ? () => controller.loginWithBunkerUrl(context) + : null, + child: Text( + controller.isBunkerLoading + ? "Loading..." + : "Login with bunker", + ), + ), + ), + if (widget.enableNostrConnectLogin) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: TextButton.icon( + onPressed: () => controller.showNostrConnectQrcode(context), + label: Text( + AppLocalizations.of(context)!.showNostrConnectQrcode, + ), + icon: Icon(Icons.qr_code_2), + ), + ), + ], + ), + ); + + final amberView = Padding( + padding: EdgeInsetsGeometry.only(bottom: bottomPadding), + child: FilledButton.icon( + onPressed: controller.isWaitingForAmber + ? null + : controller.loginWithAmber, + label: Text(AppLocalizations.of(context)!.loginWithAmber), + icon: Icon(Icons.diamond), + ), + ); + + final isAndroid = defaultTargetPlatform == TargetPlatform.android; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.enablePubkeyLogin && widget.enableNip05Login) nip05View, + if (widget.enablePubkeyLogin && widget.enableNpubLogin) npubView, + if (widget.enableNsecLogin) nsecView, + if (widget.enableNip07Login && kIsWeb) nip07View, + if (widget.enableBunkerLogin || widget.enableNostrConnectLogin) + bunkerView, + if (widget.enableAmberLogin && isAndroid) amberView, + if (widget.enableAccountCreation) createAccountView, + ], + ); + } + + Future loginWithNpub(String npub) async { + String? pubkey; + try { + pubkey = Nip19.npubToHex(npub); + } catch (e) { + return; + } + + if (widget.ndk.accounts.hasAccount(pubkey)) { + widget.ndk.accounts.switchAccount(pubkey: pubkey); + } else { + widget.ndk.accounts.loginPublicKey(pubkey: pubkey); + } + + await controller.loggedIn(); + } + + Future loginWithNsec(String nsec) async { + String? privateKey; + String? pubkey; + try { + privateKey = Nip19.nsecToHex(nsec); + pubkey = Bip340.getPublicKey(privateKey); + } catch (e) { + return; + } + + if (widget.ndk.accounts.hasAccount(pubkey)) { + widget.ndk.accounts.switchAccount(pubkey: pubkey); + } else { + widget.ndk.accounts.loginPrivateKey(pubkey: pubkey, privkey: privateKey); + } + + await controller.loggedIn(); + } + + void nip05Change(String _) { + controller.nip05LoginError = 0; + } + + Future loginWithNip05(String nip05) async { + if (!RegExp( + r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', + ).hasMatch(nip05)) { + controller.nip05LoginError = 1; + return; + } + + controller.isFetchingNip05 = true; + final nip05Result = await NdkFlutter.fetchNip05(nip05); + controller.isFetchingNip05 = false; + + final pubkey = nip05Result.pubkey; + if (pubkey == null) { + controller.nip05LoginError = 2; + return; + } + + if (widget.ndk.accounts.hasAccount(pubkey)) { + widget.ndk.accounts.switchAccount(pubkey: pubkey); + } else { + widget.ndk.accounts.loginPublicKey(pubkey: pubkey); + } + + await controller.loggedIn(); + } + + Future loginWithNip07() async { + final signer = Nip07EventSigner(); + + if (!signer.canSign()) { + await launchUrl( + Uri.parse( + 'https://chromewebstore.google.com/detail/nos2x/kpgefcfmnafjgpblomihpgmejjdanjjp', + ), + ); + return; + } + + final pubkey = await signer.getPublicKeyAsync(); + + if (widget.ndk.accounts.hasAccount(pubkey)) { + widget.ndk.accounts.switchAccount(pubkey: pubkey); + } else { + widget.ndk.accounts.loginExternalSigner(signer: signer); + } + + await controller.loggedIn(); + } +} diff --git a/packages/ndk_flutter/lib/widgets/login/nostr_connect_dialog_view.dart b/packages/ndk_flutter/lib/widgets/login/nostr_connect_dialog_view.dart new file mode 100644 index 000000000..0f25c5994 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/login/nostr_connect_dialog_view.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; + +class NostrConnectDialogView extends StatelessWidget { + final String nostrConnectURL; + + const NostrConnectDialogView({super.key, required this.nostrConnectURL}); + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData.light(), + child: AlertDialog( + title: Text(AppLocalizations.of(context)!.nostrConnectUrl), + content: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Spacer(), + AspectRatio( + aspectRatio: 1, + child: PrettyQrView.data( + data: nostrConnectURL, + decoration: const PrettyQrDecoration( + shape: PrettyQrShape.custom(PrettyQrDotsSymbol()), + ), + ), + ), + Spacer(), + ], + ), + actions: [ + FilledButton( + onPressed: () { + Clipboard.setData(ClipboardData(text: nostrConnectURL)); + }, + child: Text(AppLocalizations.of(context)!.copy), + ), + ], + ), + ); + } +} diff --git a/packages/ndk_flutter/lib/widgets/name/n_name.dart b/packages/ndk_flutter/lib/widgets/name/n_name.dart new file mode 100644 index 000000000..b2046e827 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/name/n_name.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:nip19/nip19.dart'; + +class NName extends StatelessWidget { + final Ndk ndk; + final String? pubkey; + final Metadata? metadata; + final TextStyle? style; + final int? maxLines; + final TextOverflow? overflow; + final bool displayNpub; + + String? get _pubkey => + metadata?.pubKey ?? pubkey ?? ndk.accounts.getPublicKey(); + + const NName({ + super.key, + required this.ndk, + this.pubkey, + this.metadata, + this.style, + this.maxLines = 1, + this.overflow = TextOverflow.ellipsis, + this.displayNpub = false, + }); + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _getName(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + return Text( + snapshot.data!, + style: style, + maxLines: maxLines, + overflow: overflow, + ); + } + + return Text( + displayNpub ? _formatNpub(_pubkey!) : _formatPubkey(_pubkey!), + style: style, + maxLines: maxLines, + overflow: overflow, + ); + }, + ); + } + + Future _getName() async { + try { + // Use provided metadata if available, otherwise load it + final userMetadata = + metadata ?? await ndk.metadata.loadMetadata(_pubkey!); + return userMetadata?.getName(); + } catch (e) { + return null; + } + } + + String _formatPubkey(String pubkey) { + return '${pubkey.substring(0, 6)}...${pubkey.substring(pubkey.length - 6)}'; + } + + String _formatNpub(String pubkey) { + try { + final npub = Nip19.npubFromHex(pubkey); + return '${npub.substring(0, 6)}...${npub.substring(npub.length - 6)}'; + } catch (e) { + return _formatPubkey(pubkey); + } + } +} diff --git a/packages/ndk_flutter/lib/widgets/picture/n_picture.dart b/packages/ndk_flutter/lib/widgets/picture/n_picture.dart new file mode 100644 index 000000000..ff48ca920 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/picture/n_picture.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; + +class NPicture extends StatelessWidget { + final Ndk ndk; + final String? pubkey; + final Metadata? metadata; + final bool useCircleAvatar; + final double? circleAvatarRadius; + + String? get _pubkey => + metadata?.pubKey ?? pubkey ?? ndk.accounts.getPublicKey(); + + const NPicture({ + super.key, + required this.ndk, + this.pubkey, + this.metadata, + this.useCircleAvatar = true, + this.circleAvatarRadius, + }); + + @override + Widget build(BuildContext context) { + // If metadata is already provided, use it directly without loading + if (metadata != null) { + final picture = _buildPictureContentFromMetadata(context, metadata); + + if (useCircleAvatar) { + return CircleAvatar( + radius: circleAvatarRadius, + child: ClipOval(child: AspectRatio(aspectRatio: 1, child: picture)), + ); + } + + return picture; + } + + // Only load metadata if it's not provided + return FutureBuilder( + future: ndk.metadata.loadMetadata(_pubkey!), + builder: (context, snapshot) { + final picture = _buildPictureContent(context, snapshot); + + if (useCircleAvatar) { + return CircleAvatar( + radius: circleAvatarRadius, + child: ClipOval(child: AspectRatio(aspectRatio: 1, child: picture)), + ); + } + + return picture; + }, + ); + } + + Widget _buildPictureContentFromMetadata( + BuildContext context, + Metadata? metadata, + ) { + final picture = metadata?.picture; + if (picture == null) { + return _buildDefaultPicture(context, metadata?.getName()); + } + + return _buildImagePicture(context, picture); + } + + Widget _buildPictureContent( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildDefaultPicture(context, snapshot.data?.getName()); + } + + final picture = snapshot.data?.picture; + if (picture == null) { + return _buildDefaultPicture(context, snapshot.data?.getName()); + } + + return _buildImagePicture(context, picture); + } + + Widget _buildDefaultPicture(BuildContext context, String? name) { + final initial = name?.isNotEmpty == true ? name![0].toUpperCase() : ''; + final color = NdkFlutter.getColorFromPubkey(_pubkey!); + + return Container( + color: color, + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final fontSize = constraints.maxHeight > 0 + ? constraints.maxHeight * 0.4 + : 16.0; + return Text( + initial, + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: fontSize, + ), + ); + }, + ), + ), + ); + } + + Widget _buildImagePicture(BuildContext context, String pictureUrl) { + return Image.network( + pictureUrl, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + + return ColoredBox(color: NdkFlutter.getColorFromPubkey(_pubkey!)); + }, + errorBuilder: (context, error, stackTrace) { + return _buildDefaultPicture(context, null); + }, + ); + } +} diff --git a/packages/ndk_flutter/lib/widgets/switch_account/n_switch_account.dart b/packages/ndk_flutter/lib/widgets/switch_account/n_switch_account.dart new file mode 100644 index 000000000..2f90e3657 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/switch_account/n_switch_account.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart'; +import 'package:nip07_event_signer/nip07_event_signer.dart'; + +class NSwitchAccount extends StatefulWidget { + final Ndk ndk; + final void Function(String pubkey)? onAccountSwitch; + final void Function(String pubkey)? onAccountRemove; + final void Function()? onAddAccount; + final void Function(String pubkey)? beforeAccountSwitch; + final void Function(String pubkey)? beforeAccountRemove; + + const NSwitchAccount({ + super.key, + required this.ndk, + this.onAccountSwitch, + this.onAccountRemove, + this.onAddAccount, + this.beforeAccountSwitch, + this.beforeAccountRemove, + }); + + @override + State createState() => _NSwitchAccountState(); +} + +class _NSwitchAccountState extends State { + Widget _getAccountTypeChip(BuildContext context, Account account) { + String label; + Color backgroundColor; + final l10n = AppLocalizations.of(context)!; + + switch (account.type) { + case AccountType.publicKey: + label = l10n.readOnly; + backgroundColor = Colors.blue; + break; + case AccountType.privateKey: + label = l10n.nsec; + backgroundColor = Colors.red; + break; + case AccountType.externalSigner: + if (account.signer is Nip07EventSigner) { + label = l10n.extension; + backgroundColor = Colors.orange; + } else { + label = l10n.bunker; + backgroundColor = Colors.green; + } + break; + } + + return Chip( + label: Text(label), + backgroundColor: backgroundColor.withValues(alpha: 0.2), + side: BorderSide(color: backgroundColor), + shape: StadiumBorder(), + ); + } + + @override + Widget build(BuildContext context) { + final accounts = widget.ndk.accounts.accounts.values.toList(); + final loggedPubkey = widget.ndk.accounts.getPublicKey(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...accounts.map((account) { + final pubkey = account.pubkey; + final isLoggedAccount = loggedPubkey == pubkey; + + Widget? subtitle; + void Function()? onTap; + if (!isLoggedAccount) { + subtitle = Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () { + widget.beforeAccountRemove?.call(pubkey); + widget.ndk.accounts.removeAccount(pubkey: pubkey); + NdkFlutter(ndk: widget.ndk).saveAccountsState(); + setState(() {}); + widget.onAccountRemove?.call(pubkey); + }, + child: Text(AppLocalizations.of(context)!.logout), + ), + ); + + onTap = () { + widget.beforeAccountSwitch?.call(pubkey); + widget.ndk.accounts.switchAccount(pubkey: pubkey); + NdkFlutter(ndk: widget.ndk).saveAccountsState(); + setState(() {}); + widget.onAccountSwitch?.call(pubkey); + }; + } + + return ListTile( + leading: NPicture(ndk: widget.ndk, pubkey: pubkey), + title: NName(ndk: widget.ndk, pubkey: pubkey), + subtitle: subtitle, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _getAccountTypeChip(context, account), + SizedBox(width: 8), + Opacity( + opacity: isLoggedAccount ? 1 : 0, + child: Icon( + Icons.radio_button_checked, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + onTap: onTap, + ); + }), + if (widget.onAddAccount != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: TextButton.icon( + onPressed: widget.onAddAccount, + label: Text(AppLocalizations.of(context)!.addAccount), + icon: Icon(Icons.add_circle_outline), + ), + ), + ], + ); + } +} diff --git a/packages/ndk_flutter/lib/widgets/user_profile/n_user_profile.dart b/packages/ndk_flutter/lib/widgets/user_profile/n_user_profile.dart new file mode 100644 index 000000000..127032613 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/user_profile/n_user_profile.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:ndk/ndk.dart'; +import 'package:ndk_flutter/l10n/app_localizations.dart'; +import 'package:ndk_flutter/ndk_flutter.dart'; +import 'package:nip19/nip19.dart'; + +class NUserProfile extends StatelessWidget { + final Ndk ndk; + final String? pubkey; + final Metadata? metadata; + final bool showLogoutButton; + final bool showName; + final bool showNip05Indicator; + final bool showNip05; + final VoidCallback? onLogout; + + String? get profilePubkey => + metadata?.pubKey ?? pubkey ?? ndk.accounts.getPublicKey(); + + const NUserProfile({ + super.key, + required this.ndk, + this.pubkey, + this.metadata, + this.showLogoutButton = true, + this.showName = true, + this.showNip05Indicator = true, + this.showNip05 = true, + this.onLogout, + }); + + @override + Widget build(BuildContext context) { + if (profilePubkey == null) return Container(); + + // If metadata is provided, use it directly + if (metadata != null) { + return _buildProfile(context, metadata); + } + + // Otherwise, load metadata + return FutureBuilder( + future: ndk.metadata.loadMetadata(profilePubkey!), + builder: (context, snapshot) { + return _buildProfile(context, snapshot.data); + }, + ); + } + + Widget _buildProfile(BuildContext context, Metadata? metadata) { + String name = _formatNpub(profilePubkey!); + String? nip05; + + // Check if this is the logged account + final isLoggedAccount = profilePubkey == ndk.accounts.getPublicKey(); + + if (metadata != null) { + name = metadata.getName(); + + if (metadata.cleanNip05 != null) { + nip05 = metadata.cleanNip05; + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Stack( + children: [ + Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: SizedBox( + height: 100, + width: double.maxFinite, + child: FittedBox( + fit: BoxFit.cover, + child: NBanner( + ndk: ndk, + pubkey: profilePubkey, + metadata: metadata, + ), + ), + ), + ), + SizedBox(height: 8), + Visibility( + visible: showLogoutButton && isLoggedAccount, + maintainSize: true, + maintainAnimation: true, + maintainState: true, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FilledButton.icon( + onPressed: () async { + ndk.accounts.logout(); + + if (ndk.accounts.accounts.isNotEmpty) { + final pubkey = + ndk.accounts.accounts.values.first.pubkey; + ndk.accounts.switchAccount(pubkey: pubkey); + } + + await NdkFlutter(ndk: ndk).saveAccountsState(); + + if (onLogout != null) onLogout!(); + }, + label: Text(AppLocalizations.of(context)!.logout), + icon: Icon(Icons.logout), + ), + ], + ), + ), + SizedBox(height: 8), + ], + ), + Positioned( + bottom: 0, + left: 32, + child: ClipOval( + child: Container( + color: Theme.of(context).colorScheme.surface, + padding: EdgeInsets.all(8), + child: NPicture( + ndk: ndk, + metadata: metadata, + pubkey: profilePubkey, + circleAvatarRadius: 40, + ), + ), + ), + ), + ], + ), + if (showName || showNip05) SizedBox(height: 16), + if (showName) + Row( + children: [ + Text(name, style: Theme.of(context).textTheme.displaySmall), + if (showNip05Indicator && nip05 != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Icon( + Icons.verified, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + if (showNip05 && nip05 != null) + Text(nip05, style: TextStyle(color: Theme.of(context).disabledColor)), + ], + ); + } + + String _formatPubkey(String pubkey) { + return '${pubkey.substring(0, 6)}...${pubkey.substring(pubkey.length - 6)}'; + } + + String _formatNpub(String pubkey) { + try { + final npub = Nip19.npubFromHex(pubkey); + return '${npub.substring(0, 6)}...${npub.substring(npub.length - 6)}'; + } catch (e) { + return _formatPubkey(pubkey); + } + } +} diff --git a/packages/ndk_flutter/lib/widgets/widgets.dart b/packages/ndk_flutter/lib/widgets/widgets.dart new file mode 100644 index 000000000..228720051 --- /dev/null +++ b/packages/ndk_flutter/lib/widgets/widgets.dart @@ -0,0 +1,5 @@ +export 'name/n_name.dart'; +export 'picture/n_picture.dart'; +export 'banner/n_banner.dart'; +export 'switch_account/n_switch_account.dart'; +export 'login/n_login.dart'; diff --git a/packages/ndk_flutter/pubspec.yaml b/packages/ndk_flutter/pubspec.yaml new file mode 100644 index 000000000..f80a4fab0 --- /dev/null +++ b/packages/ndk_flutter/pubspec.yaml @@ -0,0 +1,37 @@ +name: ndk_flutter +description: "A Flutter UI package providing ready-to-use widgets." +version: 0.0.1 +repository: https://github.com/relaystr/ndk +topics: + - ndk + - nostr + - widgets + +environment: + sdk: ^3.9.0 + flutter: ">=1.17.0" + +dependencies: + amberflutter: ^0.0.9 + crypto: ">=3.0.0 <4.0.0" + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + flutter_secure_storage: ">=8.0.0 <11.0.0" + http: ">=1.0.0 <2.0.0" + ndk: ^0.5.1 + ndk_amber: ^0.3.2 + nip07_event_signer: ^1.0.2 + nip19: ^0.2.0 + pretty_qr_code: ">=3.0.0 <4.0.0" + toastification: ">=3.0.0 <4.0.0" + url_launcher: ">=6.0.0 <7.0.0" + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^5.0.0 + +flutter: + generate: true