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/models/nostr_keypair.dart'; /// Screen for editing user profile information. class UserEditScreen extends StatefulWidget { final User user; const UserEditScreen({ super.key, required this.user, }); @override State createState() => _UserEditScreenState(); } class _UserEditScreenState extends State { final _formKey = GlobalKey(); final _nameController = TextEditingController(); final _websiteController = TextEditingController(); final _lightningController = TextEditingController(); final _nip05Controller = TextEditingController(); String? _profilePictureUrl; File? _selectedImageFile; 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 _pickProfilePicture() async { final picker = ImagePicker(); // Show dialog to choose source final source = await showDialog( 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); }); // 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 _uploadProfilePicture() async { if (_selectedImageFile == null) return; final mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Media service not available'), backgroundColor: Colors.orange, ), ); } return; } setState(() { _isUploading = true; }); try { final uploadResult = await mediaService.uploadImage(_selectedImageFile!); // Try to get a public shared link URL for the uploaded image (Immich only) // For Blossom, the URL is already public String imageUrl; final immichService = ServiceLocator.instance.immichService; if (immichService != null && uploadResult.containsKey('id')) { try { final publicUrl = await immichService.getPublicUrlForAsset(uploadResult['id'] as String); imageUrl = publicUrl; Logger.info('Using public URL for profile picture: $imageUrl'); } catch (e) { // Fallback to authenticated URL if public URL creation fails imageUrl = mediaService.getImageUrl(uploadResult['id'] as String); Logger.warning('Failed to create public URL, using authenticated URL: $e'); } } else { // For Blossom or if no ID, use the URL from upload result imageUrl = uploadResult['url'] as String? ?? mediaService.getImageUrl(uploadResult['hash'] as String? ?? uploadResult['id'] as String? ?? ''); } setState(() { _profilePictureUrl = imageUrl; _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 _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 = {}; 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 mediaService = ServiceLocator.instance.mediaService; if (mediaService == null) { return CircleAvatar( radius: radius, backgroundColor: Colors.grey[300], child: const Icon(Icons.person, size: 50), ); } // Try to extract asset ID from Immich URL (format: .../api/assets/{id}/original) final assetIdMatch = RegExp(r'/api/assets/([^/]+)/').firstMatch(imageUrl); String? assetId; if (assetIdMatch != null) { assetId = assetIdMatch.group(1); } else { // For Blossom URLs, use the full URL assetId = imageUrl; } if (assetId != null) { // Use MediaService to fetch image with proper authentication return FutureBuilder( future: mediaService.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 // Note: imageUrl is guaranteed to be non-null here due to early return check above return CircleAvatar( radius: radius, backgroundColor: Colors.grey[300], backgroundImage: NetworkImage(imageUrl), onBackgroundImageError: (_, __) {}, ); } Widget _buildProfilePicture() { return Center( child: Stack( children: [ _buildAuthenticatedProfilePicture(_profilePictureUrl, radius: 60), if (_isUploading) Positioned.fill( child: Container( decoration: BoxDecoration( color: Colors.black.withValues(alpha: 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'), ), ], ), ), ), ); } }