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