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:
@@ -37,6 +37,7 @@ import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
|
|||||||
import 'storage/settings.dart';
|
import 'storage/settings.dart';
|
||||||
import 'theming/dark_app_theme.dart';
|
import 'theming/dark_app_theme.dart';
|
||||||
import 'theming/light_app_theme.dart';
|
import 'theming/light_app_theme.dart';
|
||||||
|
import 'utils/app_paths.dart';
|
||||||
import 'view/login/login.dart';
|
import 'view/login/login.dart';
|
||||||
import 'widget/app_progress_indicator.dart';
|
import 'widget/app_progress_indicator.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
@@ -72,6 +73,9 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
HydratedBloc.storage = storage;
|
HydratedBloc.storage = storage;
|
||||||
}),
|
}),
|
||||||
|
Future(() async {
|
||||||
|
AppPaths.documentsDir = (await getApplicationDocumentsDirectory()).path;
|
||||||
|
}),
|
||||||
AccountData().waitForPopulation(),
|
AccountData().waitForPopulation(),
|
||||||
ShareIntentListener.instance.initialize(),
|
ShareIntentListener.instance.initialize(),
|
||||||
];
|
];
|
||||||
@@ -352,6 +356,10 @@ Future<void> _wipeUserState({
|
|||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
await HydratedBloc.storage.clear();
|
await HydratedBloc.storage.clear();
|
||||||
await const CacheView().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
|
// Stop the periodic widget refresh job so the background isolate doesn't
|
||||||
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
|
||||||
// on the next successful login.
|
// on the next successful login.
|
||||||
|
|||||||
@@ -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/feedback/feedback_dialog.dart';
|
||||||
import '../view/pages/more/roomplan/roomplan.dart';
|
import '../view/pages/more/roomplan/roomplan.dart';
|
||||||
import '../view/pages/more/share/qr_share_view.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/modules_settings_page.dart';
|
||||||
import '../view/pages/settings/settings.dart';
|
import '../view/pages/settings/settings.dart';
|
||||||
import '../view/pages/share_intent/share_chat_picker.dart';
|
import '../view/pages/share_intent/share_chat_picker.dart';
|
||||||
@@ -94,6 +95,14 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage());
|
pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void openChatBackgroundSettings(BuildContext context) {
|
||||||
|
pushScreen(
|
||||||
|
context,
|
||||||
|
withNavBar: false,
|
||||||
|
screen: const ChatBackgroundSettingsPage(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static void openFeedback(BuildContext context) {
|
static void openFeedback(BuildContext context) {
|
||||||
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
|
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> json) =>
|
||||||
|
_$ChatBackgroundSettingsFromJson(json);
|
||||||
|
Map<String, dynamic> toJson() => _$ChatBackgroundSettingsToJson(this);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'chat_background_settings.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
ChatBackgroundSettings _$ChatBackgroundSettingsFromJson(
|
||||||
|
Map<String, dynamic> 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<String, dynamic> _$ChatBackgroundSettingsToJson(
|
||||||
|
ChatBackgroundSettings instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'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',
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import 'chat_background_settings.dart';
|
||||||
import 'dev_tools_settings.dart';
|
import 'dev_tools_settings.dart';
|
||||||
import 'file_settings.dart';
|
import 'file_settings.dart';
|
||||||
import 'file_view_settings.dart';
|
import 'file_view_settings.dart';
|
||||||
@@ -22,6 +23,7 @@ class Settings {
|
|||||||
ModulesSettings modulesSettings;
|
ModulesSettings modulesSettings;
|
||||||
TimetableSettings timetableSettings;
|
TimetableSettings timetableSettings;
|
||||||
TalkSettings talkSettings;
|
TalkSettings talkSettings;
|
||||||
|
ChatBackgroundSettings chatBackgroundSettings;
|
||||||
FileSettings fileSettings;
|
FileSettings fileSettings;
|
||||||
HolidaysSettings holidaysSettings;
|
HolidaysSettings holidaysSettings;
|
||||||
FileViewSettings fileViewSettings;
|
FileViewSettings fileViewSettings;
|
||||||
@@ -35,6 +37,7 @@ class Settings {
|
|||||||
required this.modulesSettings,
|
required this.modulesSettings,
|
||||||
required this.timetableSettings,
|
required this.timetableSettings,
|
||||||
required this.talkSettings,
|
required this.talkSettings,
|
||||||
|
required this.chatBackgroundSettings,
|
||||||
required this.fileSettings,
|
required this.fileSettings,
|
||||||
required this.holidaysSettings,
|
required this.holidaysSettings,
|
||||||
required this.fileViewSettings,
|
required this.fileViewSettings,
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ Settings _$SettingsFromJson(Map<String, dynamic> json) => Settings(
|
|||||||
talkSettings: TalkSettings.fromJson(
|
talkSettings: TalkSettings.fromJson(
|
||||||
json['talkSettings'] as Map<String, dynamic>,
|
json['talkSettings'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
|
chatBackgroundSettings: ChatBackgroundSettings.fromJson(
|
||||||
|
json['chatBackgroundSettings'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
fileSettings: FileSettings.fromJson(
|
fileSettings: FileSettings.fromJson(
|
||||||
json['fileSettings'] as Map<String, dynamic>,
|
json['fileSettings'] as Map<String, dynamic>,
|
||||||
),
|
),
|
||||||
@@ -44,6 +47,7 @@ Map<String, dynamic> _$SettingsToJson(Settings instance) => <String, dynamic>{
|
|||||||
'modulesSettings': instance.modulesSettings.toJson(),
|
'modulesSettings': instance.modulesSettings.toJson(),
|
||||||
'timetableSettings': instance.timetableSettings.toJson(),
|
'timetableSettings': instance.timetableSettings.toJson(),
|
||||||
'talkSettings': instance.talkSettings.toJson(),
|
'talkSettings': instance.talkSettings.toJson(),
|
||||||
|
'chatBackgroundSettings': instance.chatBackgroundSettings.toJson(),
|
||||||
'fileSettings': instance.fileSettings.toJson(),
|
'fileSettings': instance.fileSettings.toJson(),
|
||||||
'holidaysSettings': instance.holidaysSettings.toJson(),
|
'holidaysSettings': instance.holidaysSettings.toJson(),
|
||||||
'fileViewSettings': instance.fileViewSettings.toJson(),
|
'fileViewSettings': instance.fileViewSettings.toJson(),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../../../state/app/modules/app_modules.dart';
|
import '../../../../state/app/modules/app_modules.dart';
|
||||||
|
import '../../../../storage/chat_background_settings.dart';
|
||||||
import '../../../../storage/dev_tools_settings.dart';
|
import '../../../../storage/dev_tools_settings.dart';
|
||||||
import '../../../../storage/file_settings.dart';
|
import '../../../../storage/file_settings.dart';
|
||||||
import '../../../../storage/file_view_settings.dart';
|
import '../../../../storage/file_view_settings.dart';
|
||||||
@@ -45,6 +46,15 @@ class DefaultSettings {
|
|||||||
drafts: {},
|
drafts: {},
|
||||||
draftReplies: {},
|
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(
|
fileSettings: FileSettings(
|
||||||
sortFoldersToTop: true,
|
sortFoldersToTop: true,
|
||||||
ascending: true,
|
ascending: true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../../../notification/notify_updater.dart';
|
import '../../../../notification/notify_updater.dart';
|
||||||
|
import '../../../../routing/app_routes.dart';
|
||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../utils/haptics.dart';
|
import '../../../../utils/haptics.dart';
|
||||||
import '../../../../widget/centered_leading.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(
|
ListTile(
|
||||||
leading: const CenteredLeading(
|
leading: const CenteredLeading(
|
||||||
Icon(Icons.notifications_active_outlined),
|
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_bloc.dart';
|
||||||
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
import '../../../state/app/modules/chat/bloc/chat_state.dart';
|
||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.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/clickable_app_bar.dart';
|
||||||
import '../../../widget/user_avatar.dart';
|
import '../../../widget/user_avatar.dart';
|
||||||
import 'data/chat_search_controller.dart';
|
import 'data/chat_search_controller.dart';
|
||||||
@@ -367,16 +367,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: DecoratedBox(
|
body: ChatBackground(
|
||||||
decoration: BoxDecoration(
|
|
||||||
image: DecorationImage(
|
|
||||||
image: const AssetImage('assets/background/chat.png'),
|
|
||||||
scale: 1.5,
|
|
||||||
opacity: 1,
|
|
||||||
repeat: ImageRepeat.repeat,
|
|
||||||
invertColors: AppTheme.isDarkMode(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -5,12 +5,20 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'app_progress_indicator.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
|
/// MediaQuery insets (no UCrop / native Activity needed). Returns the
|
||||||
/// cropped JPEG/PNG bytes via Navigator pop, or `null` on cancel.
|
/// 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 {
|
class AvatarCropPage extends StatefulWidget {
|
||||||
final Uint8List imageBytes;
|
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
|
@override
|
||||||
State<AvatarCropPage> createState() => _AvatarCropPageState();
|
State<AvatarCropPage> createState() => _AvatarCropPageState();
|
||||||
@@ -60,7 +68,7 @@ class _AvatarCropPageState extends State<AvatarCropPage> {
|
|||||||
child: Crop(
|
child: Crop(
|
||||||
image: widget.imageBytes,
|
image: widget.imageBytes,
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
aspectRatio: 1.0,
|
aspectRatio: widget.aspectRatio,
|
||||||
interactive: false,
|
interactive: false,
|
||||||
baseColor: theme.colorScheme.surface,
|
baseColor: theme.colorScheme.surface,
|
||||||
maskColor: Colors.black.withValues(alpha: 0.6),
|
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