part of '../settings_modal.dart'; class _ProfileSection extends ConsumerStatefulWidget { final PyramidTheme pt; const _ProfileSection({required this.pt}); @override ConsumerState<_ProfileSection> createState() => _ProfileSectionState(); } class _ProfileSectionState extends ConsumerState<_ProfileSection> { late TextEditingController _nameCtrl; late TextEditingController _statusCtrl; bool _saving = false; bool _saved = false; String? _error; Uint8List? _pendingAvatar; Uri? _avatarUri; Uint8List? _pendingBanner; String? _bannerMxcUri; Uint8List? _pendingServerBanner; bool _serverBannerSaving = false; @override void initState() { super.initState(); _nameCtrl = TextEditingController(); _statusCtrl = TextEditingController(); _loadProfile(); } @override void dispose() { _nameCtrl.dispose(); _statusCtrl.dispose(); super.dispose(); } Future _loadProfile() async { final client = await ref.read(matrixClientProvider.future); if (!mounted) return; // 1) SOFORT aus dem lokalen Cache füllen — kein Warten auf den Server. _nameCtrl.text = client.userID ?? ''; for (final room in client.rooms) { final u = room.unsafeGetUserFromMemoryOrFallback(client.userID!); if (u.displayName != null || u.avatarUrl != null) { if (u.displayName != null) _nameCtrl.text = u.displayName!; _avatarUri = u.avatarUrl; break; } } try { final bannerData = client.accountData['com.pyramid.profile.banner']; _bannerMxcUri = bannerData?.content['url'] as String?; } catch (_) {} setState(() {}); // 2) Server-Daten PARALLEL nachladen, UI aktualisiert sich pro Antwort. client .getProfileFromUserId(client.userID!) .then((profile) { if (!mounted) return; setState(() { if (profile.displayName != null) _nameCtrl.text = profile.displayName!; _avatarUri = profile.avatarUrl ?? _avatarUri; }); }).catchError((_) {}); () async { try { final apiUri = client.homeserver!.replace( path: '/_matrix/client/v3/profile/${Uri.encodeComponent(client.userID!)}', ); final resp = await client.httpClient.get( apiUri, headers: {'Authorization': 'Bearer ${client.accessToken}'}, ); if (resp.statusCode == 200 && mounted) { final data = jsonDecode(resp.body) as Map; final url = data['io.pyramid.profile.banner_url'] as String?; if (url != null) setState(() => _bannerMxcUri = url); } } catch (_) {} }(); } Future _pickAvatar() async { final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final bytes = file.bytes ?? await file.xFile.readAsBytes(); setState(() => _pendingAvatar = bytes); } Future _pickBanner() async { final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final raw = file.bytes ?? await file.xFile.readAsBytes(); if (!mounted) return; final cropped = await showDialog( context: context, builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0), ); if (cropped == null || !mounted) return; setState(() => _pendingBanner = cropped); } Future _pickServerBanner() async { final result = await FilePicker.platform.pickFiles(type: FileType.image, withData: true); final file = result?.files.first; if (file == null) return; final raw = file.bytes ?? await file.xFile.readAsBytes(); if (!mounted) return; final cropped = await showDialog( context: context, builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 2.4), ); if (cropped == null || !mounted) return; setState(() => _serverBannerSaving = true); try { final client = await ref.read(matrixClientProvider.future); final uri = await client.uploadContent(cropped, filename: 'server_banner.png', contentType: 'image/png'); await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), uri.toString()); if (mounted) setState(() { _pendingServerBanner = cropped; _serverBannerSaving = false; }); } catch (_) { if (mounted) setState(() => _serverBannerSaving = false); } } Future _removeServerBanner() async { final client = ref.read(matrixClientProvider).valueOrNull; if (client == null) return; await ref.read(serverBannerProvider.notifier).set(client.homeserver.toString(), null); if (mounted) setState(() => _pendingServerBanner = null); } Future _save() async { setState(() { _saving = true; _error = null; _saved = false; }); try { final client = await ref.read(matrixClientProvider.future); final name = _nameCtrl.text.trim(); if (name.isNotEmpty) { await client.setProfileField( client.userID!, 'displayname', {'displayname': name}, ); } if (_pendingAvatar != null) { await client.setAvatar(MatrixFile( bytes: _pendingAvatar!, name: 'avatar.jpg', mimeType: 'image/jpeg', )); setState(() => _pendingAvatar = null); } if (_pendingBanner != null) { final uri = await client.uploadContent( _pendingBanner!, filename: 'banner.png', contentType: 'image/png'); final uriStr = uri.toString(); // Store as a profile custom field so other users can see it await client.setProfileField( client.userID!, 'io.pyramid.profile.banner_url', {'io.pyramid.profile.banner_url': uriStr}, ); // Also keep in account data as legacy fallback await client.setAccountData(client.userID!, 'com.pyramid.profile.banner', {'url': uriStr}); setState(() { _bannerMxcUri = uriStr; _pendingBanner = null; }); } final status = _statusCtrl.text.trim(); await client.setPresence( client.userID!, PresenceType.online, statusMsg: status.isEmpty ? null : status, ); if (mounted) setState(() { _saving = false; _saved = true; }); await Future.delayed(const Duration(seconds: 2)); if (mounted) setState(() => _saved = false); } catch (e) { if (mounted) setState(() { _saving = false; _error = e.toString().split('\n').first; }); } } @override Widget build(BuildContext context) { final pt = widget.pt; final clientAsync = ref.watch(matrixClientProvider); final client = clientAsync.valueOrNull; return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle( title: 'Mein Profil', subtitle: 'Wie du in Pyramid erscheinst.', pt: pt), // Banner GestureDetector( onTap: _pickBanner, child: Stack( children: [ Container( height: 100, decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: _pendingBanner != null ? Image.memory(_pendingBanner!, fit: BoxFit.cover, width: double.infinity) : (client != null && _bannerMxcUri != null ? MxcImage( mxcUri: _bannerMxcUri!, client: client, fit: BoxFit.cover, width: double.infinity, ) : Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add_photo_alternate_outlined, size: 28, color: pt.fgDim), const SizedBox(height: 4), Text('Profilbanner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), )), ), Positioned( right: 8, bottom: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: pt.bg0.withAlpha(200), borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), const SizedBox(width: 4), Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ], ), ), ), ], ), ), const SizedBox(height: 16), // Avatar row Row( children: [ GestureDetector( onTap: _pickAvatar, child: Stack( children: [ Container( width: 72, height: 72, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: pt.border, width: 2), ), child: ClipOval( child: _pendingAvatar != null ? Image.memory(_pendingAvatar!, fit: BoxFit.cover) : (client != null && _avatarUri != null ? MxcAvatar( mxcUri: _avatarUri, client: client, size: 72, placeholder: (_) => _AvatarFallback( name: _nameCtrl.text, size: 72, pt: pt), ) : _AvatarFallback( name: _nameCtrl.text, size: 72, pt: pt)), ), ), Positioned( right: 0, bottom: 0, child: Container( width: 24, height: 24, decoration: BoxDecoration( color: pt.accent, shape: BoxShape.circle, border: Border.all(color: pt.bg1, width: 2), ), child: Icon(Icons.edit_rounded, size: 12, color: pt.accentFg), ), ), ], ), ), const SizedBox(width: 20), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _nameCtrl.text.isNotEmpty ? _nameCtrl.text : (client?.userID ?? ''), style: TextStyle( color: pt.fg, fontSize: 16, fontWeight: FontWeight.w600), ), const SizedBox(height: 2), SelectableText( client?.userID ?? '', style: TextStyle(color: pt.fgDim, fontSize: 12), ), const SizedBox(height: 6), if (_pendingAvatar != null) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: pt.accent.withAlpha(40), borderRadius: BorderRadius.circular(6), ), child: Text('Neues Bild ausgewählt', style: TextStyle(color: pt.accent, fontSize: 11)), ), ], ), ), ], ), const SizedBox(height: 24), _SettingsGroup(title: 'Name & Status', pt: pt, children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 14, 14, 4), child: _PrefTextField( controller: _nameCtrl, label: 'Anzeigename', pt: pt, ), ), const SizedBox(height: 2), Padding( padding: const EdgeInsets.fromLTRB(14, 4, 14, 14), child: _PrefTextField( controller: _statusCtrl, label: 'Statusnachricht (optional)', pt: pt, maxLength: 120, ), ), ]), const SizedBox(height: 20), if (_error != null) Padding( padding: const EdgeInsets.only(bottom: 12), child: _StatusCard( pt: pt, color: pt.danger, icon: Icons.error_outline_rounded, text: _error!), ), if (_saved) Padding( padding: const EdgeInsets.only(bottom: 12), child: _StatusCard( pt: pt, color: PyramidColors.success, icon: Icons.check_circle_outline_rounded, text: 'Profil gespeichert.'), ), _PrimaryBtn( label: _saving ? 'Speichern…' : 'Speichern', icon: Icons.save_outlined, pt: pt, loading: _saving, onPressed: _save, ), const SizedBox(height: 24), // ── Server-Banner ───────────────────────────────────────────────── _SectionTitle( title: 'Server-Darstellung', subtitle: 'Banner für ${client?.homeserver?.host ?? 'diesen Server'}', pt: pt, ), Consumer(builder: (context, ref, _) { final serverBanners = ref.watch(serverBannerProvider).valueOrNull ?? {}; final homeserver = client?.homeserver.toString(); final existingMxc = homeserver != null ? serverBanners[homeserver] : null; final hasBanner = _pendingServerBanner != null || existingMxc != null; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ GestureDetector( onTap: _serverBannerSaving ? null : _pickServerBanner, child: Container( height: 90, decoration: BoxDecoration( color: pt.bg3, borderRadius: BorderRadius.circular(pt.rBase), border: Border.all(color: pt.border), ), clipBehavior: Clip.hardEdge, child: Stack( fit: StackFit.expand, children: [ if (_pendingServerBanner != null) Image.memory(_pendingServerBanner!, fit: BoxFit.cover, errorBuilder: (_, _, _) => const SizedBox.shrink()) else if (existingMxc != null && client != null) MxcImage(mxcUri: existingMxc, client: client, fit: BoxFit.cover) else Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.add_photo_alternate_outlined, size: 24, color: pt.fgDim), const SizedBox(height: 4), Text('Server-Banner auswählen', style: TextStyle(color: pt.fgDim, fontSize: 12)), ], ), ), if (_serverBannerSaving) Container( color: Colors.black38, child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), ), if (hasBanner) Positioned( right: 8, bottom: 8, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: pt.bg0.withAlpha(200), borderRadius: BorderRadius.circular(6), border: Border.all(color: pt.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.edit_rounded, size: 11, color: pt.fgMuted), const SizedBox(width: 4), Text('Banner ändern', style: TextStyle(color: pt.fgMuted, fontSize: 11)), ], ), ), ), ], ), ), ), if (hasBanner) ...[ const SizedBox(height: 8), GestureDetector( onTap: _removeServerBanner, child: Text( 'Banner entfernen', style: TextStyle(color: pt.danger, fontSize: 12), ), ), ], ], ); }), const SizedBox(height: 24), _SettingsGroup(title: 'Konto', pt: pt, children: [ _SettingsRow( title: 'Passwort ändern', desc: 'Aktuelles Passwort zum Ändern erforderlich', pt: pt, showArrow: true, onTap: _changePassword, ), ]), const SizedBox(height: 12), _SettingsGroup(title: 'Gefahrenbereich', pt: pt, children: [ _SettingsRow( title: 'Konto löschen', desc: 'Konto dauerhaft deaktivieren – nicht rückgängig zu machen', pt: pt, showArrow: true, destructive: true, onTap: _deleteAccount, ), ]), ], ), ); } Future _changePassword() async { await showDialog( context: context, barrierDismissible: false, builder: (ctx) => _ChangePasswordDialog(pt: widget.pt), ); } Future _deleteAccount() async { await showDialog( context: context, barrierDismissible: false, builder: (ctx) => _DeleteAccountDialog(pt: widget.pt), ); } }