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 _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( builder: (context, _) { final settings = context.read(); 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( value: s.type, icon: const Icon(Icons.arrow_drop_down), items: ChatBackgroundType.values .map( (e) => DropdownMenuItem( 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( value: s.fit, icon: const Icon(Icons.arrow_drop_down), items: ChatBackgroundFit.values .map( (e) => DropdownMenuItem( 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 _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 _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 _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 _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 _pickColor(BuildContext context, SettingsCubit settings) async { final picked = await showModalBottomSheet( 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 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(), ), ); }