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/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:integration_test/integration_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;
|
import 'package:pyramid/main.dart' as app;
|
||||||
|
|
||||||
/// UI-Durchlauf im HANDY-Format mit der echten App (Windows-Build) und einem
|
/// 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.
|
/// Läuft nur mit gesetztem PYRAMID_PROFILE_DIR (eingeloggtes Testprofil, z. B.
|
||||||
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
|
/// pyramidtest1 unter %USERPROFILE%\.pyramid-autopilot\profile1) – nie gegen
|
||||||
/// Bernds echtes Profil. Testinhalte vorher mit
|
/// 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"
|
/// $env:PYRAMID_PROFILE_DIR="$env:USERPROFILE\.pyramid-autopilot\profile1"
|
||||||
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
/// flutter test integration_test/mobile_ui_screens_test.dart -d windows
|
||||||
///
|
///
|
||||||
/// Achtung: Keine zweite Pyramid-Instanz auf demselben Profil laufen lassen
|
/// 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
|
/// Simuliert wird ein typisches Handy: 1080×2340 px, Dichte 2,625
|
||||||
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
|
/// (≈ 411×891 dp), Statusleiste oben 24 dp, Gestenleiste unten 16 dp.
|
||||||
const _otherUser = 'pyramidtest2';
|
const _otherUser = 'pyramidtest2';
|
||||||
|
const _groupName = 'UI-Test Gruppe';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
final profile = Platform.environment['PYRAMID_PROFILE_DIR'];
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'Handy-UI: Chatliste, Chat, Bild-Vollansicht',
|
'Handy-UI: Chatliste, Chat, Bild-Vollansicht, Gruppe, Einstellungen',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
tester.view
|
tester.view
|
||||||
..physicalSize = const Size(1080, 2340)
|
..physicalSize = const Size(1080, 2340)
|
||||||
@@ -38,6 +44,7 @@ void main() {
|
|||||||
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
..padding = const FakeViewPadding(top: 63, bottom: 42)
|
||||||
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
..viewPadding = const FakeViewPadding(top: 63, bottom: 42);
|
||||||
addTearDown(tester.view.reset);
|
addTearDown(tester.view.reset);
|
||||||
|
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
||||||
|
|
||||||
app.main();
|
app.main();
|
||||||
await _pumpUntil(
|
await _pumpUntil(
|
||||||
@@ -45,12 +52,24 @@ void main() {
|
|||||||
find.text(_otherUser),
|
find.text(_otherUser),
|
||||||
timeout: const Duration(seconds: 90),
|
timeout: const Duration(seconds: 90),
|
||||||
);
|
);
|
||||||
|
final container = ProviderScope.containerOf(
|
||||||
|
tester.element(find.byType(MaterialApp).first),
|
||||||
|
);
|
||||||
await _shot(tester, '01_chatliste');
|
await _shot(tester, '01_chatliste');
|
||||||
|
|
||||||
|
// ── Direktnachricht: Sprechblasen (Handy-Standard) und Liste ──
|
||||||
await tester.tap(find.text(_otherUser).first);
|
await tester.tap(find.text(_otherUser).first);
|
||||||
await _pumpFor(tester, const Duration(seconds: 6));
|
await _pumpFor(tester, const Duration(seconds: 6));
|
||||||
|
await _scrollToNewest(tester);
|
||||||
await _shot(tester, '02_chat');
|
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.
|
// Größtes Bild im Chat = eines der Testbilder; erst sichtbar scrollen.
|
||||||
Future<void> openViewer() async {
|
Future<void> openViewer() async {
|
||||||
final image = _largestImage(tester);
|
final image = _largestImage(tester);
|
||||||
@@ -69,8 +88,7 @@ void main() {
|
|||||||
await openViewer();
|
await openViewer();
|
||||||
await _shot(tester, '03_vollansicht');
|
await _shot(tester, '03_vollansicht');
|
||||||
|
|
||||||
// Tippen NEBEN das Bild (ganz unten, unter dem Hochkant-Bild) schließt.
|
// Tippen NEBEN das Bild (ganz unten, unter dem Bild) schließt.
|
||||||
final size = tester.view.physicalSize / tester.view.devicePixelRatio;
|
|
||||||
await tester.tapAt(Offset(size.width / 2, size.height - 30));
|
await tester.tapAt(Offset(size.width / 2, size.height - 30));
|
||||||
await _pumpFor(tester, const Duration(seconds: 2));
|
await _pumpFor(tester, const Duration(seconds: 2));
|
||||||
await _shot(tester, '04_nach_tippen_daneben');
|
await _shot(tester, '04_nach_tippen_daneben');
|
||||||
@@ -90,11 +108,15 @@ void main() {
|
|||||||
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
|
reason: 'Tippen aufs Bild darf die Vollansicht nicht schließen',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Doppeltipp zoomt hinein.
|
// Doppeltipp zoomt hinein, nochmal doppelt wieder heraus.
|
||||||
|
Future<void> doubleTapCenter() async {
|
||||||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
await tester.pump(const Duration(milliseconds: 80));
|
await tester.pump(const Duration(milliseconds: 80));
|
||||||
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
await tester.tapAt(Offset(size.width / 2, size.height / 2));
|
||||||
await _pumpFor(tester, const Duration(seconds: 1));
|
await _pumpFor(tester, const Duration(seconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
await doubleTapCenter();
|
||||||
final viewer = tester.widget<InteractiveViewer>(
|
final viewer = tester.widget<InteractiveViewer>(
|
||||||
find.byType(InteractiveViewer),
|
find.byType(InteractiveViewer),
|
||||||
);
|
);
|
||||||
@@ -104,11 +126,7 @@ void main() {
|
|||||||
reason: 'Doppeltipp hat nicht gezoomt',
|
reason: 'Doppeltipp hat nicht gezoomt',
|
||||||
);
|
);
|
||||||
await _shot(tester, '05_doppeltipp_zoom');
|
await _shot(tester, '05_doppeltipp_zoom');
|
||||||
// Nochmal doppelt → zurück auf 1.
|
await doubleTapCenter();
|
||||||
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));
|
|
||||||
|
|
||||||
// Wischen nach unten schließt.
|
// Wischen nach unten schließt.
|
||||||
await tester.dragFrom(
|
await tester.dragFrom(
|
||||||
@@ -122,9 +140,46 @@ void main() {
|
|||||||
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
|
reason: 'Wischen nach unten hat die Vollansicht nicht geschlossen',
|
||||||
);
|
);
|
||||||
await _shot(tester, '06_nach_wischen');
|
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,
|
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);
|
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 {
|
Future<void> _pumpFor(WidgetTester tester, Duration duration) async {
|
||||||
final end = DateTime.now().add(duration);
|
final end = DateTime.now().add(duration);
|
||||||
while (DateTime.now().isBefore(end)) {
|
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_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:pyramid/core/app_state.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/notification_service.dart' show checkPendingReply, setAppForeground;
|
||||||
import 'package:pyramid/core/router.dart';
|
import 'package:pyramid/core/router.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
@@ -52,7 +53,9 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
Future<void> _loadPersistedPrefs() async {
|
Future<void> _loadPersistedPrefs() async {
|
||||||
final saved = await loadThemePrefs();
|
final saved = await loadThemePrefs();
|
||||||
if (!mounted) return;
|
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(accentProvider.notifier).state = saved.accentIdx;
|
||||||
ref.read(radiusProvider.notifier).state = saved.radius;
|
ref.read(radiusProvider.notifier).state = saved.radius;
|
||||||
ref.read(motionProvider.notifier).state = saved.motion;
|
ref.read(motionProvider.notifier).state = saved.motion;
|
||||||
@@ -75,7 +78,8 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
}
|
}
|
||||||
|
|
||||||
final router = ref.watch(routerProvider);
|
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 accentIdx = ref.watch(accentProvider);
|
||||||
final radius = ref.watch(radiusProvider);
|
final radius = ref.watch(radiusProvider);
|
||||||
final density = ref.watch(densityProvider);
|
final density = ref.watch(densityProvider);
|
||||||
@@ -83,17 +87,31 @@ class _PyramidAppState extends ConsumerState<PyramidApp>
|
|||||||
final accent = accentPresets[accentIdx].color;
|
final accent = accentPresets[accentIdx].color;
|
||||||
|
|
||||||
return PyramidTheme(
|
return PyramidTheme(
|
||||||
isDark: isDark,
|
variant: variant,
|
||||||
accent: accent,
|
accent: accent,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
density: density,
|
density: density,
|
||||||
motion: motion,
|
motion: motion,
|
||||||
child: MaterialApp.router(
|
child: MaterialApp.router(
|
||||||
title: 'Pyramid',
|
title: 'Pyramid',
|
||||||
theme: buildPyramidThemeData(false),
|
theme: buildPyramidThemeData(ThemeVariant.light),
|
||||||
darkTheme: buildPyramidThemeData(true),
|
darkTheme: buildPyramidThemeData(
|
||||||
themeMode: isDark ? ThemeMode.dark : ThemeMode.light,
|
variant.isDark ? variant : ThemeVariant.soft),
|
||||||
|
themeMode: variant.isDark ? ThemeMode.dark : ThemeMode.light,
|
||||||
routerConfig: router,
|
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 [
|
localizationsDelegates: const [
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.dart';
|
import 'package:matrix/matrix.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/settings_prefs.dart';
|
import 'package:pyramid/core/settings_prefs.dart';
|
||||||
import 'package:pyramid/core/theme.dart';
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:pyramid/core/matrix_client.dart';
|
import 'package:pyramid/core/matrix_client.dart';
|
||||||
@@ -7,7 +8,14 @@ import 'package:pyramid/features/rooms/rooms_provider.dart';
|
|||||||
|
|
||||||
// ─── Theme state ──────────────────────────────────────────────────────────────
|
// ─── 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
|
final accentProvider = StateProvider<int>((ref) => 0); // index into accent presets
|
||||||
|
|
||||||
const accentPresets = [
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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 ──────────────────────────
|
// ── Generic SharedPreferences-backed bool notifier ──────────────────────────
|
||||||
|
|
||||||
@@ -143,26 +145,58 @@ class VoicePrefs {
|
|||||||
// ── Theme persistence helpers ────────────────────────────────────────────────
|
// ── Theme persistence helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
Future<void> saveThemePrefs({
|
Future<void> saveThemePrefs({
|
||||||
bool? isDark,
|
ThemeVariant? variant,
|
||||||
int? accentIdx,
|
int? accentIdx,
|
||||||
double? radius,
|
double? radius,
|
||||||
double? motion,
|
double? motion,
|
||||||
|
double? textScale,
|
||||||
|
bool resetTextScale = false,
|
||||||
|
MessageStyle? messageStyle,
|
||||||
}) async {
|
}) async {
|
||||||
final p = await SharedPreferences.getInstance();
|
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 (accentIdx != null) await p.setInt('theme_accent', accentIdx);
|
||||||
if (radius != null) await p.setDouble('theme_radius', radius);
|
if (radius != null) await p.setDouble('theme_radius', radius);
|
||||||
if (motion != null) await p.setDouble('theme_motion', motion);
|
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})>
|
Future<
|
||||||
loadThemePrefs() async {
|
({
|
||||||
|
ThemeVariant variant,
|
||||||
|
int accentIdx,
|
||||||
|
double radius,
|
||||||
|
double motion,
|
||||||
|
double? textScale,
|
||||||
|
MessageStyle messageStyle,
|
||||||
|
})> loadThemePrefs() async {
|
||||||
final p = await SharedPreferences.getInstance();
|
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 (
|
return (
|
||||||
isDark: p.getBool('theme_dark') ?? true,
|
variant: variant,
|
||||||
accentIdx: p.getInt('theme_accent') ?? 0,
|
accentIdx: p.getInt('theme_accent') ?? 0,
|
||||||
radius: p.getDouble('theme_radius') ?? 12.0,
|
radius: p.getDouble('theme_radius') ?? 12.0,
|
||||||
motion: p.getDouble('theme_motion') ?? 1.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';
|
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 {
|
class PyramidColors {
|
||||||
const PyramidColors._();
|
const PyramidColors._();
|
||||||
|
|
||||||
@@ -49,7 +146,7 @@ class PyramidColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class PyramidTheme extends InheritedWidget {
|
class PyramidTheme extends InheritedWidget {
|
||||||
final bool isDark;
|
final ThemeVariant variant;
|
||||||
final Color accent;
|
final Color accent;
|
||||||
final double radius;
|
final double radius;
|
||||||
final double density;
|
final double density;
|
||||||
@@ -58,7 +155,7 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
const PyramidTheme({
|
const PyramidTheme({
|
||||||
super.key,
|
super.key,
|
||||||
required super.child,
|
required super.child,
|
||||||
this.isDark = true,
|
this.variant = ThemeVariant.soft,
|
||||||
this.accent = PyramidColors.accentAmber,
|
this.accent = PyramidColors.accentAmber,
|
||||||
this.radius = 12,
|
this.radius = 12,
|
||||||
this.density = 1,
|
this.density = 1,
|
||||||
@@ -69,17 +166,20 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
|
return context.dependOnInheritedWidgetOfExactType<PyramidTheme>()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Color get bg0 => isDark ? PyramidColors.bg0 : PyramidColors.bg0Light;
|
bool get isDark => variant.isDark;
|
||||||
Color get bg1 => isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
PyramidPalette get palette => PyramidPalette.of(variant);
|
||||||
Color get bg2 => isDark ? PyramidColors.bg2 : PyramidColors.bg2Light;
|
|
||||||
Color get bg3 => isDark ? PyramidColors.bg3 : PyramidColors.bg3Light;
|
Color get bg0 => palette.bg0;
|
||||||
Color get bgHover => isDark ? PyramidColors.bgHover : PyramidColors.bgHoverLight;
|
Color get bg1 => palette.bg1;
|
||||||
Color get bgActive => isDark ? PyramidColors.bgActive : PyramidColors.bgActiveLight;
|
Color get bg2 => palette.bg2;
|
||||||
Color get border => isDark ? PyramidColors.border : PyramidColors.borderLight;
|
Color get bg3 => palette.bg3;
|
||||||
Color get borderStrong => isDark ? PyramidColors.borderStrong : PyramidColors.borderStrongLight;
|
Color get bgHover => palette.bgHover;
|
||||||
Color get fg => isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
Color get bgActive => palette.bgActive;
|
||||||
Color get fgMuted => isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight;
|
Color get border => palette.border;
|
||||||
Color get fgDim => isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight;
|
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 danger => PyramidColors.danger;
|
||||||
Color get online => PyramidColors.online;
|
Color get online => PyramidColors.online;
|
||||||
Color get away => PyramidColors.away;
|
Color get away => PyramidColors.away;
|
||||||
@@ -104,7 +204,7 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool updateShouldNotify(PyramidTheme oldWidget) =>
|
bool updateShouldNotify(PyramidTheme oldWidget) =>
|
||||||
isDark != oldWidget.isDark ||
|
variant != oldWidget.variant ||
|
||||||
accent != oldWidget.accent ||
|
accent != oldWidget.accent ||
|
||||||
radius != oldWidget.radius ||
|
radius != oldWidget.radius ||
|
||||||
density != oldWidget.density ||
|
density != oldWidget.density ||
|
||||||
@@ -114,9 +214,11 @@ class PyramidTheme extends InheritedWidget {
|
|||||||
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
|
// Inter ist als Asset gebündelt (pubspec `fonts:`) — kein Laufzeit-Download.
|
||||||
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
|
TextStyle _base(Color color) => TextStyle(fontFamily: 'Inter', color: color);
|
||||||
|
|
||||||
ThemeData buildPyramidThemeData(bool isDark) {
|
ThemeData buildPyramidThemeData(ThemeVariant variant) {
|
||||||
final fg = isDark ? PyramidColors.fg : PyramidColors.fgLight;
|
final palette = PyramidPalette.of(variant);
|
||||||
final bg1 = isDark ? PyramidColors.bg1 : PyramidColors.bg1Light;
|
final isDark = variant.isDark;
|
||||||
|
final fg = palette.fg;
|
||||||
|
final bg1 = palette.bg1;
|
||||||
final accent = PyramidColors.accentAmber;
|
final accent = PyramidColors.accentAmber;
|
||||||
|
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@@ -137,11 +239,11 @@ ThemeData buildPyramidThemeData(bool isDark) {
|
|||||||
),
|
),
|
||||||
textTheme: TextTheme(
|
textTheme: TextTheme(
|
||||||
bodyMedium: _base(fg).copyWith(fontSize: 14),
|
bodyMedium: _base(fg).copyWith(fontSize: 14),
|
||||||
bodySmall: _base(fg).copyWith(fontSize: 12, color: isDark ? PyramidColors.fgMuted : PyramidColors.fgMutedLight),
|
bodySmall: _base(fg).copyWith(fontSize: 12, color: palette.fgMuted),
|
||||||
labelSmall: _base(fg).copyWith(fontSize: 11, color: isDark ? PyramidColors.fgDim : PyramidColors.fgDimLight),
|
labelSmall: _base(fg).copyWith(fontSize: 11, color: palette.fgDim),
|
||||||
),
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: isDark ? PyramidColors.border : PyramidColors.borderLight,
|
color: palette.border,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
space: 1,
|
space: 1,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -628,7 +628,7 @@ class _SectionLabel extends StatelessWidget {
|
|||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 4),
|
||||||
child: Text(title,
|
child: Text(title,
|
||||||
style: TextStyle(color: PyramidColors.fgDim,
|
style: TextStyle(color: PyramidTheme.of(context).fgDim,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.6)),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Darstellung laut Einstellung (Erscheinungsbild → Nachrichten):
|
||||||
|
// Sprechblasen (message_bubbles.dart) oder Liste wie bisher.
|
||||||
|
return Consumer(builder: (context, ref, _) {
|
||||||
|
final style = ref.watch(messageStyleProvider);
|
||||||
final pt = PyramidTheme.of(context);
|
final 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 firstEvent = widget.events.first;
|
||||||
final sender = firstEvent.senderFromMemoryOrFallback;
|
final sender = firstEvent.senderFromMemoryOrFallback;
|
||||||
final name = sender.calcDisplayname();
|
final name = sender.calcDisplayname();
|
||||||
@@ -168,11 +184,14 @@ class _MessageGroupState extends State<MessageGroup> {
|
|||||||
|
|
||||||
// Hover highlight + actions are now per-row (see _selectableRow), so the
|
// Hover highlight + actions are now per-row (see _selectableRow), so the
|
||||||
// group is just the column of rows.
|
// group is just the column of rows.
|
||||||
Widget content = Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: rows,
|
children: rows,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handy: nach links wischen = antworten (beide Darstellungen).
|
||||||
|
Widget _withSwipeToReply(PyramidTheme pt, Widget content) {
|
||||||
if (!_isMobile) return content;
|
if (!_isMobile) return content;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ class _MatrixHtmlText extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final spans = _parseHtml(html, baseStyle, linkColor);
|
final spans = _parseHtml(html, baseStyle, linkColor);
|
||||||
return SelectionArea(
|
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(
|
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/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:matrix/matrix.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/matrix_client.dart';
|
||||||
import 'package:pyramid/core/media_cache.dart';
|
import 'package:pyramid/core/media_cache.dart';
|
||||||
import 'package:pyramid/core/theme.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';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
part 'message/message_group_core.dart';
|
part 'message/message_group_core.dart';
|
||||||
|
part 'message/message_bubbles.dart';
|
||||||
part 'message/message_text.dart';
|
part 'message/message_text.dart';
|
||||||
part 'message/message_link_preview.dart';
|
part 'message/message_link_preview.dart';
|
||||||
part 'message/message_media.dart';
|
part 'message/message_media.dart';
|
||||||
|
|||||||
@@ -104,9 +104,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: popoverW,
|
width: popoverW,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: PyramidColors.bg0,
|
color: PyramidTheme.of(context).bg0,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: PyramidColors.border),
|
border: Border.all(color: PyramidTheme.of(context).border),
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
|
BoxShadow(color: Color(0x60000000), blurRadius: 24, offset: Offset(0, 8))
|
||||||
],
|
],
|
||||||
@@ -172,7 +172,7 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
borderRadius: BorderRadius.circular(16),
|
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,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: avatarUrl != null
|
child: avatarUrl != null
|
||||||
@@ -212,14 +212,14 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(displayName,
|
Text(displayName,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fg,
|
color: PyramidTheme.of(context).fg,
|
||||||
fontSize: 17,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w700)),
|
fontWeight: FontWeight.w700)),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(handle,
|
Text(handle,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgMuted, fontSize: 12)),
|
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Action buttons
|
// Action buttons
|
||||||
Row(
|
Row(
|
||||||
@@ -248,9 +248,9 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
),
|
),
|
||||||
if (powerLevel > 0) ...[
|
if (powerLevel > 0) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text('Rollen',
|
Text('Rollen',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgDim,
|
color: PyramidTheme.of(context).fgDim,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.8)),
|
letterSpacing: 0.8)),
|
||||||
@@ -270,16 +270,16 @@ class _ProfilePopoverState extends State<ProfilePopover> {
|
|||||||
],
|
],
|
||||||
if (joinDate != null) ...[
|
if (joinDate != null) ...[
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text('Mitglied seit',
|
Text('Mitglied seit',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgDim,
|
color: PyramidTheme.of(context).fgDim,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
letterSpacing: 0.8)),
|
letterSpacing: 0.8)),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(joinDate,
|
Text(joinDate,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
color: PyramidColors.fgMuted, fontSize: 12)),
|
color: PyramidTheme.of(context).fgMuted, fontSize: 12)),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -337,7 +337,7 @@ class _PresenceDot extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: color,
|
color: color,
|
||||||
shape: BoxShape.circle,
|
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
|
? (_hover
|
||||||
? PyramidTheme.of(context).accent.withAlpha(200)
|
? PyramidTheme.of(context).accent.withAlpha(200)
|
||||||
: PyramidTheme.of(context).accent)
|
: PyramidTheme.of(context).accent)
|
||||||
: (_hover ? PyramidColors.bgHover : PyramidColors.bg2),
|
: (_hover ? PyramidTheme.of(context).bgHover : PyramidTheme.of(context).bg2),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: widget.primary ? Colors.transparent : PyramidColors.border,
|
color: widget.primary ? Colors.transparent : PyramidTheme.of(context).border,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -393,14 +393,14 @@ class _ProfileBtnState extends State<_ProfileBtn> {
|
|||||||
size: 14,
|
size: 14,
|
||||||
color: widget.primary
|
color: widget.primary
|
||||||
? PyramidColors.accentFg
|
? PyramidColors.accentFg
|
||||||
: PyramidColors.fg),
|
: PyramidTheme.of(context).fg),
|
||||||
if (hasLabel) ...[
|
if (hasLabel) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(widget.label!,
|
Text(widget.label!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: widget.primary
|
color: widget.primary
|
||||||
? PyramidColors.accentFg
|
? PyramidColors.accentFg
|
||||||
: PyramidColors.fg,
|
: PyramidTheme.of(context).fg,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500)),
|
fontWeight: FontWeight.w500)),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ class _MessageItemState extends State<_MessageItem> {
|
|||||||
]),
|
]),
|
||||||
const SizedBox(height: 1),
|
const SizedBox(height: 1),
|
||||||
RichText(
|
RichText(
|
||||||
|
textScaler: MediaQuery.textScalerOf(context),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
text: TextSpan(children: [
|
text: TextSpan(children: [
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ part of '../settings_modal.dart';
|
|||||||
|
|
||||||
class _AppearanceSection extends StatelessWidget {
|
class _AppearanceSection extends StatelessWidget {
|
||||||
final PyramidTheme pt;
|
final PyramidTheme pt;
|
||||||
final bool isDark;
|
final ThemeVariant variant;
|
||||||
|
final double? textScale;
|
||||||
|
final MessageStyle messageStyle;
|
||||||
final int accentIdx;
|
final int accentIdx;
|
||||||
final double radius;
|
final double radius;
|
||||||
final double density;
|
final double density;
|
||||||
@@ -11,7 +13,9 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
|
|
||||||
const _AppearanceSection({
|
const _AppearanceSection({
|
||||||
required this.pt,
|
required this.pt,
|
||||||
required this.isDark,
|
required this.variant,
|
||||||
|
required this.textScale,
|
||||||
|
required this.messageStyle,
|
||||||
required this.accentIdx,
|
required this.accentIdx,
|
||||||
required this.radius,
|
required this.radius,
|
||||||
required this.density,
|
required this.density,
|
||||||
@@ -21,6 +25,12 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -30,18 +40,18 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
|
subtitle: 'Gestalte Pyramid nach deinem Geschmack.',
|
||||||
pt: pt),
|
pt: pt),
|
||||||
_SettingsGroup(title: 'Theme', pt: pt, children: [
|
_SettingsGroup(title: 'Theme', pt: pt, children: [
|
||||||
_SettingsRow(
|
_AdaptiveSettingsRow(
|
||||||
title: 'Farbmodus',
|
title: 'Farbmodus',
|
||||||
desc: 'Hell oder dunkel',
|
desc: 'Dunkel = weiches Dunkelgrau, Schwarz = wie früher (spart '
|
||||||
|
'auf OLED-Displays Akku)',
|
||||||
pt: pt,
|
pt: pt,
|
||||||
control: _SegControl(
|
control: _SegControl(
|
||||||
options: const ['Dunkel', 'Hell'],
|
options: [for (final v in variants) v.label],
|
||||||
selected: isDark ? 0 : 1,
|
selected: variants.indexOf(variant),
|
||||||
pt: pt,
|
pt: pt,
|
||||||
onSelect: (i) {
|
onSelect: (i) {
|
||||||
final dark = i == 0;
|
ref.read(themeVariantProvider.notifier).state = variants[i];
|
||||||
ref.read(themeModeProvider.notifier).state = dark;
|
saveThemePrefs(variant: variants[i]);
|
||||||
saveThemePrefs(isDark: dark);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -80,6 +90,85 @@ class _AppearanceSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 20),
|
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: [
|
_SettingsGroup(title: 'Form & Bewegung', pt: pt, children: [
|
||||||
_SettingsRow(
|
_SettingsRow(
|
||||||
title: 'Eckenrundung',
|
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:path_provider/path_provider.dart';
|
||||||
import 'package:pointycastle/export.dart' as pc;
|
import 'package:pointycastle/export.dart' as pc;
|
||||||
import 'package:pyramid/core/app_state.dart';
|
import 'package:pyramid/core/app_state.dart';
|
||||||
|
import 'package:pyramid/core/appearance.dart';
|
||||||
import 'package:pyramid/core/auth_log.dart';
|
import 'package:pyramid/core/auth_log.dart';
|
||||||
import 'package:pyramid/core/e2ee_diagnostics.dart';
|
import 'package:pyramid/core/e2ee_diagnostics.dart';
|
||||||
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
|
import 'package:pyramid/features/auth/bootstrap_dialog.dart' show isKeyBackupMissing;
|
||||||
@@ -85,7 +86,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final pt = PyramidTheme.of(context);
|
final pt = PyramidTheme.of(context);
|
||||||
final isDark = ref.watch(themeModeProvider);
|
final isDark = ref.watch(themeVariantProvider).isDark;
|
||||||
final accentIdx = ref.watch(accentProvider);
|
final accentIdx = ref.watch(accentProvider);
|
||||||
final radius = ref.watch(radiusProvider);
|
final radius = ref.watch(radiusProvider);
|
||||||
final density = ref.watch(densityProvider);
|
final density = ref.watch(densityProvider);
|
||||||
@@ -101,15 +102,21 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
|
// Handy: ganze Seite (wie System-Einstellungen) statt schwebender
|
||||||
|
// Karte mit abgedunkeltem Rand – mehr Platz, weniger Gequetsche.
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
constraints: isMobile
|
||||||
width: isMobile ? size.width * 0.97 : 740,
|
? null
|
||||||
height: isMobile ? size.height * 0.92 : 560,
|
: const BoxConstraints(maxWidth: 820, maxHeight: 660),
|
||||||
|
width: isMobile ? size.width : 740,
|
||||||
|
height: isMobile ? size.height : 560,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: pt.bg1,
|
color: pt.bg1,
|
||||||
borderRadius: BorderRadius.circular(pt.rXl),
|
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||||
border: Border.all(color: pt.border),
|
border: isMobile ? null : Border.all(color: pt.border),
|
||||||
boxShadow: [
|
boxShadow: isMobile
|
||||||
|
? null
|
||||||
|
: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withAlpha(130),
|
color: Colors.black.withAlpha(130),
|
||||||
blurRadius: 40,
|
blurRadius: 40,
|
||||||
@@ -118,7 +125,7 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(pt.rXl),
|
borderRadius: BorderRadius.circular(isMobile ? 0 : pt.rXl),
|
||||||
child: isMobile
|
child: isMobile
|
||||||
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
|
? _mobileLayout(pt, isDark, accentIdx, radius, density, motion)
|
||||||
: _desktopLayout(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,
|
Widget _buildContent(PyramidTheme pt, bool isDark, int accentIdx,
|
||||||
double radius, double density, double motion, String section) {
|
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) {
|
return switch (section) {
|
||||||
'appearance' => _AppearanceSection(
|
'appearance' => _AppearanceSection(
|
||||||
pt: pt,
|
pt: pt,
|
||||||
isDark: isDark,
|
variant: variant,
|
||||||
|
textScale: textScale,
|
||||||
|
messageStyle: messageStyle,
|
||||||
accentIdx: accentIdx,
|
accentIdx: accentIdx,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
density: density,
|
density: density,
|
||||||
@@ -261,7 +273,9 @@ class _SettingsModalState extends ConsumerState<SettingsModal> {
|
|||||||
'about' => _AboutSection(pt: pt),
|
'about' => _AboutSection(pt: pt),
|
||||||
_ => _AppearanceSection(
|
_ => _AppearanceSection(
|
||||||
pt: pt,
|
pt: pt,
|
||||||
isDark: isDark,
|
variant: variant,
|
||||||
|
textScale: textScale,
|
||||||
|
messageStyle: messageStyle,
|
||||||
accentIdx: accentIdx,
|
accentIdx: accentIdx,
|
||||||
radius: radius,
|
radius: radius,
|
||||||
density: density,
|
density: density,
|
||||||
|
|||||||
@@ -203,8 +203,44 @@ void main() {
|
|||||||
);
|
);
|
||||||
await room.sendTextEvent('Und noch eine letzte Nachricht 👋');
|
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
|
// ignore: avoid_print
|
||||||
print('UI-Testraum bereit: $roomId');
|
print('UI-Testraum bereit: $roomId, Gruppe: ${group.id}');
|
||||||
} finally {
|
} finally {
|
||||||
await _logout(c1);
|
await _logout(c1);
|
||||||
await _logout(c2);
|
await _logout(c2);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/settings_prefs.dart';
|
||||||
|
import 'package:pyramid/core/theme.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
/// Tests für die Storage-Fassade, die laut M2-Modulschnitt zur Pflicht-
|
/// Tests für die Storage-Fassade, die laut M2-Modulschnitt zur Pflicht-
|
||||||
@@ -94,22 +97,117 @@ void main() {
|
|||||||
group('Theme-Prefs', () {
|
group('Theme-Prefs', () {
|
||||||
test('leerer Store liefert die dokumentierten Defaults', () async {
|
test('leerer Store liefert die dokumentierten Defaults', () async {
|
||||||
final t = await loadThemePrefs();
|
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.accentIdx, 0);
|
||||||
expect(t.radius, 12.0);
|
expect(t.radius, 12.0);
|
||||||
expect(t.motion, 1.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 '
|
test('Roundtrip: gespeicherte Werte kommen zurück, Teil-Save lässt '
|
||||||
'den Rest unangetastet', () async {
|
'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();
|
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.
|
// Nur ein Feld ändern – die anderen dürfen nicht zurückfallen.
|
||||||
await saveThemePrefs(accentIdx: 1);
|
await saveThemePrefs(accentIdx: 1);
|
||||||
t = await loadThemePrefs();
|
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