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/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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user