feat: UI-Umbau - weiches Dunkel, Schriftgroessen-Regler, Sprechblasen umschaltbar, Einstellungen als Vollbild

Bernds Entscheidungen 2026-10-07:
- theme.dart: ThemeVariant (Dunkel = neues weiches Dunkelgrau als Standard,
  Schwarz = bisheriges Fast-Schwarz, Hell) mit PyramidPalette; Nebentexte
  kontrastreicher. Alte Einstellung theme_dark wird uebernommen
  (dunkel -> weiches Dunkel). Fest verdrahtete Dunkelfarben im Profil-Popup
  und GIF-Picker folgen jetzt dem Farbmodus.
- appearance.dart: Schriftgroesse = Pyramid-Regler x Systemeinstellung
  (Handy-Standard 115 %, PC 100 %), MessageStyle Automatisch/Sprechblasen/
  Liste, gemeinsame isMobileLayout-Regel.
- message_bubbles.dart: Sprechblasen-Darstellung (eigene rechts in
  Akzentfarbe, Gruppen mit Name + Avatar, Bilder ohne Blase in voller Groesse);
  Listen-Darstellung unveraendert, Weiche in MessageGroup.
- Formatierte Nachrichten (u. a. Antworten) nutzten RichText ohne
  Schriftskalierung -> erschienen kleiner; jetzt Text.rich.
- Einstellungen auf dem Handy als ganze Seite, Schalter unter dem Text.
- UI-Durchlauf um Liste/Hell/Gruppe/Einstellungen erweitert; Tests fuer
  Theme-Varianten, Uebernahme alter Werte, Schriftfaktor, Darstellung.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Bernd Steckmeister
