feat(accessibility): add reduceMotion scaling and minimize animations; apply RepaintBoundary to heavy widgets
This commit is contained in:
@@ -6,6 +6,7 @@ import '../services/sms_service.dart';
|
||||
import '../utils/platform_helper.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
@@ -65,7 +66,8 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
));
|
||||
|
||||
// 랜덤 파티클 생성
|
||||
_generateParticles();
|
||||
// 접근성(모션 축소) 고려한 파티클 생성
|
||||
_generateParticles(reduced: ReduceMotion.platform());
|
||||
|
||||
_animationController.forward();
|
||||
|
||||
@@ -75,15 +77,17 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
});
|
||||
}
|
||||
|
||||
void _generateParticles() {
|
||||
void _generateParticles({bool reduced = false}) {
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
final total = reduced ? 6 : 20;
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
for (int i = 0; i < total; i++) {
|
||||
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
|
||||
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
|
||||
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
|
||||
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
|
||||
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간
|
||||
final duration = (random % 10) / 10 * (reduced ? 1800 : 3000) +
|
||||
(reduced ? 1200 : 2000); // 축소 시 더 짧게
|
||||
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
||||
|
||||
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
||||
@@ -257,7 +261,14 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
BorderRadius.circular(30),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: 20, sigmaY: 20),
|
||||
sigmaX: ReduceMotion.scale(
|
||||
context,
|
||||
normal: 20,
|
||||
reduced: 8),
|
||||
sigmaY: ReduceMotion.scale(
|
||||
context,
|
||||
normal: 20,
|
||||
reduced: 8)),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -277,13 +288,17 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1.5,
|
||||
),
|
||||
boxShadow: const [
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
AppColors.shadowBlack,
|
||||
spreadRadius: 0,
|
||||
blurRadius: 30,
|
||||
offset: Offset(0, 10),
|
||||
blurRadius:
|
||||
ReduceMotion.scale(
|
||||
context,
|
||||
normal: 30,
|
||||
reduced: 12),
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
34
lib/utils/reduce_motion.dart
Normal file
34
lib/utils/reduce_motion.dart
Normal file
@@ -0,0 +1,34 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// 접근성 설정에 따른 모션 축소 여부 헬퍼
|
||||
class ReduceMotion {
|
||||
/// 플랫폼 접근성 설정을 기반으로 모션 축소 여부 반환 (context 없이 사용)
|
||||
static bool platform() {
|
||||
final features =
|
||||
WidgetsBinding.instance.platformDispatcher.accessibilityFeatures;
|
||||
// disableAnimations 신뢰
|
||||
return features.disableAnimations;
|
||||
}
|
||||
|
||||
/// MediaQuery/플랫폼 정보를 활용해 런타임에서 모션 축소 여부 반환
|
||||
static bool isEnabled(BuildContext context) {
|
||||
final mq = MediaQuery.maybeOf(context);
|
||||
if (mq != null) {
|
||||
// accessibleNavigation == 사용자가 단순한 네비게이션/애니메이션 선호
|
||||
if (mq.accessibleNavigation) return true;
|
||||
}
|
||||
return platform();
|
||||
}
|
||||
|
||||
/// 모션 강도 스케일 유틸리티
|
||||
static double scale(BuildContext context,
|
||||
{required double normal, required double reduced}) {
|
||||
return isEnabled(context) ? reduced : normal;
|
||||
}
|
||||
|
||||
/// 파티클 개수 등 정수 스케일링
|
||||
static int count(BuildContext context,
|
||||
{required int normal, required int reduced}) {
|
||||
return isEnabled(context) ? reduced : normal;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../../theme/app_colors.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../themed_text.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../utils/reduce_motion.dart';
|
||||
|
||||
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
||||
class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
@@ -250,6 +251,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
swapAnimationDuration: ReduceMotion.isEnabled(context)
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
swapAnimationCurve: Curves.easeOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
parent: animationController,
|
||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||
)),
|
||||
child: RepaintBoundary(
|
||||
child: GlassmorphismCard(
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
@@ -56,8 +57,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ThemedText.headline(
|
||||
text:
|
||||
AppLocalizations.of(context).totalExpenseSummary,
|
||||
text: AppLocalizations.of(context)
|
||||
.totalExpenseSummary,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
),
|
||||
@@ -112,7 +113,8 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ThemedText.caption(
|
||||
text: AppLocalizations.of(context).totalExpense,
|
||||
text:
|
||||
AppLocalizations.of(context).totalExpense,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -244,6 +246,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
/// 슬라이드 + 페이드 전환
|
||||
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||
@@ -11,8 +12,12 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.direction = AxisDirection.right,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
Offset begin;
|
||||
switch (direction) {
|
||||
@@ -64,8 +69,12 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.alignment = Alignment.center,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 400),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 400),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 400),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 400),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const curve = Curves.elasticOut;
|
||||
|
||||
@@ -98,8 +107,12 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
||||
RotatePageRoute({required this.page})
|
||||
: super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const curve = Curves.easeInOut;
|
||||
|
||||
@@ -135,8 +148,12 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.horizontal = true,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 800),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 800),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 800),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 800),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
final isAnimatingForward =
|
||||
animation.status == AnimationStatus.forward;
|
||||
@@ -189,8 +206,12 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
||||
this.borderRadius,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 500),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -260,8 +281,12 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
||||
required this.transitionType,
|
||||
}) : super(
|
||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||
transitionDuration: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||
transitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
reverseTransitionDuration: ReduceMotion.platform()
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 300),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
late final Offset begin;
|
||||
late final Offset end;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
||||
///
|
||||
@@ -16,6 +17,8 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reduce = ReduceMotion.isEnabled(context);
|
||||
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
|
||||
return Stack(
|
||||
children: [
|
||||
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
||||
@@ -25,15 +28,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
||||
final angle = controller.value * 2 * math.pi;
|
||||
// 사인 함수를 사용하여 부드러운 움직임 생성
|
||||
final xOffset = 20 * math.sin(angle);
|
||||
final yOffset = 10 * math.cos(angle);
|
||||
final xOffset = 20 * amp * math.sin(angle);
|
||||
final yOffset = 10 * amp * math.cos(angle);
|
||||
|
||||
return Positioned(
|
||||
right: -40 + xOffset,
|
||||
top: -60 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 회전도 선형적으로 변화하도록 수정
|
||||
angle: 0.2 * math.sin(angle * 0.5),
|
||||
angle: 0.2 * amp * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
@@ -51,15 +54,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
builder: (context, child) {
|
||||
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
||||
final xOffset = 20 * math.cos(angle);
|
||||
final yOffset = 10 * math.sin(angle);
|
||||
final xOffset = 20 * amp * math.cos(angle);
|
||||
final yOffset = 10 * amp * math.sin(angle);
|
||||
|
||||
return Positioned(
|
||||
left: -80 + xOffset,
|
||||
bottom: -70 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 반대 방향으로 회전하도록 설정
|
||||
angle: -0.3 * math.sin(angle * 0.5),
|
||||
angle: -0.3 * amp * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
@@ -78,14 +81,14 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
builder: (context, child) {
|
||||
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
||||
final xOffset = 15 * math.sin(angle * 0.7);
|
||||
final yOffset = 8 * math.cos(angle * 0.7);
|
||||
final xOffset = 15 * amp * math.sin(angle * 0.7);
|
||||
final yOffset = 8 * amp * math.cos(angle * 0.7);
|
||||
|
||||
return Positioned(
|
||||
right: 40 + xOffset,
|
||||
bottom: -40 + yOffset,
|
||||
child: Transform.rotate(
|
||||
angle: 0.4 * math.cos(angle * 0.5),
|
||||
angle: 0.4 * amp * math.cos(angle * 0.5),
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
@@ -110,8 +113,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(
|
||||
alpha: 0.1 + 0.1 * pulseController.value,
|
||||
),
|
||||
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:ui';
|
||||
import '../../utils/reduce_motion.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../common/buttons/primary_button.dart';
|
||||
import '../common/buttons/secondary_button.dart';
|
||||
@@ -27,7 +28,10 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4),
|
||||
sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassCard.withValues(alpha: 0.8),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'glassmorphism_card.dart';
|
||||
import 'themed_text.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||
///
|
||||
@@ -25,16 +26,20 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final beginOffset = ReduceMotion.isEnabled(context)
|
||||
? const Offset(0, 0.05)
|
||||
: const Offset(0, 0.2);
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Center(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
begin: beginOffset,
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutBack)),
|
||||
child: RepaintBoundary(
|
||||
child: GlassmorphismCard(
|
||||
width: null,
|
||||
margin: const EdgeInsets.all(16),
|
||||
@@ -45,8 +50,11 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
AnimatedBuilder(
|
||||
animation: rotateController,
|
||||
builder: (context, child) {
|
||||
final angleScale =
|
||||
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
|
||||
return Transform.rotate(
|
||||
angle: rotateController.value * 2 * math.pi,
|
||||
angle:
|
||||
angleScale * rotateController.value * 2 * math.pi,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
@@ -58,8 +66,8 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
color: AppColors.primaryColor
|
||||
.withValues(alpha: 0.3),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
@@ -125,6 +133,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../theme/app_colors.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
import '../utils/platform_helper.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
class FloatingNavigationBar extends StatefulWidget {
|
||||
final int selectedIndex;
|
||||
@@ -30,7 +31,9 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
_animation = CurvedAnimation(
|
||||
@@ -72,9 +75,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
right: 16,
|
||||
height: 88,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 100 * (1 - _animation.value)),
|
||||
offset: Offset(
|
||||
0,
|
||||
ReduceMotion.isEnabled(context)
|
||||
? 0
|
||||
: 100 * (1 - _animation.value)),
|
||||
child: Opacity(
|
||||
opacity: _animation.value,
|
||||
opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(4), // 그림자 공간 확보
|
||||
decoration: BoxDecoration(
|
||||
@@ -220,12 +227,14 @@ class _AddButtonState extends State<_AddButton>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 150),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.9,
|
||||
end: ReduceMotion.platform() ? 1.0 : 0.9,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import '../utils/logger.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/reduce_motion.dart';
|
||||
import 'themed_text.dart';
|
||||
|
||||
class GlassmorphismCard extends StatelessWidget {
|
||||
@@ -52,7 +53,12 @@ class GlassmorphismCard extends StatelessWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: ReduceMotion.scale(context,
|
||||
normal: blur, reduced: blur * 0.4),
|
||||
sigmaY: ReduceMotion.scale(context,
|
||||
normal: blur, reduced: blur * 0.4),
|
||||
),
|
||||
child: Container(
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
@@ -75,12 +81,13 @@ class GlassmorphismCard extends StatelessWidget {
|
||||
),
|
||||
boxShadow: boxShadow ??
|
||||
[
|
||||
const BoxShadow(
|
||||
BoxShadow(
|
||||
color: AppColors
|
||||
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
|
||||
blurRadius: 20,
|
||||
.shadowBlack, // color.md: rgba(0,0,0,0.08)
|
||||
blurRadius: ReduceMotion.scale(context,
|
||||
normal: 20, reduced: 10),
|
||||
spreadRadius: -5,
|
||||
offset: Offset(0, 10),
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -209,15 +216,18 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
final scaleValue = ReduceMotion.scale(context,
|
||||
normal: _scaleAnimation.value, reduced: 1.0);
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
scale: scaleValue,
|
||||
child: GlassmorphismCard(
|
||||
padding: widget.padding,
|
||||
margin: widget.margin,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
borderRadius: widget.borderRadius,
|
||||
blur: _blurAnimation.value,
|
||||
blur: ReduceMotion.scale(context,
|
||||
normal: _blurAnimation.value, reduced: widget.blur),
|
||||
opacity: widget.opacity,
|
||||
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
|
||||
child: widget.child,
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../utils/reduce_motion.dart';
|
||||
|
||||
// 파비콘 캐시 관리 클래스
|
||||
class FaviconCache {
|
||||
@@ -190,11 +191,14 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
// 애니메이션 컨트롤러 초기화
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: ReduceMotion.platform()
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
|
||||
CurvedAnimation(
|
||||
_scaleAnimation =
|
||||
Tween<double>(begin: 1.0, end: ReduceMotion.platform() ? 1.0 : 1.08)
|
||||
.animate(CurvedAnimation(
|
||||
parent: _animationController, curve: Curves.easeOutCubic));
|
||||
|
||||
// 초기 _previousServiceKey 설정
|
||||
@@ -548,11 +552,14 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
return RepaintBoundary(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
final scale =
|
||||
ReduceMotion.isEnabled(context) ? 1.0 : _scaleAnimation.value;
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
scale: scale,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
@@ -578,12 +585,25 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
),
|
||||
child: _buildIconContent(),
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildIconContent() {
|
||||
// 로딩 중 표시
|
||||
if (_isLoading) {
|
||||
if (ReduceMotion.isEnabled(context)) {
|
||||
return Container(
|
||||
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
@@ -633,7 +653,17 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
fadeInDuration: ReduceMotion.isEnabled(context)
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
fadeOutDuration: ReduceMotion.isEnabled(context)
|
||||
? const Duration(milliseconds: 0)
|
||||
: const Duration(milliseconds: 300),
|
||||
placeholder: (context, url) {
|
||||
if (ReduceMotion.isEnabled(context)) {
|
||||
return Container(color: AppColors.surfaceColorAlt);
|
||||
}
|
||||
return Container(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
@@ -646,7 +676,8 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorWidget: (context, url, error) => _buildFallbackIcon(),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user