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:
co-authored by
Claude Opus 5.5
parent
a1d64ec813
commit
c5f10901e6
@@ -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.
|
||||
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<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
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
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<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(
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import 'package:pretty_qr_code/pretty_qr_code.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pointycastle/export.dart' as pc;
|
||||
import 'package:pyramid/core/app_state.dart';
|
||||
import 'package:pyramid/core/appearance.dart';
|
||||
import 'package:pyramid/core/auth_log.dart';
|
||||
import 'package:pyramid/core/e2ee_diagnostics.dart';
|
||||
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
|
||||
@@ -85,7 +86,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pt = PyramidTheme.of(context);
|
||||
final isDark = ref.watch(themeModeProvider);
|
||||
final isDark = ref.watch(themeVariantProvider).isDark;
|
||||
final accentIdx = ref.watch(accentProvider);
|
||||
final radius = ref.watch(radiusProvider);
|
||||
final density = ref.watch(densityProvider);
|
||||
@@ -101,24 +102,30 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {},
|
||||
// Handy: ganze Seite (wie System-Einstellungen) statt schwebender
|
||||
// Karte mit abgedunkeltem Rand – mehr Platz, weniger Gequetsche.
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
||||
width: isMobile ? size.width * 0.97 : 740,
|
||||
height: isMobile ? size.height * 0.92 : 560,
|
||||
constraints: isMobile
|
||||
? null
|
||||
: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
||||
width: isMobile ? size.width : 740,
|
||||
height: isMobile ? size.height : 560,
|
||||
decoration: BoxDecoration(
|
||||
color: pt.bg1,
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
border: Border.all(color: pt.border),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(130),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||
border: isMobile ? null : Border.all(color: pt.border),
|
||||
boxShadow: isMobile
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(130),
|
||||
blurRadius: 40,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(pt.rXl),
|
||||
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||
child: isMobile
|
||||
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
|
||||
: _desktopLayout(pt, isDark, accentIdx, radius, density, motion),
|
||||
@@ -242,10 +249,15 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
|
||||
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
|
||||
double radius, double density, double motion, String section) {
|
||||
final variant = ref.watch(themeVariantProvider);
|
||||
final textScale = ref.watch(textScaleProvider);
|
||||
final messageStyle = ref.watch(messageStyleProvider);
|
||||
return switch (section) {
|
||||
'appearance' => _AppearanceSection(
|
||||
pt: pt,
|
||||
isDark: isDark,
|
||||
variant: variant,
|
||||
textScale: textScale,
|
||||
messageStyle: messageStyle,
|
||||
accentIdx: accentIdx,
|
||||
radius: radius,
|
||||
density: density,
|
||||
@@ -261,7 +273,9 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
||||
'about' => _AboutSection(pt: pt),
|
||||
_ => _AppearanceSection(
|
||||
pt: pt,
|
||||
isDark: isDark,
|
||||
variant: variant,
|
||||
textScale: textScale,
|
||||
messageStyle: messageStyle,
|
||||
accentIdx: accentIdx,
|
||||
radius: radius,
|
||||
density: density,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user