Files
pyramid/lib/widgets/settings/settings_profile.dart
Bernd Steckmeister 5d772389d6 refactor: Gott-Datei settings_modal.dart in 12 Dateien aufgeteilt
Reine Verschiebung per Dart part/part-of (keine Umbenennung, keine
Logikänderung) - settings_modal.dart schrumpft von 5541 auf 270 Zeilen.
Verifiziert per automatisiertem Blockvergleich gegen das Original und
flutter analyze (Baseline unverändert: 1 bekannter Hinweis).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-03 10:56:48 +02:00

514 lines
20 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> _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<String, dynamic>;
final url = data['io.pyramid.profile.banner_url'] as String?;
if (url != null) setState(() => _bannerMxcUri = url);
}
} catch (_) {}
}();
}
Future<void> _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<void> _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<Uint8List>(
context: context,
builder: (_) => BannerCropDialog(imageBytes: raw, aspectRatio: 3.0),
);
if (cropped == null || !mounted) return;
setState(() => _pendingBanner = cropped);
}
Future<void> _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<Uint8List>(
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<void> _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<void> _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<void> _changePassword() async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _ChangePasswordDialog(pt: widget.pt),
);
}
Future<void> _deleteAccount() async {
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => _DeleteAccountDialog(pt: widget.pt),
);
}
}