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:
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../state/app/modules/app_modules.dart';
|
||||
import '../../../../storage/chat_background_settings.dart';
|
||||
import '../../../../storage/dev_tools_settings.dart';
|
||||
import '../../../../storage/file_settings.dart';
|
||||
import '../../../../storage/file_view_settings.dart';
|
||||
@@ -45,6 +46,15 @@ class DefaultSettings {
|
||||
drafts: {},
|
||||
draftReplies: {},
|
||||
),
|
||||
chatBackgroundSettings: ChatBackgroundSettings(
|
||||
// Mirrors the previous hard-coded behaviour: tiled pattern, no effects.
|
||||
type: ChatBackgroundType.pattern,
|
||||
fit: ChatBackgroundFit.tile,
|
||||
colorValue: null,
|
||||
imageVersion: 0,
|
||||
dim: 0,
|
||||
blur: 0,
|
||||
),
|
||||
fileSettings: FileSettings(
|
||||
sortFoldersToTop: true,
|
||||
ascending: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../../notification/notify_updater.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../utils/haptics.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
@@ -39,6 +40,13 @@ class TalkSection extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.wallpaper_outlined),
|
||||
title: const Text('Chat-Hintergrund'),
|
||||
subtitle: const Text('Bild, Farbe und Darstellung anpassen'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => AppRoutes.openChatBackgroundSettings(context),
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(
|
||||
Icon(Icons.notifications_active_outlined),
|
||||
|
||||
@@ -14,7 +14,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
|
||||
import '../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
||||
import '../../../theming/app_theme.dart';
|
||||
import '../../../widget/chat_background.dart';
|
||||
import '../../../widget/clickable_app_bar.dart';
|
||||
import '../../../widget/user_avatar.dart';
|
||||
import 'data/chat_search_controller.dart';
|
||||
@@ -367,16 +367,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
],
|
||||
),
|
||||
),
|
||||
body: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: const AssetImage('assets/background/chat.png'),
|
||||
scale: 1.5,
|
||||
opacity: 1,
|
||||
repeat: ImageRepeat.repeat,
|
||||
invertColors: AppTheme.isDarkMode(context),
|
||||
),
|
||||
),
|
||||
body: ChatBackground(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
|
||||
Reference in New Issue
Block a user