анимация circular reveal при смене темы
This commit is contained in:
@@ -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,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;
|
||||
}
|
||||
+94
-2
@@ -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<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 +248,7 @@ class KometAppState extends State<KometApp> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_finishReveal();
|
||||
_sessionExpiredSub?.cancel();
|
||||
_loginStatusSub?.cancel();
|
||||
_vpnBypassSub?.cancel();
|
||||
@@ -321,10 +331,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 +642,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