feat: выбор обоев для чата

This commit is contained in:
torvalds
2026-07-03 17:51:20 +07:00
parent e44720c142
commit 85d27cb393
9 changed files with 1243 additions and 65 deletions
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
@immutable
class ChatWallpaperTheme {
final String id;
final String name;
final Gradient gradient;
final Color bubbleTint;
const ChatWallpaperTheme({
required this.id,
required this.name,
required this.gradient,
this.bubbleTint = Colors.transparent,
});
Widget buildBackground() => DecoratedBox(
decoration: BoxDecoration(gradient: gradient),
child: const SizedBox.expand(),
);
Widget buildPreview() => DecoratedBox(
decoration: BoxDecoration(gradient: gradient),
child: const SizedBox.expand(),
);
}
const List<ChatWallpaperTheme> kChatWallpaperThemes = <ChatWallpaperTheme>[];
ChatWallpaperTheme? chatWallpaperThemeById(String? id) {
if (id == null) return null;
for (final theme in kChatWallpaperThemes) {
if (theme.id == id) return theme;
}
return null;
}
+191
View File
@@ -0,0 +1,191 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum ChatWallpaperKind { image, theme }
@immutable
class WallpaperImageSettings {
final double dim;
final bool blur;
final bool motion;
final double offsetX;
const WallpaperImageSettings({
this.dim = 0,
this.blur = false,
this.motion = false,
this.offsetX = 0,
});
}
@immutable
class ChatWallpaper {
final ChatWallpaperKind kind;
final String? imagePath;
final String? themeId;
final double dim;
final bool blur;
final bool motion;
final double offsetX;
const ChatWallpaper.image(
String path, {
this.dim = 0,
this.blur = false,
this.motion = false,
this.offsetX = 0,
}) : kind = ChatWallpaperKind.image,
imagePath = path,
themeId = null;
const ChatWallpaper.theme(String id)
: kind = ChatWallpaperKind.theme,
imagePath = null,
themeId = id,
dim = 0,
blur = false,
motion = false,
offsetX = 0;
bool get isImage => kind == ChatWallpaperKind.image;
Map<String, dynamic> _toJson() => isImage
? {
'path': imagePath,
'dim': dim,
'blur': blur,
'motion': motion,
'offsetX': offsetX,
}
: {'theme': themeId};
static ChatWallpaper? _fromJson(Object? raw) {
if (raw is! Map) return null;
final path = raw['path'];
if (path is String && path.isNotEmpty) {
return ChatWallpaper.image(
path,
dim: (raw['dim'] as num?)?.toDouble() ?? 0,
blur: raw['blur'] == true,
motion: raw['motion'] == true,
offsetX: (raw['offsetX'] as num?)?.toDouble() ?? 0,
);
}
final theme = raw['theme'];
if (theme is String && theme.isNotEmpty) return ChatWallpaper.theme(theme);
return null;
}
}
class ChatWallpaperStore {
ChatWallpaperStore._();
static final ChatWallpaperStore instance = ChatWallpaperStore._();
static const String _prefsKey = 'chat_wallpapers';
static const String _dirName = 'chat_wallpapers';
final Map<String, ChatWallpaper> _wallpapers = {};
final ValueNotifier<int> revision = ValueNotifier(0);
bool _loaded = false;
String _key(int accountId, int chatId) => '$accountId/$chatId';
Future<void> load() async {
if (_loaded) return;
_loaded = true;
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_prefsKey);
if (raw == null) return;
try {
final map = jsonDecode(raw);
if (map is Map) {
map.forEach((k, v) {
if (k is! String) return;
final wp = ChatWallpaper._fromJson(v);
if (wp != null) _wallpapers[k] = wp;
});
}
} catch (_) {}
}
ChatWallpaper? get(int accountId, int chatId) {
if (accountId == 0) return null;
return _wallpapers[_key(accountId, chatId)];
}
Future<ChatWallpaper?> setImage(
int accountId,
int chatId,
Uint8List bytes, {
WallpaperImageSettings settings = const WallpaperImageSettings(),
}) async {
if (accountId == 0) return null;
final dir = await getApplicationDocumentsDirectory();
final wpDir = Directory('${dir.path}/$_dirName');
if (!await wpDir.exists()) await wpDir.create(recursive: true);
final stamp = DateTime.now().millisecondsSinceEpoch;
final file = File('${wpDir.path}/${accountId}_${chatId}_$stamp.img');
await file.writeAsBytes(bytes, flush: true);
final wallpaper = ChatWallpaper.image(
file.path,
dim: settings.dim,
blur: settings.blur,
motion: settings.motion,
offsetX: settings.offsetX,
);
await _store(accountId, chatId, wallpaper);
return wallpaper;
}
Future<ChatWallpaper> setTheme(
int accountId,
int chatId,
String themeId,
) async {
final wallpaper = ChatWallpaper.theme(themeId);
await _store(accountId, chatId, wallpaper);
return wallpaper;
}
Future<void> clear(int accountId, int chatId) => _store(accountId, chatId, null);
Future<void> _store(
int accountId,
int chatId,
ChatWallpaper? wallpaper,
) async {
if (accountId == 0) return;
final key = _key(accountId, chatId);
final previous = _wallpapers[key];
if (previous != null &&
previous.isImage &&
previous.imagePath != wallpaper?.imagePath) {
unawaited(_deleteImage(previous.imagePath));
}
if (wallpaper == null) {
if (previous == null) return;
_wallpapers.remove(key);
} else {
_wallpapers[key] = wallpaper;
}
revision.value++;
final prefs = await SharedPreferences.getInstance();
final serializable = <String, dynamic>{};
_wallpapers.forEach((k, v) => serializable[k] = v._toJson());
await prefs.setString(_prefsKey, jsonEncode(serializable));
}
Future<void> _deleteImage(String? path) async {
if (path == null) return;
try {
final file = File(path);
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
+72 -1
View File
@@ -37,6 +37,7 @@ import '../../../core/protocol/packet.dart';
import '../../../core/push/push_service.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/storage/chat_activity_store.dart';
import '../../../core/storage/chat_wallpaper_store.dart';
import '../../../core/storage/draft_store.dart';
import '../../../core/cache/info_cache.dart';
import '../../../core/cache/message_session_cache.dart';
@@ -67,7 +68,10 @@ import '../../widgets/sticker_panel.dart';
import '../../widgets/sticker_pack_sheet.dart';
import '../../widgets/swipe_to_pop.dart';
import '../../widgets/schedule_time_picker.dart';
import '../../widgets/chat_wallpaper_sheet.dart';
import '../../widgets/chat_wallpaper_view.dart';
import 'scheduled_messages_screen.dart';
import 'chat_wallpaper_preview_screen.dart';
class _UploadStatus {
final bool active;
@@ -325,6 +329,7 @@ class _ChatScreenState extends State<ChatScreen>
bool _floatingDateScheduled = false;
int _myId = 0;
CachedChat? chat;
ChatWallpaper? _wallpaper;
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
Timer? _floatingDateTimer;
@@ -428,6 +433,7 @@ class _ChatScreenState extends State<ChatScreen>
if (!mounted) return;
_myId = p?.id ?? 0;
_restoreDraft();
unawaited(_loadWallpaper());
unawaited(_refreshBadge());
ChatsModule.getChat(_myId, widget.chatId)
@@ -2417,7 +2423,7 @@ class _ChatScreenState extends State<ChatScreen>
ChatMenuItem(
icon: Symbols.wallpaper,
label: 'Изменить обои',
onTap: () {},
onTap: _openWallpaperSheet,
),
ChatMenuItem(
icon: Symbols.mop,
@@ -2433,6 +2439,67 @@ class _ChatScreenState extends State<ChatScreen>
);
}
Future<void> _loadWallpaper() async {
await ChatWallpaperStore.instance.load();
if (!mounted) return;
final wp = ChatWallpaperStore.instance.get(_myId, widget.chatId);
if (wp != _wallpaper) setState(() => _wallpaper = wp);
}
Future<void> _openWallpaperSheet() async {
if (_myId == 0) return;
final pick = await showChatWallpaperSheet(context, current: _wallpaper);
if (pick == null || !mounted) return;
final store = ChatWallpaperStore.instance;
switch (pick.type) {
case WallpaperPickType.none:
await store.clear(_myId, widget.chatId);
if (mounted) setState(() => _wallpaper = null);
break;
case WallpaperPickType.theme:
final theme = pick.theme;
if (theme == null) break;
final wp = await store.setTheme(_myId, widget.chatId, theme.id);
if (mounted) setState(() => _wallpaper = wp);
break;
case WallpaperPickType.gallery:
await _pickWallpaperFromGallery();
break;
}
}
Future<void> _pickWallpaperFromGallery() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
withData: true,
);
if (result == null || result.files.isEmpty) return;
final bytes = result.files.first.bytes;
if (bytes == null) {
if (mounted) showCustomNotification(context, 'Не удалось прочитать файл');
return;
}
if (!mounted) return;
final settings = await Navigator.of(context).push<WallpaperImageSettings>(
MaterialPageRoute(
builder: (_) => ChatWallpaperPreviewScreen(imageBytes: bytes),
),
);
if (settings == null || !mounted) return;
final wp = await ChatWallpaperStore.instance.setImage(
_myId,
widget.chatId,
bytes,
settings: settings,
);
if (!mounted) return;
if (wp == null) {
showCustomNotification(context, 'Не удалось сохранить обои');
return;
}
setState(() => _wallpaper = wp);
}
Future<bool?> _showConfirmDialog({
required String title,
required String body,
@@ -3483,6 +3550,10 @@ class _ChatScreenState extends State<ChatScreen>
Expanded(
child: Stack(
children: [
if (_wallpaper != null)
Positioned.fill(
child: ChatWallpaperView(wallpaper: _wallpaper!),
),
Positioned.fill(
child: _isLoading && _messages.isEmpty
? _buildShimmerLoading()
@@ -0,0 +1,433 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/storage/chat_wallpaper_store.dart';
import 'package:komet/frontend/widgets/chat_wallpaper_view.dart';
class ChatWallpaperPreviewScreen extends StatefulWidget {
final Uint8List imageBytes;
final WallpaperImageSettings initial;
const ChatWallpaperPreviewScreen({
super.key,
required this.imageBytes,
this.initial = const WallpaperImageSettings(dim: 0.2),
});
@override
State<ChatWallpaperPreviewScreen> createState() =>
_ChatWallpaperPreviewScreenState();
}
class _ChatWallpaperPreviewScreenState
extends State<ChatWallpaperPreviewScreen> {
late double _dim = widget.initial.dim;
late bool _blur = widget.initial.blur;
late bool _motion = widget.initial.motion;
late double _offsetX = widget.initial.offsetX;
late final MemoryImage _image = MemoryImage(widget.imageBytes);
void _pan(double dx, double width) {
if (width <= 0) return;
setState(() {
_offsetX = (_offsetX - dx / width * 2).clamp(-1.0, 1.0);
});
}
void _apply() {
Navigator.pop(
context,
WallpaperImageSettings(
dim: _dim,
blur: _blur,
motion: _motion,
offsetX: _offsetX,
),
);
}
@override
Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width;
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: (d) => _pan(d.delta.dx, width),
child: WallpaperImageLayer(
image: _image,
dim: _dim,
blur: _blur,
motion: _motion,
offsetX: _offsetX,
),
),
const IgnorePointer(child: _EdgeScrim()),
SafeArea(
child: Column(
children: [
_appBar(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 0),
child: _DimmingSlider(
value: _dim,
onChanged: (v) => setState(() => _dim = v),
),
),
const Expanded(child: IgnorePointer(child: _SamplePreview())),
_controls(),
],
),
),
],
),
);
}
Widget _appBar() {
return SizedBox(
height: 56,
child: Row(
children: [
IconButton(
icon: const Icon(Symbols.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
const Text(
'Обои',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
],
),
);
}
Widget _controls() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: _ToggleChip(
label: 'Размытие',
value: _blur,
onTap: () => setState(() => _blur = !_blur),
),
),
const SizedBox(width: 12),
Expanded(
child: _ToggleChip(
label: 'Движение',
value: _motion,
onTap: () => setState(() => _motion = !_motion),
),
),
],
),
const SizedBox(height: 12),
_ApplyButton(onTap: _apply),
],
),
);
}
}
class _Frosted extends StatelessWidget {
final double radius;
final Widget child;
const _Frosted({required this.radius, required this.child});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 24, sigmaY: 24),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: child,
),
),
);
}
}
class _EdgeScrim extends StatelessWidget {
const _EdgeScrim();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0x59000000),
Color(0x00000000),
Color(0x00000000),
Color(0x66000000),
],
stops: [0.0, 0.16, 0.74, 1.0],
),
),
child: SizedBox.expand(),
);
}
}
class _DimmingSlider extends StatelessWidget {
final double value;
final ValueChanged<double> onChanged;
const _DimmingSlider({required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
void update(double dx) => onChanged((dx / width).clamp(0.0, 1.0));
final fraction = value.clamp(0.0, 1.0);
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (d) => update(d.localPosition.dx),
onHorizontalDragUpdate: (d) => update(d.localPosition.dx),
child: _Frosted(
radius: 14,
child: SizedBox(
width: double.infinity,
height: 48,
child: Stack(
fit: StackFit.expand,
children: [
Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: fraction,
heightFactor: 1,
child: ColoredBox(
color: Colors.white.withValues(alpha: 0.85),
),
),
),
_DimLabel(value: fraction, color: Colors.white),
ClipRect(
clipper: _RevealClipper(fraction),
child: _DimLabel(value: fraction, color: Colors.black),
),
],
),
),
),
);
},
);
}
}
class _DimLabel extends StatelessWidget {
final double value;
final Color color;
const _DimLabel({required this.value, required this.color});
@override
Widget build(BuildContext context) {
final style = TextStyle(
color: color,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Text('Затемнение', style: style),
const Spacer(),
Text('${(value * 100).round()}%', style: style),
],
),
);
}
}
class _RevealClipper extends CustomClipper<Rect> {
final double fraction;
const _RevealClipper(this.fraction);
@override
Rect getClip(Size size) => Rect.fromLTWH(0, 0, size.width * fraction, size.height);
@override
bool shouldReclip(_RevealClipper oldClipper) =>
oldClipper.fraction != fraction;
}
class _ToggleChip extends StatelessWidget {
final String label;
final bool value;
final VoidCallback onTap;
const _ToggleChip({
required this.label,
required this.value,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: _Frosted(
radius: 24,
child: SizedBox(
height: 48,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 140),
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: value ? Colors.white : Colors.transparent,
border: Border.all(color: Colors.white, width: 2),
),
child: value
? const Icon(Symbols.check, size: 16, color: Colors.black)
: null,
),
const SizedBox(width: 10),
Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
],
),
),
),
);
}
}
class _ApplyButton extends StatelessWidget {
final VoidCallback onTap;
const _ApplyButton({required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: _Frosted(
radius: 26,
child: const SizedBox(
height: 52,
child: Center(
child: Text(
'Применить',
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
),
),
),
);
}
}
class _SamplePreview extends StatelessWidget {
const _SamplePreview();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_bubble(
text: 'Как насчёт новых обоев для этого чата?',
color: cs.surfaceContainerHighest.withValues(alpha: 0.92),
textColor: cs.onSurface,
alignment: Alignment.centerLeft,
),
const SizedBox(height: 8),
_bubble(
text: 'Отличная идея.',
color: cs.primary,
textColor: cs.onPrimary,
alignment: Alignment.centerRight,
),
],
),
),
);
}
Widget _bubble({
required String text,
required Color color,
required Color textColor,
required Alignment alignment,
}) {
return Align(
alignment: alignment,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 260),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(18),
),
child: Text(
text,
style: TextStyle(
color: textColor,
fontSize: 15,
fontFamily: 'Outfit',
),
),
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -500,7 +501,10 @@ class _ColorPickerCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_HueStripPicker(color: col, onChanged: onColorChanged),
_ColorWheelPicker(
color: col,
onChanged: onColorChanged,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
@@ -660,79 +664,65 @@ class _BubbleBehaviorCard extends StatelessWidget {
}
}
class _HueStripPicker extends StatelessWidget {
class _ColorWheelPicker extends StatefulWidget {
final Color color;
final ValueChanged<Color> onChanged;
const _HueStripPicker({required this.color, required this.onChanged});
const _ColorWheelPicker({required this.color, required this.onChanged});
static const _gradient = LinearGradient(
colors: [
Color(0xFFFF0000),
Color(0xFFFFFF00),
Color(0xFF00FF00),
Color(0xFF00FFFF),
Color(0xFF0000FF),
Color(0xFFFF00FF),
Color(0xFFFF0000),
],
);
@override
State<_ColorWheelPicker> createState() => _ColorWheelPickerState();
}
class _ColorWheelPickerState extends State<_ColorWheelPicker> {
late HSVColor _hsv;
late Color _lastEmitted;
@override
void initState() {
super.initState();
_hsv = HSVColor.fromColor(widget.color).withValue(1);
_lastEmitted = widget.color;
}
@override
void didUpdateWidget(covariant _ColorWheelPicker oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.color != _lastEmitted) {
_hsv = HSVColor.fromColor(widget.color).withValue(1);
_lastEmitted = widget.color;
}
}
void _handleWheel(Offset local, double size) {
final radius = size / 2;
final dx = local.dx - radius;
final dy = local.dy - radius;
final sat = (math.sqrt(dx * dx + dy * dy) / radius).clamp(0.0, 1.0);
var hue = math.atan2(dy, dx) * 180 / math.pi;
if (hue < 0) hue += 360;
final hsv = _hsv.withHue(hue).withSaturation(sat);
setState(() => _hsv = hsv);
final color = hsv.toColor();
_lastEmitted = color;
widget.onChanged(color);
}
@override
Widget build(BuildContext context) {
final hue = HSVColor.fromColor(color).hue;
const trackHeight = 26.0;
const thumbDiameter = 30.0;
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
void emit(double dx) {
final clamped = dx.clamp(0.0, width);
final newHue = (clamped / width) * 360;
onChanged(HSVColor.fromAHSV(1, newHue, 1, 1).toColor());
}
final wheelSize = math.min(260.0, constraints.maxWidth);
final thumbLeft = (hue / 360) * width - thumbDiameter / 2;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => emit(d.localPosition.dx),
onPanUpdate: (d) => emit(d.localPosition.dx),
child: SizedBox(
height: thumbDiameter + 4,
child: Stack(
children: [
Center(
child: Container(
height: trackHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(trackHeight / 2),
gradient: _gradient,
),
),
),
Positioned(
left: thumbLeft.clamp(0, width - thumbDiameter),
top: (thumbDiameter + 4 - thumbDiameter) / 2,
child: Container(
width: thumbDiameter,
height: thumbDiameter,
decoration: BoxDecoration(
color: HSVColor.fromAHSV(1, hue, 1, 1).toColor(),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 4,
offset: const Offset(0, 1),
),
],
),
),
),
],
return Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onPanDown: (d) => _handleWheel(d.localPosition, wheelSize),
onPanUpdate: (d) => _handleWheel(d.localPosition, wheelSize),
child: SizedBox(
width: wheelSize,
height: wheelSize,
child: CustomPaint(painter: _WheelPainter(hsv: _hsv)),
),
),
);
@@ -740,3 +730,47 @@ class _HueStripPicker extends StatelessWidget {
);
}
}
class _WheelPainter extends CustomPainter {
final HSVColor hsv;
const _WheelPainter({required this.hsv});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
final rect = Rect.fromCircle(center: center, radius: radius);
final hueShader = SweepGradient(
colors: [
for (var i = 0; i <= 360; i += 30)
HSVColor.fromAHSV(1, (i % 360).toDouble(), 1, 1).toColor(),
],
stops: [for (var i = 0; i <= 360; i += 30) i / 360],
).createShader(rect);
canvas.drawCircle(center, radius, Paint()..shader = hueShader);
final satShader = RadialGradient(
colors: [Colors.white, Colors.white.withValues(alpha: 0)],
).createShader(rect);
canvas.drawCircle(center, radius, Paint()..shader = satShader);
final angle = hsv.hue * math.pi / 180;
final thumb = Offset(
center.dx + hsv.saturation * radius * math.cos(angle),
center.dy + hsv.saturation * radius * math.sin(angle),
);
canvas.drawShadow(
Path()..addOval(Rect.fromCircle(center: thumb, radius: 13)),
Colors.black,
2,
false,
);
canvas.drawCircle(thumb, 13, Paint()..color = Colors.white);
canvas.drawCircle(thumb, 10, Paint()..color = hsv.toColor());
}
@override
bool shouldRepaint(_WheelPainter oldDelegate) => oldDelegate.hsv != hsv;
}
@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:komet/core/config/chat_wallpaper_themes.dart';
import 'package:komet/core/storage/chat_wallpaper_store.dart';
import 'package:komet/frontend/widgets/sheet_helpers.dart';
enum WallpaperPickType { none, theme, gallery }
class WallpaperPick {
final WallpaperPickType type;
final ChatWallpaperTheme? theme;
const WallpaperPick.none()
: type = WallpaperPickType.none,
theme = null;
const WallpaperPick.gallery()
: type = WallpaperPickType.gallery,
theme = null;
const WallpaperPick.theme(this.theme) : type = WallpaperPickType.theme;
}
Future<WallpaperPick?> showChatWallpaperSheet(
BuildContext context, {
required ChatWallpaper? current,
}) {
return showModalBottomSheet<WallpaperPick>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.45),
builder: (_) => _ChatWallpaperSheet(current: current),
);
}
class _ChatWallpaperSheet extends StatelessWidget {
final ChatWallpaper? current;
const _ChatWallpaperSheet({required this.current});
bool get _isNoneSelected => current == null;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return SafeArea(
top: false,
child: Container(
decoration: BoxDecoration(
color: cs.surfaceContainerHigh,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SheetGrabber(),
_header(context, cs),
const SizedBox(height: 12),
_themeRow(context, cs),
const SizedBox(height: 20),
_galleryButton(context, cs),
const SizedBox(height: 12),
],
),
),
);
}
Widget _header(BuildContext context, ColorScheme cs) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
IconButton(
icon: Icon(Symbols.close, color: cs.onSurface),
onPressed: () => Navigator.pop(context),
),
const SizedBox(width: 4),
Text(
'Выбрать тему',
style: TextStyle(
color: cs.onSurface,
fontSize: 22,
fontWeight: FontWeight.w700,
fontFamily: 'Outfit',
),
),
],
),
);
}
Widget _themeRow(BuildContext context, ColorScheme cs) {
return SizedBox(
height: 172,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
_NoneTile(
selected: _isNoneSelected,
onTap: () => Navigator.pop(context, const WallpaperPick.none()),
),
for (final theme in kChatWallpaperThemes)
_ThemeTile(
theme: theme,
selected: current?.themeId == theme.id,
onTap: () =>
Navigator.pop(context, WallpaperPick.theme(theme)),
),
],
),
);
}
Widget _galleryButton(BuildContext context, ColorScheme cs) {
return TextButton(
onPressed: () => Navigator.pop(context, const WallpaperPick.gallery()),
child: Text(
'Выбрать обои из галереи',
style: TextStyle(
color: cs.primary,
fontSize: 17,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
);
}
}
class _TileFrame extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
final Widget child;
const _TileFrame({
required this.selected,
required this.onTap,
required this.child,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(right: 12),
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 112,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected ? cs.primary : Colors.transparent,
width: 2.5,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(15),
child: child,
),
),
),
);
}
}
class _NoneTile extends StatelessWidget {
final bool selected;
final VoidCallback onTap;
const _NoneTile({required this.selected, required this.onTap});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return _TileFrame(
selected: selected,
onTap: onTap,
child: ColoredBox(
color: cs.surfaceContainerHighest,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Без\nтемы',
textAlign: TextAlign.center,
style: TextStyle(
color: cs.onSurface,
fontSize: 18,
height: 1.1,
fontWeight: FontWeight.w600,
fontFamily: 'Outfit',
),
),
const SizedBox(height: 14),
const Icon(Symbols.close, color: Color(0xFFFF3B30), size: 40),
],
),
),
);
}
}
class _ThemeTile extends StatelessWidget {
final ChatWallpaperTheme theme;
final bool selected;
final VoidCallback onTap;
const _ThemeTile({
required this.theme,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return _TileFrame(
selected: selected,
onTap: onTap,
child: theme.buildPreview(),
);
}
}
@@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:komet/core/config/chat_wallpaper_themes.dart';
import 'package:komet/core/storage/chat_wallpaper_store.dart';
class ChatWallpaperView extends StatelessWidget {
final ChatWallpaper wallpaper;
const ChatWallpaperView({super.key, required this.wallpaper});
@override
Widget build(BuildContext context) {
if (wallpaper.isImage) {
final path = wallpaper.imagePath;
if (path == null) return const SizedBox.shrink();
return WallpaperImageLayer(
image: FileImage(File(path)),
dim: wallpaper.dim,
blur: wallpaper.blur,
motion: wallpaper.motion,
offsetX: wallpaper.offsetX,
);
}
final theme = chatWallpaperThemeById(wallpaper.themeId);
if (theme == null) return const SizedBox.shrink();
return theme.buildBackground();
}
}
class WallpaperImageLayer extends StatefulWidget {
final ImageProvider image;
final double dim;
final bool blur;
final bool motion;
final double offsetX;
const WallpaperImageLayer({
super.key,
required this.image,
this.dim = 0,
this.blur = false,
this.motion = false,
this.offsetX = 0,
});
@override
State<WallpaperImageLayer> createState() => _WallpaperImageLayerState();
}
class _WallpaperImageLayerState extends State<WallpaperImageLayer> {
static const double _maxShift = 20;
static const double _motionScale = 1.16;
static const double _blurSigma = 22;
static const Duration _transition = Duration(milliseconds: 320);
final ValueNotifier<Offset> _offset = ValueNotifier(Offset.zero);
StreamSubscription<AccelerometerEvent>? _sub;
@override
void initState() {
super.initState();
if (widget.motion) _startMotion();
}
@override
void didUpdateWidget(WallpaperImageLayer old) {
super.didUpdateWidget(old);
if (widget.motion && !old.motion) _startMotion();
if (!widget.motion && old.motion) _stopMotion();
}
void _startMotion() {
_offset.value = Offset.zero;
_sub ??= accelerometerEventStream(samplingPeriod: SensorInterval.gameInterval)
.listen(_onAccelerometer, onError: (_) {}, cancelOnError: false);
}
void _stopMotion() {
_sub?.cancel();
_sub = null;
}
void _onAccelerometer(AccelerometerEvent event) {
final targetX = (-event.x / 9.8).clamp(-1.0, 1.0) * _maxShift;
final targetY = (event.y / 9.8).clamp(-1.0, 1.0) * _maxShift;
final prev = _offset.value;
_offset.value = Offset(
prev.dx + (targetX - prev.dx) * 0.12,
prev.dy + (targetY - prev.dy) * 0.12,
);
}
@override
void dispose() {
_sub?.cancel();
_offset.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final image = Image(
image: widget.image,
fit: BoxFit.cover,
alignment: Alignment(widget.offsetX.clamp(-1.0, 1.0), 0),
width: double.infinity,
height: double.infinity,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
);
return TweenAnimationBuilder<double>(
tween: Tween(end: widget.blur ? _blurSigma : 0.0),
duration: _transition,
curve: Curves.easeInOut,
child: image,
builder: (context, sigma, blurChild) {
final blurred = sigma > 0.05
? ImageFiltered(
imageFilter: ui.ImageFilter.blur(
sigmaX: sigma,
sigmaY: sigma,
tileMode: TileMode.clamp,
),
child: blurChild,
)
: blurChild!;
return TweenAnimationBuilder<double>(
tween: Tween(end: widget.motion ? 1.0 : 0.0),
duration: _transition,
curve: Curves.easeInOut,
child: blurred,
builder: (context, motionT, motionChild) {
final layer = motionT > 0.001
? ValueListenableBuilder<Offset>(
valueListenable: _offset,
builder: (context, offset, child) => Transform.translate(
offset: offset * motionT,
child: Transform.scale(
scale: 1 + (_motionScale - 1) * motionT,
child: child,
),
),
child: motionChild,
)
: motionChild!;
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
layer,
if (widget.dim > 0)
ColoredBox(
color: Colors.black.withValues(alpha: widget.dim),
),
],
),
);
},
);
},
);
}
}
+16
View File
@@ -1221,6 +1221,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.4"
sensors_plus:
dependency: "direct main"
description:
name: sensors_plus
sha256: "31182c626623de79d3f7e1f9b43028f940821ee012c092f68f165fe55b867822"
url: "https://pub.dev"
source: hosted
version: "7.1.0"
sensors_plus_platform_interface:
dependency: transitive
description:
name: sensors_plus_platform_interface
sha256: "9e12a92569bf14ae04b2f086c3e087177a47e527b7666ae1bbe5f70f8450ab19"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
share_plus:
dependency: "direct main"
description:
+1
View File
@@ -79,6 +79,7 @@ dependencies:
media_kit_libs_windows_video: ^1.0.11
media_kit_libs_linux: ^1.2.1
media_kit_libs_macos_video: ^1.1.4
sensors_plus: ^7.1.0
dev_dependencies:
flutter_test: