diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 77ba8de..fb752e1 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -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 )); // 랜덤 파티클 생성 - _generateParticles(); + // 접근성(모션 축소) 고려한 파티클 생성 + _generateParticles(reduced: ReduceMotion.platform()); _animationController.forward(); @@ -75,15 +77,17 @@ class _SplashScreenState extends State }); } - 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 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 .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), ), ], ), diff --git a/lib/utils/reduce_motion.dart b/lib/utils/reduce_motion.dart new file mode 100644 index 0000000..756da6f --- /dev/null +++ b/lib/utils/reduce_motion.dart @@ -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; + } +} diff --git a/lib/widgets/analysis/monthly_expense_chart_card.dart b/lib/widgets/analysis/monthly_expense_chart_card.dart index 71f751a..30eb01e 100644 --- a/lib/widgets/analysis/monthly_expense_chart_card.dart +++ b/lib/widgets/analysis/monthly_expense_chart_card.dart @@ -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, ), ), ), diff --git a/lib/widgets/analysis/total_expense_summary_card.dart b/lib/widgets/analysis/total_expense_summary_card.dart index ad78407..232ad0c 100644 --- a/lib/widgets/analysis/total_expense_summary_card.dart +++ b/lib/widgets/analysis/total_expense_summary_card.dart @@ -43,201 +43,204 @@ class TotalExpenseSummaryCard extends StatelessWidget { parent: animationController, curve: const Interval(0.2, 0.8, curve: Curves.easeOut), )), - child: GlassmorphismCard( - blur: 10, - opacity: 0.1, - borderRadius: 16, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ThemedText.headline( - text: - AppLocalizations.of(context).totalExpenseSummary, - style: const TextStyle( - fontSize: 18, + child: RepaintBoundary( + child: GlassmorphismCard( + blur: 10, + opacity: 0.1, + borderRadius: 16, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ThemedText.headline( + text: AppLocalizations.of(context) + .totalExpenseSummary, + style: const TextStyle( + fontSize: 18, + ), ), - ), - IconButton( - icon: const Icon(Icons.content_copy), - iconSize: 20, - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () async { - final totalExpenseText = - CurrencyUtil.formatTotalAmountWithLocale( - totalExpense, locale); - await Clipboard.setData( - ClipboardData(text: totalExpenseText)); - HapticFeedbackHelper.lightImpact(); - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(AppLocalizations.of(context) - .totalExpenseCopied(totalExpenseText)), - duration: const Duration(seconds: 2), - behavior: SnackBarBehavior.floating, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + IconButton( + icon: const Icon(Icons.content_copy), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () async { + final totalExpenseText = + CurrencyUtil.formatTotalAmountWithLocale( + totalExpense, locale); + await Clipboard.setData( + ClipboardData(text: totalExpenseText)); + HapticFeedbackHelper.lightImpact(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context) + .totalExpenseCopied(totalExpenseText)), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + backgroundColor: AppColors.glassBackground + .withValues(alpha: 0.3), + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), ), - backgroundColor: AppColors.glassBackground - .withValues(alpha: 0.3), - margin: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - ), - ); - }, - ), - ], - ), - const SizedBox(height: 8), - ThemedText.subtitle( - text: AppLocalizations.of(context).monthlyTotalAmount, - style: const TextStyle( - fontSize: 14, + ); + }, + ), + ], ), - ), - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ThemedText.caption( - text: AppLocalizations.of(context).totalExpense, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 4), - ThemedText( - CurrencyUtil.formatTotalAmountWithLocale( - totalExpense, locale), - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - letterSpacing: -0.5, - ), - ), - ], - ), + const SizedBox(height: 8), + ThemedText.subtitle( + text: AppLocalizations.of(context).monthlyTotalAmount, + style: const TextStyle( + fontSize: 14, ), - const SizedBox(width: 16), - Expanded( - child: Column( - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glassBackground - .withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.glassBorder - .withValues(alpha: 0.2), - ), - ), - child: const FaIcon( - FontAwesomeIcons.listCheck, - size: 16, - color: AppColors.primaryColor, - ), + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: + AppLocalizations.of(context).totalExpense, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ThemedText.caption( - text: AppLocalizations.of(context) - .totalServices, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - ThemedText( - AppLocalizations.of(context) - .subscriptionCount( - subscriptions.length), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], + ), + const SizedBox(height: 4), + ThemedText( + CurrencyUtil.formatTotalAmountWithLocale( + totalExpense, locale), + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + letterSpacing: -0.5, ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: AppColors.glassBackground - .withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: AppColors.glassBorder - .withValues(alpha: 0.2), - ), - ), - child: const FaIcon( - FontAwesomeIcons.chartLine, - size: 16, - color: AppColors.successColor, - ), - ), - const SizedBox(width: 12), - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ThemedText.caption( - text: AppLocalizations.of(context) - .averageCost, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 2), - ThemedText( - CurrencyUtil - .formatTotalAmountWithLocale( - subscriptions.isEmpty - ? 0 - : totalExpense / - subscriptions.length, - locale), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ], - ), - ], + ), + ], + ), ), - ), - ], - ), - ], + const SizedBox(width: 16), + Expanded( + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glassBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.glassBorder + .withValues(alpha: 0.2), + ), + ), + child: const FaIcon( + FontAwesomeIcons.listCheck, + size: 16, + color: AppColors.primaryColor, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: AppLocalizations.of(context) + .totalServices, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + ThemedText( + AppLocalizations.of(context) + .subscriptionCount( + subscriptions.length), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glassBackground + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.glassBorder + .withValues(alpha: 0.2), + ), + ), + child: const FaIcon( + FontAwesomeIcons.chartLine, + size: 16, + color: AppColors.successColor, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: AppLocalizations.of(context) + .averageCost, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + ThemedText( + CurrencyUtil + .formatTotalAmountWithLocale( + subscriptions.isEmpty + ? 0 + : totalExpense / + subscriptions.length, + locale), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/widgets/animated_page_transitions.dart b/lib/widgets/animated_page_transitions.dart index e003c9d..43dc8c3 100644 --- a/lib/widgets/animated_page_transitions.dart +++ b/lib/widgets/animated_page_transitions.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'dart:math' as math; +import '../utils/reduce_motion.dart'; /// 슬라이드 + 페이드 전환 class SlidePageRoute extends PageRouteBuilder { @@ -11,8 +12,12 @@ class SlidePageRoute extends PageRouteBuilder { 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 extends PageRouteBuilder { 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 extends PageRouteBuilder { 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 extends PageRouteBuilder { 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 extends PageRouteBuilder { 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 extends PageRouteBuilder { 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; diff --git a/lib/widgets/animated_wave_background.dart b/lib/widgets/animated_wave_background.dart index 8f4ca27..76141cd 100644 --- a/lib/widgets/animated_wave_background.dart +++ b/lib/widgets/animated_wave_background.dart @@ -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), ), ), diff --git a/lib/widgets/dialogs/delete_confirmation_dialog.dart b/lib/widgets/dialogs/delete_confirmation_dialog.dart index eb9f362..8670da6 100644 --- a/lib/widgets/dialogs/delete_confirmation_dialog.dart +++ b/lib/widgets/dialogs/delete_confirmation_dialog.dart @@ -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), diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index e049e15..878c0d3 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -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,102 +26,110 @@ 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(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), child: Center( child: SlideTransition( position: Tween( - begin: const Offset(0, 0.2), + begin: beginOffset, end: Offset.zero, ).animate(CurvedAnimation( parent: slideController, curve: Curves.easeOutBack)), - child: GlassmorphismCard( - width: null, - margin: const EdgeInsets.all(16), - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedBuilder( - animation: rotateController, - builder: (context, child) { - return Transform.rotate( - angle: rotateController.value * 2 * math.pi, - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: AppColors.blueGradient, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: - AppColors.primaryColor.withValues(alpha: 0.3), - spreadRadius: 0, - blurRadius: 16, - offset: const Offset(0, 8), + child: RepaintBoundary( + child: GlassmorphismCard( + width: null, + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: rotateController, + builder: (context, child) { + final angleScale = + ReduceMotion.isEnabled(context) ? 0.2 : 1.0; + return Transform.rotate( + angle: + angleScale * rotateController.value * 2 * math.pi, + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: AppColors.blueGradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - ], + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.primaryColor + .withValues(alpha: 0.3), + spreadRadius: 0, + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: const Icon( + Icons.subscriptions_outlined, + size: 48, + color: AppColors.pureWhite, + ), ), - child: const Icon( - Icons.subscriptions_outlined, - size: 48, + ); + }, + ), + const SizedBox(height: 32), + ThemedText( + AppLocalizations.of(context).noSubscriptions, + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, + ), + const SizedBox(height: 8), + ThemedText( + AppLocalizations.of(context).addSubscriptionNow, + fontSize: 16, + opacity: 0.7, + ), + const SizedBox(height: 32), + MouseRegion( + onEnter: (_) => {}, + onExit: (_) => {}, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 16, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 4, + backgroundColor: AppColors.primaryColor, + ), + onPressed: () { + HapticFeedback.mediumImpact(); + onAddPressed(); + }, + child: Text( + AppLocalizations.of(context).addSubscription, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, color: AppColors.pureWhite, ), ), - ); - }, - ), - const SizedBox(height: 32), - ThemedText( - AppLocalizations.of(context).noSubscriptions, - fontSize: 22, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - ), - const SizedBox(height: 8), - ThemedText( - AppLocalizations.of(context).addSubscriptionNow, - fontSize: 16, - opacity: 0.7, - ), - const SizedBox(height: 32), - MouseRegion( - onEnter: (_) => {}, - onExit: (_) => {}, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - horizontal: 32, - vertical: 16, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 4, - backgroundColor: AppColors.primaryColor, - ), - onPressed: () { - HapticFeedback.mediumImpact(); - onAddPressed(); - }, - child: Text( - AppLocalizations.of(context).addSubscription, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - letterSpacing: 0.5, - color: AppColors.pureWhite, - ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 64c2fbe..326b369 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -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 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 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( begin: 1.0, - end: 0.9, + end: ReduceMotion.platform() ? 1.0 : 0.9, ).animate(CurvedAnimation( parent: _controller, curve: Curves.easeInOut, diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 8fa8868..0b33e29 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -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 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, diff --git a/lib/widgets/website_icon.dart b/lib/widgets/website_icon.dart index 1696982..db8db44 100644 --- a/lib/widgets/website_icon.dart +++ b/lib/widgets/website_icon.dart @@ -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,12 +191,15 @@ class _WebsiteIconState extends State // 애니메이션 컨트롤러 초기화 _animationController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 300), + duration: ReduceMotion.platform() + ? const Duration(milliseconds: 0) + : const Duration(milliseconds: 300), ); - _scaleAnimation = Tween(begin: 1.0, end: 1.08).animate( - CurvedAnimation( - parent: _animationController, curve: Curves.easeOutCubic)); + _scaleAnimation = + Tween(begin: 1.0, end: ReduceMotion.platform() ? 1.0 : 1.08) + .animate(CurvedAnimation( + parent: _animationController, curve: Curves.easeOutCubic)); // 초기 _previousServiceKey 설정 _previousServiceKey = _serviceKey; @@ -548,11 +552,14 @@ class _WebsiteIconState extends State @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 ), 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,20 +653,31 @@ class _WebsiteIconState extends State width: widget.size, height: widget.size, fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: AppColors.surfaceColorAlt, - child: Center( - child: SizedBox( - width: widget.size * 0.4, - height: widget.size * 0.4, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withValues(alpha: 0.7)), + 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( + width: widget.size * 0.4, + height: widget.size * 0.4, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + AppColors.primaryColor.withValues(alpha: 0.7)), + ), ), ), - ), - ), + ); + }, errorWidget: (context, url, error) => _buildFallbackIcon(), ), );