feat: add Nostr account login with account switching
Create Release / release (push) Canceled after 0s

This commit is contained in:
Gringo
2026-03-10 22:07:41 +01:00
parent e21e947ae2
commit 0ee8be009f
10 changed files with 474 additions and 3 deletions
+1
View File
@@ -11,6 +11,7 @@
.svn/
.swiftpm/
migrate_working_dir/
.env
# IntelliJ related
*.iml
+1
View File
@@ -0,0 +1 @@
const anonymousPubkeyKey = 'anonymous_pubkey';
+106
View File
@@ -0,0 +1,106 @@
import 'package:file_transfer/routes.dart';
import 'package:get/get.dart';
import 'package:ndk/ndk.dart';
import 'package:ndk/shared/nips/nip01/bip340.dart';
import 'package:ndk_flutter/ndk_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../constants.dart';
class AccountController extends GetxController {
final ndk = Get.find<Ndk>();
final ndkFlutter = Get.find<NdkFlutter>();
final _anonymousPubkey = RxnString();
final _isLoading = true.obs;
final _hasRealAccount = false.obs;
String? get anonymousPubkey => _anonymousPubkey.value;
bool get isLoading => _isLoading.value;
bool get isLoggedIn => ndk.accounts.getLoggedAccount() != null;
String? get pubkey => ndk.accounts.getLoggedAccount()?.pubkey;
bool get hasRealAccount => _hasRealAccount.value;
@override
void onInit() {
super.onInit();
_loadAccountStatus();
_listenToAuthChanges();
}
/// Listen to NDK auth state changes to update UI automatically
void _listenToAuthChanges() {
ndk.accounts.authStateChanges.listen((account) {
_updateState();
});
}
void _updateState() {
// Check if there's any account other than the anonymous one
final accounts = ndk.accounts.accounts.values;
_hasRealAccount.value = accounts.any(
(a) => a.pubkey != _anonymousPubkey.value,
);
update();
}
Future<void> _loadAccountStatus() async {
final prefs = await SharedPreferences.getInstance();
_anonymousPubkey.value = prefs.getString(anonymousPubkeyKey);
_updateState();
_isLoading.value = false;
}
/// Handle menu selection from account popup
void handleMenuSelection(String? value) {
if (value == 'add_account') {
Get.toNamed(AppRoutes.login);
} else if (value == 'logout') {
handleLogout();
} else if (value?.startsWith('switch_') == true) {
final switchPubkey = value!.substring(7);
handleSwitchAccount(switchPubkey);
}
}
/// Handle logout action
Future<void> handleLogout() async {
// Logout the current active account
ndk.accounts.logout();
// Switch to another account if available
final remainingAccounts = ndk.accounts.accounts.values;
if (remainingAccounts.isNotEmpty) {
final accountToSwitch = remainingAccounts.first;
ndk.accounts.switchAccount(pubkey: accountToSwitch.pubkey);
} else {
// No accounts left, create a new anonymous account
await _createAnonymousAccount();
}
await ndkFlutter.saveAccountsState();
updateAuth();
}
/// Handle account switch
Future<void> handleSwitchAccount(String pubkey) async {
ndk.accounts.switchAccount(pubkey: pubkey);
await ndkFlutter.saveAccountsState();
updateAuth();
}
/// Create a new anonymous account
Future<void> _createAnonymousAccount() async {
final keyPair = Bip340.generatePrivateKey();
ndk.accounts.loginPrivateKey(
pubkey: keyPair.publicKey,
privkey: keyPair.privateKey!,
);
await ndkFlutter.saveAccountsState();
// Update the anonymous pubkey in preferences
final prefs = await SharedPreferences.getInstance();
await prefs.setString(anonymousPubkeyKey, keyPair.publicKey);
updateAuth();
}
void updateAuth() => update();
}
+14 -1
View File
@@ -1,7 +1,10 @@
import 'package:file_transfer/constants.dart';
import 'package:file_transfer/controllers/account_controller.dart';
import 'package:file_transfer/controllers/file_share_controller.dart';
import 'package:file_transfer/controllers/home_controller.dart';
import 'package:file_transfer/pages/file_share_page.dart';
import 'package:file_transfer/pages/home_page.dart';
import 'package:file_transfer/pages/login_page.dart';
import 'package:file_transfer/routes.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -9,7 +12,9 @@ import 'package:get/get.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/l10n/app_localizations.dart' as ndk_flutter;
import 'package:toastification/toastification.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -27,16 +32,22 @@ void main() async {
await ndkFlutter.restoreAccountsState();
final prefs = await SharedPreferences.getInstance();
if (ndk.accounts.accounts.isEmpty) {
// First time: create anonymous account
final keyPair = Bip340.generatePrivateKey();
ndk.accounts.loginPrivateKey(
pubkey: keyPair.publicKey,
privkey: keyPair.privateKey!,
);
await ndkFlutter.saveAccountsState();
await prefs.setString(anonymousPubkeyKey, keyPair.publicKey);
}
// Initialize AccountController after NDK is set up
Get.put(AccountController());
runApp(const MainApp());
}
@@ -50,6 +61,7 @@ class MainApp extends StatelessWidget {
title: 'File Transfer',
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
localizationsDelegates: const [ndk_flutter.AppLocalizations.delegate],
getPages: [
GetPage(
name: AppRoutes.home,
@@ -58,6 +70,7 @@ class MainApp extends StatelessWidget {
Get.put(HomePageController());
}),
),
GetPage(name: AppRoutes.login, page: () => const LoginPage()),
GetPage(
name: AppRoutes.fileShare,
page: () => const FileSharePage(),
+247 -1
View File
@@ -1,9 +1,13 @@
import 'package:desktop_drop/desktop_drop.dart';
import 'package:file_transfer/controllers/account_controller.dart';
import 'package:file_transfer/controllers/home_controller.dart';
import 'package:file_transfer/routes.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dropzone/flutter_dropzone.dart';
import 'package:get/get.dart';
import 'package:ndk/ndk.dart';
import 'package:ndk_flutter/ndk_flutter.dart';
class UploadView extends GetView<HomePageController> {
const UploadView({super.key});
@@ -12,9 +16,251 @@ class UploadView extends GetView<HomePageController> {
Widget build(BuildContext context) {
final isSmallScreen = MediaQuery.of(context).size.width < 600;
final colorScheme = Theme.of(context).colorScheme;
final accountController = Get.find<AccountController>();
final ndkFlutter = Get.find<NdkFlutter>();
final ndk = Get.find<Ndk>();
return Scaffold(
appBar: AppBar(title: const Text('File Transfer')),
appBar: AppBar(
title: const Text('File Transfer'),
actions: [
GetBuilder<AccountController>(
builder: (_) {
if (accountController.isLoading) {
return const SizedBox.shrink();
}
if (!accountController.hasRealAccount) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: TextButton.icon(
onPressed: () => Get.toNamed(AppRoutes.login),
icon: const Icon(Icons.login),
label: const Text('Login'),
),
);
}
final pubkey = accountController.pubkey;
if (pubkey == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: () {
final accounts = ndk.accounts.accounts.values;
final currentPubkey = ndk.accounts
.getLoggedAccount()
?.pubkey;
if (isSmallScreen) {
showModalBottomSheet<String>(
context: context,
showDragHandle: true,
builder: (ctx) => Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final account in accounts)
ListTile(
leading: SizedBox(
width: 32,
height: 32,
child: account.pubkey == currentPubkey
? Stack(
children: [
NPicture(
ndkFlutter: ndkFlutter,
pubkey: account.pubkey,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(
color: colorScheme.surface,
width: 1,
),
),
),
),
],
)
: NPicture(
ndkFlutter: ndkFlutter,
pubkey: account.pubkey,
),
),
title:
account.pubkey ==
accountController.anonymousPubkey
? const Text('Anonymous')
: NName(
ndkFlutter: ndkFlutter,
pubkey: account.pubkey,
style: TextStyle(
fontWeight:
account.pubkey == currentPubkey
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: account.pubkey == currentPubkey
? const Text('Current')
: null,
onTap: () {
Navigator.pop(ctx);
accountController.handleSwitchAccount(
account.pubkey,
);
},
),
const SizedBox(height: 8),
const Divider(),
ListTile(
leading: const Icon(Icons.add_circle_outline),
title: const Text('Add account'),
onTap: () {
Navigator.pop(ctx);
Get.toNamed(AppRoutes.login);
},
),
ListTile(
leading: Icon(
Icons.logout,
color: colorScheme.error,
),
title: Text(
'Logout',
style: TextStyle(color: colorScheme.error),
),
onTap: () {
Navigator.pop(ctx);
accountController.handleLogout();
},
),
],
),
);
} else {
showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(
MediaQuery.of(context).size.width,
0,
0,
0,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: colorScheme.outlineVariant,
width: 1,
),
),
items: [
for (final account in accounts)
PopupMenuItem<String>(
value: 'switch_${account.pubkey}',
child: ListTile(
leading: SizedBox(
width: 32,
height: 32,
child: account.pubkey == currentPubkey
? Stack(
children: [
NPicture(
ndkFlutter: ndkFlutter,
pubkey: account.pubkey,
),
Positioned(
bottom: 0,
right: 0,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: colorScheme.primary,
shape: BoxShape.circle,
border: Border.all(
color: colorScheme.surface,
width: 1,
),
),
),
),
],
)
: NPicture(
ndkFlutter: ndkFlutter,
pubkey: account.pubkey,
),
),
title:
account.pubkey ==
accountController.anonymousPubkey
? const Text('Anonymous')
: NName(
ndkFlutter: ndkFlutter,
pubkey: account.pubkey,
style: TextStyle(
fontWeight:
account.pubkey == currentPubkey
? FontWeight.bold
: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: account.pubkey == currentPubkey
? const Text('Current')
: null,
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'add_account',
child: ListTile(
leading: Icon(Icons.add_circle_outline),
title: Text('Add account'),
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
PopupMenuItem(
value: 'logout',
child: ListTile(
leading: Icon(
Icons.logout,
color: colorScheme.error,
),
title: Text(
'Logout',
style: TextStyle(color: colorScheme.error),
),
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
],
).then(accountController.handleMenuSelection);
}
},
child: NPicture(ndkFlutter: ndkFlutter),
),
);
},
),
],
),
body: kIsWeb
? _buildWebDropzone(context, isSmallScreen, colorScheme)
: _buildDesktopDropzone(context, isSmallScreen, colorScheme),
+44
View File
@@ -0,0 +1,44 @@
import 'package:file_transfer/routes.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:ndk_flutter/ndk_flutter.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
void _navigateBack(BuildContext context) {
if (Navigator.canPop(context)) {
Navigator.pop(context);
} else {
Get.offAllNamed(AppRoutes.home);
}
}
@override
Widget build(BuildContext context) {
final ndkFlutter = Get.find<NdkFlutter>();
return Scaffold(
appBar: AppBar(
title: const Text('Connect to Nostr'),
leading: IconButton(
icon: const BackButtonIcon(),
onPressed: () => _navigateBack(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: NLogin(
ndkFlutter: ndkFlutter,
enablePubkeyLogin: false,
onLoggedIn: () => _navigateBack(context),
),
),
),
),
);
}
}
+1
View File
@@ -1,5 +1,6 @@
class AppRoutes {
static const home = '/';
static const login = '/login';
static const fileShare = '/f/:nevent/:encodedPrivateKey';
static String fileShareRoute(String nevent, String encodedPrivateKey) {
@@ -9,6 +9,7 @@ import desktop_drop
import file_picker
import file_saver
import flutter_secure_storage_darwin
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@@ -16,5 +17,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
}
+56
View File
@@ -636,6 +636,62 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
url: "https://pub.dev"
source: hosted
version: "2.4.21"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine:
dependency: transitive
description: flutter
+2 -1
View File
@@ -1,7 +1,7 @@
name: file_transfer
description: "Zero SPOF file transfer over Nostr. E2E encrypted, decentralized, and private."
publish_to: 'none'
version: 0.1.0+1
version: 0.2.0
environment:
sdk: ^3.11.0
@@ -24,6 +24,7 @@ dependencies:
toastification: ^3.0.3
file_transfer_sdk:
path: packages/file_transfer_sdk
shared_preferences: ^2.5.4
dev_dependencies:
flutter_test: