Merge pull request #26 from KometTeam/feature/theme-reveal
Анимации: circular reveal темы + меню действий на сообщении
This commit is contained in:
@@ -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 'Список';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user