refactor: remove unreferenced widgets/utilities and backup file in lib
This commit is contained in:
@@ -1,173 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// 위험한 액션에 사용되는 Danger 버튼
|
||||
/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다.
|
||||
class DangerButton extends StatefulWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool requireConfirmation;
|
||||
final String? confirmationTitle;
|
||||
final String? confirmationMessage;
|
||||
final IconData? icon;
|
||||
final double? width;
|
||||
final double height;
|
||||
final double fontSize;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double borderRadius;
|
||||
final bool enableHoverEffect;
|
||||
|
||||
const DangerButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.requireConfirmation = false,
|
||||
this.confirmationTitle,
|
||||
this.confirmationMessage,
|
||||
this.icon,
|
||||
this.width,
|
||||
this.height = 60,
|
||||
this.fontSize = 18,
|
||||
this.padding,
|
||||
this.borderRadius = 16,
|
||||
this.enableHoverEffect = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DangerButton> createState() => _DangerButtonState();
|
||||
}
|
||||
|
||||
class _DangerButtonState extends State<DangerButton> {
|
||||
bool _isHovered = false;
|
||||
|
||||
static const Color _dangerColor = AppColors.dangerColor;
|
||||
|
||||
Future<void> _handlePress() async {
|
||||
if (widget.requireConfirmation) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
title: Text(
|
||||
widget.confirmationTitle ?? widget.text,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
),
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _dangerColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon ?? Icons.warning_amber_rounded,
|
||||
color: _dangerColor,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
widget.confirmationMessage ?? '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _dangerColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.text,
|
||||
style: const TextStyle(color: AppColors.pureWhite),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
} else {
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget button = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: widget.width ?? double.infinity,
|
||||
height: widget.height,
|
||||
transform: widget.enableHoverEffect && _isHovered
|
||||
? (Matrix4.identity()..scale(1.02))
|
||||
: Matrix4.identity(),
|
||||
child: ElevatedButton(
|
||||
onPressed: widget.onPressed != null ? _handlePress : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _dangerColor,
|
||||
foregroundColor: AppColors.pureWhite,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
||||
),
|
||||
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
||||
disabledBackgroundColor: _dangerColor.withValues(alpha: 0.6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (widget.icon != null) ...[
|
||||
Icon(
|
||||
widget.icon,
|
||||
color: AppColors.pureWhite,
|
||||
size: _isHovered ? 24 : 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
widget.text,
|
||||
style: TextStyle(
|
||||
fontSize: widget.fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.pureWhite,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.enableHoverEffect) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 섹션별 컨텐츠를 감싸는 기본 카드 위젯
|
||||
/// 폼 섹션, 정보 표시 섹션 등에 사용됩니다.
|
||||
class SectionCard extends StatelessWidget {
|
||||
final String? title;
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Color? backgroundColor;
|
||||
final double borderRadius;
|
||||
final List<BoxShadow>? boxShadow;
|
||||
final Border? border;
|
||||
final double? height;
|
||||
final double? width;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SectionCard({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.backgroundColor,
|
||||
this.borderRadius = 20,
|
||||
this.boxShadow,
|
||||
this.border,
|
||||
this.height,
|
||||
this.width,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final effectiveBackgroundColor = backgroundColor ?? Colors.white;
|
||||
final effectiveShadow = boxShadow ??
|
||||
[
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
];
|
||||
|
||||
Widget card = Container(
|
||||
height: height,
|
||||
width: width,
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
boxShadow: effectiveShadow,
|
||||
border: border,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/// 투명한 배경의 섹션 카드
|
||||
/// 어두운 배경 위에서 사용하기 적합합니다.
|
||||
class TransparentSectionCard extends StatelessWidget {
|
||||
final String? title;
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final double opacity;
|
||||
final double borderRadius;
|
||||
final Color? borderColor;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const TransparentSectionCard({
|
||||
super.key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.margin,
|
||||
this.opacity = 0.15,
|
||||
this.borderRadius = 16,
|
||||
this.borderColor,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget card = Container(
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: opacity),
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
border: borderColor != null
|
||||
? Border.all(color: borderColor!, width: 1)
|
||||
: null,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
/// 정보 표시용 카드
|
||||
/// 읽기 전용 정보를 표시할 때 사용합니다.
|
||||
class InfoCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData? icon;
|
||||
final Color? iconColor;
|
||||
final Color? backgroundColor;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final double borderRadius;
|
||||
|
||||
const InfoCard({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.icon,
|
||||
this.iconColor,
|
||||
this.backgroundColor,
|
||||
this.padding,
|
||||
this.borderRadius = 12,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
padding: padding ?? const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: iconColor ?? theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 로딩 오버레이 위젯
|
||||
/// 비동기 작업 중 화면을 덮는 로딩 인디케이터를 표시합니다.
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
final Widget child;
|
||||
final String? message;
|
||||
final Color? backgroundColor;
|
||||
final Color? indicatorColor;
|
||||
final double opacity;
|
||||
|
||||
const LoadingOverlay({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.child,
|
||||
this.message,
|
||||
this.backgroundColor,
|
||||
this.indicatorColor,
|
||||
this.opacity = 0.7,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (isLoading)
|
||||
Container(
|
||||
color: (backgroundColor ?? Colors.black).withValues(alpha: opacity),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: indicatorColor ?? Theme.of(context).primaryColor,
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 로딩 다이얼로그
|
||||
/// 모달 형태의 로딩 인디케이터를 표시합니다.
|
||||
class LoadingDialog {
|
||||
static Future<void> show({
|
||||
required BuildContext context,
|
||||
String? message,
|
||||
Color? barrierColor,
|
||||
bool barrierDismissible = false,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: barrierDismissible,
|
||||
barrierColor: barrierColor ?? Colors.black54,
|
||||
builder: (context) => PopScope(
|
||||
canPop: barrierDismissible,
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static void hide(BuildContext context) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 커스텀 로딩 인디케이터
|
||||
/// 다양한 스타일의 로딩 애니메이션을 제공합니다.
|
||||
class CustomLoadingIndicator extends StatefulWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
final LoadingStyle style;
|
||||
|
||||
const CustomLoadingIndicator({
|
||||
super.key,
|
||||
this.size = 50,
|
||||
this.color,
|
||||
this.style = LoadingStyle.circular,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomLoadingIndicator> createState() => _CustomLoadingIndicatorState();
|
||||
}
|
||||
|
||||
class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(seconds: 1),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
|
||||
_animation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveColor = widget.color ?? Theme.of(context).primaryColor;
|
||||
|
||||
switch (widget.style) {
|
||||
case LoadingStyle.circular:
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: CircularProgressIndicator(
|
||||
color: effectiveColor,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
);
|
||||
|
||||
case LoadingStyle.dots:
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size / 3,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(3, (index) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
final delay = index * 0.2;
|
||||
final value = (_animation.value - delay).clamp(0.0, 1.0);
|
||||
return Container(
|
||||
width: widget.size / 5,
|
||||
height: widget.size / 5,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
effectiveColor.withValues(alpha: 0.3 + value * 0.7),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
case LoadingStyle.pulse:
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: effectiveColor.withValues(alpha: 0.3),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: widget.size * (0.3 + _animation.value * 0.5),
|
||||
height: widget.size * (0.3 + _animation.value * 0.5),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color:
|
||||
effectiveColor.withValues(alpha: 1 - _animation.value),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LoadingStyle {
|
||||
circular,
|
||||
dots,
|
||||
pulse,
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/exchange_rate_service.dart';
|
||||
|
||||
/// 환율 정보를 표시하는 위젯
|
||||
/// 달러 금액을 입력받아 원화 금액으로 변환하여 표시합니다.
|
||||
class ExchangeRateWidget extends StatefulWidget {
|
||||
/// 달러 금액 변화 감지용 TextEditingController
|
||||
final TextEditingController costController;
|
||||
|
||||
/// 환율 정보를 보여줄지 여부 (통화가 달러일 때만 true)
|
||||
final bool showExchangeRate;
|
||||
|
||||
const ExchangeRateWidget({
|
||||
Key? key,
|
||||
required this.costController,
|
||||
required this.showExchangeRate,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ExchangeRateWidget> createState() => _ExchangeRateWidgetState();
|
||||
}
|
||||
|
||||
class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
|
||||
final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||
String _exchangeRateInfo = '';
|
||||
String _convertedAmount = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExchangeRate();
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.costController.removeListener(_updateConvertedAmount);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ExchangeRateWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 통화 변경 감지(달러->원화 또는 원화->달러)되면 리스너 해제 및 재등록
|
||||
if (oldWidget.showExchangeRate != widget.showExchangeRate) {
|
||||
oldWidget.costController.removeListener(_updateConvertedAmount);
|
||||
|
||||
if (widget.showExchangeRate) {
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
_loadExchangeRate();
|
||||
_updateConvertedAmount();
|
||||
} else {
|
||||
setState(() {
|
||||
_exchangeRateInfo = '';
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 로드
|
||||
Future<void> _loadExchangeRate() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_exchangeRateInfo = rateInfo;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 달러 금액이 변경될 때 원화 금액 업데이트
|
||||
Future<void> _updateConvertedAmount() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
try {
|
||||
// 금액 입력값에서 콤마 제거 후 숫자로 변환
|
||||
final text = widget.costController.text.replaceAll(',', '');
|
||||
if (text.isEmpty) {
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(text);
|
||||
if (amount != null) {
|
||||
final converted =
|
||||
await _exchangeRateService.getFormattedKrwAmount(amount);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_convertedAmount = converted;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 발생 시 빈 문자열 표시
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 텍스트 위젯 생성
|
||||
Widget buildExchangeRateInfo() {
|
||||
if (_exchangeRateInfo.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_exchangeRateInfo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 환산 금액 텍스트 위젯 생성
|
||||
Widget buildConvertedAmount() {
|
||||
if (_convertedAmount.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_convertedAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.showExchangeRate) {
|
||||
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
|
||||
}
|
||||
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 익스포즈드 메서드: 환율 정보 문자열 가져오기
|
||||
String get exchangeRateInfo => _exchangeRateInfo;
|
||||
|
||||
// 익스포즈드 메서드: 변환된 금액 문자열 가져오기
|
||||
String get convertedAmount => _convertedAmount;
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/haptic_feedback_helper.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class ExpandableFab extends StatefulWidget {
|
||||
final List<FabAction> actions;
|
||||
final double distance;
|
||||
|
||||
const ExpandableFab({
|
||||
super.key,
|
||||
required this.actions,
|
||||
this.distance = 100.0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ExpandableFab> createState() => _ExpandableFabState();
|
||||
}
|
||||
|
||||
class _ExpandableFabState extends State<ExpandableFab>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _expandAnimation;
|
||||
late Animation<double> _rotateAnimation;
|
||||
bool _isExpanded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_expandAnimation = CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutBack,
|
||||
reverseCurve: Curves.easeInBack,
|
||||
);
|
||||
|
||||
_rotateAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: math.pi / 4,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle() {
|
||||
setState(() {
|
||||
_isExpanded = !_isExpanded;
|
||||
});
|
||||
|
||||
if (_isExpanded) {
|
||||
HapticFeedbackHelper.mediumImpact();
|
||||
_controller.forward();
|
||||
} else {
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
_controller.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: [
|
||||
// 배경 오버레이 (확장 시)
|
||||
if (_isExpanded)
|
||||
GestureDetector(
|
||||
onTap: _toggle,
|
||||
child: AnimatedBuilder(
|
||||
animation: _expandAnimation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
color: AppColors.shadowBlack
|
||||
.withValues(alpha: 3.75 * _expandAnimation.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 액션 버튼들
|
||||
...widget.actions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final action = entry.value;
|
||||
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _expandAnimation,
|
||||
builder: (context, child) {
|
||||
final distance = widget.distance * _expandAnimation.value;
|
||||
final x = distance * math.cos(angle);
|
||||
final y = distance * math.sin(angle);
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(-x, -y),
|
||||
child: ScaleTransition(
|
||||
scale: _expandAnimation,
|
||||
child: FloatingActionButton.small(
|
||||
heroTag: 'fab_action_$index',
|
||||
onPressed: _isExpanded
|
||||
? () {
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
_toggle();
|
||||
action.onPressed();
|
||||
}
|
||||
: null,
|
||||
backgroundColor: action.color ?? AppColors.primaryColor,
|
||||
child: Icon(
|
||||
action.icon,
|
||||
size: 20,
|
||||
color: AppColors.pureWhite,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
|
||||
// 메인 FAB
|
||||
AnimatedBuilder(
|
||||
animation: _rotateAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.rotate(
|
||||
angle: _rotateAnimation.value,
|
||||
child: FloatingActionButton(
|
||||
onPressed: _toggle,
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
child: Icon(
|
||||
_isExpanded ? Icons.close : Icons.add,
|
||||
size: 28,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// 라벨 표시
|
||||
if (_isExpanded)
|
||||
...widget.actions.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final action = entry.value;
|
||||
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _expandAnimation,
|
||||
builder: (context, child) {
|
||||
final distance = widget.distance * _expandAnimation.value;
|
||||
final x = distance * math.cos(angle);
|
||||
final y = distance * math.sin(angle);
|
||||
|
||||
return Transform.translate(
|
||||
offset: Offset(-x - 80, -y),
|
||||
child: FadeTransition(
|
||||
opacity: _expandAnimation,
|
||||
child: GlassmorphismCard(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
borderRadius: 8,
|
||||
blur: 10,
|
||||
child: Text(
|
||||
action.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FabAction {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final Color? color;
|
||||
|
||||
const FabAction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.color,
|
||||
});
|
||||
}
|
||||
|
||||
// 드래그 가능한 FAB
|
||||
class DraggableFab extends StatefulWidget {
|
||||
final Widget child;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const DraggableFab({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DraggableFab> createState() => _DraggableFabState();
|
||||
}
|
||||
|
||||
class _DraggableFabState extends State<DraggableFab> {
|
||||
Offset _position = const Offset(20, 20);
|
||||
bool _isDragging = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenSize = MediaQuery.of(context).size;
|
||||
final padding = widget.padding ?? const EdgeInsets.all(20);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
right: _position.dx,
|
||||
bottom: _position.dy,
|
||||
child: GestureDetector(
|
||||
onPanStart: (_) {
|
||||
setState(() => _isDragging = true);
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
_position = Offset(
|
||||
(_position.dx - details.delta.dx).clamp(
|
||||
padding.right,
|
||||
screenSize.width - 100 - padding.left,
|
||||
),
|
||||
(_position.dy - details.delta.dy).clamp(
|
||||
padding.bottom,
|
||||
screenSize.height - 200 - padding.top,
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
setState(() => _isDragging = false);
|
||||
HapticFeedbackHelper.lightImpact();
|
||||
},
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
scale: _isDragging ? 0.9 : 1.0,
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'themed_text.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// 글래스모피즘 효과가 적용된 통일된 앱바
|
||||
class GlassmorphicAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final bool automaticallyImplyLeading;
|
||||
final double elevation;
|
||||
final Color? backgroundColor;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final PreferredSizeWidget? bottom;
|
||||
final bool centerTitle;
|
||||
final double? titleSpacing;
|
||||
final VoidCallback? onBackPressed;
|
||||
|
||||
const GlassmorphicAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.elevation = 0,
|
||||
this.backgroundColor,
|
||||
this.blur = 20,
|
||||
this.opacity = 0.1,
|
||||
this.bottom,
|
||||
this.centerTitle = false,
|
||||
this.titleSpacing,
|
||||
this.onBackPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(
|
||||
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final canPop = Navigator.of(context).canPop();
|
||||
|
||||
return ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(backgroundColor ??
|
||||
(isDarkMode
|
||||
? AppColors.glassBackgroundDark
|
||||
: AppColors.glassBackground))
|
||||
.withValues(alpha: opacity),
|
||||
(backgroundColor ??
|
||||
(isDarkMode
|
||||
? AppColors.glassSurfaceDark
|
||||
: AppColors.glassSurface))
|
||||
.withValues(alpha: opacity * 0.8),
|
||||
],
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDarkMode
|
||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||
: AppColors.glassBorder.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight,
|
||||
child: NavigationToolbar(
|
||||
leading: leading ??
|
||||
(automaticallyImplyLeading &&
|
||||
(canPop || onBackPressed != null)
|
||||
? _buildBackButton(context)
|
||||
: null),
|
||||
middle: _buildTitle(context),
|
||||
trailing: actions != null
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: actions!,
|
||||
)
|
||||
: null,
|
||||
centerMiddle: centerTitle,
|
||||
middleSpacing:
|
||||
titleSpacing ?? NavigationToolbar.kMiddleSpacing,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (bottom != null) bottom!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBackButton(BuildContext context) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: onBackPressed ??
|
||||
() {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: AppLocalizations.of(context).back,
|
||||
color: ThemedText.getContrastColor(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 투명 스타일 팩토리
|
||||
static GlassmorphicAppBar transparent({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
VoidCallback? onBackPressed,
|
||||
}) {
|
||||
return GlassmorphicAppBar(
|
||||
title: title,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
blur: 30,
|
||||
opacity: 0.05,
|
||||
onBackPressed: onBackPressed,
|
||||
);
|
||||
}
|
||||
|
||||
/// 반투명 스타일 팩토리
|
||||
static GlassmorphicAppBar translucent({
|
||||
required String title,
|
||||
List<Widget>? actions,
|
||||
Widget? leading,
|
||||
VoidCallback? onBackPressed,
|
||||
}) {
|
||||
return GlassmorphicAppBar(
|
||||
title: title,
|
||||
actions: actions,
|
||||
leading: leading,
|
||||
blur: 20,
|
||||
opacity: 0.15,
|
||||
onBackPressed: onBackPressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함)
|
||||
class GlassmorphicSliverAppBar extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final Widget? leading;
|
||||
final double expandedHeight;
|
||||
final bool floating;
|
||||
final bool pinned;
|
||||
final bool snap;
|
||||
final Widget? flexibleSpace;
|
||||
final double blur;
|
||||
final double opacity;
|
||||
final bool automaticallyImplyLeading;
|
||||
final VoidCallback? onBackPressed;
|
||||
final bool centerTitle;
|
||||
|
||||
const GlassmorphicSliverAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.leading,
|
||||
this.expandedHeight = kToolbarHeight,
|
||||
this.floating = false,
|
||||
this.pinned = true,
|
||||
this.snap = false,
|
||||
this.flexibleSpace,
|
||||
this.blur = 20,
|
||||
this.opacity = 0.1,
|
||||
this.automaticallyImplyLeading = true,
|
||||
this.onBackPressed,
|
||||
this.centerTitle = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
final canPop = Navigator.of(context).canPop();
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: expandedHeight,
|
||||
floating: floating,
|
||||
pinned: pinned,
|
||||
snap: snap,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
automaticallyImplyLeading: false,
|
||||
leading: leading ??
|
||||
(automaticallyImplyLeading && (canPop || onBackPressed != null)
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
onPressed: onBackPressed ??
|
||||
() {
|
||||
HapticFeedback.lightImpact();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
splashRadius: 24,
|
||||
tooltip: AppLocalizations.of(context).back,
|
||||
)
|
||||
: null),
|
||||
actions: actions,
|
||||
centerTitle: centerTitle,
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
final top = constraints.biggest.height;
|
||||
final isCollapsed =
|
||||
top <= kToolbarHeight + MediaQuery.of(context).padding.top;
|
||||
|
||||
return FlexibleSpaceBar(
|
||||
title: isCollapsed
|
||||
? ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
centerTitle: centerTitle,
|
||||
titlePadding:
|
||||
const EdgeInsets.only(left: 16, bottom: 16, right: 16),
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// 글래스모피즘 배경
|
||||
ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
(isDarkMode
|
||||
? AppColors.glassBackgroundDark
|
||||
: AppColors.glassBackground)
|
||||
.withValues(alpha: opacity),
|
||||
(isDarkMode
|
||||
? AppColors.glassSurfaceDark
|
||||
: AppColors.glassSurface)
|
||||
.withValues(alpha: opacity * 0.8),
|
||||
],
|
||||
),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: isDarkMode
|
||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||
: AppColors.glassBorder.withValues(alpha: 0.5),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 확장 상태에서만 보이는 타이틀
|
||||
if (!isCollapsed)
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: ThemedText.headline(
|
||||
text: title,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 커스텀 flexibleSpace가 있으면 추가
|
||||
if (flexibleSpace != null) flexibleSpace!,
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,315 +42,321 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 16,
|
||||
blur: 15,
|
||||
backgroundColor: AppColors.glassCard,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: AppColors.mainGradient
|
||||
.map((color) => color.withValues(alpha: 0.2))
|
||||
.toList(),
|
||||
),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder,
|
||||
width: 1,
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 180,
|
||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||
child: RepaintBoundary(
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 16,
|
||||
blur: 15,
|
||||
backgroundColor: AppColors.glassCard,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: AppColors.mainGradient
|
||||
.map((color) => color.withValues(alpha: 0.2))
|
||||
.toList(),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.transparent,
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder,
|
||||
width: 1,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 애니메이션 웨이브 배경
|
||||
Positioned.fill(
|
||||
child: AnimatedWaveBackground(
|
||||
controller: waveController,
|
||||
pulseController: pulseController,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 180,
|
||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 애니메이션 웨이브 배경
|
||||
Positioned.fill(
|
||||
child: AnimatedWaveBackground(
|
||||
controller: waveController,
|
||||
pulseController: pulseController,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)
|
||||
.monthlyTotalSubscriptionCost,
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)
|
||||
.monthlyTotalSubscriptionCost,
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
// 환율 정보 표시 (영어 사용자는 표시 안함)
|
||||
if (locale != 'en')
|
||||
FutureBuilder<String>(
|
||||
future:
|
||||
CurrencyUtil.getExchangeRateInfoForLocale(
|
||||
locale),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData &&
|
||||
snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F2FF),
|
||||
borderRadius:
|
||||
BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFBFDBFE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)
|
||||
.exchangeRateDisplay
|
||||
.replaceAll('@', snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 월별 총 비용 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
// 환율 정보 표시 (영어 사용자는 표시 안함)
|
||||
if (locale != 'en')
|
||||
FutureBuilder<String>(
|
||||
future:
|
||||
CurrencyUtil.getExchangeRateInfoForLocale(
|
||||
locale),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData &&
|
||||
snapshot.data!.isNotEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F2FF),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFBFDBFE),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context)
|
||||
.exchangeRateDisplay
|
||||
.replaceAll('@', snapshot.data!),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 월별 총 비용 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final decimals = (defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final decimals = (defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency == 'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: '',
|
||||
decimalDigits: decimals,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency == 'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: '',
|
||||
decimalDigits: decimals,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
currencySymbol,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
currencySymbol,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 연간 비용 및 총 구독 수 표시
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final yearlyCost = monthlyCost * 12;
|
||||
final decimals = (defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context)
|
||||
.estimatedAnnualCost,
|
||||
value: NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency == 'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(yearlyCost),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context)
|
||||
.totalSubscriptionServices,
|
||||
value:
|
||||
'$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
// 이벤트 절약액 표시
|
||||
if (activeEvents > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.2),
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryColor
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 연간 비용 및 총 구독 수 표시
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 14,
|
||||
color: AppColors
|
||||
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)
|
||||
.eventDiscountActive,
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 이벤트 절약액 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalEventSavingsInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final eventSavings = snapshot.data!;
|
||||
final decimals =
|
||||
(defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final monthlyCost = snapshot.data!;
|
||||
final yearlyCost = monthlyCost * 12;
|
||||
final decimals = (defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency ==
|
||||
'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
|
||||
style: const TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
return Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context)
|
||||
.estimatedAnnualCost,
|
||||
value: NumberFormat.currency(
|
||||
locale: defaultCurrency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency == 'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(yearlyCost),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: AppLocalizations.of(context)
|
||||
.totalSubscriptionServices,
|
||||
value:
|
||||
'$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
// 이벤트 절약액 표시
|
||||
if (activeEvents > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.2),
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryColor
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
Colors.white.withValues(alpha: 0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 14,
|
||||
color: AppColors
|
||||
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)
|
||||
.eventDiscountActive,
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 이벤트 절약액 표시 (언어별 기본 통화)
|
||||
FutureBuilder<double>(
|
||||
future: CurrencyUtil
|
||||
.calculateTotalEventSavingsInDefaultCurrency(
|
||||
provider.subscriptions,
|
||||
locale,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final eventSavings = snapshot.data!;
|
||||
final decimals =
|
||||
(defaultCurrency == 'KRW' ||
|
||||
defaultCurrency == 'JPY')
|
||||
? 0
|
||||
: 2;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: defaultCurrency ==
|
||||
'KRW'
|
||||
? 'ko_KR'
|
||||
: defaultCurrency == 'JPY'
|
||||
? 'ja_JP'
|
||||
: defaultCurrency ==
|
||||
'CNY'
|
||||
? 'zh_CN'
|
||||
: 'en_US',
|
||||
symbol: currencySymbol,
|
||||
decimalDigits: decimals,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primaryColor,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
|
||||
style: const TextStyle(
|
||||
color: AppColors.navyGray,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class SkeletonLoading extends StatelessWidget {
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
|
||||
const SkeletonLoading({
|
||||
Key? key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius = 8.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 단일 스켈레톤 아이템이 요청된 경우
|
||||
if (width != null || height != null) {
|
||||
return _buildSingleSkeleton();
|
||||
}
|
||||
|
||||
// 기본 전체 화면 스켈레톤
|
||||
return Column(
|
||||
children: [
|
||||
// 요약 카드 스켈레톤
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSkeletonColumn(),
|
||||
_buildSkeletonColumn(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 구독 목록 스켈레톤
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSingleSkeleton() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
Colors.grey[300]!,
|
||||
Colors.grey[100]!,
|
||||
Colors.grey[300]!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonColumn() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
|
||||
class SpringAnimationWidget extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Duration delay;
|
||||
final SpringDescription spring;
|
||||
final Offset? initialOffset;
|
||||
final double? initialScale;
|
||||
final double? initialRotation;
|
||||
|
||||
const SpringAnimationWidget({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.delay = Duration.zero,
|
||||
this.spring = const SpringDescription(
|
||||
mass: 1,
|
||||
stiffness: 100,
|
||||
damping: 10,
|
||||
),
|
||||
this.initialOffset,
|
||||
this.initialScale,
|
||||
this.initialRotation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
|
||||
}
|
||||
|
||||
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<Offset> _offsetAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _rotationAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
// 오프셋 애니메이션
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
begin: widget.initialOffset ?? const Offset(0, 50),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 스케일 애니메이션
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: widget.initialScale ?? 0.5,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 회전 애니메이션
|
||||
_rotationAnimation = Tween<double>(
|
||||
begin: widget.initialRotation ?? 0.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
// 지연 후 애니메이션 시작
|
||||
Future.delayed(widget.delay, () {
|
||||
if (mounted) {
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: _offsetAnimation.value,
|
||||
child: Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Transform.rotate(
|
||||
angle: _rotationAnimation.value,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 바운스 효과가 있는 버튼
|
||||
class BouncyButton extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onPressed;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final BoxDecoration? decoration;
|
||||
|
||||
const BouncyButton({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onPressed,
|
||||
this.padding,
|
||||
this.decoration,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BouncyButton> createState() => _BouncyButtonState();
|
||||
}
|
||||
|
||||
class _BouncyButtonState extends State<BouncyButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 1.0,
|
||||
end: 0.95,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
_controller.reverse();
|
||||
widget.onPressed?.call();
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
child: AnimatedBuilder(
|
||||
animation: _scaleAnimation,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Container(
|
||||
padding: widget.padding,
|
||||
decoration: widget.decoration,
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 중력 효과 애니메이션
|
||||
class GravityAnimation extends StatefulWidget {
|
||||
final Widget child;
|
||||
final double gravity;
|
||||
final double bounceFactor;
|
||||
final double initialVelocity;
|
||||
|
||||
const GravityAnimation({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.gravity = 9.8,
|
||||
this.bounceFactor = 0.8,
|
||||
this.initialVelocity = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GravityAnimation> createState() => _GravityAnimationState();
|
||||
}
|
||||
|
||||
class _GravityAnimationState extends State<GravityAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
double _position = 0;
|
||||
double _velocity = 0;
|
||||
final double _floor = 300;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_velocity = widget.initialVelocity;
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 10),
|
||||
)..addListener(_updatePhysics);
|
||||
|
||||
_controller.repeat();
|
||||
}
|
||||
|
||||
void _updatePhysics() {
|
||||
setState(() {
|
||||
// 속도 업데이트 (중력 적용)
|
||||
_velocity += widget.gravity * 0.016; // 60fps 가정
|
||||
|
||||
// 위치 업데이트
|
||||
_position += _velocity;
|
||||
|
||||
// 바닥 충돌 감지
|
||||
if (_position >= _floor) {
|
||||
_position = _floor;
|
||||
_velocity = -_velocity * widget.bounceFactor;
|
||||
|
||||
// 너무 작은 바운스는 멈춤
|
||||
if (_velocity.abs() < 1) {
|
||||
_velocity = 0;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, _position),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 물결 효과 애니메이션
|
||||
class RippleAnimation extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Color rippleColor;
|
||||
final Duration duration;
|
||||
|
||||
const RippleAnimation({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.rippleColor = Colors.blue,
|
||||
this.duration = const Duration(milliseconds: 600),
|
||||
});
|
||||
|
||||
@override
|
||||
State<RippleAnimation> createState() => _RippleAnimationState();
|
||||
}
|
||||
|
||||
class _RippleAnimationState extends State<RippleAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: widget.duration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Curves.easeOut,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTap() {
|
||||
_controller.forward(from: 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _handleTap,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 100 + 200 * _animation.value,
|
||||
height: 100 + 200 * _animation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: widget.rippleColor.withValues(
|
||||
alpha: (1 - _animation.value) * 0.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
widget.child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user