implemented a customizable chat background system with support for patterns, solid colors, and gallery images; added a dedicated settings page with live preview and adjustable blur/dim effects, updated the image cropper to support flexible aspect ratios for wallpapers, and integrated file cleanup logic during account logout.

This commit is contained in:
2026-05-31 19:20:18 +02:00
parent 5ebf5bccdb
commit 6e12da08c0
14 changed files with 771 additions and 14 deletions
@@ -0,0 +1,425 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/chat_background_settings.dart';
import '../../../storage/settings.dart' as model;
import '../../../utils/app_paths.dart';
import '../../../utils/haptics.dart';
import '../../../widget/async_action_button.dart';
import '../../../widget/chat_background.dart';
import '../../../widget/chat_background_picker_sheet.dart';
import '../../../widget/confirm_dialog.dart';
import 'data/default_settings.dart';
/// Predefined background colours offered for [ChatBackgroundType.color]. Keeps
/// us off a heavyweight colour-picker dependency while covering the common
/// messenger palette (neutrals + muted accents) that read well behind bubbles.
const List<Color> _colorPalette = [
Color(0xffefeae2), // legacy beige
Color(0xffffffff),
Color(0xffe7ecef),
Color(0xffd9e4dd),
Color(0xffe4ddec),
Color(0xfff3e2d4),
Color(0xff2b3942), // dark teal
Color(0xff1f1f1f),
Color(0xff0b141a), // whatsapp dark
Color(0xff3a2e2e),
];
/// Full-page editor for the global chat background, reached from the settings
/// list. Mirrors [ModulesSettingsPage]: a slim section tile navigates here, the
/// actual controls (source, fill, sliders) and a live preview live on this page.
class ChatBackgroundSettingsPage extends StatelessWidget {
const ChatBackgroundSettingsPage({super.key});
@override
Widget build(BuildContext context) =>
BlocBuilder<SettingsCubit, model.Settings>(
builder: (context, _) {
final settings = context.read<SettingsCubit>();
final s = settings.val().chatBackgroundSettings;
final isModified =
s.toJson().toString() !=
DefaultSettings.get().chatBackgroundSettings.toJson().toString();
final showImageControls =
s.type == ChatBackgroundType.pattern ||
s.type == ChatBackgroundType.image;
final showDim = s.type != ChatBackgroundType.none;
return Scaffold(
appBar: AppBar(
title: const Text('Chat-Hintergrund'),
actions: [
IconButton(
tooltip: 'Auf Standard zurücksetzen',
icon: const Icon(Icons.undo_outlined),
onPressed: isModified
? () => ConfirmDialog(
title: 'Hintergrund zurücksetzen?',
content:
'Quelle, Farbe und Darstellungsoptionen werden '
'auf das Standardmuster zurückgesetzt.',
confirmButton: 'Zurücksetzen',
onConfirm: () {
settings.val(write: true).chatBackgroundSettings =
DefaultSettings.get().chatBackgroundSettings;
},
).asDialog(context)
: null,
),
],
),
body: ListView(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: _Preview(),
),
ListTile(
leading: const Icon(Icons.wallpaper_outlined),
title: const Text('Quelle'),
trailing: DropdownButton<ChatBackgroundType>(
value: s.type,
icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundType.values
.map(
(e) => DropdownMenuItem<ChatBackgroundType>(
value: e,
child: Row(
children: [
Icon(_typeIcon(e)),
const SizedBox(width: 10),
Text(_typeLabel(e)),
],
),
),
)
.toList(),
onChanged: (e) => _onTypeChanged(context, settings, e!),
),
),
if (s.type == ChatBackgroundType.image)
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('Bild ändern'),
onTap: () => _pickImage(context, settings),
),
if (s.type == ChatBackgroundType.image)
ListTile(
leading: const Icon(Icons.crop_outlined),
title: const Text('Zuschneiden'),
subtitle: const Text('Aktuelles Bild zuschneiden'),
onTap: () => _cropImage(context, settings),
),
if (s.type == ChatBackgroundType.color)
ListTile(
leading: const Icon(Icons.palette_outlined),
title: const Text('Farbe wählen'),
trailing: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Color(
s.colorValue ?? _colorPalette.first.toARGB32(),
),
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor,
),
),
),
onTap: () => _pickColor(context, settings),
),
if (showImageControls)
ListTile(
leading: const Icon(Icons.fit_screen_outlined),
title: const Text('Füllmodus'),
trailing: DropdownButton<ChatBackgroundFit>(
value: s.fit,
icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundFit.values
.map(
(e) => DropdownMenuItem<ChatBackgroundFit>(
value: e,
child: Row(
children: [
Icon(_fitIcon(e)),
const SizedBox(width: 10),
Text(_fitLabel(e)),
],
),
),
)
.toList(),
onChanged: (e) {
Haptics.selection();
settings.val(write: true).chatBackgroundSettings.fit =
e!;
},
),
),
if (showDim)
_SliderTile(
icon: Icons.brightness_4_outlined,
label: 'Abdunkeln',
value: s.dim,
onChanged: (v) =>
settings.val(write: true).chatBackgroundSettings.dim = v,
),
if (showImageControls)
_SliderTile(
icon: Icons.blur_on_outlined,
label: 'Weichzeichnen',
value: s.blur,
min: 0,
max: 25,
onChanged: (v) => settings
.val(write: true)
.chatBackgroundSettings
.blur = v,
),
],
),
);
},
);
Future<void> _onTypeChanged(
BuildContext context,
SettingsCubit settings,
ChatBackgroundType type,
) async {
// Selecting "image" without a stored file would render an empty/broken
// FileImage — pick one first and only commit the type on success.
if (type == ChatBackgroundType.image &&
!File(AppPaths.chatBackgroundImage).existsSync()) {
await _pickImage(context, settings);
return;
}
Haptics.selection();
settings.val(write: true).chatBackgroundSettings.type = type;
}
Future<void> _pickImage(BuildContext context, SettingsCubit settings) async {
// Picks the image as-is — cropping is a separate, explicit step so the
// user isn't forced through a cropper after every pick.
final bytes = await showChatBackgroundPickerSheet(context);
if (bytes == null || !context.mounted) return;
await _storeImage(context, settings, bytes);
}
Future<void> _cropImage(BuildContext context, SettingsCubit settings) async {
final file = File(AppPaths.chatBackgroundImage);
if (!file.existsSync()) return;
final current = await file.readAsBytes();
if (!context.mounted) return;
final cropped = await cropChatBackgroundImage(context, current);
if (cropped == null || !context.mounted) return;
await _storeImage(context, settings, cropped);
}
Future<void> _storeImage(
BuildContext context,
SettingsCubit settings,
Uint8List bytes,
) async {
await runWithErrorDialog(context, () async {
final file = File(AppPaths.chatBackgroundImage);
await file.writeAsBytes(bytes);
// Same filename across replacements → the decoded image is cached under
// an identical key. Evict so the new bytes actually show.
await FileImage(file).evict();
final cs = settings.val(write: true).chatBackgroundSettings;
cs.imageVersion++;
cs.type = ChatBackgroundType.image;
});
}
Future<void> _pickColor(BuildContext context, SettingsCubit settings) async {
final picked = await showModalBottomSheet<int>(
context: context,
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
child: Wrap(
spacing: 16,
runSpacing: 16,
children: _colorPalette
.map(
(c) => InkWell(
customBorder: const CircleBorder(),
onTap: () => Navigator.of(sheetContext).pop(c.toARGB32()),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: c,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(sheetContext).dividerColor,
),
),
),
),
)
.toList(),
),
),
),
);
if (picked == null) return;
Haptics.selection();
final cs = settings.val(write: true).chatBackgroundSettings;
cs.colorValue = picked;
cs.type = ChatBackgroundType.color;
}
IconData _typeIcon(ChatBackgroundType type) => switch (type) {
ChatBackgroundType.pattern => Icons.texture_outlined,
ChatBackgroundType.image => Icons.image_outlined,
ChatBackgroundType.color => Icons.format_color_fill_outlined,
ChatBackgroundType.none => Icons.hide_image_outlined,
};
String _typeLabel(ChatBackgroundType type) => switch (type) {
ChatBackgroundType.pattern => 'Standardmuster',
ChatBackgroundType.image => 'Galerie-Bild',
ChatBackgroundType.color => 'Einfarbig',
ChatBackgroundType.none => 'Kein Hintergrund',
};
IconData _fitIcon(ChatBackgroundFit fit) => switch (fit) {
ChatBackgroundFit.cover => Icons.crop_free_outlined,
ChatBackgroundFit.tile => Icons.grid_on_outlined,
ChatBackgroundFit.center => Icons.filter_center_focus_outlined,
};
String _fitLabel(ChatBackgroundFit fit) => switch (fit) {
ChatBackgroundFit.cover => 'Füllen',
ChatBackgroundFit.tile => 'Kacheln',
ChatBackgroundFit.center => 'Zentrieren',
};
}
/// Live preview that reuses the real [ChatBackground] renderer with a couple of
/// sample messages, so it stays in sync with the chat as settings change.
class _Preview extends StatelessWidget {
@override
Widget build(BuildContext context) {
final dark = Theme.of(context).brightness == Brightness.dark;
// Approximate the real bubble colours from chat_bubble_styles.dart.
final remoteColor = dark ? const Color(0xff202c33) : Colors.white;
final selfColor = dark ? const Color(0xff005c4b) : const Color(0xffd3d3d3);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 180,
width: double.infinity,
child: ChatBackground(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bubble(
context,
alignment: Alignment.centerLeft,
color: remoteColor,
text: 'Wie gefällt dir der neue Hintergrund?',
time: '09:41',
),
const SizedBox(height: 8),
_bubble(
context,
alignment: Alignment.centerRight,
color: selfColor,
text: 'Sieht richtig gut aus! 🎉',
time: '09:42',
),
],
),
),
),
),
);
}
Widget _bubble(
BuildContext context, {
required Alignment alignment,
required Color color,
required String text,
required String time,
}) {
final onColor = ThemeData.estimateBrightnessForColor(color) == Brightness.dark
? Colors.white
: Colors.black87;
return Align(
alignment: alignment,
child: Container(
constraints: const BoxConstraints(maxWidth: 220),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(12),
boxShadow: const [
BoxShadow(color: Colors.black26, blurRadius: 1, offset: Offset(0, 1)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(text, style: TextStyle(color: onColor, fontSize: 13)),
Text(
time,
style: TextStyle(
color: onColor.withValues(alpha: 0.6),
fontSize: 10,
),
),
],
),
),
);
}
}
class _SliderTile extends StatelessWidget {
final IconData icon;
final String label;
final double value;
final double min;
final double max;
final ValueChanged<double> onChanged;
const _SliderTile({
required this.icon,
required this.label,
required this.value,
required this.onChanged,
this.min = 0,
this.max = 1,
});
@override
Widget build(BuildContext context) => ListTile(
leading: Icon(icon),
title: Text(label),
subtitle: Slider(
value: value.clamp(min, max),
min: min,
max: max,
onChanged: onChanged,
onChangeEnd: (_) => Haptics.selection(),
),
);
}