feat: выбор обоев для чата
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import '../../../core/protocol/packet.dart';
|
|||||||
import '../../../core/push/push_service.dart';
|
import '../../../core/push/push_service.dart';
|
||||||
import '../../../core/storage/app_database.dart';
|
import '../../../core/storage/app_database.dart';
|
||||||
import '../../../core/storage/chat_activity_store.dart';
|
import '../../../core/storage/chat_activity_store.dart';
|
||||||
|
import '../../../core/storage/chat_wallpaper_store.dart';
|
||||||
import '../../../core/storage/draft_store.dart';
|
import '../../../core/storage/draft_store.dart';
|
||||||
import '../../../core/cache/info_cache.dart';
|
import '../../../core/cache/info_cache.dart';
|
||||||
import '../../../core/cache/message_session_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/sticker_pack_sheet.dart';
|
||||||
import '../../widgets/swipe_to_pop.dart';
|
import '../../widgets/swipe_to_pop.dart';
|
||||||
import '../../widgets/schedule_time_picker.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 'scheduled_messages_screen.dart';
|
||||||
|
import 'chat_wallpaper_preview_screen.dart';
|
||||||
|
|
||||||
class _UploadStatus {
|
class _UploadStatus {
|
||||||
final bool active;
|
final bool active;
|
||||||
@@ -325,6 +329,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
bool _floatingDateScheduled = false;
|
bool _floatingDateScheduled = false;
|
||||||
int _myId = 0;
|
int _myId = 0;
|
||||||
CachedChat? chat;
|
CachedChat? chat;
|
||||||
|
ChatWallpaper? _wallpaper;
|
||||||
|
|
||||||
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
|
final ValueNotifier<DateTime?> _floatingDate = ValueNotifier(null);
|
||||||
Timer? _floatingDateTimer;
|
Timer? _floatingDateTimer;
|
||||||
@@ -428,6 +433,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
_myId = p?.id ?? 0;
|
_myId = p?.id ?? 0;
|
||||||
_restoreDraft();
|
_restoreDraft();
|
||||||
|
unawaited(_loadWallpaper());
|
||||||
unawaited(_refreshBadge());
|
unawaited(_refreshBadge());
|
||||||
|
|
||||||
ChatsModule.getChat(_myId, widget.chatId)
|
ChatsModule.getChat(_myId, widget.chatId)
|
||||||
@@ -2417,7 +2423,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
ChatMenuItem(
|
ChatMenuItem(
|
||||||
icon: Symbols.wallpaper,
|
icon: Symbols.wallpaper,
|
||||||
label: 'Изменить обои',
|
label: 'Изменить обои',
|
||||||
onTap: () {},
|
onTap: _openWallpaperSheet,
|
||||||
),
|
),
|
||||||
ChatMenuItem(
|
ChatMenuItem(
|
||||||
icon: Symbols.mop,
|
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({
|
Future<bool?> _showConfirmDialog({
|
||||||
required String title,
|
required String title,
|
||||||
required String body,
|
required String body,
|
||||||
@@ -3483,6 +3550,10 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
if (_wallpaper != null)
|
||||||
|
Positioned.fill(
|
||||||
|
child: ChatWallpaperView(wallpaper: _wallpaper!),
|
||||||
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: _isLoading && _messages.isEmpty
|
child: _isLoading && _messages.isEmpty
|
||||||
? _buildShimmerLoading()
|
? _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:async';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -500,7 +501,10 @@ class _ColorPickerCard extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_HueStripPicker(color: col, onChanged: onColorChanged),
|
_ColorWheelPicker(
|
||||||
|
color: col,
|
||||||
|
onChanged: onColorChanged,
|
||||||
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -660,79 +664,65 @@ class _BubbleBehaviorCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HueStripPicker extends StatelessWidget {
|
class _ColorWheelPicker extends StatefulWidget {
|
||||||
final Color color;
|
final Color color;
|
||||||
final ValueChanged<Color> onChanged;
|
final ValueChanged<Color> onChanged;
|
||||||
|
|
||||||
const _HueStripPicker({required this.color, required this.onChanged});
|
const _ColorWheelPicker({required this.color, required this.onChanged});
|
||||||
|
|
||||||
static const _gradient = LinearGradient(
|
@override
|
||||||
colors: [
|
State<_ColorWheelPicker> createState() => _ColorWheelPickerState();
|
||||||
Color(0xFFFF0000),
|
}
|
||||||
Color(0xFFFFFF00),
|
|
||||||
Color(0xFF00FF00),
|
class _ColorWheelPickerState extends State<_ColorWheelPicker> {
|
||||||
Color(0xFF00FFFF),
|
late HSVColor _hsv;
|
||||||
Color(0xFF0000FF),
|
late Color _lastEmitted;
|
||||||
Color(0xFFFF00FF),
|
|
||||||
Color(0xFFFF0000),
|
@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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hue = HSVColor.fromColor(color).hue;
|
|
||||||
const trackHeight = 26.0;
|
|
||||||
const thumbDiameter = 30.0;
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final wheelSize = math.min(260.0, 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 thumbLeft = (hue / 360) * width - thumbDiameter / 2;
|
return Center(
|
||||||
|
child: GestureDetector(
|
||||||
return GestureDetector(
|
behavior: HitTestBehavior.opaque,
|
||||||
behavior: HitTestBehavior.opaque,
|
onPanDown: (d) => _handleWheel(d.localPosition, wheelSize),
|
||||||
onPanDown: (d) => emit(d.localPosition.dx),
|
onPanUpdate: (d) => _handleWheel(d.localPosition, wheelSize),
|
||||||
onPanUpdate: (d) => emit(d.localPosition.dx),
|
child: SizedBox(
|
||||||
child: SizedBox(
|
width: wheelSize,
|
||||||
height: thumbDiameter + 4,
|
height: wheelSize,
|
||||||
child: Stack(
|
child: CustomPaint(painter: _WheelPainter(hsv: _hsv)),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1221,6 +1221,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.4"
|
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:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ dependencies:
|
|||||||
media_kit_libs_windows_video: ^1.0.11
|
media_kit_libs_windows_video: ^1.0.11
|
||||||
media_kit_libs_linux: ^1.2.1
|
media_kit_libs_linux: ^1.2.1
|
||||||
media_kit_libs_macos_video: ^1.1.4
|
media_kit_libs_macos_video: ^1.1.4
|
||||||
|
sensors_plus: ^7.1.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user