2026-10-07 17:16:07 +02:00
co-authored by Claude Opus 5.5
parent a1d64ec813
commit c5f10901e6
17 changed files with 973 additions and 102 deletions
+18 -18
View File
@@ -104,9 +104,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
child: Container(
width: popoverW,
decoration: BoxDecoration(
color: PyramidColors.bg0,
color: PyramidTheme.of(context).bg0,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: PyramidColors.border),
border: Border.all(color: PyramidTheme.of(context).border),
boxShadow: const [
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
],
@@ -172,7 +172,7 @@ class _ProfilePopoverState extends State<ProfilePopover> {
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: PyramidColors.bg0, width: 3),
border: Border.all(color: PyramidTheme.of(context).bg0, width: 3),
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null
@@ -212,14 +212,14 @@ class _ProfilePopoverState extends State<ProfilePopover> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName,
style: const TextStyle(
color: PyramidColors.fg,
style: TextStyle(
color: PyramidTheme.of(context).fg,
fontSize: 17,
fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(handle,
style: const TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)),
style: TextStyle(
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
const SizedBox(height: 12),
// Action buttons
Row(
@@ -248,9 +248,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
),
if (powerLevel > 0) ...[
const SizedBox(height: 14),
const Text('Rollen',
Text('Rollen',
style: TextStyle(
color: PyramidColors.fgDim,
color: PyramidTheme.of(context).fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
@@ -270,16 +270,16 @@ class _ProfilePopoverState extends State<ProfilePopover> {
],
if (joinDate != null) ...[
const SizedBox(height: 14),
const Text('Mitglied seit',
Text('Mitglied seit',
style: TextStyle(
color: PyramidColors.fgDim,
color: PyramidTheme.of(context).fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8)),
const SizedBox(height: 4),
Text(joinDate,
style: const TextStyle(
color: PyramidColors.fgMuted, fontSize: 12)),
style: TextStyle(
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
],
],
),
@@ -337,7 +337,7 @@ class _PresenceDot extends StatelessWidget {
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(color: PyramidColors.bg0, width: 2),
border: Border.all(color: PyramidTheme.of(context).bg0, width: 2),
),
);
}
@@ -380,10 +380,10 @@ class _ProfileBtnState extends State<_ProfileBtn> {
? (_hover
? PyramidTheme.of(context).accent.withAlpha(200)
: PyramidTheme.of(context).accent)
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2),
: (_hover ? PyramidTheme.of(context).bgHover : PyramidTheme.of(context).bg2),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.primary ? Colors.transparent : PyramidColors.border,
color: widget.primary ? Colors.transparent : PyramidTheme.of(context).border,
),
),
child: Row(
@@ -393,14 +393,14 @@ class _ProfileBtnState extends State<_ProfileBtn> {
size: 14,
color: widget.primary
? PyramidColors.accentFg
: PyramidColors.fg),
: PyramidTheme.of(context).fg),
if (hasLabel) ...[
const SizedBox(width: 6),
Text(widget.label!,
style: TextStyle(
color: widget.primary
? PyramidColors.accentFg
: PyramidColors.fg,
: PyramidTheme.of(context).fg,
fontSize: 13,
fontWeight: FontWeight.w500)),
],
+1
View File
@@ -623,6 +623,7 @@ class _MessageItemState extends State<_MessageItem> {
]),
const SizedBox(height: 1),
RichText(
textScaler: MediaQuery.textScalerOf(context),
maxLines: 2,
overflow: TextOverflow.ellipsis,
text: TextSpan(children: [
+146 -9
View File
@@ -2,7 +2,9 @@ part of '../settings_modal.dart';
class _AppearanceSection extends StatelessWidget {
final PyramidTheme pt;
final bool isDark;
final ThemeVariant variant;
final double? textScale;
final MessageStyle messageStyle;
final int accentIdx;
final double radius;
final double density;
@@ -11,7 +13,9 @@ class _AppearanceSection extends StatelessWidget {
const _AppearanceSection({
required this.pt,
required this.isDark,
required this.variant,
required this.textScale,
required this.messageStyle,
required this.accentIdx,
required this.radius,
required this.density,
@@ -21,6 +25,12 @@ class _AppearanceSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final mobile = isMobileLayout(MediaQuery.sizeOf(context));
final autoScale = defaultTextScale(mobile: mobile);
final effectiveScale = textScale ?? autoScale;
final variants = ThemeVariant.values;
final styles = MessageStyle.values;
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -30,18 +40,18 @@ class _AppearanceSection extends StatelessWidget {
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
pt: pt),
_SettingsGroup(title: 'Theme', pt: pt, children: [
_SettingsRow(
_AdaptiveSettingsRow(
title: 'Farbmodus',
desc: 'Hell oder dunkel',
desc: 'Dunkel = weiches Dunkelgrau, Schwarz = wie früher (spart '
'auf OLED-Displays Akku)',
pt: pt,
control: _SegControl(
options: const ['Dunkel', 'Hell'],
selected: isDark ? 0 : 1,
options: [for (final v in variants) v.label],
selected: variants.indexOf(variant),
pt: pt,
onSelect: (i) {
final dark = i == 0;
ref.read(themeModeProvider.notifier).state = dark;
saveThemePrefs(isDark: dark);
ref.read(themeVariantProvider.notifier).state = variants[i];
saveThemePrefs(variant: variants[i]);
},
),
),
@@ -80,6 +90,85 @@ class _AppearanceSection extends StatelessWidget {
),
]),
const SizedBox(height: 20),
_SettingsGroup(title: 'Text & Chat', pt: pt, children: [
_AdaptiveSettingsRow(
title: 'Schriftgröße',
desc: textScale == null
? 'Automatisch (${(autoScale * 100).round()} %) – zusätzlich '
'zur Schriftgröße des Geräts'
: '${(effectiveScale * 100).round()} %',
pt: pt,
control: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: mobile ? 250 : 170,
child: SliderTheme(
data: SliderThemeData(
activeTrackColor: pt.accent,
thumbColor: pt.accent,
inactiveTrackColor: pt.border,
trackHeight: 3,
thumbShape:
const RoundSliderThumbShape(enabledThumbRadius: 7),
),
child: Slider(
value: effectiveScale.clamp(minTextScale, maxTextScale),
min: minTextScale,
max: maxTextScale,
divisions: ((maxTextScale - minTextScale) / 0.05).round(),
onChanged: (v) {
final rounded = (v * 20).round() / 20;
ref.read(textScaleProvider.notifier).state = rounded;
saveThemePrefs(textScale: rounded);
},
),
),
),
if (textScale != null)
TextButton(
onPressed: () {
ref.read(textScaleProvider.notifier).state = null;
saveThemePrefs(resetTextScale: true);
},
child: Text('Standard',
style: TextStyle(color: pt.accent, fontSize: 12)),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: pt.bg3,
borderRadius: BorderRadius.circular(pt.rBase),
),
child: Text(
'Vorschau: So groß erscheinen Nachrichten im Chat.',
style: TextStyle(color: pt.fg, fontSize: 14),
),
),
),
_Divider(pt: pt),
_AdaptiveSettingsRow(
title: 'Nachrichten',
desc: 'Automatisch: Handy Sprechblasen, PC Liste',
pt: pt,
control: _SegControl(
options: [for (final m in styles) m.label],
selected: styles.indexOf(messageStyle),
pt: pt,
onSelect: (i) {
ref.read(messageStyleProvider.notifier).state = styles[i];
saveThemePrefs(messageStyle: styles[i]);
},
),
),
]),
const SizedBox(height: 20),
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
_SettingsRow(
title: 'Eckenrundung',
@@ -185,3 +274,51 @@ class _AppearanceSection extends StatelessWidget {
);
}
}
/// Wie [_SettingsRow], aber auf schmalen Bildschirmen steht das Bedienelement
/// UNTER dem Text statt daneben (sonst quetschen sich Schalter mit drei
/// Optionen auf dem Handy).
class _AdaptiveSettingsRow extends StatelessWidget {
final String title;
final String? desc;
final PyramidTheme pt;
final Widget control;
const _AdaptiveSettingsRow({
required this.title,
required this.pt,
required this.control,
this.desc,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
if (constraints.maxWidth >= 520) {
return _SettingsRow(title: title, desc: desc, pt: pt, control: control);
}
// Volle Breite: _SettingsGroup zentriert seine Kinder sonst.
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: TextStyle(
color: pt.fg, fontSize: 13, fontWeight: FontWeight.w500)),
if (desc != null) ...[
const SizedBox(height: 2),
Text(desc!, style: TextStyle(color: pt.fgDim, fontSize: 12)),
],
const SizedBox(height: 10),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: control,
),
],
),
);
});
}
}
+30 -16
View File
@@ -21,6 +21,7 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
import 'package:path_provider/path_provider.dart';
import 'package:pointycastle/export.dart' as pc;
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/auth_log.dart';
import 'package:pyramid/core/e2ee_diagnostics.dart';
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
@@ -85,7 +86,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
@override
Widget build(BuildContext context) {
final pt = PyramidTheme.of(context);
final isDark = ref.watch(themeModeProvider);
final isDark = ref.watch(themeVariantProvider).isDark;
final accentIdx = ref.watch(accentProvider);
final radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider);
@@ -101,24 +102,30 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
child: Center(
child: GestureDetector(
onTap: () {},
// Handy: ganze Seite (wie System-Einstellungen) statt schwebender
// Karte mit abgedunkeltem Rand – mehr Platz, weniger Gequetsche.
child: Container(
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660),
width: isMobile ? size.width * 0.97 : 740,
height: isMobile ? size.height * 0.92 : 560,
constraints: isMobile
? null
: const BoxConstraints(maxWidth: 820, maxHeight: 660),
width: isMobile ? size.width : 740,
height: isMobile ? size.height : 560,
decoration: BoxDecoration(
color: pt.bg1,
borderRadius: BorderRadius.circular(pt.rXl),
border: Border.all(color: pt.border),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(130),
blurRadius: 40,
offset: const Offset(0, 12),
),
],
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
border: isMobile ? null : Border.all(color: pt.border),
boxShadow: isMobile
? null
: [
BoxShadow(
color: Colors.black.withAlpha(130),
blurRadius: 40,
offset: const Offset(0, 12),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(pt.rXl),
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
child: isMobile
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
: _desktopLayout(pt, isDark, accentIdx, radius, density, motion),
@@ -242,10 +249,15 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
double radius, double density, double motion, String section) {
final variant = ref.watch(themeVariantProvider);
final textScale = ref.watch(textScaleProvider);
final messageStyle = ref.watch(messageStyleProvider);
return switch (section) {
'appearance' => _AppearanceSection(
pt: pt,
isDark: isDark,
variant: variant,
textScale: textScale,
messageStyle: messageStyle,
accentIdx: accentIdx,
radius: radius,
density: density,
@@ -261,7 +273,9 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
'about' => _AboutSection(pt: pt),
_ => _AppearanceSection(
pt: pt,
isDark: isDark,
variant: variant,
textScale: textScale,
messageStyle: messageStyle,
accentIdx: accentIdx,
radius: radius,
density: density,