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:
@@ -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<AvatarCropPage> createState() => _AvatarCropPageState();
|
||||
@@ -60,7 +68,7 @@ class _AvatarCropPageState extends State<AvatarCropPage> {
|
||||
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),
|
||||
|
||||
@@ -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<SettingsCubit>().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;
|
||||
}
|
||||
}
|
||||
@@ -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<Uint8List?> showChatBackgroundPickerSheet(BuildContext context) async {
|
||||
Uint8List? result;
|
||||
await showModalBottomSheet<void>(
|
||||
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<Uint8List?> cropChatBackgroundImage(
|
||||
BuildContext context,
|
||||
Uint8List bytes,
|
||||
) => Navigator.of(context).push<Uint8List>(
|
||||
MaterialPageRoute(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => AvatarCropPage(imageBytes: bytes, aspectRatio: null),
|
||||
),
|
||||
);
|
||||
|
||||
Future<Uint8List?> _pickRaw(Future<XFile?> Function() pick) async {
|
||||
final picked = await pick();
|
||||
if (picked == null) return null;
|
||||
return picked.readAsBytes();
|
||||
}
|
||||
Reference in New Issue
Block a user