diff --git a/integration_test/mobile_ui_screens_test.dart b/integration_test/mobile_ui_screens_test.dart index 0891784..ce1f3a4 100644 --- a/integration_test/mobile_ui_screens_test.dart +++ b/integration_test/mobile_ui_screens_test.dart @@ -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 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. - 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)); + // Doppeltipp zoomt hinein, nochmal doppelt wieder heraus. + Future 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( 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 _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 _pumpFor(WidgetTester tester, Duration duration) async { final end = DateTime.now().add(duration); while (DateTime.now().isBefore(end)) { diff --git a/lib/core/app.dart b/lib/core/app.dart index df12de8..29d7c8b 100644 --- a/lib/core/app.dart +++ b/lib/core/app.dart @@ -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 Future _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 } 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 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, diff --git a/lib/core/app_state.dart b/lib/core/app_state.dart index 605436a..1ec05fa 100644 --- a/lib/core/app_state.dart +++ b/lib/core/app_state.dart @@ -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((ref) => true); // true = dark +final themeVariantProvider = + StateProvider((ref) => ThemeVariant.soft); + +/// Pyramid-Schriftfaktor; `null` = automatisch (Handy größer, PC 1,0). +final textScaleProvider = StateProvider((ref) => null); + +final messageStyleProvider = + StateProvider((ref) => MessageStyle.auto); final accentProvider = StateProvider((ref) => 0); // index into accent presets const accentPresets = [ diff --git a/lib/core/appearance.dart b/lib/core/appearance.dart new file mode 100644 index 0000000..52f1d67 --- /dev/null +++ b/lib/core/appearance.dart @@ -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); +} diff --git a/lib/core/settings_prefs.dart b/lib/core/settings_prefs.dart index 8d09968..7f8e590 100644 --- a/lib/core/settings_prefs.dart +++ b/lib/core/settings_prefs.dart @@ -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 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')), ); } diff --git a/lib/core/theme.dart b/lib/core/theme.dart index b7d1574..c417c9a 100644 --- a/lib/core/theme.dart +++ b/lib/core/theme.dart @@ -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()!; } - 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, ), diff --git a/lib/features/chat/gif_sticker_picker.dart b/lib/features/chat/gif_sticker_picker.dart index 6a65def..363a55e 100644 --- a/lib/features/chat/gif_sticker_picker.dart +++ b/lib/features/chat/gif_sticker_picker.dart @@ -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)), diff --git a/lib/features/chat/message/message_bubbles.dart b/lib/features/chat/message/message_bubbles.dart new file mode 100644 index 0000000..f020c54 --- /dev/null +++ b/lib/features/chat/message/message_bubbles.dart @@ -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 = []; + 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, + ), + ), + ); +} diff --git a/lib/features/chat/message/message_group_core.dart b/lib/features/chat/message/message_group_core.dart index cdfb6c6..0639602 100644 --- a/lib/features/chat/message/message_group_core.dart +++ b/lib/features/chat/message/message_group_core.dart @@ -64,7 +64,23 @@ class _MessageGroupState extends State { @override Widget build(BuildContext context) { - final pt = PyramidTheme.of(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 { // 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( diff --git a/lib/features/chat/message/message_text.dart b/lib/features/chat/message/message_text.dart index 9481d5d..38369a0 100644 --- a/lib/features/chat/message/message_text.dart +++ b/lib/features/chat/message/message_text.dart @@ -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)), ); } } diff --git a/lib/features/chat/message_group.dart b/lib/features/chat/message_group.dart index 7fd5795..db6c211 100644 --- a/lib/features/chat/message_group.dart +++ b/lib/features/chat/message_group.dart @@ -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'; diff --git a/lib/widgets/profile_popover.dart b/lib/widgets/profile_popover.dart index fded0d5..de86ebf 100644 --- a/lib/widgets/profile_popover.dart +++ b/lib/widgets/profile_popover.dart @@ -104,9 +104,9 @@ class _ProfilePopoverState extends State { 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 { 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 { 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 { ), 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 { ], 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)), ], diff --git a/lib/widgets/search_modal.dart b/lib/widgets/search_modal.dart index ee237b9..b373ad1 100644 --- a/lib/widgets/search_modal.dart +++ b/lib/widgets/search_modal.dart @@ -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: [ diff --git a/lib/widgets/settings/settings_appearance.dart b/lib/widgets/settings/settings_appearance.dart index 6da9b93..277955c 100644 --- a/lib/widgets/settings/settings_appearance.dart +++ b/lib/widgets/settings/settings_appearance.dart @@ -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, + ), + ], + ), + ); + }); + } +} diff --git a/lib/widgets/settings_modal.dart b/lib/widgets/settings_modal.dart index d80906e..32b7181 100644 --- a/lib/widgets/settings_modal.dart +++ b/lib/widgets/settings_modal.dart @@ -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 { @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 { 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 { 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 { 'about' => _AboutSection(pt: pt), _ => _AppearanceSection( pt: pt, - isDark: isDark, + variant: variant, + textScale: textScale, + messageStyle: messageStyle, accentIdx: accentIdx, radius: radius, density: density, diff --git a/test/live_ui_fixture_test.dart b/test/live_ui_fixture_test.dart index 86241f0..fe34f1f 100644 --- a/test/live_ui_fixture_test.dart +++ b/test/live_ui_fixture_test.dart @@ -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); diff --git a/test/settings_prefs_test.dart b/test/settings_prefs_test.dart index af1f393..1ddeccc 100644 --- a/test/settings_prefs_test.dart +++ b/test/settings_prefs_test.dart @@ -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', + ); }); });