You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

842 lines
31 KiB

import 'package:flutter/material.dart';
import '../../core/logger.dart';
import '../../core/service_locator.dart';
import '../../data/session/session_service.dart';
import '../../data/firebase/firebase_service.dart';
import '../../data/nostr/nostr_service.dart';
import '../../data/nostr/models/nostr_keypair.dart';
/// Screen for user session management (login/logout).
class SessionScreen extends StatefulWidget {
final VoidCallback? onSessionChanged;
const SessionScreen({
super.key,
this.onSessionChanged,
});
@override
State<SessionScreen> createState() => _SessionScreenState();
}
class _SessionScreenState extends State<SessionScreen> {
final TextEditingController _usernameController = TextEditingController();
final TextEditingController _userIdController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _nostrKeyController = TextEditingController();
bool _isLoading = false;
bool _useFirebaseAuth = false;
bool _useNostrLogin = false;
NostrKeyPair? _generatedKeyPair;
@override
void initState() {
super.initState();
// Check if Firebase Auth is available
_useFirebaseAuth = ServiceLocator.instance.firebaseService?.isEnabled == true &&
ServiceLocator.instance.firebaseService?.config.authEnabled == true;
}
@override
void dispose() {
_usernameController.dispose();
_userIdController.dispose();
_emailController.dispose();
_passwordController.dispose();
_nostrKeyController.dispose();
super.dispose();
}
Future<void> _handleLogin() async {
if (ServiceLocator.instance.sessionService == null) return;
setState(() {
_isLoading = true;
});
try {
// Handle Nostr login
if (_useNostrLogin) {
final nostrKey = _nostrKeyController.text.trim();
if (nostrKey.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter nsec or npub key'),
),
);
}
return;
}
// Validate format
if (!nostrKey.startsWith('nsec') && !nostrKey.startsWith('npub')) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Invalid key format. Expected nsec or npub.'),
backgroundColor: Colors.red,
),
);
}
return;
}
// Login with Nostr
await ServiceLocator.instance.sessionService!.loginWithNostr(nostrKey);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Nostr login successful'),
),
);
setState(() {});
widget.onSessionChanged?.call();
}
return;
}
// Handle Firebase or regular login
if (_useFirebaseAuth && ServiceLocator.instance.firebaseService != null) {
// Use Firebase Auth for authentication
final email = _emailController.text.trim();
final password = _passwordController.text.trim();
if (email.isEmpty || password.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter email and password'),
),
);
}
return;
}
// Authenticate with Firebase
final firebaseUser = await ServiceLocator.instance.firebaseService!.loginWithEmailPassword(
email: email,
password: password,
);
// Create session with Firebase user info
await ServiceLocator.instance.sessionService!.login(
id: firebaseUser.uid,
username: firebaseUser.email?.split('@').first ?? firebaseUser.uid,
token: await firebaseUser.getIdToken(),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login successful'),
),
);
setState(() {});
// Notify parent that session state changed
widget.onSessionChanged?.call();
}
} else {
// Simple validation mode (no Firebase Auth)
final username = _usernameController.text.trim();
final userId = _userIdController.text.trim();
if (username.isEmpty || userId.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter username and user ID'),
),
);
}
return;
}
// Basic validation: require minimum length
if (userId.length < 3) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('User ID must be at least 3 characters'),
),
);
}
return;
}
if (username.length < 2) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Username must be at least 2 characters'),
),
);
}
return;
}
// Create session (demo mode - no real authentication)
await ServiceLocator.instance.sessionService!.login(
id: userId,
username: username,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Login successful (demo mode)'),
backgroundColor: Colors.orange,
),
);
setState(() {});
// Notify parent that session state changed
widget.onSessionChanged?.call();
}
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Login failed: ${e.toString().replaceAll('FirebaseServiceException: ', '').replaceAll('SessionException: ', '')}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _handleLogout() async {
if (ServiceLocator.instance.sessionService == null) return;
setState(() {
_isLoading = true;
});
try {
// Logout from session service first
await ServiceLocator.instance.sessionService!.logout();
// Also logout from Firebase Auth if enabled
if (_useFirebaseAuth && ServiceLocator.instance.firebaseService != null) {
try {
await ServiceLocator.instance.firebaseService!.logout();
} catch (e) {
// Log error but don't fail logout - session is already cleared
Logger.warning('Firebase logout failed: $e');
}
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Logout successful'),
),
);
setState(() {});
// Notify parent that session state changed
widget.onSessionChanged?.call();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Logout failed: ${e.toString().replaceAll('SessionException: ', '')}'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
Future<void> _handleRefresh() async {
if (ServiceLocator.instance.sessionService == null) return;
try {
await ServiceLocator.instance.sessionService!.refreshNostrProfile();
if (mounted) {
setState(() {});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Session data refreshed'),
duration: Duration(seconds: 2),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to refresh: ${e.toString().replaceAll('SessionException: ', '')}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
@override
Widget build(BuildContext context) {
final isLoggedIn = ServiceLocator.instance.sessionService?.isLoggedIn ?? false;
final currentUser = ServiceLocator.instance.sessionService?.currentUser;
return Scaffold(
appBar: AppBar(
title: const Text('Session'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _buildRefreshableContent(isLoggedIn, currentUser),
);
}
Widget _buildRefreshableContent(bool isLoggedIn, currentUser) {
final canRefresh = isLoggedIn &&
(currentUser?.nostrProfile != null || currentUser?.nostrPrivateKey != null);
final content = SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (isLoggedIn && currentUser != null) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Current Session',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
// Display Nostr profile if available
if (currentUser.nostrProfile != null) ...[
Row(
children: [
if (currentUser.nostrProfile!.picture != null)
CircleAvatar(
radius: 30,
backgroundImage: NetworkImage(
currentUser.nostrProfile!.picture!,
),
onBackgroundImageError: (_, __) {},
)
else
const CircleAvatar(
radius: 30,
child: Icon(Icons.person),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentUser.nostrProfile!.name ??
currentUser.nostrProfile!.displayName,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
if (currentUser.nostrProfile!.about != null)
Text(
currentUser.nostrProfile!.about!,
style: TextStyle(
fontSize: 14,
color: Colors.grey[600],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 12),
],
// NIP-05 section
if (currentUser.nostrProfile?.nip05 != null &&
currentUser.nostrProfile!.nip05!.isNotEmpty)
_Nip05Section(
nip05: currentUser.nostrProfile!.nip05!,
publicKey: currentUser.id,
nostrService: ServiceLocator.instance.nostrService,
),
if (currentUser.nostrProfile?.nip05 != null &&
currentUser.nostrProfile!.nip05!.isNotEmpty)
const SizedBox(height: 12),
Text('User ID: ${currentUser.id.substring(0, currentUser.id.length > 32 ? 32 : currentUser.id.length)}${currentUser.id.length > 32 ? '...' : ''}'),
Text('Username: ${currentUser.username}'),
Text(
'Created: ${DateTime.fromMillisecondsSinceEpoch(currentUser.createdAt).toString().split('.')[0]}',
),
],
),
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _handleLogout,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Logout'),
),
] else ...[
const Icon(
Icons.person_outline,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
const Text(
'Login',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (!_useFirebaseAuth) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.orange.shade700, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Demo mode: No authentication required. Enter any valid user ID and username.',
style: TextStyle(
fontSize: 12,
color: Colors.orange.shade700,
),
),
),
],
),
),
],
const SizedBox(height: 24),
// Login method selector
SegmentedButton<bool>(
segments: const [
ButtonSegment<bool>(
value: false,
label: Text('Regular'),
),
ButtonSegment<bool>(
value: true,
label: Text('Nostr'),
),
],
selected: {_useNostrLogin},
onSelectionChanged: (Set<bool> newSelection) {
setState(() {
_useNostrLogin = newSelection.first;
});
},
),
const SizedBox(height: 24),
if (_useNostrLogin) ...[
// Key pair generation section
if (ServiceLocator.instance.nostrService != null) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Generate Key Pair',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
ElevatedButton.icon(
onPressed: () {
setState(() {
_generatedKeyPair = ServiceLocator.instance.nostrService!.generateKeyPair();
});
},
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Generate'),
),
],
),
if (_generatedKeyPair != null) ...[
const SizedBox(height: 16),
// npub display
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'npub (Public Key):',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
SelectableText(
_generatedKeyPair!.toNpub(),
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.content_copy),
tooltip: 'Copy to field',
onPressed: () {
_nostrKeyController.text = _generatedKeyPair!.toNpub();
},
),
],
),
const SizedBox(height: 12),
// nsec display
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'nsec (Private Key):',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
SelectableText(
_generatedKeyPair!.toNsec(),
style: const TextStyle(
fontSize: 11,
fontFamily: 'monospace',
),
),
],
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.content_copy),
tooltip: 'Copy to field',
onPressed: () {
_nostrKeyController.text = _generatedKeyPair!.toNsec();
},
),
],
),
],
],
),
),
),
const SizedBox(height: 16),
],
TextField(
controller: _nostrKeyController,
decoration: const InputDecoration(
labelText: 'Nostr Key (nsec or npub)',
hintText: 'Enter your nsec or npub key',
border: OutlineInputBorder(),
helperText: 'Enter your Nostr private key (nsec) or public key (npub)',
),
maxLines: 3,
minLines: 1,
),
] else if (_useFirebaseAuth) ...[
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'Email',
hintText: 'Enter your email',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
),
const SizedBox(height: 16),
TextField(
controller: _passwordController,
decoration: const InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
border: OutlineInputBorder(),
),
obscureText: true,
autofillHints: const [AutofillHints.password],
),
] else ...[
TextField(
controller: _userIdController,
decoration: const InputDecoration(
labelText: 'User ID',
hintText: 'Enter user ID (min 3 characters)',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _usernameController,
decoration: const InputDecoration(
labelText: 'Username',
hintText: 'Enter username (min 2 characters)',
border: OutlineInputBorder(),
),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: _isLoading ? null : _handleLogin,
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Login'),
),
],
],
),
);
if (canRefresh) {
return RefreshIndicator(
onRefresh: _handleRefresh,
child: content,
);
} else {
return content;
}
}
}
/// Widget for displaying NIP-05 information including domain and preferred relays.
class _Nip05Section extends StatefulWidget {
final String nip05;
final String publicKey;
final NostrService? nostrService;
const _Nip05Section({
required this.nip05,
required this.publicKey,
this.nostrService,
});
@override
State<_Nip05Section> createState() => _Nip05SectionState();
}
class _Nip05SectionState extends State<_Nip05Section> {
List<String> _preferredRelays = [];
bool _isLoading = false;
String? _error;
@override
void initState() {
super.initState();
_loadPreferredRelays();
}
Future<void> _loadPreferredRelays() async {
if (ServiceLocator.instance.nostrService == null) {
setState(() {
_error = 'Nostr service not available';
});
return;
}
setState(() {
_isLoading = true;
_error = null;
});
try {
final relays = await ServiceLocator.instance.nostrService!.fetchPreferredRelaysFromNip05(
widget.nip05,
widget.publicKey,
);
setState(() {
_preferredRelays = relays;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString().replaceAll('NostrException: ', '');
_isLoading = false;
});
}
}
String _getDomain() {
final parts = widget.nip05.split('@');
if (parts.length == 2) {
return parts[1];
}
return widget.nip05;
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'NIP-05',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.verified, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.nip05,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
const SizedBox(height: 8),
Text(
'Domain: ${_getDomain()}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 12),
const Text(
'Preferred Relays',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
if (_isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
else if (_error != null)
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.error_outline, size: 16, color: Colors.red.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
_error!,
style: TextStyle(
fontSize: 12,
color: Colors.red.shade700,
),
),
),
],
),
)
else if (_preferredRelays.isEmpty)
Text(
'No preferred relays found',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
)
else
..._preferredRelays.map((relay) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
children: [
Icon(
Icons.link,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 8),
Expanded(
child: Text(
relay,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
),
],
),
)),
],
);
}
}

Powered by TurnKey Linux.