5d772389d6
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>
514 lines
20 KiB
Dart
514 lines
20 KiB
Dart
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),
|
||
);
|
||
}
|
||
}
|