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
+79 -12
View File
@@ -3,8 +3,12 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/main.dart' as app;
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
@@ -13,24 +17,26 @@ import 'package:pyramid/main.dart' as app;
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
/// Bernds echtes Profil. Testinhalte vorher mit
/// `test/live_ui_fixture_test.dart` anlegen (DM von pyramidtest2 mit Bildern).
/// `test/live_ui_fixture_test.dart` anlegen (DM + Gruppe mit pyramidtest2).
///
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
///
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
/// (zwei Clients auf einer olm-DB).
/// (zwei Clients auf einer olm-DB). Ansichten (Darstellung, Farbmodus, Raum)
/// werden nur über den App-Zustand umgeschaltet – nichts wird gespeichert.
///
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
const _otherUser = 'pyramidtest2';
const _groupName = 'UI-Test Gruppe';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
testWidgets(
'Handy-UI: Chatliste, Chat, Bild-Vollansicht',
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
(tester) async {
tester.view
..physicalSize = const Size(1080, 2340)
@@ -38,6 +44,7 @@ void main() {
..padding = const FakeViewPadding(top: 63, bottom: 42)
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
addTearDown(tester.view.reset);
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
app.main();
await _pumpUntil(
@@ -45,12 +52,24 @@ void main() {
find.text(_otherUser),
timeout: const Duration(seconds: 90),
);
final container = ProviderScope.containerOf(
tester.element(find.byType(MaterialApp).first),
);
await _shot(tester, '01_chatliste');
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
await tester.tap(find.text(_otherUser).first);
await _pumpFor(tester, const Duration(seconds: 6));
await _scrollToNewest(tester);
await _shot(tester, '02_chat');
container.read(messageStyleProvider.notifier).state = MessageStyle.list;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '02b_chat_liste');
container.read(messageStyleProvider.notifier).state = MessageStyle.auto;
await _pumpFor(tester, const Duration(seconds: 2));
// ── Bild-Vollansicht ──
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
Future<void> openViewer() async {
final image = _largestImage(tester);
@@ -69,8 +88,7 @@ void main() {
await openViewer();
await _shot(tester, '03_vollansicht');
// Tippen NEBEN das Bild (ganz unten, unter dem Hochkant-Bild) schließt.
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
// Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt.
await tester.tapAt(Offset(size.width / 2, size.height - 30));
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '04_nach_tippen_daneben');
@@ -90,11 +108,15 @@ void main() {
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
);
// Doppeltipp zoomt hinein.
// Doppeltipp zoomt hinein, nochmal doppelt wieder heraus.
Future<void> doubleTapCenter() async {
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await tester.pump(const Duration(milliseconds: 80));
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
}
await doubleTapCenter();
final viewer = tester.widget<InteractiveViewer>(
find.byType(InteractiveViewer),
);
@@ -104,11 +126,7 @@ void main() {
reason: 'Doppeltipp hat nicht gezoomt',
);
await _shot(tester, '05_doppeltipp_zoom');
// Nochmal doppelt → zurück auf 1.
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await tester.pump(const Duration(milliseconds: 80));
await tester.tapAt(Offset(size.width / 2, size.height / 2));
await _pumpFor(tester, const Duration(seconds: 1));
await doubleTapCenter();
// Wischen nach unten schließt.
await tester.dragFrom(
@@ -122,9 +140,46 @@ void main() {
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
);
await _shot(tester, '06_nach_wischen');
// ── Farbmodus Hell (nur Zustand) ──
final savedVariant = container.read(themeVariantProvider);
container.read(themeVariantProvider.notifier).state = ThemeVariant.light;
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '07_chat_hell');
container.read(themeVariantProvider.notifier).state = savedVariant;
await _pumpFor(tester, const Duration(seconds: 1));
// ── Gruppenraum (Namen + Avatare in den Sprechblasen) ──
container.read(activeRoomIdProvider.notifier).state = null;
container.read(activeSpaceIdProvider.notifier).state = 'rooms';
await _pumpUntil(
tester,
find.text(_groupName),
timeout: const Duration(seconds: 30),
);
await tester.tap(find.text(_groupName).first);
await _pumpFor(tester, const Duration(seconds: 5));
await _scrollToNewest(tester);
await _shot(tester, '08_gruppe');
// ── Einstellungen → Erscheinungsbild ──
container.read(activeModalProvider.notifier).state = ModalKind.settings;
await _pumpFor(tester, const Duration(seconds: 2));
await tester.tap(find.text('Erscheinungsbild').first);
await _pumpFor(tester, const Duration(seconds: 2));
await _shot(tester, '09_einstellungen_erscheinungsbild');
await tester.drag(
find.text('Schriftgröße').first,
const Offset(0, -500),
warnIfMissed: false,
);
await _pumpFor(tester, const Duration(seconds: 1));
await _shot(tester, '10_einstellungen_text_chat');
container.read(activeModalProvider.notifier).state = ModalKind.none;
await _pumpFor(tester, const Duration(seconds: 1));
},
skip: profile == null,
timeout: const Timeout(Duration(minutes: 5)),
timeout: const Timeout(Duration(minutes: 6)),
);
}
@@ -143,6 +198,18 @@ Finder? _largestImage(WidgetTester tester) {
return best == null ? null : find.byElementPredicate((e) => e == best);
}
/// Ganz nach unten zu den neuesten Nachrichten scrollen.
Future<void> _scrollToNewest(WidgetTester tester) async {
for (var i = 0; i < 6; i++) {
final lists = find.byType(Scrollable);
if (lists.evaluate().isEmpty) return;
await tester.fling(lists.first, const Offset(0, -1500), 3000,
warnIfMissed: false);
await _pumpFor(tester, const Duration(milliseconds: 500));
}
await _pumpFor(tester, const Duration(seconds: 2));
}
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
final end = DateTime.now().add(duration);
while (DateTime.now().isBefore(end)) {
+24 -6
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:pyramid/core/app_state.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/notification_service.dart' show checkPendingReply, setAppForeground;
import 'package:pyramid/core/router.dart';
import 'package:pyramid/core/settings_prefs.dart';
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
Future<void> _loadPersistedPrefs() async {
final saved = await loadThemePrefs();
if (!mounted) return;
ref.read(themeModeProvider.notifier).state = saved.isDark;
ref.read(themeVariantProvider.notifier).state = saved.variant;
ref.read(textScaleProvider.notifier).state = saved.textScale;
ref.read(messageStyleProvider.notifier).state = saved.messageStyle;
ref.read(accentProvider.notifier).state = saved.accentIdx;
ref.read(radiusProvider.notifier).state = saved.radius;
ref.read(motionProvider.notifier).state = saved.motion;
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
}
final router = ref.watch(routerProvider);
final isDark = ref.watch(themeModeProvider);
final variant = ref.watch(themeVariantProvider);
final textScale = ref.watch(textScaleProvider);
final accentIdx = ref.watch(accentProvider);
final radius = ref.watch(radiusProvider);
final density = ref.watch(densityProvider);
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
final accent = accentPresets[accentIdx].color;
return PyramidTheme(
isDark: isDark,
variant: variant,
accent: accent,
radius: radius,
density: density,
motion: motion,
child: MaterialApp.router(
title: 'Pyramid',
theme: buildPyramidThemeData(false),
darkTheme: buildPyramidThemeData(true),
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
theme: buildPyramidThemeData(ThemeVariant.light),
darkTheme: buildPyramidThemeData(
variant.isDark ? variant : ThemeVariant.soft),
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
routerConfig: router,
// Schriftgröße: Pyramid-Regler × Handy-/Systemeinstellung, für alle
// Seiten und Dialoge (liegt über dem Navigator).
builder: (context, child) {
final mq = MediaQuery.of(context);
final factor = textScale ??
defaultTextScale(mobile: isMobileLayout(mq.size));
return MediaQuery(
data: mq.copyWith(
textScaler: PyramidTextScaler(mq.textScaler, factor),
),
child: child!,
);
},
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
+9 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:pyramid/core/matrix_client.dart';
@@ -7,7 +8,14 @@ import 'package:pyramid/features/rooms/rooms_provider.dart';
// ─── Theme state ──────────────────────────────────────────────────────────────
final themeModeProvider = StateProvider<bool>((ref) => true); // true = dark
final themeVariantProvider =
StateProvider<ThemeVariant>((ref) => ThemeVariant.soft);
/// Pyramid-Schriftfaktor; `null` = automatisch (Handy größer, PC 1,0).
final textScaleProvider = StateProvider<double?>((ref) => null);
final messageStyleProvider =
StateProvider<MessageStyle>((ref) => MessageStyle.auto);
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
const accentPresets = [
+73
View File
@@ -0,0 +1,73 @@
import 'package:flutter/widgets.dart';
/// Darstellungs-Einstellungen, die nicht reine Farben sind (die stehen in
/// theme.dart): Handy-Layout-Erkennung, Schriftgröße, Nachrichten-Darstellung.
/// Unterhalb dieser Breite (oder sehr flach, z. B. Handy quer) zeigt Pyramid
/// das Handy-Layout (eine Spalte, Chat als eigene Seite).
bool isMobileLayout(Size size) => size.width < 900 || size.height < 500;
// ── Schriftgröße ───────────────────────────────────────────────────────────
/// Standard-Schriftfaktor, solange der Nutzer nichts eingestellt hat: auf dem
/// Handy größer (Bernds Wunsch 2026-10-07: ≈ 16 statt 14 px), am PC wie bisher.
double defaultTextScale({required bool mobile}) => mobile ? 1.15 : 1.0;
const minTextScale = 0.85;
const maxTextScale = 1.5;
/// Kombiniert die Schriftgröße des Systems (Handy-Einstellung) mit dem
/// Pyramid-eigenen Regler: erst Pyramid-Faktor, dann die (ggf. nichtlineare)
/// Systemskalierung.
class PyramidTextScaler extends TextScaler {
const PyramidTextScaler(this.system, this.factor);
final TextScaler system;
final double factor;
@override
double scale(double fontSize) => system.scale(fontSize * factor);
@override
// ignore: deprecated_member_use
double get textScaleFactor => system.textScaleFactor * factor;
@override
bool operator ==(Object other) =>
other is PyramidTextScaler &&
other.system == system &&
other.factor == factor;
@override
int get hashCode => Object.hash(system, factor);
}
// ── Nachrichten-Darstellung ────────────────────────────────────────────────
/// Wie Nachrichten im Chat aussehen (Einstellungen → Erscheinungsbild).
enum MessageStyle {
/// Handy: Sprechblasen, PC: Liste.
auto,
/// Sprechblasen wie WhatsApp/Signal (eigene rechts, andere links).
bubbles,
/// Liste wie Discord/Element (Name + Text untereinander).
list;
String get label => switch (this) {
MessageStyle.auto => 'Automatisch',
MessageStyle.bubbles => 'Sprechblasen',
MessageStyle.list => 'Liste',
};
/// Tatsächlich anzuwendende Darstellung.
bool useBubbles({required bool mobile}) => switch (this) {
MessageStyle.auto => mobile,
MessageStyle.bubbles => true,
MessageStyle.list => false,
};
static MessageStyle fromName(String? name) => MessageStyle.values
.firstWhere((v) => v.name == name, orElse: () => MessageStyle.auto);
}
+39 -5
View File
@@ -2,6 +2,8 @@ import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/theme.dart';
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
@@ -143,26 +145,58 @@ class VoicePrefs {
// ── Theme persistence helpers ────────────────────────────────────────────────
Future<void> saveThemePrefs({
bool? isDark,
ThemeVariant? variant,
int? accentIdx,
double? radius,
double? motion,
double? textScale,
bool resetTextScale = false,
MessageStyle? messageStyle,
}) async {
final p = await SharedPreferences.getInstance();
if (isDark != null) await p.setBool('theme_dark', isDark);
if (variant != null) {
await p.setString('theme_variant', variant.name);
// Altes Feld mitpflegen (falls jemand auf eine ältere Version zurückgeht).
await p.setBool('theme_dark', variant.isDark);
}
if (accentIdx != null) await p.setInt('theme_accent', accentIdx);
if (radius != null) await p.setDouble('theme_radius', radius);
if (motion != null) await p.setDouble('theme_motion', motion);
if (resetTextScale) {
await p.remove('ui_text_scale');
} else if (textScale != null) {
await p.setDouble('ui_text_scale', textScale);
}
if (messageStyle != null) {
await p.setString('chat_message_style', messageStyle.name);
}
}
Future<({bool isDark, int accentIdx, double radius, double motion})>
loadThemePrefs() async {
Future<
({
ThemeVariant variant,
int accentIdx,
double radius,
double motion,
double? textScale,
MessageStyle messageStyle,
})> loadThemePrefs() async {
final p = await SharedPreferences.getInstance();
// Vor 2026-10-07 gab es nur hell/dunkel ('theme_dark'). Wer dunkel hatte,
// bekommt jetzt das neue „Weiche Dunkel“ (Bernds Entscheidung).
final storedVariant = p.getString('theme_variant');
final variant = storedVariant != null
? ThemeVariant.fromName(storedVariant)
: (p.getBool('theme_dark') ?? true)
? ThemeVariant.soft
: ThemeVariant.light;
return (
isDark: p.getBool('theme_dark') ?? true,
variant: variant,
accentIdx: p.getInt('theme_accent') ?? 0,
radius: p.getDouble('theme_radius') ?? 12.0,
motion: p.getDouble('theme_motion') ?? 1.0,
textScale: p.getDouble('ui_text_scale'),
messageStyle: MessageStyle.fromName(p.getString('chat_message_style')),
);
}
+123 -21
View File
@@ -1,6 +1,103 @@
import 'package:flutter/material.dart';
// Pyramid design tokens — matching mockup CSS variables exactly
/// Farbschema der App (Einstellungen → Erscheinungsbild → Farbmodus).
enum ThemeVariant {
/// „Weiches Dunkel“ – Dunkelgrau statt Fast-Schwarz, mehr Kontrast bei
/// Text. Standard seit 2026-10-07 (Bernds Entscheidung).
soft,
/// Das bisherige Fast-Schwarz (spart auf OLED-Displays etwas Akku).
black,
/// Hell.
light;
bool get isDark => this != ThemeVariant.light;
String get label => switch (this) {
ThemeVariant.soft => 'Dunkel',
ThemeVariant.black => 'Schwarz',
ThemeVariant.light => 'Hell',
};
static ThemeVariant fromName(String? name) =>
ThemeVariant.values.firstWhere((v) => v.name == name,
orElse: () => ThemeVariant.soft);
}
/// Die Flächen- und Textfarben eines [ThemeVariant].
class PyramidPalette {
const PyramidPalette({
required this.bg0,
required this.bg1,
required this.bg2,
required this.bg3,
required this.bgHover,
required this.bgActive,
required this.border,
required this.borderStrong,
required this.fg,
required this.fgMuted,
required this.fgDim,
});
final Color bg0, bg1, bg2, bg3, bgHover, bgActive, border, borderStrong;
final Color fg, fgMuted, fgDim;
/// Weiches Dunkel: alle Flächen ~10 % heller als „Schwarz“, Nebentexte
/// deutlich kontrastreicher (fgDim auf bg1 ≈ 5:1 statt ≈ 3,8:1).
static const soft = PyramidPalette(
bg0: Color(0xFF15161A),
bg1: Color(0xFF1B1C21),
bg2: Color(0xFF212228),
bg3: Color(0xFF282A31),
bgHover: Color(0xFF2F3139),
bgActive: Color(0xFF373945),
border: Color(0xFF2C2E36),
borderStrong: Color(0xFF3A3C46),
fg: Color(0xFFF2F2F5),
fgMuted: Color(0xFFB7B7C2),
fgDim: Color(0xFF8B8B99),
);
static const black = PyramidPalette(
bg0: PyramidColors.bg0,
bg1: PyramidColors.bg1,
bg2: PyramidColors.bg2,
bg3: PyramidColors.bg3,
bgHover: PyramidColors.bgHover,
bgActive: PyramidColors.bgActive,
border: PyramidColors.border,
borderStrong: PyramidColors.borderStrong,
fg: PyramidColors.fg,
fgMuted: PyramidColors.fgMuted,
fgDim: PyramidColors.fgDim,
);
static const light = PyramidPalette(
bg0: PyramidColors.bg0Light,
bg1: PyramidColors.bg1Light,
bg2: PyramidColors.bg2Light,
bg3: PyramidColors.bg3Light,
bgHover: PyramidColors.bgHoverLight,
bgActive: PyramidColors.bgActiveLight,
border: PyramidColors.borderLight,
borderStrong: PyramidColors.borderStrongLight,
fg: PyramidColors.fgLight,
fgMuted: PyramidColors.fgMutedLight,
fgDim: PyramidColors.fgDimLight,
);
static PyramidPalette of(ThemeVariant variant) => switch (variant) {
ThemeVariant.soft => soft,
ThemeVariant.black => black,
ThemeVariant.light => light,
};
}
// Pyramid design tokens — matching mockup CSS variables exactly.
// Die dunklen Werte hier sind die Variante „Schwarz“; Widgets sollten die
// Farben über PyramidTheme.of(context) holen (folgt dem gewählten Schema).
class PyramidColors {
const PyramidColors._();
@@ -49,7 +146,7 @@ class PyramidColors {
}
class PyramidTheme extends InheritedWidget {
final bool isDark;
final ThemeVariant variant;
final Color accent;
final double radius;
final double density;
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
const PyramidTheme({
super.key,
required super.child,
this.isDark = true,
this.variant = ThemeVariant.soft,
this.accent = PyramidColors.accentAmber,
this.radius = 12,
this.density = 1,
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
}
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light;
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light;
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight;
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight;
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight;
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight;
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight;
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight;
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight;
bool get isDark => variant.isDark;
PyramidPalette get palette => PyramidPalette.of(variant);
Color get bg0 => palette.bg0;
Color get bg1 => palette.bg1;
Color get bg2 => palette.bg2;
Color get bg3 => palette.bg3;
Color get bgHover => palette.bgHover;
Color get bgActive => palette.bgActive;
Color get border => palette.border;
Color get borderStrong => palette.borderStrong;
Color get fg => palette.fg;
Color get fgMuted => palette.fgMuted;
Color get fgDim => palette.fgDim;
Color get danger => PyramidColors.danger;
Color get online => PyramidColors.online;
Color get away => PyramidColors.away;
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
@override
bool updateShouldNotify(PyramidTheme oldWidget) =>
isDark != oldWidget.isDark ||
variant != oldWidget.variant ||
accent != oldWidget.accent ||
radius != oldWidget.radius ||
density != oldWidget.density ||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
ThemeData buildPyramidThemeData(bool isDark) {
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight;
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
ThemeData buildPyramidThemeData(ThemeVariant variant) {
final palette = PyramidPalette.of(variant);
final isDark = variant.isDark;
final fg = palette.fg;
final bg1 = palette.bg1;
final accent = PyramidColors.accentAmber;
return ThemeData(
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
),
textTheme: TextTheme(
bodyMedium: _base(fg).copyWith(fontSize: 14),
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight),
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight),
bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
),
dividerTheme: DividerThemeData(
color: isDark ? PyramidColors.border : PyramidColors.borderLight,
color: palette.border,
thickness: 1,
space: 1,
),
+1 -1
View File
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
child: Text(title,
style: TextStyle(color: PyramidColors.fgDim,
style: TextStyle(color: PyramidTheme.of(context).fgDim,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.6)),
@@ -0,0 +1,261 @@
part of '../message_group.dart';
// Sprechblasen-Darstellung (Einstellung „Nachrichten: Sprechblasen“, auf dem
// Handy Standard – Bernds Entscheidung 2026-10-07). Eigene Nachrichten rechts
// in Akzentfarbe, andere links; in Gruppenräumen mit Name + Avatar, in
// Direktnachrichten ohne. Bilder/Videos ohne Blase, dafür in voller Größe.
// Inhalt (Text, Antwort-Zitat, Medien), Reaktionen, Auswahl, Hover-Aktionen
// und Wischen-zum-Antworten kommen aus denselben Bausteinen wie die Liste.
const _kBubbleMaxWidthFactor = 0.82;
const _kBubbleAvatarSize = 30.0;
const _kBubbleAvatarGap = 8.0;
extension _BubbleBuilder on _MessageGroupState {
Widget _buildBubbles(BuildContext context, PyramidTheme pt) {
final firstEvent = widget.events.first;
final isOwn = firstEvent.senderId == widget.currentUserId;
final isDm = widget.room?.isDirectChat ?? false;
// Name + Avatar nur bei fremden Nachrichten in Gruppenräumen.
final showSender = !isOwn && !isDm;
final sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname();
final initial = name.isNotEmpty ? name[0].toUpperCase() : '?';
final color = _colorFromId(firstEvent.senderId);
return LayoutBuilder(builder: (context, constraints) {
final rowWidth = constraints.maxWidth - 24; // 12 px Rand links/rechts
final senderSpace =
showSender ? _kBubbleAvatarSize + _kBubbleAvatarGap : 0.0;
final maxBubbleWidth =
((rowWidth - senderSpace) * _kBubbleMaxWidthFactor).clamp(120.0, 560.0);
final rows = <Widget>[];
for (var j = 0; j < widget.events.length; j++) {
final event = widget.events[j];
final isFirst = j == 0;
final isLast = j == widget.events.length - 1;
final bubbleColumn = Column(
crossAxisAlignment:
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_Bubble(
event: event,
pt: pt,
isOwn: isOwn,
isFirst: isFirst,
isLast: isLast,
senderName: showSender && isFirst ? name : null,
senderColor: color,
maxWidth: maxBubbleWidth,
timeline: widget.timeline,
),
if (widget.timeline != null)
_ReactionsRow(
event: event,
timeline: widget.timeline!,
room: widget.room,
currentUserId: widget.currentUserId,
pt: pt,
),
],
);
final line = Row(
mainAxisAlignment:
isOwn ? MainAxisAlignment.end : MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSender) ...[
SizedBox(
width: _kBubbleAvatarSize,
child: isFirst
? _Avatar(
initial: initial,
color: color,
pt: pt,
size: _kBubbleAvatarSize,
avatarUri: sender.avatarUrl,
)
: null,
),
const SizedBox(width: _kBubbleAvatarGap),
],
Flexible(child: bubbleColumn),
],
);
rows.add(_selectableRow(
event,
pt,
Padding(
padding: EdgeInsets.only(
top: isFirst ? pt.msgGap / 2 : 3,
left: 12,
right: 12,
),
child: line,
),
));
if (widget.readEventIds.contains(event.eventId)) {
rows.add(_ReadMarker(pt: pt));
}
if (widget.fullyReadId == event.eventId && !isLast) {
rows.add(_InlineUnreadDivider(pt: pt));
}
}
if (widget.showStatusIndicator &&
isOwn &&
widget.room != null &&
!widget.readEventIds.contains(widget.events.last.eventId)) {
rows.add(Padding(
padding: const EdgeInsets.only(right: 16, bottom: 1),
child: Align(
alignment: Alignment.centerRight,
child: _MessageStatus(lastEvent: widget.events.last, pt: pt),
),
));
}
rows.add(const SizedBox(height: 2));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: rows,
);
});
}
}
/// Eine einzelne Sprechblase (bzw. bei Bildern/Videos: das Medium ohne Blase).
class _Bubble extends StatelessWidget {
final Event event;
final PyramidTheme pt;
final bool isOwn;
final bool isFirst;
final bool isLast;
final String? senderName;
final Color senderColor;
final double maxWidth;
final Timeline? timeline;
const _Bubble({
required this.event,
required this.pt,
required this.isOwn,
required this.isFirst,
required this.isLast,
required this.senderName,
required this.senderColor,
required this.maxWidth,
this.timeline,
});
Event get _displayEvent =>
timeline != null ? event.getDisplayEvent(timeline!) : event;
bool get _isVisualMedia {
final e = _displayEvent;
if (e.type == EventTypes.Sticker) return true;
return e.type == EventTypes.Message &&
(e.messageType == MessageTypes.Image ||
e.messageType == MessageTypes.Video);
}
/// Eigene Blase: Akzentfarbe, mit der Fläche verrechnet (bleibt lesbar).
static Color ownColor(PyramidTheme pt) => Color.alphaBlend(
pt.accent.withValues(alpha: pt.isDark ? 0.30 : 0.24),
pt.bg1,
);
BorderRadius get _radius {
const big = Radius.circular(18);
const small = Radius.circular(6);
// Aufeinanderfolgende Blasen „kleben“ auf der Absenderseite zusammen.
final topSide = isFirst ? big : small;
final bottomSide = isLast ? big : small;
return isOwn
? BorderRadius.only(
topLeft: big, bottomLeft: big, topRight: topSide, bottomRight: bottomSide)
: BorderRadius.only(
topRight: big, bottomRight: big, topLeft: topSide, bottomLeft: bottomSide);
}
@override
Widget build(BuildContext context) {
final time = Text(
_formatMessageTime(event.originServerTs),
style: TextStyle(color: pt.fgDim, fontSize: 11),
);
final failed = event.status.isError
? _SendFailedRow(event: event, pt: pt)
: null;
if (_isVisualMedia) {
// Ohne Blase: Bilder/Videos sollen so groß wie möglich sein.
final box = _reservedImageSize(
_displayEvent,
maxW: maxWidth.clamp(80.0, 420.0),
maxH: inlineMediaMaxHeight(context),
);
return Column(
crossAxisAlignment:
isOwn ? CrossAxisAlignment.end : CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (senderName != null) _senderLabel(),
SizedBox(
width: box?.width ?? maxWidth,
child: _MessageContent(event: event, pt: pt, timeline: timeline),
),
?failed,
Padding(padding: const EdgeInsets.only(top: 2), child: time),
],
);
}
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 7, 12, 6),
decoration: BoxDecoration(
color: isOwn ? ownColor(pt) : pt.bg3,
borderRadius: _radius,
),
// Außen „end“: Inhalt (breitestes Kind) füllt die Blase, die Uhrzeit
// sitzt unten rechts – ohne die Blase auf volle Breite zu ziehen.
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (senderName != null) _senderLabel(),
_MessageContent(event: event, pt: pt, timeline: timeline),
?failed,
],
),
const SizedBox(height: 2),
time,
],
),
),
);
}
Widget _senderLabel() => Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
senderName!,
style: TextStyle(
color: senderColor,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
);
}
@@ -64,7 +64,23 @@ class _MessageGroupState extends State<MessageGroup> {
@override
Widget build(BuildContext context) {
// Darstellung laut Einstellung (Erscheinungsbild → Nachrichten):
// Sprechblasen (message_bubbles.dart) oder Liste wie bisher.
return Consumer(builder: (context, ref, _) {
final style = ref.watch(messageStyleProvider);
final pt = PyramidTheme.of(context);
final bubbles =
style.useBubbles(mobile: isMobileLayout(MediaQuery.sizeOf(context)));
return _withSwipeToReply(
pt,
bubbles ? _buildBubbles(context, pt) : _buildList(context, pt),
);
});
}
// Listen-Darstellung (Discord-Stil): Avatar + Name + Zeit, darunter die
// Nachrichten des Absenders bündig untereinander.
Widget _buildList(BuildContext context, PyramidTheme pt) {
final firstEvent = widget.events.first;
final sender = firstEvent.senderFromMemoryOrFallback;
final name = sender.calcDisplayname();
@@ -168,11 +184,14 @@ class _MessageGroupState extends State<MessageGroup> {
// Hover highlight + actions are now per-row (see _selectableRow), so the
// group is just the column of rows.
Widget content = Column(
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rows,
);
}
// Handy: nach links wischen = antworten (beide Darstellungen).
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
if (!_isMobile) return content;
return GestureDetector(
+2 -2
View File
@@ -213,7 +213,7 @@ class _MatrixHtmlText extends StatelessWidget {
Widget build(BuildContext context) {
final spans = _parseHtml(html, baseStyle, linkColor);
return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
);
}
@@ -388,7 +388,7 @@ class _PlainLinkText extends StatelessWidget {
}
return SelectionArea(
child: RichText(text: TextSpan(children: spans, style: baseStyle)),
child: Text.rich(TextSpan(children: spans, style: baseStyle)),
);
}
}
+3
View File
@@ -9,6 +9,8 @@ import 'package:pyramid/features/chat/media_player.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:matrix/matrix.dart';
import 'package:pyramid/core/app_state.dart' show messageStyleProvider;
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/matrix_client.dart';
import 'package:pyramid/core/media_cache.dart';
import 'package:pyramid/core/theme.dart';
@@ -22,6 +24,7 @@ import 'package:pyramid/widgets/mxc_image.dart';
import 'package:url_launcher/url_launcher.dart';
part 'message/message_group_core.dart';
part 'message/message_bubbles.dart';
part 'message/message_text.dart';
part 'message/message_link_preview.dart';
part 'message/message_media.dart';
+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,
),
],
),
);
});
}
}
+24 -10
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,15 +102,21 @@ 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: [
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,
@@ -118,7 +125,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
],
),
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,
+37 -1
View File
@@ -203,8 +203,44 @@ void main() {
);
await room.sendTextEvent('Und noch eine letzte Nachricht 👋');
// Eigene Nachrichten (aus Sicht von pyramidtest1 = rechts).
final myDm = c1.getRoomById(roomId)!;
await myDm.sendTextEvent('Danke, sieht gut aus!');
await myDm.sendTextEvent(
'Und eine etwas längere eigene Antwort, damit man sieht, wie '
'Sprechblasen umbrechen, wenn der Text über mehrere Zeilen geht.',
);
// Gruppenraum (Name + Avatar je Absender in der Sprechblasen-Ansicht).
const groupName = 'UI-Test Gruppe';
Room? group;
for (final r in c2.rooms) {
if (r.membership == Membership.join && r.name == groupName) {
group = r;
}
}
if (group == null) {
final groupId = await c2.createRoom(
name: groupName,
preset: CreateRoomPreset.privateChat,
invite: [c1.userID!],
);
await c2
.waitForRoomInSync(groupId, join: true)
.timeout(const Duration(seconds: 60));
group = c2.getRoomById(groupId)!;
await c1.oneShotSync().timeout(const Duration(seconds: 60));
await c1.getRoomById(groupId)?.join();
await c1
.waitForRoomInSync(groupId, join: true)
.timeout(const Duration(seconds: 60));
}
await group.sendTextEvent('Hallo zusammen! Wer ist am Samstag dabei?');
await group.sendTextEvent('Ich bringe Kuchen mit.');
await c1.getRoomById(group.id)!.sendTextEvent('Ich komme gern!');
// ignore: avoid_print
print('UI-Testraum bereit: $roomId');
print('UI-Testraum bereit: $roomId, Gruppe: ${group.id}');
} finally {
await _logout(c1);
await _logout(c2);
+102 -4
View File
@@ -1,5 +1,8 @@
import 'package:flutter/painting.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pyramid/core/appearance.dart';
import 'package:pyramid/core/settings_prefs.dart';
import 'package:pyramid/core/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Tests für die Storage-Fassade, die laut M2-Modulschnitt zur Pflicht-
@@ -94,22 +97,117 @@ void main() {
group('Theme-Prefs', () {
test('leerer Store liefert die dokumentierten Defaults', () async {
final t = await loadThemePrefs();
expect(t.isDark, true);
expect(
t.variant,
ThemeVariant.soft,
reason: 'Standard seit 2026-10-07: weiches Dunkel',
);
expect(t.accentIdx, 0);
expect(t.radius, 12.0);
expect(t.motion, 1.0);
expect(t.textScale, isNull, reason: 'null = automatisch');
expect(t.messageStyle, MessageStyle.auto);
});
test('Roundtrip: gespeicherte Werte kommen zurück, Teil-Save lässt '
'den Rest unangetastet', () async {
await saveThemePrefs(isDark: false, accentIdx: 3, radius: 8, motion: 0.5);
await saveThemePrefs(
variant: ThemeVariant.light,
accentIdx: 3,
radius: 8,
motion: 0.5,
textScale: 1.3,
messageStyle: MessageStyle.list,
);
var t = await loadThemePrefs();
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 3, 8.0, 0.5));
expect(
(
t.variant,
t.accentIdx,
t.radius,
t.motion,
t.textScale,
t.messageStyle,
),
(ThemeVariant.light, 3, 8.0, 0.5, 1.3, MessageStyle.list),
);
// Nur ein Feld ändern – die anderen dürfen nicht zurückfallen.
await saveThemePrefs(accentIdx: 1);
t = await loadThemePrefs();
expect((t.isDark, t.accentIdx, t.radius, t.motion), (false, 1, 8.0, 0.5));
expect(
(
t.variant,
t.accentIdx,
t.radius,
t.motion,
t.textScale,
t.messageStyle,
),
(ThemeVariant.light, 1, 8.0, 0.5, 1.3, MessageStyle.list),
);
// Schriftgröße zurück auf automatisch.
await saveThemePrefs(resetTextScale: true);
t = await loadThemePrefs();
expect(t.textScale, isNull);
});
test(
'Übernahme alter Einstellungen (vor 2026-10-07 nur theme_dark)',
() async {
SharedPreferences.setMockInitialValues({'theme_dark': true});
expect(
(await loadThemePrefs()).variant,
ThemeVariant.soft,
reason: 'wer dunkel hatte, bekommt das neue weiche Dunkel',
);
SharedPreferences.setMockInitialValues({'theme_dark': false});
expect((await loadThemePrefs()).variant, ThemeVariant.light);
},
);
test(
'Variante „Schwarz“ wird gespeichert und theme_dark mitgepflegt',
() async {
await saveThemePrefs(variant: ThemeVariant.black);
final p = await SharedPreferences.getInstance();
expect(p.getString('theme_variant'), 'black');
expect(
p.getBool('theme_dark'),
true,
reason: 'ältere App-Versionen lesen nur theme_dark',
);
expect((await loadThemePrefs()).variant, ThemeVariant.black);
},
);
});
group('Darstellung', () {
test('Sprechblasen: Automatisch = Handy ja, PC nein', () {
expect(MessageStyle.auto.useBubbles(mobile: true), isTrue);
expect(MessageStyle.auto.useBubbles(mobile: false), isFalse);
expect(MessageStyle.bubbles.useBubbles(mobile: false), isTrue);
expect(MessageStyle.list.useBubbles(mobile: true), isFalse);
});
test('Schriftfaktor wirkt zusätzlich zur Systemeinstellung', () {
const system = TextScaler.linear(1.2);
const scaler = PyramidTextScaler(system, 1.15);
expect(scaler.scale(14), closeTo(14 * 1.15 * 1.2, 0.001));
expect(defaultTextScale(mobile: true), greaterThan(1.0));
expect(defaultTextScale(mobile: false), 1.0);
});
test('Handy-Layout-Grenze', () {
expect(isMobileLayout(const Size(411, 891)), isTrue);
expect(isMobileLayout(const Size(1280, 720)), isFalse);
expect(
isMobileLayout(const Size(891, 411)),
isTrue,
reason: 'Handy quer (flach) bleibt Handy-Layout',
);
});
});