From c78a6840361cb07be4baecc739cc0907eacba8c6 Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 22 May 2026 18:17:56 +0000 Subject: [PATCH] =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20circular=20reveal=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=20=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile/theme_settings_screen.dart | 137 +++++++++++------- lib/frontend/widgets/theme_reveal.dart | 70 +++++++++ lib/main.dart | 96 +++++++++++- 3 files changed, 249 insertions(+), 54 deletions(-) create mode 100644 lib/frontend/widgets/theme_reveal.dart diff --git a/lib/frontend/screens/profile/theme_settings_screen.dart b/lib/frontend/screens/profile/theme_settings_screen.dart index 9e08c3d..2332fe5 100644 --- a/lib/frontend/screens/profile/theme_settings_screen.dart +++ b/lib/frontend/screens/profile/theme_settings_screen.dart @@ -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 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( - 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( + valueListenable: AppAmoled.current, + builder: (context, value, _) { + return Switch( + value: value, + onChanged: (v) { + Haptics.selection(); + KometApp.stateOf(context)?.applyAmoledWithReveal( + v, + _lastPointerPosition, + ); + }, + ); + }, + ), + ], + ), ), ), ); diff --git a/lib/frontend/widgets/theme_reveal.dart b/lib/frontend/widgets/theme_reveal.dart new file mode 100644 index 0000000..3db78d0 --- /dev/null +++ b/lib/frontend/widgets/theme_reveal.dart @@ -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 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 { + 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; +} diff --git a/lib/main.dart b/lib/main.dart index 0e86ce3..2931a62 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,6 @@ 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'; @@ -31,6 +33,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); @@ -128,9 +131,15 @@ class KometApp extends StatefulWidget { State createState() => KometAppState(); } -class KometAppState extends State with WidgetsBindingObserver { +class KometAppState extends State + 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 +248,7 @@ class KometAppState extends State with WidgetsBindingObserver { @override void dispose() { + _finishReveal(); _sessionExpiredSub?.cancel(); _loginStatusSub?.cancel(); _vpnBypassSub?.cancel(); @@ -321,10 +331,89 @@ class KometAppState extends State with WidgetsBindingObserver { await AppThemeModeConfig.save(mode); } + void applyThemeModeWithReveal(AppThemeMode mode, Offset center) { + if (AppThemeModeConfig.current.value == mode) return; + _runThemeReveal(center, () => AppThemeModeConfig.save(mode)); + } + Future 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 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 applyThemeSchedule(ThemeSchedule schedule) async { await AppThemeSchedule.save(schedule); } @@ -553,7 +642,10 @@ class KometAppState extends State with WidgetsBindingObserver { fit: StackFit.expand, clipBehavior: Clip.none, children: [ - sChild!, + RepaintBoundary( + key: _captureBoundaryKey, + child: sChild!, + ), if (fpsOn) const FpsOverlayLayer(), ], );