parent
72aedb6c40
commit
52c449356a
@ -0,0 +1,479 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../../core/service_locator.dart';
|
||||||
|
import '../../core/logger.dart';
|
||||||
|
import '../../data/session/models/user.dart';
|
||||||
|
import '../../data/nostr/models/nostr_profile.dart';
|
||||||
|
import '../../data/nostr/nostr_service.dart';
|
||||||
|
import '../../data/nostr/models/nostr_keypair.dart';
|
||||||
|
import '../../data/immich/immich_service.dart';
|
||||||
|
|
||||||
|
/// Screen for editing user profile information.
|
||||||
|
class UserEditScreen extends StatefulWidget {
|
||||||
|
final User user;
|
||||||
|
|
||||||
|
const UserEditScreen({
|
||||||
|
super.key,
|
||||||
|
required this.user,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserEditScreen> createState() => _UserEditScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserEditScreenState extends State<UserEditScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _nameController = TextEditingController();
|
||||||
|
final _websiteController = TextEditingController();
|
||||||
|
final _lightningController = TextEditingController();
|
||||||
|
final _nip05Controller = TextEditingController();
|
||||||
|
|
||||||
|
String? _profilePictureUrl;
|
||||||
|
File? _selectedImageFile;
|
||||||
|
Uint8List? _selectedImageBytes;
|
||||||
|
bool _isUploading = false;
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadProfileData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController.dispose();
|
||||||
|
_websiteController.dispose();
|
||||||
|
_lightningController.dispose();
|
||||||
|
_nip05Controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadProfileData() {
|
||||||
|
final profile = widget.user.nostrProfile;
|
||||||
|
if (profile != null) {
|
||||||
|
_nameController.text = profile.name ?? '';
|
||||||
|
_websiteController.text = profile.website ?? '';
|
||||||
|
_lightningController.text = profile.lud16 ?? '';
|
||||||
|
_nip05Controller.text = profile.nip05 ?? '';
|
||||||
|
_profilePictureUrl = profile.picture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickProfilePicture() async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
|
||||||
|
// Show dialog to choose source
|
||||||
|
final source = await showDialog<ImageSource>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Select Profile Picture'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.camera_alt),
|
||||||
|
title: const Text('Take Photo'),
|
||||||
|
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.photo_library),
|
||||||
|
title: const Text('Choose from Gallery'),
|
||||||
|
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (source == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final pickedFile = await picker.pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 85,
|
||||||
|
maxWidth: 800,
|
||||||
|
maxHeight: 800,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pickedFile != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedImageFile = File(pickedFile.path);
|
||||||
|
_selectedImageBytes = null; // Will be loaded when uploading
|
||||||
|
});
|
||||||
|
|
||||||
|
// Automatically upload the image
|
||||||
|
await _uploadProfilePicture();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Failed to pick image', e);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to pick image: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadProfilePicture() async {
|
||||||
|
if (_selectedImageFile == null) return;
|
||||||
|
|
||||||
|
final immichService = ServiceLocator.instance.immichService;
|
||||||
|
if (immichService == null) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Immich service not available'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isUploading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uploadResponse = await immichService.uploadImage(_selectedImageFile!);
|
||||||
|
|
||||||
|
// Try to get a public shared link URL for the uploaded image
|
||||||
|
// This will be used in the Nostr profile so it can be accessed without authentication
|
||||||
|
String imageUrl;
|
||||||
|
try {
|
||||||
|
final publicUrl = await immichService.getPublicUrlForAsset(uploadResponse.id);
|
||||||
|
imageUrl = publicUrl;
|
||||||
|
Logger.info('Using public URL for profile picture: $imageUrl');
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback to authenticated URL if public URL creation fails
|
||||||
|
imageUrl = immichService.getImageUrl(uploadResponse.id);
|
||||||
|
Logger.warning('Failed to create public URL, using authenticated URL: $e');
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_profilePictureUrl = imageUrl;
|
||||||
|
_selectedImageBytes = null;
|
||||||
|
_isUploading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Profile picture uploaded successfully'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Failed to upload profile picture', e);
|
||||||
|
setState(() {
|
||||||
|
_isUploading = false;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to upload profile picture: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveProfile() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
if (_isSaving) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSaving = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final sessionService = ServiceLocator.instance.sessionService;
|
||||||
|
final nostrService = ServiceLocator.instance.nostrService;
|
||||||
|
|
||||||
|
if (sessionService == null || nostrService == null) {
|
||||||
|
throw Exception('Session or Nostr service not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user
|
||||||
|
final currentUser = sessionService.currentUser;
|
||||||
|
if (currentUser == null) {
|
||||||
|
throw Exception('No user logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has private key (required for publishing)
|
||||||
|
if (currentUser.nostrPrivateKey == null) {
|
||||||
|
throw Exception('Private key not available. Cannot update profile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create updated profile (or new one if doesn't exist)
|
||||||
|
final updatedProfile = NostrProfile(
|
||||||
|
publicKey: currentUser.id,
|
||||||
|
name: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(),
|
||||||
|
website: _websiteController.text.trim().isEmpty ? null : _websiteController.text.trim(),
|
||||||
|
lud16: _lightningController.text.trim().isEmpty ? null : _lightningController.text.trim(),
|
||||||
|
nip05: _nip05Controller.text.trim().isEmpty ? null : _nip05Controller.text.trim(),
|
||||||
|
picture: _profilePictureUrl,
|
||||||
|
about: currentUser.nostrProfile?.about, // Preserve existing about if any
|
||||||
|
banner: currentUser.nostrProfile?.banner, // Preserve existing banner if any
|
||||||
|
rawMetadata: currentUser.nostrProfile?.rawMetadata ?? {},
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert profile to metadata map for Nostr
|
||||||
|
final metadata = <String, dynamic>{};
|
||||||
|
if (updatedProfile.name != null) metadata['name'] = updatedProfile.name;
|
||||||
|
if (updatedProfile.picture != null) metadata['picture'] = updatedProfile.picture;
|
||||||
|
if (updatedProfile.website != null) metadata['website'] = updatedProfile.website;
|
||||||
|
if (updatedProfile.lud16 != null) metadata['lud16'] = updatedProfile.lud16;
|
||||||
|
if (updatedProfile.nip05 != null) metadata['nip05'] = updatedProfile.nip05;
|
||||||
|
if (updatedProfile.about != null) metadata['about'] = updatedProfile.about;
|
||||||
|
if (updatedProfile.banner != null) metadata['banner'] = updatedProfile.banner;
|
||||||
|
|
||||||
|
// Publish to Nostr (kind 0 event - profile metadata)
|
||||||
|
Logger.info('Publishing profile update to Nostr (kind 0)...');
|
||||||
|
final keyPair = NostrKeyPair.fromNsec(currentUser.nostrPrivateKey!);
|
||||||
|
final publishedEvent = await nostrService.syncMetadata(
|
||||||
|
metadata: metadata,
|
||||||
|
privateKey: keyPair.privateKey,
|
||||||
|
kind: 0, // Kind 0 is for profile metadata
|
||||||
|
);
|
||||||
|
Logger.info('Profile published to Nostr: ${publishedEvent.id}');
|
||||||
|
|
||||||
|
// Update user in session service
|
||||||
|
final updatedUser = currentUser.copyWith(
|
||||||
|
nostrProfile: updatedProfile,
|
||||||
|
username: updatedProfile.name ?? currentUser.username,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update session service (we'll need to add a method for this)
|
||||||
|
await sessionService.updateUserProfile(updatedUser);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Profile updated successfully'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(true); // Return true to indicate success
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Logger.error('Failed to save profile', e);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Failed to save profile: $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isSaving = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a profile picture widget using ImmichService for authenticated access.
|
||||||
|
Widget _buildAuthenticatedProfilePicture(String? imageUrl, {double radius = 60}) {
|
||||||
|
if (imageUrl == null) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: Colors.grey[300],
|
||||||
|
child: Icon(Icons.person, size: radius),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final immichService = ServiceLocator.instance.immichService;
|
||||||
|
|
||||||
|
// Try to extract asset ID from URL (format: .../api/assets/{id}/original)
|
||||||
|
final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl);
|
||||||
|
|
||||||
|
if (assetIdMatch != null && immichService != null) {
|
||||||
|
final assetId = assetIdMatch.group(1);
|
||||||
|
if (assetId != null) {
|
||||||
|
// Use ImmichService to fetch image with proper authentication
|
||||||
|
return FutureBuilder<Uint8List?>(
|
||||||
|
future: immichService.fetchImageBytes(assetId, isThumbnail: true),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: Colors.grey[300],
|
||||||
|
child: const CircularProgressIndicator(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError || !snapshot.hasData || snapshot.data == null) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: Colors.grey[300],
|
||||||
|
child: const Icon(Icons.broken_image),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundImage: MemoryImage(snapshot.data!),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to direct network image if not an Immich URL or service unavailable
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: Colors.grey[300],
|
||||||
|
backgroundImage: NetworkImage(imageUrl),
|
||||||
|
onBackgroundImageError: (_, __) {},
|
||||||
|
child: imageUrl == null ? Icon(Icons.person, size: radius) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProfilePicture() {
|
||||||
|
return Center(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
_buildAuthenticatedProfilePicture(_profilePictureUrl, radius: 60),
|
||||||
|
if (_isUploading)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).primaryColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.camera_alt, color: Colors.white),
|
||||||
|
onPressed: _isUploading ? null : _pickProfilePicture,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Edit Profile'),
|
||||||
|
actions: [
|
||||||
|
if (_isSaving)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16.0),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
onPressed: _saveProfile,
|
||||||
|
tooltip: 'Save',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildProfilePicture(),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Name',
|
||||||
|
hintText: 'Your display name',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.person),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _websiteController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Website',
|
||||||
|
hintText: 'https://example.com',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.language),
|
||||||
|
),
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _lightningController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Lightning Address',
|
||||||
|
hintText: 'yourname@domain.com',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.bolt),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _nip05Controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'NIP-05 ID',
|
||||||
|
hintText: 'yourname@domain.com',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.verified),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isSaving ? null : _saveProfile,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
|
child: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Save Profile'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in new issue