Merge pull request #26 from KometTeam/feature/theme-reveal

Анимации: circular reveal темы + меню действий на сообщении
This commit is contained in:
klockky
2026-05-24 16:38:38 +03:00
committed by GitHub
9 changed files with 1234 additions and 56 deletions
+47
View File
@@ -0,0 +1,47 @@
<!--
Стиль оформления PR (заполняйте по этому шаблону).
Заголовок PR:
feat: краткое перечисление фич + scope → целевая ветка
Примеры:
feat: чаты, группы, файлы, real-time + security-фиксы #17 → dev/0.5.0
fix(ios): исключить Firebase из сборки iOS → dev/0.5.0
Префиксы: feat / fix / refactor / chore / docs (можно со scope, напр. fix(ios)).
Удалите неиспользуемые разделы. Описание — на русском языке.
-->
## Описание
<!-- 1–3 предложения: что переносим и зачем. Укажите исходную и целевую ветку. -->
**Объём:** N файлов, +X / Y.
## Что вошло
<!-- Группируйте по областям с эмодзи. Оставляйте только релевантные блоки. -->
### 💬 Чаты и сообщения
-
### 👤 Профиль и контакты
-
### 🔒 Безопасность
<!-- При наличии: ссылка на issue и номера находок. -->
-
### 🛠 Прочее
-
## Коммиты
<!-- Список `hash` + краткое описание. -->
- `xxxxxxx`
## Проверка
- [ ] `flutter analyze`
- [ ] Сборка `komet` flavor
- [ ] Ручная проверка затронутых сценариев
- [ ] Регресс по безопасности (если затронута)
@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum MessageActionsStyle { radial, list }
class AppMessageActionsStyle {
static const prefKey = 'app_message_actions_style';
static final ValueNotifier<MessageActionsStyle> current = ValueNotifier(
MessageActionsStyle.radial,
);
static Future<MessageActionsStyle> load() async {
final prefs = await SharedPreferences.getInstance();
return _parse(prefs.getString(prefKey));
}
static Future<void> save(MessageActionsStyle style) async {
current.value = style;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, style.name);
}
static MessageActionsStyle _parse(String? val) {
if (val == MessageActionsStyle.list.name) return MessageActionsStyle.list;
return MessageActionsStyle.radial;
}
static String label(MessageActionsStyle style) {
switch (style) {
case MessageActionsStyle.radial:
return 'Радиальное';
case MessageActionsStyle.list:
return 'Список';
}
}
}
+102 -2
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:io' show File;
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/file_uploader.dart';
@@ -17,8 +19,10 @@ import '../../../core/protocol/packet.dart';
import '../../../core/storage/app_database.dart';
import '../../../core/utils/haptics.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../core/config/app_message_actions_style.dart';
import '../../../models/attachment.dart';
import '../../widgets/message_bubble.dart';
import '../../widgets/message_actions_overlay.dart';
import '../../widgets/attachment_panel.dart';
class _UploadStatus {
@@ -878,16 +882,22 @@ class _ChatScreenState extends State<ChatScreen>
overrideStatus: _effectiveStatus(message),
);
final pressable = _LongPressBubble(
message: message,
isMe: isMe,
child: bubble,
);
if (message.id == _lastSentId) {
return _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) setState(() => _lastSentId = null);
},
child: bubble,
child: pressable,
);
}
return bubble;
return pressable;
},
),
),
@@ -1678,6 +1688,96 @@ IconData _iconForFilename(String? name) {
}
}
class _LongPressBubble extends StatefulWidget {
final Widget child;
final CachedMessage message;
final bool isMe;
const _LongPressBubble({
required this.child,
required this.message,
required this.isMe,
});
@override
State<_LongPressBubble> createState() => _LongPressBubbleState();
}
class _LongPressBubbleState extends State<_LongPressBubble> {
final GlobalKey _boundaryKey = GlobalKey();
MessageActionsController? _controller;
@override
void dispose() {
_controller?.commit();
_controller = null;
super.dispose();
}
void _onLongPressStart(LongPressStartDetails details) {
final ctx = _boundaryKey.currentContext;
if (ctx == null) return;
final renderObject = ctx.findRenderObject();
if (renderObject is! RenderRepaintBoundary) return;
final origin = renderObject.localToGlobal(Offset.zero);
final rect = origin & renderObject.size;
final rawDpr = MediaQuery.of(ctx).devicePixelRatio;
final dpr = rawDpr > 2.0 ? 2.0 : rawDpr;
final ui.Image snapshot;
try {
snapshot = renderObject.toImageSync(pixelRatio: dpr);
} catch (_) {
return;
}
Haptics.medium();
final controller = MessageActionsController();
controller.attach(details.globalPosition);
_controller = controller;
showMessageActions(
context: ctx,
snapshot: snapshot,
originRect: rect,
tapPoint: details.globalPosition,
isMe: widget.isMe,
messageText: widget.message.text,
controller: controller,
style: AppMessageActionsStyle.current.value,
onDispose: () {
if (identical(_controller, controller)) {
_controller = null;
}
controller.dispose();
},
);
}
@override
Widget build(BuildContext context) {
return Listener(
behavior: HitTestBehavior.deferToChild,
onPointerMove: (event) => _controller?.updatePointer(event.position),
onPointerUp: (event) => _controller?.commit(),
onPointerCancel: (event) => _controller?.commit(),
child: GestureDetector(
behavior: HitTestBehavior.deferToChild,
onLongPressStart: _onLongPressStart,
onLongPressMoveUpdate: (d) =>
_controller?.updatePointer(d.globalPosition),
onLongPressEnd: (_) => _controller?.commit(),
child: RepaintBoundary(
key: _boundaryKey,
child: widget.child,
),
),
);
}
}
class _SentMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
import '../../../core/utils/haptics.dart';
import 'appearance_screen.dart';
import 'font_settings_screen.dart';
import 'message_actions_screen.dart';
import 'theme_settings_screen.dart';
class _CustomizationCategory {
@@ -43,6 +44,12 @@ class CustomizationScreen extends StatelessWidget {
subtitle: 'Шрифт приложения, свои шрифты, размер текста',
builder: _buildFontSettings,
),
_CustomizationCategory(
icon: Symbols.touch_app,
title: 'Меню действий',
subtitle: 'Радиальное или список — для долгого нажатия на сообщение',
builder: _buildMessageActions,
),
];
static Widget _buildAppearance(BuildContext context) =>
@@ -54,6 +61,9 @@ class CustomizationScreen extends StatelessWidget {
static Widget _buildThemeSettings(BuildContext context) =>
const ThemeSettingsScreen();
static Widget _buildMessageActions(BuildContext context) =>
const MessageActionsScreen();
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../../core/config/app_message_actions_style.dart';
import '../../../core/utils/haptics.dart';
class MessageActionsScreen extends StatelessWidget {
const MessageActionsScreen({super.key});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.surface,
appBar: AppBarM3E(
titleText: 'Меню действий',
backgroundColor: cs.surface,
),
body: SafeArea(
top: false,
child: ListView(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 120),
children: const [
_StyleCard(),
],
),
),
);
}
}
class _StyleCard extends StatelessWidget {
const _StyleCard();
static const _items = [
(
style: MessageActionsStyle.radial,
icon: Symbols.bubble_chart,
label: 'Радиальное',
description: 'Дуга кнопок вокруг точки нажатия',
),
(
style: MessageActionsStyle.list,
icon: Symbols.menu,
label: 'Список',
description: 'Вертикальное меню рядом с сообщением',
),
];
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Стиль',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Как показывается меню при долгом нажатии на сообщение',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 8),
ValueListenableBuilder<MessageActionsStyle>(
valueListenable: AppMessageActionsStyle.current,
builder: (context, current, _) {
return Column(
children: [
for (final item in _items)
_StyleTile(
icon: item.icon,
label: item.label,
description: item.description,
selected: current == item.style,
onTap: () {
if (current == item.style) return;
Haptics.selection();
AppMessageActionsStyle.save(item.style);
},
),
],
);
},
),
],
),
),
);
}
}
class _StyleTile extends StatelessWidget {
final IconData icon;
final String label;
final String description;
final bool selected;
final VoidCallback onTap;
const _StyleTile({
required this.icon,
required this.label,
required this.description,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
children: [
Icon(icon, color: cs.onSurface, size: 22, weight: 500),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
description,
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 12.5,
height: 1.3,
),
),
],
),
),
const SizedBox(width: 8),
Icon(
selected
? Symbols.radio_button_checked
: Symbols.radio_button_unchecked,
color: selected ? cs.primary : cs.outline,
size: 22,
fill: selected ? 1 : 0,
),
],
),
),
),
);
}
}
@@ -81,10 +81,11 @@ class _ThemeModeCard extends StatelessWidget {
icon: item.icon,
label: item.label,
selected: current == item.mode,
onTap: () {
onTap: (position) {
if (current == item.mode) return;
Haptics.selection();
KometApp.stateOf(context)?.applyThemeMode(item.mode);
KometApp.stateOf(context)
?.applyThemeModeWithReveal(item.mode, position);
},
),
],
@@ -98,11 +99,11 @@ class _ThemeModeCard extends StatelessWidget {
}
}
class _ModeTile extends StatelessWidget {
class _ModeTile extends StatefulWidget {
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
final ValueChanged<Offset> onTap;
const _ModeTile({
required this.icon,
@@ -111,23 +112,31 @@ class _ModeTile extends StatelessWidget {
required this.onTap,
});
@override
State<_ModeTile> createState() => _ModeTileState();
}
class _ModeTileState extends State<_ModeTile> {
Offset _lastTapPosition = Offset.zero;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
onTapDown: (d) => _lastTapPosition = d.globalPosition,
onTap: () => widget.onTap(_lastTapPosition),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
children: [
Icon(icon, color: cs.onSurface, size: 22, weight: 500),
Icon(widget.icon, color: cs.onSurface, size: 22, weight: 500),
const SizedBox(width: 14),
Expanded(
child: Text(
label,
widget.label,
style: TextStyle(
color: cs.onSurface,
fontSize: 15,
@@ -136,10 +145,12 @@ class _ModeTile extends StatelessWidget {
),
),
Icon(
selected ? Symbols.radio_button_checked : Symbols.radio_button_unchecked,
color: selected ? cs.primary : cs.outline,
widget.selected
? Symbols.radio_button_checked
: Symbols.radio_button_unchecked,
color: widget.selected ? cs.primary : cs.outline,
size: 22,
fill: selected ? 1 : 0,
fill: widget.selected ? 1 : 0,
),
],
),
@@ -149,54 +160,76 @@ class _ModeTile extends StatelessWidget {
}
}
class _AmoledCard extends StatelessWidget {
class _AmoledCard extends StatefulWidget {
const _AmoledCard();
@override
State<_AmoledCard> createState() => _AmoledCardState();
}
class _AmoledCardState extends State<_AmoledCard> {
Offset _lastPointerPosition = Offset.zero;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
return Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 12, 14),
child: Row(
children: [
Icon(Symbols.contrast, color: cs.onSurface, size: 24, weight: 500),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AMOLED-чёрный',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'Чистый чёрный фон для OLED-экранов',
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
],
return Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (e) => _lastPointerPosition = e.position,
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(28),
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 12, 14),
child: Row(
children: [
Icon(
Symbols.contrast,
color: cs.onSurface,
size: 24,
weight: 500,
),
),
ValueListenableBuilder<bool>(
valueListenable: AppAmoled.current,
builder: (context, value, _) {
return Switch(
value: value,
onChanged: (v) {
Haptics.selection();
KometApp.stateOf(context)?.applyAmoled(v);
},
);
},
),
],
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AMOLED-чёрный',
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'Чистый чёрный фон для OLED-экранов',
style: TextStyle(
color: cs.onSurfaceVariant,
fontSize: 13,
),
),
],
),
),
ValueListenableBuilder<bool>(
valueListenable: AppAmoled.current,
builder: (context, value, _) {
return Switch(
value: value,
onChanged: (v) {
Haptics.selection();
KometApp.stateOf(context)?.applyAmoledWithReveal(
v,
_lastPointerPosition,
);
},
);
},
),
],
),
),
),
);
@@ -0,0 +1,614 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../core/config/app_message_actions_style.dart';
import '../../core/utils/haptics.dart';
import 'custom_notification.dart';
class MessageActionsController extends ChangeNotifier {
Offset? pointer;
Offset? initialPointer;
bool committed = false;
bool movedSignificantly = false;
bool _attached = false;
void attach(Offset initial) {
if (_attached) return;
_attached = true;
initialPointer = initial;
pointer = initial;
GestureBinding.instance.pointerRouter.addGlobalRoute(_onPointerEvent);
}
void updatePointer(Offset p) {
if (committed) return;
pointer = p;
if (initialPointer != null &&
!movedSignificantly &&
(p - initialPointer!).distance > 18) {
movedSignificantly = true;
}
notifyListeners();
}
void _onPointerEvent(PointerEvent event) {
if (committed) return;
if (event is PointerMoveEvent) {
updatePointer(event.position);
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
commit();
}
}
void commit() {
if (committed) return;
committed = true;
notifyListeners();
}
@override
void dispose() {
if (_attached) {
GestureBinding.instance.pointerRouter.removeGlobalRoute(_onPointerEvent);
_attached = false;
}
super.dispose();
}
}
void showMessageActions({
required BuildContext context,
required ui.Image snapshot,
required Rect originRect,
required Offset tapPoint,
required bool isMe,
required String? messageText,
required MessageActionsController controller,
required MessageActionsStyle style,
required VoidCallback onDispose,
}) {
final overlay = Overlay.of(context, rootOverlay: true);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => _MessageActionsLayer(
snapshot: snapshot,
originRect: originRect,
tapPoint: tapPoint,
isMe: isMe,
messageText: messageText,
controller: controller,
style: style,
onDismiss: () {
if (entry.mounted) entry.remove();
onDispose();
},
),
);
overlay.insert(entry);
}
class _MessageActionsLayer extends StatefulWidget {
final ui.Image snapshot;
final Rect originRect;
final Offset tapPoint;
final bool isMe;
final String? messageText;
final MessageActionsController controller;
final MessageActionsStyle style;
final VoidCallback onDismiss;
const _MessageActionsLayer({
required this.snapshot,
required this.originRect,
required this.tapPoint,
required this.isMe,
required this.messageText,
required this.controller,
required this.style,
required this.onDismiss,
});
@override
State<_MessageActionsLayer> createState() => _MessageActionsLayerState();
}
class _MessageActionsLayerState extends State<_MessageActionsLayer>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final Animation<double> _animation;
bool _closing = false;
static const double _radius = 92.0;
static const double _arcSpan = math.pi * 0.62;
static const double _btnSize = 52.0;
static const double _hitRadius = 40.0;
static const double _hMargin = 8.0;
late List<_Action> _actions;
bool _showBelow = true;
Offset _anchor = Offset.zero;
List<Offset> _buttonCenters = const [];
List<Rect> _buttonHitRects = const [];
Rect _menuRect = Rect.zero;
bool _initialized = false;
int _hoveredIndex = -1;
bool _committedFired = false;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 320),
reverseDuration: const Duration(milliseconds: 220),
);
_animation = CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_animController.forward();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
_initialized = true;
_actions = _buildActions();
final screenSize = MediaQuery.sizeOf(context);
if (widget.style == MessageActionsStyle.radial) {
_computeRadialGeometry(screenSize);
} else {
_computeListGeometry(screenSize);
}
widget.controller.addListener(_onControllerUpdate);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _onControllerUpdate();
});
}
void _computeRadialGeometry(Size screenSize) {
_showBelow = widget.tapPoint.dy < screenSize.height * 0.55;
_anchor = _showBelow
? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16)
: Offset(widget.tapPoint.dx, widget.originRect.top - 16);
_buttonCenters = _computeButtonCenters(screenSize);
_buttonHitRects = [
for (final c in _buttonCenters)
Rect.fromCenter(center: c, width: _hitRadius * 2, height: _hitRadius * 2),
];
}
void _computeListGeometry(Size screenSize) {
final n = _actions.length;
const menuWidth = 244.0;
const itemHeight = 48.0;
const vPad = 8.0;
final menuHeight = n * itemHeight + vPad * 2;
final spaceBelow = screenSize.height - widget.originRect.bottom - 24;
final spaceAbove = widget.originRect.top - 24;
_showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove;
final menuY = _showBelow
? widget.originRect.bottom + 10
: widget.originRect.top - 10 - menuHeight;
final rawX = widget.isMe
? widget.originRect.right - menuWidth
: widget.originRect.left;
final menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble();
_menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, menuHeight);
_buttonHitRects = [
for (int i = 0; i < n; i++)
Rect.fromLTWH(
menuX,
menuY + vPad + i * itemHeight,
menuWidth,
itemHeight,
),
];
}
@override
void dispose() {
_animController.dispose();
widget.controller.removeListener(_onControllerUpdate);
widget.snapshot.dispose();
super.dispose();
}
List<_Action> _buildActions() {
final hasText = widget.messageText != null && widget.messageText!.isNotEmpty;
return <_Action>[
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
_Action(Symbols.reply, 'Ответить', () => _stub('Ответ')),
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
_Action(
Symbols.delete,
'Удалить',
() => _stub('Удаление'),
destructive: true,
),
];
}
List<Offset> _computeButtonCenters(Size screenSize) {
final n = _actions.length;
if (n == 0) return const [];
final base = _showBelow ? math.pi * 0.5 : -math.pi * 0.5;
final start = base - _arcSpan / 2;
final step = n == 1 ? 0.0 : _arcSpan / (n - 1);
final minX = _hMargin + _btnSize / 2;
final maxX = screenSize.width - _hMargin - _btnSize / 2;
return [
for (int i = 0; i < n; i++)
() {
final angle = start + step * i;
var p = _anchor + Offset(math.cos(angle), math.sin(angle)) * _radius;
if (p.dx < minX) p = Offset(minX, p.dy);
if (p.dx > maxX) p = Offset(maxX, p.dy);
return p;
}(),
];
}
void _onControllerUpdate() {
if (!mounted) return;
final p = widget.controller.pointer;
if (p != null) {
final newHovered = _findButtonAt(p);
if (newHovered != _hoveredIndex) {
if (newHovered != -1) Haptics.selection();
setState(() => _hoveredIndex = newHovered);
}
}
if (widget.controller.committed && !_committedFired) {
_committedFired = true;
_onCommit();
}
}
int _findButtonAt(Offset p) {
for (int i = 0; i < _buttonHitRects.length; i++) {
if (_buttonHitRects[i].contains(p)) return i;
}
return -1;
}
void _onCommit() {
if (_hoveredIndex != -1 && widget.controller.movedSignificantly) {
Haptics.medium();
_actions[_hoveredIndex].onTap();
} else if (widget.controller.movedSignificantly) {
_close();
}
}
Future<void> _close() async {
if (!mounted || _closing) return;
_closing = true;
try {
await _animController.reverse();
} catch (_) {}
if (!mounted) return;
widget.onDismiss();
}
Future<void> _copy() async {
final text = widget.messageText;
if (text != null && text.isNotEmpty) {
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
showCustomNotification(context, 'Скопировано');
}
await _close();
}
Future<void> _stub(String name) async {
if (!mounted) return;
showCustomNotification(context, '$name — пока в разработке');
await _close();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
return AnimatedBuilder(
animation: _animation,
builder: (ctx, _) {
final t = _animation.value.clamp(0.0, 1.0);
final blurSigma = 14.0 * t;
final bubbleScale = 1.0 + 0.05 * t;
return GestureDetector(
onTap: _close,
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
Positioned.fill(
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: blurSigma,
sigmaY: blurSigma,
),
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.22 * t),
),
),
),
Positioned(
left: widget.originRect.left,
top: widget.originRect.top,
width: widget.originRect.width,
height: widget.originRect.height,
child: Transform.scale(
scale: bubbleScale,
child: RawImage(
image: widget.snapshot,
width: widget.originRect.width,
height: widget.originRect.height,
fit: BoxFit.fill,
),
),
),
if (widget.style == MessageActionsStyle.radial) ...[
..._buildButtons(t),
_buildLabelBanner(size, t),
] else
_buildListMenu(t),
],
),
);
},
);
}
Widget _buildListMenu(double t) {
final cs = Theme.of(context).colorScheme;
final eased = Curves.easeOutCubic.transform(t);
final scale = 0.88 + 0.12 * eased;
return Positioned(
left: _menuRect.left,
top: _menuRect.top,
width: _menuRect.width,
height: _menuRect.height,
child: Opacity(
opacity: eased,
child: Transform.scale(
scale: scale,
alignment: Alignment(
widget.isMe ? 1.0 : -1.0,
_showBelow ? -1.0 : 1.0,
),
child: Material(
color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18),
clipBehavior: Clip.antiAlias,
elevation: 8,
shadowColor: Colors.black.withValues(alpha: 0.4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
for (int i = 0; i < _actions.length; i++)
_ListMenuItem(
action: _actions[i],
highlighted: _hoveredIndex == i,
),
const SizedBox(height: 8),
],
),
),
),
),
);
}
List<Widget> _buildButtons(double t) {
final n = _actions.length;
return [
for (int i = 0; i < n; i++)
Builder(
builder: (_) {
final delay = (i / n) * 0.25;
final localT = ((t - delay) / (1.0 - delay)).clamp(0.0, 1.0);
final eased = Curves.easeOutBack.transform(localT);
final isHovered = _hoveredIndex == i;
final hoverScale = isHovered ? 1.18 : 1.0;
final entryScale = 0.4 + 0.6 * eased;
final centerAtFull = _buttonCenters[i];
final centerAtT = _anchor +
(centerAtFull - _anchor) * eased;
return Positioned(
left: centerAtT.dx - _btnSize / 2,
top: centerAtT.dy - _btnSize / 2,
width: _btnSize,
height: _btnSize,
child: Opacity(
opacity: localT,
child: Transform.scale(
scale: entryScale,
child: AnimatedScale(
scale: hoverScale,
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: _ActionButton(
action: _actions[i],
highlighted: isHovered,
),
),
),
),
);
},
),
];
}
Widget _buildLabelBanner(Size size, double t) {
final label =
_hoveredIndex == -1 ? null : _actions[_hoveredIndex].label;
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Positioned(
left: 0,
right: 0,
bottom: bottomInset + 36,
child: IgnorePointer(
child: Center(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 140),
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: ScaleTransition(
scale: Tween<double>(begin: 0.85, end: 1.0).animate(anim),
child: child,
),
),
child: label == null
? const SizedBox(key: ValueKey('empty'), height: 0)
: Container(
key: ValueKey('label_$label'),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 9,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.72 * t),
borderRadius: BorderRadius.circular(24),
),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
);
}
}
class _Action {
final IconData icon;
final String label;
final VoidCallback onTap;
final bool destructive;
const _Action(
this.icon,
this.label,
this.onTap, {
this.destructive = false,
});
}
class _ListMenuItem extends StatelessWidget {
final _Action action;
final bool highlighted;
const _ListMenuItem({required this.action, required this.highlighted});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final pillBg = action.destructive ? cs.error : cs.primary;
final onPill = action.destructive ? cs.onError : cs.onPrimary;
final restFg = action.destructive ? cs.error : cs.onSurface;
final fg = highlighted ? onPill : restFg;
return SizedBox(
height: 48,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
Haptics.tap();
action.onTap();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: highlighted ? pillBg : Colors.transparent,
borderRadius: BorderRadius.circular(20),
),
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Icon(action.icon, color: fg, size: 22),
const SizedBox(width: 14),
Expanded(
child: Text(
action.label,
style: TextStyle(
color: fg,
fontSize: 15,
fontWeight:
highlighted ? FontWeight.w600 : FontWeight.w500,
),
),
),
],
),
),
),
),
),
);
}
}
class _ActionButton extends StatelessWidget {
final _Action action;
final bool highlighted;
const _ActionButton({
required this.action,
required this.highlighted,
});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final bgColor = highlighted ? cs.primary : cs.surfaceContainerHighest;
final iconColor = highlighted ? cs.onPrimary : cs.onSurface;
return AnimatedContainer(
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
color: bgColor,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.32),
blurRadius: highlighted ? 14 : 8,
offset: const Offset(0, 3),
),
],
),
child: Material(
color: Colors.transparent,
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
onTap: () {
Haptics.tap();
action.onTap();
},
child: Center(
child: Icon(action.icon, color: iconColor, size: 24),
),
),
),
);
}
}
+70
View File
@@ -0,0 +1,70 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
class ThemeRevealOverlay {
static OverlayEntry build({
required ui.Image snapshot,
required Offset center,
required Animation<double> animation,
}) {
return OverlayEntry(
builder: (ctx) {
final size = MediaQuery.sizeOf(ctx);
final maxRadius = _maxRadius(center, size);
return IgnorePointer(
child: AnimatedBuilder(
animation: animation,
builder: (_, __) {
final t = Curves.easeInOutCubic.transform(
animation.value.clamp(0.0, 1.0),
);
return ClipPath(
clipper: _RevealClipper(
center: center,
radius: maxRadius * t,
),
child: Opacity(
opacity: 1.0 - (t * t * t * t),
child: RawImage(
image: snapshot,
width: size.width,
height: size.height,
fit: BoxFit.fill,
),
),
);
},
),
);
},
);
}
static double _maxRadius(Offset center, Size size) {
final dx = math.max(center.dx, size.width - center.dx);
final dy = math.max(center.dy, size.height - center.dy);
return math.sqrt(dx * dx + dy * dy);
}
}
class _RevealClipper extends CustomClipper<Path> {
final Offset center;
final double radius;
_RevealClipper({required this.center, required this.radius});
@override
Path getClip(Size size) {
final full = Path()..addRect(Offset.zero & size);
if (radius <= 0) return full;
final hole = Path()
..addOval(Rect.fromCircle(center: center, radius: radius));
return Path.combine(PathOperation.difference, full, hole);
}
@override
bool shouldReclip(covariant _RevealClipper old) =>
old.center != center || old.radius != radius;
}
+97 -2
View File
@@ -1,7 +1,10 @@
import 'dart:async';
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:komet/l10n/app_localizations.dart';
import 'package:m3e_collection/m3e_collection.dart';
import 'package:package_info_plus/package_info_plus.dart';
@@ -13,6 +16,7 @@ import 'core/config/app_bubble_behavior.dart';
import 'core/config/app_bubble_shape.dart';
import 'core/config/app_cache_extent.dart';
import 'core/config/app_fonts.dart';
import 'core/config/app_message_actions_style.dart';
import 'core/config/app_theme_mode.dart';
import 'core/config/app_theme_schedule.dart';
import 'backend/modules/account.dart';
@@ -31,6 +35,7 @@ import 'frontend/debug/fps_overlay_layer.dart';
import 'frontend/screens/auth/login_screen.dart';
import 'frontend/screens/chats/chat_list_screen.dart';
import 'frontend/widgets/custom_notification.dart';
import 'frontend/widgets/theme_reveal.dart';
final api = Api();
final accountModule = AccountModule(api);
@@ -86,6 +91,7 @@ void main() async {
AppThemeModeConfig.current.value = await AppThemeModeConfig.load();
AppAmoled.current.value = await AppAmoled.load();
AppThemeSchedule.current.value = await AppThemeSchedule.load();
AppMessageActionsStyle.current.value = await AppMessageActionsStyle.load();
runApp(
KometApp(
initialLocale: initialLocale,
@@ -128,9 +134,15 @@ class KometApp extends StatefulWidget {
State<KometApp> createState() => KometAppState();
}
class KometAppState extends State<KometApp> with WidgetsBindingObserver {
class KometAppState extends State<KometApp>
with WidgetsBindingObserver, TickerProviderStateMixin {
static const _fallbackSeed = Color(0xFFC1C4FF);
final GlobalKey _captureBoundaryKey = GlobalKey();
OverlayEntry? _revealEntry;
AnimationController? _revealController;
ui.Image? _revealImage;
late Locale _locale;
late String _fontId;
bool _isLoggingOut = false;
@@ -239,6 +251,7 @@ class KometAppState extends State<KometApp> with WidgetsBindingObserver {
@override
void dispose() {
_finishReveal();
_sessionExpiredSub?.cancel();
_loginStatusSub?.cancel();
_vpnBypassSub?.cancel();
@@ -321,10 +334,89 @@ class KometAppState extends State<KometApp> with WidgetsBindingObserver {
await AppThemeModeConfig.save(mode);
}
void applyThemeModeWithReveal(AppThemeMode mode, Offset center) {
if (AppThemeModeConfig.current.value == mode) return;
_runThemeReveal(center, () => AppThemeModeConfig.save(mode));
}
Future<void> applyAmoled(bool value) async {
await AppAmoled.save(value);
}
void applyAmoledWithReveal(bool value, Offset center) {
if (AppAmoled.current.value == value) return;
_runThemeReveal(center, () => AppAmoled.save(value));
}
void _runThemeReveal(Offset center, Future<void> Function() apply) {
final overlay = KometApp.navigatorKey.currentState?.overlay;
final ctx = _captureBoundaryKey.currentContext;
if (overlay == null || ctx == null) {
apply();
return;
}
if (MediaQuery.disableAnimationsOf(ctx)) {
apply();
return;
}
final renderObject = ctx.findRenderObject();
if (renderObject is! RenderRepaintBoundary) {
apply();
return;
}
final ui.Image snapshot;
try {
final dpr = math.min(MediaQuery.of(ctx).devicePixelRatio, 2.0);
snapshot = renderObject.toImageSync(pixelRatio: dpr);
} catch (_) {
apply();
return;
}
_finishReveal();
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 650),
);
final entry = ThemeRevealOverlay.build(
snapshot: snapshot,
center: center,
animation: controller,
);
_revealController = controller;
_revealEntry = entry;
_revealImage = snapshot;
overlay.insert(entry);
apply();
WidgetsBinding.instance.endOfFrame.then((_) {
if (_revealController != controller) return;
controller.forward().then(
(_) {
if (_revealController != controller) return;
_finishReveal();
},
onError: (_) {},
);
});
}
void _finishReveal() {
_revealEntry?.remove();
_revealEntry = null;
_revealController?.dispose();
_revealController = null;
final img = _revealImage;
_revealImage = null;
if (img != null) {
WidgetsBinding.instance.addPostFrameCallback((_) => img.dispose());
}
}
Future<void> applyThemeSchedule(ThemeSchedule schedule) async {
await AppThemeSchedule.save(schedule);
}
@@ -553,7 +645,10 @@ class KometAppState extends State<KometApp> with WidgetsBindingObserver {
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
sChild!,
RepaintBoundary(
key: _captureBoundaryKey,
child: sChild!,
),
if (fpsOn) const FpsOverlayLayer(),
],
);