diff --git a/lib/main.dart b/lib/main.dart index 483b2d2..96db3e0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -37,6 +37,7 @@ import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; import 'storage/settings.dart'; import 'theming/dark_app_theme.dart'; import 'theming/light_app_theme.dart'; +import 'utils/app_paths.dart'; import 'view/login/login.dart'; import 'widget/app_progress_indicator.dart'; import 'widget/breaker/breaker.dart'; @@ -72,6 +73,9 @@ Future main() async { ); HydratedBloc.storage = storage; }), + Future(() async { + AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path; + }), AccountData().waitForPopulation(), ShareIntentListener.instance.initialize(), ]; @@ -352,6 +356,10 @@ Future _wipeUserState({ await prefs.clear(); await HydratedBloc.storage.clear(); await const CacheView().clear(); + // The chat background image lives outside HydratedStorage, so clear it too + // (best-effort) to avoid orphaning the previous user's wallpaper. + final backgroundImage = File(AppPaths.chatBackgroundImage); + if (backgroundImage.existsSync()) backgroundImage.deleteSync(); // Stop the periodic widget refresh job so the background isolate doesn't // wake up every 30 minutes only to write `loggedIn=false`. Re-registers // on the next successful login. diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index 69803d6..0eeb663 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -18,6 +18,7 @@ import '../view/pages/marianum_message/marianum_message_view.dart'; import '../view/pages/more/feedback/feedback_dialog.dart'; import '../view/pages/more/roomplan/roomplan.dart'; import '../view/pages/more/share/qr_share_view.dart'; +import '../view/pages/settings/chat_background_settings_page.dart'; import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/settings.dart'; import '../view/pages/share_intent/share_chat_picker.dart'; @@ -94,6 +95,14 @@ class AppRoutes { pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage()); } + static void openChatBackgroundSettings(BuildContext context) { + pushScreen( + context, + withNavBar: false, + screen: const ChatBackgroundSettingsPage(), + ); + } + static void openFeedback(BuildContext context) { pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); } diff --git a/lib/storage/chat_background_settings.dart b/lib/storage/chat_background_settings.dart new file mode 100644 index 0000000..40112fb --- /dev/null +++ b/lib/storage/chat_background_settings.dart @@ -0,0 +1,46 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'chat_background_settings.g.dart'; + +/// Source of the chat background. +/// * [pattern] — the bundled tiled asset (legacy default, dark-mode inverted) +/// * [image] — a user-picked image stored at [AppPaths.chatBackgroundImage] +/// * [color] — a single solid colour ([ChatBackgroundSettings.colorValue]) +/// * [none] — plain theme surface colour +enum ChatBackgroundType { pattern, image, color, none } + +/// How [ChatBackgroundType.pattern]/[ChatBackgroundType.image] fill the area. +enum ChatBackgroundFit { cover, tile, center } + +@JsonSerializable() +class ChatBackgroundSettings { + ChatBackgroundType type; + ChatBackgroundFit fit; + + /// ARGB colour for [ChatBackgroundType.color]; null until the user picks one. + int? colorValue; + + /// Monotonically increasing cache-buster. The image lives under a constant + /// filename, so Flutter's [ImageCache] can't tell a replacement apart — this + /// counter drives a [ValueKey] that forces a fresh subtree after a swap. + int imageVersion; + + /// Strength of the black dim overlay drawn above the background (0..1). + double dim; + + /// Gaussian blur sigma applied to the pattern/image layer (0..~25). + double blur; + + ChatBackgroundSettings({ + required this.type, + required this.fit, + required this.colorValue, + required this.imageVersion, + required this.dim, + required this.blur, + }); + + factory ChatBackgroundSettings.fromJson(Map json) => + _$ChatBackgroundSettingsFromJson(json); + Map toJson() => _$ChatBackgroundSettingsToJson(this); +} diff --git a/lib/storage/chat_background_settings.g.dart b/lib/storage/chat_background_settings.g.dart new file mode 100644 index 0000000..db468fb --- /dev/null +++ b/lib/storage/chat_background_settings.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_background_settings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +ChatBackgroundSettings _$ChatBackgroundSettingsFromJson( + Map json, +) => ChatBackgroundSettings( + type: $enumDecode(_$ChatBackgroundTypeEnumMap, json['type']), + fit: $enumDecode(_$ChatBackgroundFitEnumMap, json['fit']), + colorValue: (json['colorValue'] as num?)?.toInt(), + imageVersion: (json['imageVersion'] as num).toInt(), + dim: (json['dim'] as num).toDouble(), + blur: (json['blur'] as num).toDouble(), +); + +Map _$ChatBackgroundSettingsToJson( + ChatBackgroundSettings instance, +) => { + 'type': _$ChatBackgroundTypeEnumMap[instance.type]!, + 'fit': _$ChatBackgroundFitEnumMap[instance.fit]!, + 'colorValue': instance.colorValue, + 'imageVersion': instance.imageVersion, + 'dim': instance.dim, + 'blur': instance.blur, +}; + +const _$ChatBackgroundTypeEnumMap = { + ChatBackgroundType.pattern: 'pattern', + ChatBackgroundType.image: 'image', + ChatBackgroundType.color: 'color', + ChatBackgroundType.none: 'none', +}; + +const _$ChatBackgroundFitEnumMap = { + ChatBackgroundFit.cover: 'cover', + ChatBackgroundFit.tile: 'tile', + ChatBackgroundFit.center: 'center', +}; diff --git a/lib/storage/settings.dart b/lib/storage/settings.dart index 7fa4a18..fb3b028 100644 --- a/lib/storage/settings.dart +++ b/lib/storage/settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'chat_background_settings.dart'; import 'dev_tools_settings.dart'; import 'file_settings.dart'; import 'file_view_settings.dart'; @@ -22,6 +23,7 @@ class Settings { ModulesSettings modulesSettings; TimetableSettings timetableSettings; TalkSettings talkSettings; + ChatBackgroundSettings chatBackgroundSettings; FileSettings fileSettings; HolidaysSettings holidaysSettings; FileViewSettings fileViewSettings; @@ -35,6 +37,7 @@ class Settings { required this.modulesSettings, required this.timetableSettings, required this.talkSettings, + required this.chatBackgroundSettings, required this.fileSettings, required this.holidaysSettings, required this.fileViewSettings, diff --git a/lib/storage/settings.g.dart b/lib/storage/settings.g.dart index 6da7a81..1ace484 100644 --- a/lib/storage/settings.g.dart +++ b/lib/storage/settings.g.dart @@ -18,6 +18,9 @@ Settings _$SettingsFromJson(Map json) => Settings( talkSettings: TalkSettings.fromJson( json['talkSettings'] as Map, ), + chatBackgroundSettings: ChatBackgroundSettings.fromJson( + json['chatBackgroundSettings'] as Map, + ), fileSettings: FileSettings.fromJson( json['fileSettings'] as Map, ), @@ -44,6 +47,7 @@ Map _$SettingsToJson(Settings instance) => { 'modulesSettings': instance.modulesSettings.toJson(), 'timetableSettings': instance.timetableSettings.toJson(), 'talkSettings': instance.talkSettings.toJson(), + 'chatBackgroundSettings': instance.chatBackgroundSettings.toJson(), 'fileSettings': instance.fileSettings.toJson(), 'holidaysSettings': instance.holidaysSettings.toJson(), 'fileViewSettings': instance.fileViewSettings.toJson(), diff --git a/lib/utils/app_paths.dart b/lib/utils/app_paths.dart new file mode 100644 index 0000000..6c80dc2 --- /dev/null +++ b/lib/utils/app_paths.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +/// Holds filesystem paths that are resolved once during app startup so they can +/// be read synchronously from widget `build()` methods (which cannot await the +/// async `path_provider` lookups). [documentsDir] is populated in `main()` +/// before `runApp`. +class AppPaths { + /// Absolute path to the app's documents directory. Set exactly once during + /// startup; reading it before that completes throws (intentional — surfaces + /// an init-order bug instead of silently using an empty path). + static late final String documentsDir; + + /// Persistent location of the user-chosen chat background image. Constant + /// filename, so replacing the image overwrites in place — callers must + /// `evict()` it from the image cache after writing. + static String get chatBackgroundImage => + '$documentsDir${Platform.pathSeparator}chat_background.img'; +} diff --git a/lib/view/pages/settings/chat_background_settings_page.dart b/lib/view/pages/settings/chat_background_settings_page.dart new file mode 100644 index 0000000..bee5fb8 --- /dev/null +++ b/lib/view/pages/settings/chat_background_settings_page.dart @@ -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 _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(), + ), + ); +} diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart index 2e92119..4bedd1a 100644 --- a/lib/view/pages/settings/data/default_settings.dart +++ b/lib/view/pages/settings/data/default_settings.dart @@ -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, diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart index eeac06e..3b28e54 100644 --- a/lib/view/pages/settings/sections/talk_section.dart +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -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), diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index 39d47ba..f3680d6 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -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 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( diff --git a/lib/widget/avatar_crop_page.dart b/lib/widget/avatar_crop_page.dart index ae49362..4651a06 100644 --- a/lib/widget/avatar_crop_page.dart +++ b/lib/widget/avatar_crop_page.dart @@ -5,12 +5,20 @@ import 'package:flutter/material.dart'; import 'app_progress_indicator.dart'; -/// Full-screen 1:1 cropper. Pure-Flutter so it inherits the app theme and +/// Full-screen cropper. Pure-Flutter so it inherits the app theme and /// MediaQuery insets (no UCrop / native Activity needed). Returns the /// cropped JPEG/PNG bytes via Navigator pop, or `null` on cancel. +/// +/// Defaults to a 1:1 crop (avatar use). Pass [aspectRatio] to override, or +/// `null` for a free-form crop (e.g. chat backgrounds). class AvatarCropPage extends StatefulWidget { final Uint8List imageBytes; - const AvatarCropPage({required this.imageBytes, super.key}); + final double? aspectRatio; + const AvatarCropPage({ + required this.imageBytes, + this.aspectRatio = 1.0, + super.key, + }); @override State createState() => _AvatarCropPageState(); @@ -60,7 +68,7 @@ class _AvatarCropPageState extends State { child: Crop( image: widget.imageBytes, controller: _controller, - aspectRatio: 1.0, + aspectRatio: widget.aspectRatio, interactive: false, baseColor: theme.colorScheme.surface, maskColor: Colors.black.withValues(alpha: 0.6), diff --git a/lib/widget/chat_background.dart b/lib/widget/chat_background.dart new file mode 100644 index 0000000..2505413 --- /dev/null +++ b/lib/widget/chat_background.dart @@ -0,0 +1,110 @@ +import 'dart:io'; +import 'dart:ui' show ImageFilter, TileMode; + +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 '../theming/app_theme.dart'; +import '../utils/app_paths.dart'; + +/// Renders the configurable chat background behind [child]. +/// +/// Layering (bottom → top): the background source (pattern / image / colour / +/// none), an optional black dim overlay, then [child]. The pattern source +/// keeps the legacy dark-mode colour inversion; user images never invert. +/// Reads [ChatBackgroundSettings] reactively, so the in-app live preview and +/// the real chat update together as the user drags the sliders. +class ChatBackground extends StatelessWidget { + final Widget child; + const ChatBackground({required this.child, super.key}); + + static const _fallbackColor = Color(0xffefeae2); + + @override + Widget build(BuildContext context) { + final s = context.watch().val().chatBackgroundSettings; + final dark = AppTheme.isDarkMode(context); + + final Widget background; + switch (s.type) { + case ChatBackgroundType.none: + background = ColoredBox(color: Theme.of(context).colorScheme.surface); + case ChatBackgroundType.color: + background = ColoredBox(color: Color(s.colorValue ?? _fallbackColor.toARGB32())); + case ChatBackgroundType.pattern: + background = _imageLayer( + const AssetImage('assets/background/chat.png'), + s, + dark, + isPattern: true, + ); + case ChatBackgroundType.image: + background = KeyedSubtree( + // imageVersion changes on every replacement, forcing a fresh subtree + // alongside the explicit ImageCache evict in the settings handler. + key: ValueKey(s.imageVersion), + child: _imageLayer( + FileImage(File(AppPaths.chatBackgroundImage)), + s, + dark, + isPattern: false, + ), + ); + } + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill(child: background), + if (s.dim > 0) + Positioned.fill( + child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)), + ), + child, + ], + ); + } + + Widget _imageLayer( + ImageProvider provider, + ChatBackgroundSettings s, + bool dark, { + required bool isPattern, + }) { + final tiled = s.fit == ChatBackgroundFit.tile; + final fit = switch (s.fit) { + ChatBackgroundFit.cover => BoxFit.cover, + ChatBackgroundFit.center => BoxFit.none, + ChatBackgroundFit.tile => null, + }; + + Widget layer = DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: provider, + fit: fit, + alignment: Alignment.center, + // The legacy pattern was rendered at scale 1.5 while tiling. + scale: isPattern && tiled ? 1.5 : 1.0, + repeat: tiled ? ImageRepeat.repeat : ImageRepeat.noRepeat, + invertColors: isPattern && dark, + ), + ), + ); + + if (s.blur > 0) { + layer = ImageFiltered( + // mirror keeps cover-fit edges filled instead of bleeding to transparent. + imageFilter: ImageFilter.blur( + sigmaX: s.blur, + sigmaY: s.blur, + tileMode: TileMode.mirror, + ), + child: layer, + ); + } + return layer; + } +} diff --git a/lib/widget/chat_background_picker_sheet.dart b/lib/widget/chat_background_picker_sheet.dart new file mode 100644 index 0000000..54eec36 --- /dev/null +++ b/lib/widget/chat_background_picker_sheet.dart @@ -0,0 +1,75 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import 'avatar_crop_page.dart'; +import 'file_pick.dart'; + +/// Bottom sheet with "from gallery" and "take photo" actions for choosing a +/// chat background. Returns the picked image bytes **as-is** — cropping is a +/// separate, explicit step (see [cropChatBackgroundImage]) so the user isn't +/// forced through a cropper after every pick. Returns `null` if the user +/// cancelled everything. +Future showChatBackgroundPickerSheet(BuildContext context) async { + Uint8List? result; + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + padding: EdgeInsets.only( + bottom: 16 + MediaQuery.viewInsetsOf(sheetContext).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('Aus Galerie wählen'), + onTap: () async { + final bytes = await _pickRaw(FilePick.singleGalleryPick); + if (bytes == null || !sheetContext.mounted) return; + result = bytes; + Navigator.of(sheetContext).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.photo_camera_outlined), + title: const Text('Foto aufnehmen'), + onTap: () async { + final bytes = await _pickRaw(FilePick.cameraPick); + if (bytes == null || !sheetContext.mounted) return; + result = bytes; + Navigator.of(sheetContext).pop(); + }, + ), + ], + ), + ), + ), + ); + return result; +} + +/// Opens the free-form cropper on [bytes] and returns the cropped result, or +/// `null` if the user cancelled. Free aspect ratio since the background fills +/// the whole screen. +Future cropChatBackgroundImage( + BuildContext context, + Uint8List bytes, +) => Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => AvatarCropPage(imageBytes: bytes, aspectRatio: null), + ), +); + +Future _pickRaw(Future Function() pick) async { + final picked = await pick(); + if (picked == null) return null; + return picked.readAsBytes(); +}