diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index e07633e..a7d2af5 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -28,6 +28,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import 'package:gwid/screens/chat_encryption_settings_screen.dart'; +import 'package:flutter_colorpicker/flutter_colorpicker.dart'; import 'package:gwid/services/chat_encryption_service.dart'; bool _debugShowExactDate = false; @@ -123,6 +124,10 @@ class _ChatScreenState extends State { false; // TODO: hook real state later ChatEncryptionConfig? _encryptionConfigForCurrentChat; bool _sendEncryptedForCurrentChat = false; + bool _specialMessagesEnabled = false; + + bool _showKometColorPicker = false; + String? _currentKometColorPrefix; bool _isSearching = false; final TextEditingController _searchController = TextEditingController(); @@ -353,6 +358,214 @@ class _ChatScreenState extends State { null; // Будет установлено при получении CONTROL сообщения с event 'pin' _initializeChat(); _loadEncryptionConfig(); + _loadSpecialMessagesSetting(); + + _textController.addListener(() { + _handleTextChangedForKometColor(); + }); + } + + Future _loadSpecialMessagesSetting() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _specialMessagesEnabled = + prefs.getBool('special_messages_enabled') ?? false; + }); + } + + void _showSpecialMessagesPanel() { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Особые сообщения', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + _SpecialMessageButton( + label: 'Цветной текст', + template: "komet.color_#''", + icon: Icons.color_lens, + onTap: () { + Navigator.pop(context); + Future.microtask(() { + if (!mounted) return; + final currentText = _textController.text; + final cursorPos = _textController.selection.baseOffset.clamp( + 0, + currentText.length, + ); + final template = "komet.color_#"; + final newText = + currentText.substring(0, cursorPos) + + template + + currentText.substring(cursorPos); + _textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: cursorPos + template.length - 2, + ), + ); + }); + }, + ), + const SizedBox(height: 8), + _SpecialMessageButton( + label: 'Переливающийся текст', + template: "komet.cosmetic.galaxy' ваш текст '", + icon: Icons.auto_awesome, + onTap: () { + Navigator.pop(context); + Future.microtask(() { + if (!mounted) return; + final currentText = _textController.text; + final cursorPos = _textController.selection.baseOffset.clamp( + 0, + currentText.length, + ); + final template = "komet.cosmetic.galaxy' ваш текст '"; + final newText = + currentText.substring(0, cursorPos) + + template + + currentText.substring(cursorPos); + _textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: cursorPos + template.length - 2, + ), + ); + }); + }, + ), + const SizedBox(height: 8), + _SpecialMessageButton( + label: 'Пульсирующий текст', + template: "komet.cosmetic.pulse#", + icon: Icons.radio_button_checked, + onTap: () { + Navigator.pop(context); + Future.microtask(() { + if (!mounted) return; + final currentText = _textController.text; + final cursorPos = _textController.selection.baseOffset.clamp( + 0, + currentText.length, + ); + final template = "komet.cosmetic.pulse#"; + final newText = + currentText.substring(0, cursorPos) + + template + + currentText.substring(cursorPos); + _textController.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed( + offset: cursorPos + template.length, + ), + ); + }); + }, + ), + const SizedBox(height: 16), + ], + ), + ), + ); + } + + Future _handleTextChangedForKometColor() async { + final prefs = await SharedPreferences.getInstance(); + final autoCompleteEnabled = + prefs.getBool('komet_auto_complete_enabled') ?? false; + + if (!autoCompleteEnabled) { + if (_showKometColorPicker) { + setState(() { + _showKometColorPicker = false; + _currentKometColorPrefix = null; + }); + } + return; + } + + final text = _textController.text; + final cursorPos = _textController.selection.baseOffset; + const prefix1 = 'komet.color_#'; + const prefix2 = 'komet.cosmetic.pulse#'; + + // Ищем префикс в позиции курсора или перед ним + String? detectedPrefix; + int? prefixStartPos; + + // Проверяем, находится ли курсор сразу после префикса + for (final prefix in [prefix1, prefix2]) { + // Ищем последнее вхождение префикса перед курсором + int searchStart = 0; + int lastFound = -1; + while (true) { + final found = text.indexOf(prefix, searchStart); + if (found == -1 || found > cursorPos) break; + if (found + prefix.length <= cursorPos) { + lastFound = found; + } + searchStart = found + 1; + } + + if (lastFound != -1) { + final afterPrefix = text.substring( + lastFound + prefix.length, + cursorPos, + ); + // Если после префикса до курсора ничего нет (или только пробелы) - показываем панель + if (afterPrefix.isEmpty || afterPrefix.trim().isEmpty) { + // Проверяем, что после курсора нет завершенного блока (нет HEX и кавычек) + final afterCursor = cursorPos < text.length + ? text.substring(cursorPos) + : ''; + // Если после курсора сразу идет HEX код (6 символов) и кавычка - не показываем + if (afterCursor.length < 7 || + !RegExp(r"^[0-9A-Fa-f]{6}'").hasMatch(afterCursor)) { + detectedPrefix = prefix; + prefixStartPos = lastFound; + break; + } + } + } + } + + if (detectedPrefix != null && prefixStartPos != null) { + final after = text.substring( + prefixStartPos + detectedPrefix.length, + cursorPos, + ); + // Если после # до курсора ничего нет — показываем панельку + if (after.isEmpty || after.trim().isEmpty) { + if (!_showKometColorPicker || + _currentKometColorPrefix != detectedPrefix) { + setState(() { + _showKometColorPicker = true; + _currentKometColorPrefix = detectedPrefix; + }); + } + return; + } + } + + if (_showKometColorPicker) { + setState(() { + _showKometColorPicker = false; + _currentKometColorPrefix = null; + }); + } } Future _loadEncryptionConfig() async { @@ -3350,83 +3563,146 @@ class _ChatScreenState extends State { Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Expanded( - child: Focus( - focusNode: - _textFocusNode, // 2. focusNode теперь здесь - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - if (event.logicalKey == - LogicalKeyboardKey.enter) { - final bool isShiftPressed = - HardwareKeyboard.instance.logicalKeysPressed - .contains( - LogicalKeyboardKey.shiftLeft, - ) || - HardwareKeyboard.instance.logicalKeysPressed - .contains( - LogicalKeyboardKey.shiftRight, - ); - - if (!isShiftPressed) { - _sendMessage(); - return KeyEventResult.handled; - } - } - } - return KeyEventResult.ignored; - }, - - child: TextField( - controller: _textController, - - enabled: !isBlocked, - keyboardType: TextInputType.multiline, - textInputAction: TextInputAction.newline, - minLines: 1, - maxLines: 5, - decoration: InputDecoration( - hintText: isBlocked - ? 'Пользователь заблокирован' - : 'Сообщение...', - filled: true, - isDense: true, - fillColor: isBlocked - ? Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withOpacity(0.25) - : Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withOpacity(0.4), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide.none, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 18.0, - vertical: 12.0, + if (_specialMessagesEnabled) + Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(24), + onTap: isBlocked ? null : _showSpecialMessagesPanel, + child: Padding( + padding: const EdgeInsets.all(6.0), + child: Icon( + Icons.auto_fix_high, + color: isBlocked + ? Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.3) + : Theme.of(context).colorScheme.primary, + size: 24, ), ), - - onChanged: isBlocked - ? null - : (v) { - if (v.isNotEmpty) { - _scheduleTypingPing(); - } - }, ), ), + if (_specialMessagesEnabled) const SizedBox(width: 4), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_showKometColorPicker) + _KometColorPickerBar( + onColorSelected: (color) { + if (_currentKometColorPrefix == null) return; + final hex = color.value + .toRadixString(16) + .padLeft(8, '0') + .substring(2) + .toUpperCase(); + + String newText; + int cursorOffset; + + if (_currentKometColorPrefix == + 'komet.color_#') { + newText = + '$_currentKometColorPrefix$hex\'ваш текст\''; + final textLength = newText.length; + cursorOffset = textLength - 12; + } else if (_currentKometColorPrefix == + 'komet.cosmetic.pulse#') { + newText = + '$_currentKometColorPrefix$hex\'ваш текст\''; + final textLength = newText.length; + cursorOffset = textLength - 12; + } else { + return; + } + + _textController.text = newText; + _textController.selection = TextSelection( + baseOffset: cursorOffset, + extentOffset: newText.length - 1, + ); + }, + ), + Focus( + focusNode: + _textFocusNode, // 2. focusNode теперь здесь + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + if (event.logicalKey == + LogicalKeyboardKey.enter) { + final bool isShiftPressed = + HardwareKeyboard + .instance + .logicalKeysPressed + .contains( + LogicalKeyboardKey.shiftLeft, + ) || + HardwareKeyboard + .instance + .logicalKeysPressed + .contains( + LogicalKeyboardKey.shiftRight, + ); + + if (!isShiftPressed) { + _sendMessage(); + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + }, + child: TextField( + controller: _textController, + enabled: !isBlocked, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + minLines: 1, + maxLines: 5, + decoration: InputDecoration( + hintText: isBlocked + ? 'Пользователь заблокирован' + : 'Сообщение...', + filled: true, + isDense: true, + fillColor: isBlocked + ? Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.25) + : Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withOpacity(0.4), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 18.0, + vertical: 12.0, + ), + ), + onChanged: isBlocked + ? null + : (v) { + if (v.isNotEmpty) { + _scheduleTypingPing(); + } + }, + ), + ), + ], + ), ), const SizedBox(width: 4), Material( @@ -3885,6 +4161,7 @@ class _ChatScreenState extends State { void dispose() { _typingTimer?.cancel(); _apiSubscription?.cancel(); + _textController.removeListener(_handleTextChangedForKometColor); _textController.dispose(); _textFocusNode.dispose(); _searchController.dispose(); @@ -4002,6 +4279,193 @@ class _ChatScreenState extends State { } } +class _SpecialMessageButton extends StatelessWidget { + final String label; + final String template; + final IconData icon; + final VoidCallback onTap; + + const _SpecialMessageButton({ + required this.label, + required this.template, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(icon, color: colors.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + template, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 12, + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: colors.onSurfaceVariant), + ], + ), + ), + ); + } +} + +class _KometColorPickerBar extends StatefulWidget { + final ValueChanged onColorSelected; + + const _KometColorPickerBar({required this.onColorSelected}); + + @override + State<_KometColorPickerBar> createState() => _KometColorPickerBarState(); +} + +class _KometColorPickerBarState extends State<_KometColorPickerBar> { + Color _currentColor = Colors.red; + + void _showColorPickerDialog(BuildContext context) { + Color pickedColor = _currentColor; + + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Выберите цвет'), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + content: SingleChildScrollView( + child: StatefulBuilder( + builder: (BuildContext context, StateSetter setState) { + return ColorPicker( + pickerColor: pickedColor, + onColorChanged: (color) { + setState(() => pickedColor = color); + }, + enableAlpha: false, + pickerAreaHeightPercent: 0.8, + ); + }, + ), + ), + actions: [ + TextButton( + child: const Text('Отмена'), + onPressed: () => Navigator.of(dialogContext).pop(), + ), + TextButton( + child: const Text('Готово'), + onPressed: () { + widget.onColorSelected(pickedColor); + setState(() { + _currentColor = pickedColor; + }); + Navigator.of(dialogContext).pop(); + }, + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + const double diameter = 32; + + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest.withOpacity(0.9), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: Text( + 'Выберите цвет для komet.color', + style: TextStyle( + fontSize: 12, + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(width: 12), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + _showColorPickerDialog(context); + }, + child: Container( + width: diameter, + height: diameter, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: const SweepGradient( + colors: [ + Colors.red, + Colors.yellow, + Colors.green, + Colors.cyan, + Colors.blue, + Colors.purple, + Colors.red, + ], + ), + ), + child: Center( + child: Container( + width: diameter - 12, + height: diameter - 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _currentColor, + border: Border.all(color: colors.surface, width: 1), + ), + ), + ), + ), + ), + ], + ), + ); + } +} + class _EditMessageDialog extends StatefulWidget { final String initialText; final Function(String) onSave; diff --git a/lib/screens/settings/bypass_screen.dart b/lib/screens/settings/bypass_screen.dart index 7f5dfd7..31b7056 100644 --- a/lib/screens/settings/bypass_screen.dart +++ b/lib/screens/settings/bypass_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:gwid/utils/theme_provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; class BypassScreen extends StatefulWidget { final bool isModal; @@ -14,6 +15,42 @@ class BypassScreen extends StatefulWidget { class _BypassScreenState extends State { // 0 – обходы, 1 – фишки int _selectedTab = 0; + bool _kometAutoCompleteEnabled = false; + bool _specialMessagesEnabled = false; + bool _isLoadingSettings = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _kometAutoCompleteEnabled = + prefs.getBool('komet_auto_complete_enabled') ?? false; + _specialMessagesEnabled = + prefs.getBool('special_messages_enabled') ?? false; + _isLoadingSettings = false; + }); + } + + Future _saveSpecialMessages(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('special_messages_enabled', value); + setState(() { + _specialMessagesEnabled = value; + }); + } + + Future _saveKometAutoComplete(bool value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool('komet_auto_complete_enabled', value); + setState(() { + _kometAutoCompleteEnabled = value; + }); + } @override Widget build(BuildContext context) { @@ -206,7 +243,7 @@ class _BypassScreenState extends State { ), const SizedBox(height: 8), Text( - "В будущих версиях можно будет подсвечивать отдельные буквы и слова в нике с помощью простого синтаксиса.", + "В будущих версиях можно будет подсвечивать отдельные буквы и слова в нике с помощью простого синтаксиса, а также добавлять визуальные эффекты к сообщениям.", style: TextStyle( color: colors.onSurfaceVariant, fontSize: 14, @@ -227,7 +264,7 @@ class _BypassScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "Простой пример:", + "Простой пример (цветники):", style: TextStyle( fontWeight: FontWeight.w600, color: colors.onSurface, @@ -262,6 +299,68 @@ class _BypassScreenState extends State { ], ), const SizedBox(height: 16), + Text( + "Пример (пульсирующий текст):", + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + SelectableText( + "komet.cosmetic.pulse#FF0000'пульсирующий текст'", + style: TextStyle( + fontFamily: 'monospace', + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + "Отображение: текст «пульсирующий текст» в пузыре сообщения пульсирует указанным цветом (в данном случае красным).", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 16), + Text( + "Пример (переливающийся ч/б текст в сообщении):", + style: TextStyle( + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + SelectableText( + "komet.cosmetic.galaxy'тестовое сообщение'", + style: TextStyle( + fontFamily: 'monospace', + color: colors.onSurface, + ), + ), + const SizedBox(height: 8), + Text( + "Отображение:", + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 12, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: colors.surfaceVariant.withOpacity(0.6), + borderRadius: BorderRadius.circular(12), + ), + child: const _GalaxyDemoText( + text: "тестовое сообщение", + ), + ), + const SizedBox(height: 16), Text( "Сложный пример:", style: TextStyle( @@ -361,6 +460,64 @@ class _BypassScreenState extends State { ], ), ), + const SizedBox(height: 24), + if (!_isLoadingSettings) ...[ + Consumer( + builder: (context, themeProvider, child) { + return Card( + child: SwitchListTile( + title: const Text( + 'Авто-дополнение уникальных сообщений', + style: TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: const Text( + 'Показывать панель выбора цвета при вводе komet.color#', + ), + value: _kometAutoCompleteEnabled, + onChanged: (value) { + _saveKometAutoComplete(value); + }, + secondary: Icon( + _kometAutoCompleteEnabled + ? Icons.auto_awesome + : Icons.auto_awesome_outlined, + color: _kometAutoCompleteEnabled + ? colors.primary + : colors.onSurfaceVariant, + ), + ), + ); + }, + ), + const SizedBox(height: 16), + Consumer( + builder: (context, themeProvider, child) { + return Card( + child: SwitchListTile( + title: const Text( + 'Включить список особых сообщений', + style: TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: const Text( + 'Показывать кнопку для быстрой вставки шаблонов особых сообщений', + ), + value: _specialMessagesEnabled, + onChanged: (value) { + _saveSpecialMessages(value); + }, + secondary: Icon( + _specialMessagesEnabled + ? Icons.auto_fix_high + : Icons.auto_fix_high_outlined, + color: _specialMessagesEnabled + ? colors.primary + : colors.onSurfaceVariant, + ), + ), + ); + }, + ), + ], ], ), ), @@ -506,6 +663,67 @@ class _BypassScreenState extends State { } } +class _GalaxyDemoText extends StatefulWidget { + final String text; + + const _GalaxyDemoText({required this.text}); + + @override + State<_GalaxyDemoText> createState() => _GalaxyDemoTextState(); +} + +class _GalaxyDemoTextState extends State<_GalaxyDemoText> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + final color = Color.lerp(Colors.black, Colors.white, t)!; + + return ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color, + Color.lerp(Colors.white, Colors.black, t)!, + ], + ).createShader(bounds); + }, + blendMode: BlendMode.srcIn, + child: Text( + widget.text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ); + } +} + class _SegmentButton extends StatelessWidget { final bool selected; final String label; diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 51c3ea2..92057a3 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -176,6 +176,183 @@ class _KometColoredSegment { _KometColoredSegment(this.text, this.color); } +enum _KometSegmentType { + normal, + colored, + galaxy, + pulse, +} + +class _KometSegment { + final String text; + final _KometSegmentType type; + final Color? color; // Для colored и pulse + + _KometSegment(this.text, this.type, {this.color}); +} + +class _GalaxyAnimatedText extends StatefulWidget { + final String text; + + const _GalaxyAnimatedText({required this.text}); + + @override + State<_GalaxyAnimatedText> createState() => _GalaxyAnimatedTextState(); +} + +class _GalaxyAnimatedTextState extends State<_GalaxyAnimatedText> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(reverse: true); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + final color = Color.lerp(Colors.black, Colors.white, t)!; + + return ShaderMask( + shaderCallback: (Rect bounds) { + return LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + color, + Color.lerp(Colors.white, Colors.black, t)!, + ], + ).createShader(bounds); + }, + blendMode: BlendMode.srcIn, + child: Text( + widget.text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ); + } +} + +class _PulseAnimatedText extends StatefulWidget { + final String text; + + const _PulseAnimatedText({required this.text}); + + @override + State<_PulseAnimatedText> createState() => _PulseAnimatedTextState(); +} + +class _PulseAnimatedTextState extends State<_PulseAnimatedText> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + Color? _pulseColor; + + @override + void initState() { + super.initState(); + _parseColor(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(reverse: true); + } + + void _parseColor() { + final text = widget.text; + const prefix = "komet.cosmetic.pulse#"; + if (!text.startsWith(prefix)) { + _pulseColor = Colors.red; + return; + } + + final afterHash = text.substring(prefix.length); + final quoteIndex = afterHash.indexOf("'"); + if (quoteIndex == -1) { + _pulseColor = Colors.red; + return; + } + + final hexStr = afterHash.substring(0, quoteIndex).trim(); + _pulseColor = _parseHexColor(hexStr); + } + + Color _parseHexColor(String hex) { + String hexClean = hex.trim(); + if (hexClean.startsWith('#')) { + hexClean = hexClean.substring(1); + } + if (hexClean.length == 6) { + try { + return Color(int.parse('FF$hexClean', radix: 16)); + } catch (e) { + return Colors.red; + } + } + return Colors.red; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final text = widget.text; + const prefix = "komet.cosmetic.pulse#"; + if (!text.startsWith(prefix) || !text.endsWith("'")) { + return Text(text); + } + + final afterHash = text.substring(prefix.length); + final quoteIndex = afterHash.indexOf("'"); + if (quoteIndex == -1 || quoteIndex + 1 >= afterHash.length) { + return Text(text); + } + + final messageText = afterHash.substring(quoteIndex + 1, afterHash.length - 1); + final baseColor = _pulseColor ?? Colors.red; + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + final t = _controller.value; + final opacity = 0.5 + (t * 0.5); + final color = baseColor.withOpacity(opacity); + + return Text( + messageText, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: color, + ), + ); + }, + ); + } +} + class ChatMessageBubble extends StatelessWidget { final Message message; final bool isMe; @@ -3871,14 +4048,7 @@ class ChatMessageBubble extends StatelessWidget { ), ) else if (decryptedText != null) - Linkify( - text: decryptedText!, - style: defaultTextStyle, - linkStyle: linkStyle, - onOpen: onOpenLink, - options: const LinkifyOptions(humanize: false), - textAlign: TextAlign.left, - ) + _buildMixedMessageContent(decryptedText!, defaultTextStyle, linkStyle, onOpenLink) else if (message.text.contains("welcome.saved.dialog.message")) Linkify( text: @@ -3889,8 +4059,9 @@ class ChatMessageBubble extends StatelessWidget { options: const LinkifyOptions(humanize: false), textAlign: TextAlign.left, ) - else if (message.text.contains("komet.color_")) - _buildKometColorRichText(message.text, defaultTextStyle) + else if (message.text.contains("komet.cosmetic.") || + message.text.contains("komet.color_")) + _buildMixedMessageContent(message.text, defaultTextStyle, linkStyle, onOpenLink) else Linkify( text: message.text, @@ -4018,6 +4189,144 @@ class ChatMessageBubble extends StatelessWidget { ]; } + /// Парсит сообщение на сегменты с разными эффектами + List<_KometSegment> _parseMixedMessageSegments(String text) { + final segments = <_KometSegment>[]; + int index = 0; + + while (index < text.length) { + // Ищем ближайший маркер + int nextPulse = text.indexOf("komet.cosmetic.pulse#", index); + int nextGalaxy = text.indexOf("komet.cosmetic.galaxy'", index); + int nextColor = text.indexOf("komet.color_", index); + + // Находим ближайший маркер + int nextMarker = text.length; + String? markerType; + if (nextPulse != -1 && nextPulse < nextMarker) { + nextMarker = nextPulse; + markerType = "pulse"; + } + if (nextGalaxy != -1 && nextGalaxy < nextMarker) { + nextMarker = nextGalaxy; + markerType = "galaxy"; + } + if (nextColor != -1 && nextColor < nextMarker) { + nextMarker = nextColor; + markerType = "color"; + } + + // Если маркер не найден, добавляем оставшийся текст как обычный + if (markerType == null) { + if (index < text.length) { + segments.add(_KometSegment(text.substring(index), _KometSegmentType.normal)); + } + break; + } + + // Добавляем текст до маркера как обычный + if (nextMarker > index) { + segments.add(_KometSegment(text.substring(index, nextMarker), _KometSegmentType.normal)); + } + + // Обрабатываем найденный маркер + if (markerType == "pulse") { + const prefix = "komet.cosmetic.pulse#"; + final afterHash = text.substring(nextMarker + prefix.length); + final quoteIndex = afterHash.indexOf("'"); + if (quoteIndex != -1 && quoteIndex >= 6) { + final hexStr = afterHash.substring(0, quoteIndex).trim(); + final textStart = quoteIndex + 1; + final secondQuote = afterHash.indexOf("'", textStart); + if (secondQuote != -1) { + final segmentText = afterHash.substring(textStart, secondQuote); + final color = _parseKometHexColor(hexStr, null); + segments.add(_KometSegment(segmentText, _KometSegmentType.pulse, color: color)); + index = nextMarker + prefix.length + secondQuote + 2; // +2 для двух кавычек + continue; + } + } + // Если парсинг не удался, добавляем как обычный текст + segments.add(_KometSegment(text.substring(nextMarker, nextMarker + prefix.length + 10), _KometSegmentType.normal)); + index = nextMarker + prefix.length + 10; + } else if (markerType == "galaxy") { + const prefix = "komet.cosmetic.galaxy'"; + final textStart = nextMarker + prefix.length; + final quoteIndex = text.indexOf("'", textStart); + if (quoteIndex != -1) { + final segmentText = text.substring(textStart, quoteIndex); + segments.add(_KometSegment(segmentText, _KometSegmentType.galaxy)); + index = quoteIndex + 1; + continue; + } + // Если парсинг не удался, добавляем как обычный текст + segments.add(_KometSegment(text.substring(nextMarker, textStart + 10), _KometSegmentType.normal)); + index = textStart + 10; + } else if (markerType == "color") { + const marker = 'komet.color_'; + final colorStart = nextMarker + marker.length; + final firstQuote = text.indexOf("'", colorStart); + if (firstQuote != -1) { + final colorStr = text.substring(colorStart, firstQuote); + final textStart = firstQuote + 1; + final secondQuote = text.indexOf("'", textStart); + if (secondQuote != -1) { + final segmentText = text.substring(textStart, secondQuote); + final color = _parseKometHexColor(colorStr, null); + segments.add(_KometSegment(segmentText, _KometSegmentType.colored, color: color)); + index = secondQuote + 1; + continue; + } + } + // Если парсинг не удался, добавляем как обычный текст + segments.add(_KometSegment(text.substring(nextMarker, colorStart + 10), _KometSegmentType.normal)); + index = colorStart + 10; + } + } + + return segments; + } + + /// Строит виджет для смешанного сообщения с разными эффектами + Widget _buildMixedMessageContent( + String text, + TextStyle baseStyle, + TextStyle linkStyle, + Future Function(LinkableElement) onOpenLink, + ) { + final segments = _parseMixedMessageSegments(text); + + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: segments.map((seg) { + switch (seg.type) { + case _KometSegmentType.normal: + return Linkify( + text: seg.text, + style: baseStyle, + linkStyle: linkStyle, + onOpen: onOpenLink, + options: const LinkifyOptions(humanize: false), + ); + case _KometSegmentType.colored: + return Linkify( + text: seg.text, + style: baseStyle.copyWith(color: seg.color), + linkStyle: linkStyle, + onOpen: onOpenLink, + options: const LinkifyOptions(humanize: false), + ); + case _KometSegmentType.galaxy: + return _GalaxyAnimatedText(text: seg.text); + case _KometSegmentType.pulse: + // Создаем строку в правильном формате для _PulseAnimatedText + final hexStr = seg.color!.value.toRadixString(16).padLeft(8, '0').substring(2).toUpperCase(); + return _PulseAnimatedText(text: "komet.cosmetic.pulse#$hexStr'${seg.text}'"); + } + }).toList(), + ); + } + /// Строит раскрашенный текст на основе синтаксиса komet.color_#HEX'текст'. /// Если цвет некорректный, используется красный. Widget _buildKometColorRichText(String rawText, TextStyle baseStyle) {