From f9de0f331cff4874c3b20f9c781e2769e8d1f699 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sat, 4 Jul 2026 13:48:12 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BD=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 1 + assets/lottie/ic_clock.json | 1 + assets/lottie/ic_search.json | 1 + assets/lottie/ic_settings.json | 1 + design/animations/.gitkeep | 0 lib/core/config/app_animations.dart | 9 +++ .../screens/chats/chat_list_screen.dart | 2 + lib/frontend/screens/chats/chat_screen.dart | 37 ++++++++- .../widgets/animated_icon_button.dart | 77 ++++++++++++++++++ .../widgets/animated_lottie_icon.dart | 78 +++++++++++++++++++ lib/frontend/widgets/sliding_pill_nav.dart | 31 +++++--- lib/main.dart | 13 ++++ pubspec.yaml | 1 + 13 files changed, 236 insertions(+), 16 deletions(-) create mode 100644 assets/lottie/ic_clock.json create mode 100644 assets/lottie/ic_search.json create mode 100644 assets/lottie/ic_settings.json create mode 100644 design/animations/.gitkeep create mode 100644 lib/core/config/app_animations.dart create mode 100644 lib/frontend/widgets/animated_icon_button.dart create mode 100644 lib/frontend/widgets/animated_lottie_icon.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 51e1a45..7277238 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -29,6 +29,7 @@ icon: Symbols.settings, label: 'Настройки', longPressable: true, + animationAsset: AppAnimations.settings, ), ]; diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 8ea92a9..8d1340f 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -25,6 +25,7 @@ import 'package:komet/frontend/screens/chats/chat_list_screen.dart'; import 'package:komet/frontend/screens/chats/poll_create_screen.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import 'package:komet/frontend/widgets/chat_menu_overlay.dart'; +import 'package:komet/frontend/widgets/animated_lottie_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../main.dart'; import '../../../backend/api.dart'; @@ -42,6 +43,7 @@ import '../../../core/storage/draft_store.dart'; import '../../../core/cache/info_cache.dart'; import '../../../core/cache/message_session_cache.dart'; import '../../../core/utils/haptics.dart'; +import '../../../core/config/app_animations.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/config/app_message_actions_style.dart'; import '../../../core/config/app_swipe_back_desktop.dart'; @@ -2268,7 +2270,25 @@ class _ChatScreenState extends State ), child: const SizedBox.expand(), ) - : null, + : (chrome == ChatChromeStyle.none && !glossy) + ? IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + cs.surface, + cs.surface, + cs.surface.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.72, 1.0], + ), + ), + child: const SizedBox.expand(), + ), + ) + : null, foregroundColor: cs.onSurface, surfaceTintColor: Colors.transparent, iconTheme: IconThemeData(color: cs.onSurface), @@ -2368,8 +2388,13 @@ class _ChatScreenState extends State onPressed: _closeSearch, ); final searchBtn = IconButton( - icon: Icon(Symbols.search, weight: glossy ? 500 : 400, - color: cs.onSurface), + icon: AnimatedLottieIcon( + asset: AppAnimations.search, + color: cs.onSurface, + size: 24, + active: true, + animateOnMount: true, + ), onPressed: () { _searchDebounce?.cancel(); _runSearch(_searchController.text); @@ -4380,6 +4405,7 @@ class _ChatScreenState extends State children: [ Expanded( child: Stack( + fit: StackFit.expand, children: [ if (_wallpaper != null) Positioned.fill( @@ -4509,7 +4535,10 @@ class _ChatScreenState extends State double _floatingDateTop(BuildContext context) { if (AppChatChrome.current.value == ChatChromeStyle.color) return 8; - return MediaQuery.paddingOf(context).top + 8; + final glossy = AppVisualStyle.current.value == VisualStyle.glossy; + return MediaQuery.paddingOf(context).top + + (glossy ? _glossyHeaderHeight : kToolbarHeight) + + 8; } Widget _buildLoadMoreIndicator() { diff --git a/lib/frontend/widgets/animated_icon_button.dart b/lib/frontend/widgets/animated_icon_button.dart new file mode 100644 index 0000000..7b8b735 --- /dev/null +++ b/lib/frontend/widgets/animated_icon_button.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +class AnimatedIconButton extends StatefulWidget { + final String asset; + final VoidCallback? onPressed; + final double size; + final String? tooltip; + + const AnimatedIconButton({ + super.key, + required this.asset, + this.onPressed, + this.size = 28, + this.tooltip, + }); + + @override + State createState() => _AnimatedIconButtonState(); +} + +class _AnimatedIconButtonState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 400), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _enter() => _controller.forward(); + + void _exit() => _controller.reverse(); + + void _tapDown() => _controller.forward(from: 0); + + @override + Widget build(BuildContext context) { + final Widget button = MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => _enter(), + onExit: (_) => _exit(), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onPressed, + onTapDown: (_) => _tapDown(), + child: SizedBox.square( + dimension: widget.size, + child: Lottie.asset( + widget.asset, + controller: _controller, + fit: BoxFit.contain, + onLoaded: (composition) { + _controller.duration = composition.duration; + }, + ), + ), + ), + ); + + final tooltip = widget.tooltip; + if (tooltip != null) { + return Tooltip(message: tooltip, child: button); + } + return button; + } +} diff --git a/lib/frontend/widgets/animated_lottie_icon.dart b/lib/frontend/widgets/animated_lottie_icon.dart new file mode 100644 index 0000000..1091cda --- /dev/null +++ b/lib/frontend/widgets/animated_lottie_icon.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; + +class AnimatedLottieIcon extends StatefulWidget { + final String asset; + final Color color; + final double size; + final bool active; + final bool animateOnMount; + + const AnimatedLottieIcon({ + super.key, + required this.asset, + required this.color, + required this.size, + this.active = false, + this.animateOnMount = false, + }); + + @override + State createState() => _AnimatedLottieIconState(); +} + +class _AnimatedLottieIconState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + if (widget.active && widget.animateOnMount) { + _controller.forward(from: 0); + } else { + _controller.value = widget.active ? 1.0 : 0.0; + } + } + + @override + void didUpdateWidget(AnimatedLottieIcon oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.active && !oldWidget.active) { + _controller.forward(from: 0); + } else if (!widget.active && oldWidget.active) { + _controller.value = 0; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: widget.size, + child: Lottie.asset( + widget.asset, + controller: _controller, + fit: BoxFit.contain, + delegates: LottieDelegates( + values: [ + ValueDelegate.color(const ['**'], value: widget.color), + ValueDelegate.strokeColor(const ['**'], value: widget.color), + ], + ), + onLoaded: (composition) { + _controller.duration = composition.duration; + }, + ), + ); + } +} diff --git a/lib/frontend/widgets/sliding_pill_nav.dart b/lib/frontend/widgets/sliding_pill_nav.dart index 29169fb..751759e 100644 --- a/lib/frontend/widgets/sliding_pill_nav.dart +++ b/lib/frontend/widgets/sliding_pill_nav.dart @@ -2,17 +2,20 @@ import 'package:flutter/material.dart'; import '../../core/config/app_pill_gradient.dart'; import '../../core/config/app_visual_style.dart'; +import 'animated_lottie_icon.dart'; import 'glossy_pill.dart'; class PillNavItem { final IconData icon; final String label; final bool longPressable; + final String? animationAsset; const PillNavItem({ required this.icon, required this.label, this.longPressable = false, + this.animationAsset, }); } @@ -210,6 +213,20 @@ class _PillNavCell extends StatelessWidget { required this.onLongPress, }); + Widget _buildIcon() { + final color = selected ? cs.onPrimary : cs.onSurface; + final asset = item.animationAsset; + if (asset != null) { + return AnimatedLottieIcon( + asset: asset, + color: color, + size: iconSize, + active: selected, + ); + } + return Icon(item.icon, color: color, size: iconSize, fill: 1); + } + @override Widget build(BuildContext context) { final opacityDuration = animationDuration == Duration.zero @@ -223,24 +240,14 @@ class _PillNavCell extends StatelessWidget { behavior: HitTestBehavior.opaque, child: Center( child: iconsOnly - ? Icon( - item.icon, - color: selected ? cs.onPrimary : cs.onSurface, - size: iconSize, - fill: 1, - ) + ? _buildIcon() : FittedBox( fit: BoxFit.scaleDown, child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - item.icon, - color: selected ? cs.onPrimary : cs.onSurface, - size: iconSize, - fill: 1, - ), + _buildIcon(), AnimatedContainer( duration: animationDuration, curve: Curves.easeOutCubic, diff --git a/lib/main.dart b/lib/main.dart index 7635a80..7479c92 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'dart:ui' as ui; import 'package:dynamic_color/dynamic_color.dart'; +import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:video_player_media_kit/video_player_media_kit.dart'; @@ -80,6 +81,16 @@ final RouteObserver> appRouteObserver = bool isOnemeFlavor = false; +const PageTransitionsTheme _appPageTransitions = PageTransitionsTheme( + builders: { + TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.windows: ZoomPageTransitionsBuilder(), + TargetPlatform.linux: ZoomPageTransitionsBuilder(), + }, +); + Future _loadInitialLocale() async { final prefs = await SharedPreferences.getInstance(); final code = prefs.getString('app_locale'); @@ -711,6 +722,7 @@ class KometAppState extends State ThemeData( useMaterial3: true, colorScheme: light, + pageTransitionsTheme: _appPageTransitions, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.light).textTheme, @@ -721,6 +733,7 @@ class KometAppState extends State ThemeData( useMaterial3: true, colorScheme: dark, + pageTransitionsTheme: _appPageTransitions, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.dark).textTheme, diff --git a/pubspec.yaml b/pubspec.yaml index b61e7b7..e18ca7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -125,6 +125,7 @@ flutter: - assets/komet.png - assets/komet_icon.png - assets/meteor_icon.png + - assets/lottie/ fonts: - family: Inter