feat: 글래스모피즘 디자인 시스템 및 색상 가이드 전면 적용

- @doc/color.md 가이드라인에 따른 색상 시스템 전면 개편
- 딥 블루(#2563EB), 스카이 블루(#60A5FA) 메인 컬러로 변경
- 모든 화면과 위젯에 글래스모피즘 효과 일관성 있게 적용
- darkNavy, navyGray 등 새로운 텍스트 색상 체계 도입
- 공통 스낵바 및 다이얼로그 컴포넌트 추가
- Claude AI 프로젝트 컨텍스트 파일(CLAUDE.md) 추가

영향받은 컴포넌트:
- 10개 스크린 (main, settings, detail, splash 등)
- 30개 이상 위젯 (buttons, cards, forms 등)
- 테마 시스템 (AppColors, AppTheme)

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-11 18:41:05 +09:00
parent 83c5e3d64e
commit 2f60ef585a
46 changed files with 1096 additions and 580 deletions

View File

@@ -26,11 +26,11 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(appBarOpacity),
color: Colors.white.withValues(alpha: appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.1 * appBarOpacity),
color: Colors.black.withValues(alpha: 0.1 * appBarOpacity),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 4),
@@ -51,7 +51,7 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
shadows: appBarOpacity > 0.6
? [
Shadow(
color: Colors.black.withOpacity(0.2),
color: Colors.black.withValues(alpha: 0.2),
offset: const Offset(0, 1),
blurRadius: 2,
)

View File

@@ -44,7 +44,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
border: Border.all(
color: controller.isEventActive
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.2),
: Colors.grey.withValues(alpha: 0.2),
width: controller.isEventActive ? 2 : 1,
),
),

View File

@@ -297,7 +297,7 @@ class _CurrencyOption extends StatelessWidget {
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.1),
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
@@ -350,7 +350,7 @@ class _BillingCycleSelector extends StatelessWidget {
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withOpacity(0.1),
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
@@ -402,14 +402,14 @@ class _CategorySelector extends StatelessWidget {
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withOpacity(0.1),
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
category.emoji,
category.icon,
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 6),

View File

@@ -32,7 +32,7 @@ class AddSubscriptionHeader extends StatelessWidget {
),
boxShadow: [
BoxShadow(
color: controller.gradientColors[0].withOpacity(0.3),
color: controller.gradientColors[0].withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
@@ -44,7 +44,7 @@ class AddSubscriptionHeader extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget {
@@ -23,7 +24,7 @@ class AnalysisBadge extends StatelessWidget {
width: size,
height: size,
decoration: BoxDecoration(
color: Colors.white,
color: AppColors.pureWhite,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
@@ -31,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
color: AppColors.shadowBlack,
blurRadius: 10,
spreadRadius: 2,
),
@@ -48,7 +49,7 @@ class AnalysisBadge extends StatelessWidget {
style: const TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: Colors.black87,
color: AppColors.darkNavy,
),
),
const SizedBox(height: 2),
@@ -68,7 +69,7 @@ class AnalysisBadge extends StatelessWidget {
displayText,
style: const TextStyle(
fontSize: 7,
color: Colors.black54,
color: AppColors.navyGray,
),
);
}

View File

@@ -3,6 +3,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart';
import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
@@ -73,7 +74,7 @@ class EventAnalysisCard extends StatelessWidget {
const FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: Colors.white,
color: AppColors.pureWhite,
),
const SizedBox(width: 4),
Text(
@@ -81,7 +82,7 @@ class EventAnalysisCard extends StatelessWidget {
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
color: AppColors.pureWhite,
),
),
],
@@ -159,10 +160,10 @@ class EventAnalysisCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
color: AppColors.darkNavy.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
color: AppColors.darkNavy.withValues(alpha: 0.1),
),
),
child: Row(
@@ -194,7 +195,7 @@ class EventAnalysisCard extends StatelessWidget {
fontSize: 12,
decoration: TextDecoration
.lineThrough,
color: Colors.grey,
color: AppColors.navyGray,
),
);
}
@@ -205,7 +206,7 @@ class EventAnalysisCard extends StatelessWidget {
const Icon(
Icons.arrow_forward,
size: 12,
color: Colors.grey,
color: AppColors.navyGray,
),
const SizedBox(width: 8),
FutureBuilder<String>(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'dart:math' as math;
import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
@@ -44,7 +45,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxAmount + (maxAmount * 0.1),
color: Colors.grey.withValues(alpha: 0.1),
color: AppColors.navyGray.withValues(alpha: 0.1),
),
),
],
@@ -124,7 +125,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
),
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey.withValues(alpha: 0.1),
color: AppColors.navyGray.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
@@ -163,14 +164,14 @@ class MonthlyExpenseChartCard extends StatelessWidget {
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: Colors.blueGrey.shade800,
tooltipBgColor: AppColors.darkNavy,
tooltipRoundedRadius: 8,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
const TextStyle(
color: Colors.white,
color: AppColors.pureWhite,
fontWeight: FontWeight.bold,
),
children: [
@@ -179,7 +180,7 @@ class MonthlyExpenseChartCard extends StatelessWidget {
monthlyData[group.x]['totalExpense']
as double),
style: const TextStyle(
color: Colors.yellow,
color: Color(0xFFFBBF24),
fontSize: 14,
fontWeight: FontWeight.w500,
),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
import 'analysis_badge.dart';
@@ -68,7 +69,7 @@ class SubscriptionPieChartCard extends StatelessWidget {
titleStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
color: AppColors.pureWhite,
shadows: const [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],

View File

@@ -139,7 +139,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
child: const FaIcon(
FontAwesomeIcons.listCheck,
size: 16,
color: Colors.blue,
color: AppColors.primaryColor,
),
),
const SizedBox(width: 12),
@@ -181,7 +181,7 @@ class TotalExpenseSummaryCard extends StatelessWidget {
child: const FaIcon(
FontAwesomeIcons.chartLine,
size: 16,
color: Colors.green,
color: AppColors.successColor,
),
),
const SizedBox(width: 12),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 위험한 액션에 사용되는 Danger 버튼
/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다.
@@ -39,7 +40,7 @@ class DangerButton extends StatefulWidget {
class _DangerButtonState extends State<DangerButton> {
bool _isHovered = false;
static const Color _dangerColor = Color(0xFFDC2626);
static const Color _dangerColor = AppColors.dangerColor;
Future<void> _handlePress() async {
if (widget.requireConfirmation) {
@@ -62,7 +63,7 @@ class _DangerButtonState extends State<DangerButton> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _dangerColor.withOpacity(0.1),
color: _dangerColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -98,7 +99,7 @@ class _DangerButtonState extends State<DangerButton> {
),
child: Text(
widget.text,
style: const TextStyle(color: Colors.white),
style: const TextStyle(color: AppColors.pureWhite),
),
),
],
@@ -126,14 +127,14 @@ class _DangerButtonState extends State<DangerButton> {
onPressed: widget.onPressed != null ? _handlePress : null,
style: ElevatedButton.styleFrom(
backgroundColor: _dangerColor,
foregroundColor: Colors.white,
foregroundColor: AppColors.pureWhite,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.borderRadius),
),
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
elevation: widget.enableHoverEffect && _isHovered ? 8 : 4,
shadowColor: _dangerColor.withOpacity(0.5),
disabledBackgroundColor: _dangerColor.withOpacity(0.6),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor: _dangerColor.withValues(alpha: 0.6),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -142,7 +143,7 @@ class _DangerButtonState extends State<DangerButton> {
if (widget.icon != null) ...[
Icon(
widget.icon,
color: Colors.white,
color: AppColors.pureWhite,
size: _isHovered ? 24 : 20,
),
const SizedBox(width: 8),
@@ -152,7 +153,7 @@ class _DangerButtonState extends State<DangerButton> {
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w600,
color: Colors.white,
color: AppColors.pureWhite,
),
),
],

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 주요 액션에 사용되는 Primary 버튼
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
@@ -43,7 +44,7 @@ class _PrimaryButtonState extends State<PrimaryButton> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor;
final effectiveForegroundColor = widget.foregroundColor ?? Colors.white;
final effectiveForegroundColor = widget.foregroundColor ?? AppColors.pureWhite;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
@@ -61,9 +62,9 @@ class _PrimaryButtonState extends State<PrimaryButton> {
borderRadius: BorderRadius.circular(widget.borderRadius),
),
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
elevation: widget.enableHoverEffect && _isHovered ? 8 : 4,
shadowColor: effectiveBackgroundColor.withOpacity(0.5),
disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.6),
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
shadowColor: Colors.black.withValues(alpha: 0.08),
disabledBackgroundColor: effectiveBackgroundColor.withValues(alpha: 0.6),
),
child: widget.isLoading
? SizedBox(

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 부차적인 액션에 사용되는 Secondary 버튼
/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다.
@@ -42,10 +43,8 @@ class _SecondaryButtonState extends State<SecondaryButton> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBorderColor = widget.borderColor ??
theme.colorScheme.onSurface.withOpacity(0.2);
final effectiveTextColor = widget.textColor ??
theme.colorScheme.onSurface.withOpacity(0.8);
final effectiveBorderColor = widget.borderColor ?? AppColors.secondaryColor;
final effectiveTextColor = widget.textColor ?? AppColors.primaryColor;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
@@ -63,7 +62,7 @@ class _SecondaryButtonState extends State<SecondaryButton> {
),
side: BorderSide(
color: _isHovered
? effectiveBorderColor.withOpacity(0.4)
? effectiveBorderColor.withValues(alpha: 0.4)
: effectiveBorderColor,
width: widget.borderWidth,
),
@@ -72,7 +71,7 @@ class _SecondaryButtonState extends State<SecondaryButton> {
horizontal: 24,
),
backgroundColor: _isHovered
? theme.colorScheme.onSurface.withOpacity(0.05)
? AppColors.glassBackground
: Colors.transparent,
),
child: Row(
@@ -142,13 +141,13 @@ class _TextLinkButtonState extends State<TextLinkButton> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor = widget.color ?? theme.colorScheme.primary;
final effectiveColor = widget.color ?? AppColors.primaryColor;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isHovered
? theme.colorScheme.onSurface.withOpacity(0.05)
? theme.colorScheme.onSurface.withValues(alpha: 0.05)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),

View File

@@ -36,7 +36,7 @@ class SectionCard extends StatelessWidget {
final effectiveBackgroundColor = backgroundColor ?? Colors.white;
final effectiveShadow = boxShadow ?? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -116,7 +116,7 @@ class TransparentSectionCard extends StatelessWidget {
Widget card = Container(
margin: margin,
decoration: BoxDecoration(
color: Colors.white.withOpacity(opacity),
color: Colors.white.withValues(alpha: opacity),
borderRadius: BorderRadius.circular(borderRadius),
border: borderColor != null
? Border.all(color: borderColor!, width: 1)
@@ -134,7 +134,7 @@ class TransparentSectionCard extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white.withOpacity(0.9),
color: Colors.white.withValues(alpha: 0.9),
),
),
const SizedBox(height: 12),
@@ -207,7 +207,7 @@ class InfoCard extends StatelessWidget {
label,
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withOpacity(0.6),
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),

View File

@@ -53,7 +53,7 @@ class ConfirmationDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (iconColor ?? effectiveConfirmColor).withOpacity(0.1),
color: (iconColor ?? effectiveConfirmColor).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
@@ -163,7 +163,7 @@ class SuccessDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
color: Colors.green.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
@@ -271,7 +271,7 @@ class ErrorDialog extends StatelessWidget {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
color: Colors.red.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(

View File

@@ -27,7 +27,7 @@ class LoadingOverlay extends StatelessWidget {
child,
if (isLoading)
Container(
color: (backgroundColor ?? Colors.black).withOpacity(opacity),
color: (backgroundColor ?? Colors.black).withValues(alpha: opacity),
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
@@ -36,7 +36,7 @@ class LoadingOverlay extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
@@ -193,7 +193,7 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
width: widget.size / 5,
height: widget.size / 5,
decoration: BoxDecoration(
color: effectiveColor.withOpacity(0.3 + value * 0.7),
color: effectiveColor.withValues(alpha: 0.3 + value * 0.7),
shape: BoxShape.circle,
),
);
@@ -212,7 +212,7 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withOpacity(0.3),
color: effectiveColor.withValues(alpha: 0.3),
),
child: Center(
child: Container(
@@ -220,7 +220,7 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
height: widget.size * (0.3 + _animation.value * 0.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withOpacity(1 - _animation.value),
color: effectiveColor.withValues(alpha: 1 - _animation.value),
),
),
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../../theme/app_colors.dart';
/// 공통 텍스트 필드 위젯
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
@@ -68,7 +69,7 @@ class BaseTextField extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 8),
@@ -91,18 +92,18 @@ class BaseTextField extends StatelessWidget {
cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? TextStyle(
fontSize: 16,
color: theme.colorScheme.onSurface,
color: AppColors.textPrimary,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
color: AppColors.textMuted,
),
prefixIcon: prefixIcon,
prefixText: prefixText,
suffixIcon: suffixIcon,
filled: true,
fillColor: fillColor ?? Colors.white,
fillColor: fillColor ?? AppColors.glassBackground,
contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
@@ -117,7 +118,10 @@ class BaseTextField extends StatelessWidget {
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
borderSide: BorderSide(
color: AppColors.textSecondary,
width: 1,
),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),

View File

@@ -68,7 +68,6 @@ class DatePickerField extends StatelessWidget {
surface: Colors.white,
onSurface: Colors.black,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
@@ -98,7 +97,7 @@ class DatePickerField extends StatelessWidget {
fontSize: 16,
color: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.5),
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
@@ -106,8 +105,8 @@ class DatePickerField extends StatelessWidget {
Icons.calendar_today,
size: 20,
color: enabled
? theme.colorScheme.onSurface.withOpacity(0.6)
: theme.colorScheme.onSurface.withOpacity(0.3),
? theme.colorScheme.onSurface.withValues(alpha: 0.6)
: theme.colorScheme.onSurface.withValues(alpha: 0.3),
),
],
),
@@ -214,7 +213,6 @@ class _DateRangeItem extends StatelessWidget {
surface: Colors.white,
onSurface: Colors.black,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
@@ -239,7 +237,7 @@ class _DateRangeItem extends StatelessWidget {
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 4),
@@ -252,7 +250,7 @@ class _DateRangeItem extends StatelessWidget {
fontWeight: FontWeight.w500,
color: date != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.4),
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
),
),
],

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 앱 전체에서 사용되는 통합 스낵바
/// 성공, 에러, 정보 등 다양한 타입의 메시지를 표시합니다.
class AppSnackBar {
/// 성공 메시지를 표시합니다.
static void showSuccess({
required BuildContext context,
required String message,
IconData icon = Icons.check_circle_rounded,
Duration duration = const Duration(seconds: 3),
bool showAtTop = true,
}) {
_show(
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.successColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
duration: duration,
showAtTop: showAtTop,
);
}
/// 에러 메시지를 표시합니다.
static void showError({
required BuildContext context,
required String message,
IconData icon = Icons.error_rounded,
Duration duration = const Duration(seconds: 4),
bool showAtTop = true,
}) {
_show(
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.dangerColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
duration: duration,
showAtTop: showAtTop,
);
}
/// 정보 메시지를 표시합니다.
static void showInfo({
required BuildContext context,
required String message,
IconData icon = Icons.info_rounded,
Duration duration = const Duration(seconds: 3),
bool showAtTop = true,
}) {
_show(
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.primaryColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
duration: duration,
showAtTop: showAtTop,
);
}
/// 경고 메시지를 표시합니다.
static void showWarning({
required BuildContext context,
required String message,
IconData icon = Icons.warning_amber_rounded,
Duration duration = const Duration(seconds: 3),
bool showAtTop = true,
}) {
_show(
context: context,
message: message,
icon: icon,
backgroundColor: AppColors.warningColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
duration: duration,
showAtTop: showAtTop,
);
}
/// 커스텀 스낵바를 표시합니다.
static void showCustom({
required BuildContext context,
required String message,
required IconData icon,
required Color backgroundColor,
Color iconColor = AppColors.pureWhite,
Color textColor = AppColors.pureWhite,
Duration duration = const Duration(seconds: 3),
bool showAtTop = true,
SnackBarAction? action,
}) {
_show(
context: context,
message: message,
icon: icon,
backgroundColor: backgroundColor,
iconColor: iconColor,
textColor: textColor,
duration: duration,
showAtTop: showAtTop,
action: action,
);
}
/// 내부적으로 스낵바를 표시하는 메서드
static void _show({
required BuildContext context,
required String message,
required IconData icon,
required Color backgroundColor,
required Color iconColor,
required Color textColor,
required Duration duration,
required bool showAtTop,
SnackBarAction? action,
}) {
// 기존 스낵바 제거
ScaffoldMessenger.of(context).hideCurrentSnackBar();
// 새 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
// 아이콘
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: iconColor,
size: 20,
),
),
const SizedBox(width: 12),
// 메시지
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: textColor,
height: 1.3,
),
),
),
],
),
backgroundColor: backgroundColor,
behavior: SnackBarBehavior.floating,
margin: showAtTop
? EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16,
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120,
)
: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
duration: duration,
dismissDirection: DismissDirection.horizontal,
action: action,
),
);
}
/// 로딩 스낵바를 표시합니다. (자동으로 사라지지 않음)
static ScaffoldFeatureController<SnackBar, SnackBarClosedReason> showLoading({
required BuildContext context,
required String message,
bool showAtTop = true,
}) {
// 기존 스낵바 제거
ScaffoldMessenger.of(context).hideCurrentSnackBar();
return ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
// 로딩 인디케이터
Container(
width: 24,
height: 24,
margin: const EdgeInsets.only(right: 12),
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: AppColors.pureWhite,
),
),
// 메시지
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.pureWhite,
),
),
),
],
),
backgroundColor: AppColors.primaryColor,
behavior: SnackBarBehavior.floating,
margin: showAtTop
? EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16,
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120,
)
: const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 4,
duration: const Duration(days: 365), // 자동으로 사라지지 않음
dismissDirection: DismissDirection.none, // 스와이프로 닫을 수 없음
),
);
}
/// 액션 버튼이 있는 스낵바를 표시합니다.
static void showWithAction({
required BuildContext context,
required String message,
required String actionLabel,
required VoidCallback onActionPressed,
IconData icon = Icons.info_rounded,
Color backgroundColor = AppColors.primaryColor,
Duration duration = const Duration(seconds: 4),
bool showAtTop = true,
}) {
_show(
context: context,
message: message,
icon: icon,
backgroundColor: backgroundColor,
iconColor: AppColors.pureWhite,
textColor: AppColors.pureWhite,
duration: duration,
showAtTop: showAtTop,
action: SnackBarAction(
label: actionLabel,
textColor: AppColors.pureWhite,
onPressed: onActionPressed,
),
);
}
}

View File

@@ -240,7 +240,7 @@ class _CurrencyOption extends StatelessWidget {
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.withOpacity(0.1),
: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
@@ -291,7 +291,7 @@ class _BillingCycleSelector extends StatelessWidget {
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withOpacity(0.1),
color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
@@ -341,14 +341,14 @@ class _CategorySelector extends StatelessWidget {
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withOpacity(0.1),
color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
category.emoji,
category.icon,
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 6),

View File

@@ -0,0 +1,182 @@
import 'package:flutter/material.dart';
import 'dart:ui';
import '../../theme/app_colors.dart';
import '../common/buttons/primary_button.dart';
import '../common/buttons/secondary_button.dart';
/// 삭제 확인 다이얼로그
/// 글래스모피즘 스타일의 삭제 확인 다이얼로그입니다.
class DeleteConfirmationDialog extends StatelessWidget {
final String serviceName;
const DeleteConfirmationDialog({
super.key,
required this.serviceName,
});
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
child: Stack(
children: [
// 글래스모피즘 배경
ClipRRect(
borderRadius: BorderRadius.circular(24),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: BoxDecoration(
color: AppColors.glassCard.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
),
),
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 아이콘
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.delete_forever_rounded,
color: Colors.red,
size: 40,
),
),
const SizedBox(height: 24),
// 타이틀
const Text(
'구독 삭제',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 12),
// 설명
RichText(
textAlign: TextAlign.center,
text: TextSpan(
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
height: 1.5,
),
children: [
const TextSpan(text: '정말로 '),
TextSpan(
text: serviceName,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const TextSpan(text: ' 구독을\n삭제하시겠습니까?'),
],
),
),
const SizedBox(height: 8),
// 경고 메시지
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.red.withValues(alpha: 0.2),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.red.withValues(alpha: 0.8),
size: 20,
),
const SizedBox(width: 8),
const Text(
'이 작업은 되돌릴 수 없습니다',
style: TextStyle(
fontSize: 14,
color: Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 32),
// 버튼들
Row(
children: [
Expanded(
child: SecondaryButton(
text: '취소',
onPressed: () {
Navigator.of(context).pop(false);
},
),
),
const SizedBox(width: 12),
Expanded(
child: PrimaryButton(
text: '삭제',
icon: Icons.delete_rounded,
onPressed: () {
Navigator.of(context).pop(true);
},
backgroundColor: Colors.red,
),
),
],
),
],
),
),
),
),
],
),
),
);
}
/// 삭제 확인 다이얼로그를 표시합니다.
static Future<bool> show({
required BuildContext context,
required String serviceName,
}) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.5),
builder: (context) => DeleteConfirmationDialog(
serviceName: serviceName,
),
);
return result ?? false;
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'dart:math' as math;
import 'glassmorphism_card.dart';
import 'themed_text.dart';
import '../theme/app_colors.dart';
/// 구독이 없을 때 표시되는 빈 화면 위젯
///
@@ -49,14 +50,14 @@ class EmptyStateWidget extends StatelessWidget {
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF2563EB)],
colors: AppColors.blueGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF3B82F6).withValues(alpha: 0.3),
color: AppColors.primaryColor.withValues(alpha: 0.3),
spreadRadius: 0,
blurRadius: 16,
offset: const Offset(0, 8),
@@ -66,7 +67,7 @@ class EmptyStateWidget extends StatelessWidget {
child: const Icon(
Icons.subscriptions_outlined,
size: 48,
color: Colors.white,
color: AppColors.pureWhite,
),
),
);
@@ -100,7 +101,7 @@ class EmptyStateWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
elevation: 4,
backgroundColor: const Color(0xFF3B82F6),
backgroundColor: AppColors.primaryColor,
),
onPressed: () {
HapticFeedback.mediumImpact();
@@ -112,7 +113,7 @@ class EmptyStateWidget extends StatelessWidget {
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
color: Colors.white,
color: AppColors.pureWhite,
),
),
),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;
import '../theme/app_colors.dart';
import '../utils/haptic_feedback_helper.dart';
@@ -82,7 +81,7 @@ class _ExpandableFabState extends State<ExpandableFab>
animation: _expandAnimation,
builder: (context, child) {
return Container(
color: Colors.black.withValues(alpha: 0.3 * _expandAnimation.value),
color: AppColors.shadowBlack.withValues(alpha: 3.75 * _expandAnimation.value),
);
},
),
@@ -118,7 +117,7 @@ class _ExpandableFabState extends State<ExpandableFab>
child: Icon(
action.icon,
size: 20,
color: Colors.white,
color: AppColors.pureWhite,
),
),
),
@@ -176,6 +175,7 @@ class _ExpandableFabState extends State<ExpandableFab>
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
),
),
),

View File

@@ -72,42 +72,58 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
offset: Offset(0, 100 * (1 - _animation.value)),
child: Opacity(
opacity: _animation.value,
child: GlassmorphismCard(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
borderRadius: 24,
blur: 10.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_NavigationItem(
icon: Icons.home_rounded,
label: '',
isSelected: widget.selectedIndex == 0,
onTap: () => _onItemTapped(0),
child: Stack(
children: [
// 차단 레이어 - 크기 명시
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(24),
),
),
_NavigationItem(
icon: Icons.analytics_rounded,
label: '분석',
isSelected: widget.selectedIndex == 1,
onTap: () => _onItemTapped(1),
),
// 글래스모피즘 레이어
GlassmorphismCard(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
borderRadius: 24,
blur: 10.0,
backgroundColor: Colors.transparent,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_NavigationItem(
icon: Icons.home_rounded,
label: '',
isSelected: widget.selectedIndex == 0,
onTap: () => _onItemTapped(0),
),
_NavigationItem(
icon: Icons.analytics_rounded,
label: '분석',
isSelected: widget.selectedIndex == 1,
onTap: () => _onItemTapped(1),
),
_AddButton(
onTap: () => _onItemTapped(2),
),
_NavigationItem(
icon: Icons.qr_code_scanner_rounded,
label: 'SMS',
isSelected: widget.selectedIndex == 3,
onTap: () => _onItemTapped(3),
),
_NavigationItem(
icon: Icons.settings_rounded,
label: '설정',
isSelected: widget.selectedIndex == 4,
onTap: () => _onItemTapped(4),
),
],
),
_AddButton(
onTap: () => _onItemTapped(2),
),
_NavigationItem(
icon: Icons.qr_code_scanner_rounded,
label: 'SMS',
isSelected: widget.selectedIndex == 3,
onTap: () => _onItemTapped(3),
),
_NavigationItem(
icon: Icons.settings_rounded,
label: '설정',
isSelected: widget.selectedIndex == 4,
onTap: () => _onItemTapped(4),
),
],
),
),
],
),
),
),
@@ -137,8 +153,6 @@ class _NavigationItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
@@ -147,7 +161,7 @@ class _NavigationItem extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF14B8A6).withValues(alpha: 0.1)
? AppColors.primaryColor.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
@@ -158,9 +172,7 @@ class _NavigationItem extends StatelessWidget {
duration: const Duration(milliseconds: 200),
child: Icon(
icon,
color: isSelected
? const Color(0xFF14B8A6)
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
color: isSelected ? AppColors.primaryColor : AppColors.navyGray,
size: isSelected ? 26 : 24,
),
),
@@ -170,9 +182,7 @@ class _NavigationItem extends StatelessWidget {
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? const Color(0xFF14B8A6)
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
color: isSelected ? AppColors.primaryColor : AppColors.navyGray,
),
child: Text(label),
),
@@ -243,17 +253,17 @@ class _AddButtonState extends State<_AddButton>
colors: AppColors.blueGradient,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
boxShadow: const [
BoxShadow(
color: AppColors.primaryColor.withValues(alpha: 0.3),
color: AppColors.shadowBlack,
blurRadius: 12,
offset: const Offset(0, 4),
offset: Offset(0, 4),
),
],
),
child: const Icon(
Icons.add_rounded,
color: Colors.white,
color: AppColors.pureWhite,
size: 28,
),
),

View File

@@ -64,8 +64,8 @@ class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget
border: Border(
bottom: BorderSide(
color: isDarkMode
? AppColors.glassBorderDark.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.3),
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
),
),
@@ -268,8 +268,8 @@ class GlassmorphicSliverAppBar extends StatelessWidget {
border: Border(
bottom: BorderSide(
color: isDarkMode
? AppColors.glassBorderDark.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.3),
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.5),
width: 0.5,
),
),

View File

@@ -105,17 +105,8 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
return widget.backgroundGradient!;
}
// 시간대별 기본 그라디언트
final hour = DateTime.now().hour;
if (hour >= 6 && hour < 10) {
return AppColors.morningGradient;
} else if (hour >= 10 && hour < 17) {
return AppColors.dayGradient;
} else if (hour >= 17 && hour < 20) {
return AppColors.eveningGradient;
} else {
return AppColors.nightGradient;
}
// 디폴트 그라디언트
return AppColors.mainGradient;
}
@override
@@ -166,7 +157,11 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradientColors.map((color) => color.withValues(alpha: 0.1)).toList(),
colors: [
AppColors.backgroundColor,
...gradientColors.map((color) => color.withValues(alpha: 0.05)).toList(),
AppColors.backgroundColor,
],
),
),
),
@@ -201,7 +196,7 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
return CustomPaint(
painter: WavePainter(
animation: _waveController,
waveColor: AppColors.primaryColor.withValues(alpha: 0.1),
waveColor: AppColors.secondaryColor.withValues(alpha: 0.1),
),
);
},
@@ -244,7 +239,7 @@ class ParticlePainter extends CustomPainter {
final progress = animation.value;
final y = (particle.y + progress * particle.speed) % 1.0;
paint.color = Colors.white.withValues(alpha: particle.opacity);
paint.color = AppColors.pureWhite.withValues(alpha: particle.opacity);
canvas.drawCircle(
Offset(particle.x * size.width, y * size.height),
particle.size,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
import 'themed_text.dart';
class GlassmorphismCard extends StatelessWidget {
final Widget child;
@@ -54,9 +55,7 @@ class GlassmorphismCard extends StatelessWidget {
child: Container(
padding: padding,
decoration: BoxDecoration(
color: backgroundColor ?? (isDarkMode
? AppColors.glassCardDark
: AppColors.glassCard),
color: backgroundColor ?? AppColors.glassCard,
gradient: gradient ?? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -67,20 +66,22 @@ class GlassmorphismCard extends StatelessWidget {
borderRadius: BorderRadius.circular(borderRadius),
border: border ?? Border.all(
color: isDarkMode
? AppColors.glassBorderDark
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.glassBorder,
width: 1.5,
width: 1,
),
boxShadow: boxShadow ?? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
color: AppColors.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
blurRadius: 20,
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
),
child: child,
child: GlassmorphicIndicator(
child: child,
),
),
),
),

View File

@@ -39,17 +39,15 @@ class MainScreenSummaryCard extends StatelessWidget {
child: GlassmorphismCard(
borderRadius: 24,
blur: 15,
backgroundColor: AppColors.primaryColor.withValues(alpha: 0.2),
backgroundColor: AppColors.glassCard,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primaryColor.withValues(alpha: 0.3),
AppColors.primaryColor.withBlue(
(AppColors.primaryColor.blue * 1.3)
.clamp(0, 255)
.toInt()).withValues(alpha: 0.2),
],
colors: AppColors.mainGradient.map((color) => color.withValues(alpha: 0.2)).toList(),
),
border: Border.all(
color: AppColors.glassBorder,
width: 1,
),
child: Container(
width: double.infinity,
@@ -81,7 +79,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
'이번 달 총 구독 비용',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 15,
fontWeight: FontWeight.w500,
),
@@ -98,7 +96,7 @@ class MainScreenSummaryCard extends StatelessWidget {
decimalDigits: 0,
).format(monthlyCost),
style: const TextStyle(
color: Colors.white,
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 32,
fontWeight: FontWeight.bold,
letterSpacing: -1,
@@ -108,7 +106,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
'',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 16,
fontWeight: FontWeight.w500,
),
@@ -149,7 +147,7 @@ class MainScreenSummaryCard extends StatelessWidget {
),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
color: AppColors.primaryColor.withValues(alpha: 0.3),
width: 1,
),
),
@@ -165,7 +163,7 @@ class MainScreenSummaryCard extends StatelessWidget {
child: const Icon(
Icons.local_offer_rounded,
size: 14,
color: Colors.white,
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
),
),
const SizedBox(width: 10),
@@ -175,7 +173,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
'이벤트 할인 중',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 11,
fontWeight: FontWeight.w500,
),
@@ -190,7 +188,7 @@ class MainScreenSummaryCard extends StatelessWidget {
decimalDigits: 0,
).format(eventSavings),
style: const TextStyle(
color: Colors.white,
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
fontSize: 14,
fontWeight: FontWeight.bold,
),
@@ -198,7 +196,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
' 절약 ($activeEvents개)',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.85),
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -229,7 +227,7 @@ class MainScreenSummaryCard extends StatelessWidget {
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
color: AppColors.glassBackground,
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -238,7 +236,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
title,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.85),
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12,
fontWeight: FontWeight.w500,
),
@@ -247,7 +245,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text(
value,
style: const TextStyle(
color: Colors.white,
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 14,
fontWeight: FontWeight.bold,
),

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
class SpringAnimationWidget extends StatefulWidget {
@@ -212,7 +211,7 @@ class _GravityAnimationState extends State<GravityAnimation>
late AnimationController _controller;
double _position = 0;
double _velocity = 0;
double _floor = 300;
final double _floor = 300;
@override
void initState() {

View File

@@ -190,14 +190,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return false;
}
Color _getCardColor() {
return Colors.white;
}
@override
Widget build(BuildContext context) {
final isNearBilling = _isNearBilling();
final Color cardColor = _getCardColor();
return Hero(
tag: 'subscription_${widget.subscription.id}',
@@ -225,27 +221,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
padding: EdgeInsets.zero,
borderRadius: 16,
blur: _isHovering ? 15 : 10,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isHovering
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.borderColor,
width: _isHovering ? 1.5 : 0.5,
),
boxShadow: [
BoxShadow(
color: AppColors.primaryColor.withValues(alpha:
0.03 + (0.05 * _hoverController.value)),
blurRadius: 8 + (8 * _hoverController.value),
spreadRadius: 0,
offset: Offset(0, 4 + (2 * _hoverController.value)),
),
],
),
width: double.infinity, // 전체 너비를 차지하도록 설정
child: Column(
children: [
// 그라데이션 상단 바 효과
@@ -300,7 +276,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 18,
color: Color(0xFF1E293B),
color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -334,7 +310,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
Icon(
Icons.local_offer_rounded,
size: 11,
color: Colors.white,
color: AppColors.pureWhite,
),
SizedBox(width: 3),
Text(
@@ -342,7 +318,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
color: AppColors.pureWhite,
),
),
],
@@ -371,7 +347,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
),
),
),
@@ -409,7 +385,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
decoration: TextDecoration.lineThrough,
),
),
@@ -539,7 +515,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
),
),
],
@@ -555,7 +531,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
],
),
),
),
),
),
);

View File

@@ -6,6 +6,8 @@ import '../widgets/staggered_list_animation.dart';
import '../widgets/app_navigator.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import './dialogs/delete_confirmation_dialog.dart';
import './common/snackbar/app_snackbar.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
@@ -92,14 +94,30 @@ class SubscriptionListWidget extends StatelessWidget {
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onDelete: () async {
// 삭제 확인 다이얼로그
final provider = Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
// 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: subscriptions[subIndex].serviceName,
);
if (shouldDelete && context.mounted) {
// 사용자가 확인한 경우에만 삭제 진행
final provider = Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
if (context.mounted) {
AppSnackBar.showSuccess(
context: context,
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.',
icon: Icons.delete_forever_rounded,
);
}
}
},
),
),

View File

@@ -2,12 +2,11 @@ import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../utils/haptic_feedback_helper.dart';
import 'subscription_card.dart';
import '../theme/app_colors.dart';
class SwipeableSubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
final Future<void> Function()? onDelete;
final VoidCallback? onTap;
const SwipeableSubscriptionCard({
@@ -27,12 +26,15 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
late AnimationController _controller;
late Animation<double> _animation;
double _dragStartX = 0;
double _dragExtent = 0;
double _currentOffset = 0; // 현재 카드의 실제 위치
bool _isDragging = false; // 드래그 중인지 여부
bool _isSwipingLeft = false;
bool _hapticTriggered = false;
double _screenWidth = 0;
double _cardWidth = 0; // 카드의 실제 너비 (margin 제외)
static const double _swipeThreshold = 80.0;
static const double _deleteThreshold = 150.0;
static const double _actionThresholdPercent = 0.15; // 15%에서 액션 버튼 표시
static const double _deleteThresholdPercent = 0.40; // 40%에서 삭제/편집 실행
@override
void initState() {
@@ -48,81 +50,137 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
parent: _controller,
curve: Curves.easeOutExpo,
));
// 애니메이션 상태 리스너 추가
_controller.addStatusListener(_onAnimationStatusChanged);
// 애니메이션 리스너 추가
_controller.addListener(_onAnimationUpdate);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_screenWidth = MediaQuery.of(context).size.width;
_cardWidth = _screenWidth - 32; // 좌우 margin 16px씩 제외
}
@override
void didUpdateWidget(SwipeableSubscriptionCard oldWidget) {
super.didUpdateWidget(oldWidget);
// 위젯이 업데이트될 때 카드를 원위치로 복귀
if (oldWidget.subscription.id != widget.subscription.id) {
_controller.stop();
setState(() {
_currentOffset = 0;
_isDragging = false;
});
}
}
@override
void dispose() {
_controller.removeListener(_onAnimationUpdate);
_controller.removeStatusListener(_onAnimationStatusChanged);
_controller.stop();
_controller.dispose();
super.dispose();
}
void _onAnimationUpdate() {
if (!_isDragging) {
setState(() {
_currentOffset = _animation.value;
});
}
}
void _onAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.completed && !_isDragging) {
setState(() {
_currentOffset = _animation.value;
});
}
}
void _handleDragStart(DragStartDetails details) {
_dragStartX = details.localPosition.dx;
_hapticTriggered = false;
_isDragging = true;
_controller.stop(); // 진행 중인 애니메이션 중지
}
void _handleDragUpdate(DragUpdateDetails details) {
final delta = details.localPosition.dx - _dragStartX;
setState(() {
_dragExtent = delta;
_currentOffset = delta;
_isSwipingLeft = delta < 0;
});
// 햅틱 피드백 트리거
if (!_hapticTriggered && _dragExtent.abs() > _swipeThreshold) {
// 햅틱 피드백 트리거 (카드 너비의 15%)
final actionThreshold = _cardWidth * _actionThresholdPercent;
if (!_hapticTriggered && _currentOffset.abs() > actionThreshold) {
_hapticTriggered = true;
HapticFeedbackHelper.mediumImpact();
}
// 삭제 임계값에 도달했을 때 강한 햅틱
if (_dragExtent.abs() > _deleteThreshold && _hapticTriggered) {
// 삭제 임계값에 도달했을 때 강한 햅틱 (카드 너비의 40%)
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
if (_currentOffset.abs() > deleteThreshold && _hapticTriggered) {
HapticFeedbackHelper.heavyImpact();
_hapticTriggered = false; // 반복 방지
}
}
void _handleDragEnd(DragEndDetails details) {
void _handleDragEnd(DragEndDetails details) async {
_isDragging = false;
final velocity = details.velocity.pixelsPerSecond.dx;
final extent = _dragExtent.abs();
final extent = _currentOffset.abs();
if (extent > _deleteThreshold || velocity.abs() > 800) {
// 삭제 액션
// 카드 너비의 40% 계산
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
if (extent > deleteThreshold || velocity.abs() > 800) {
// 40% 이상 스와이프 시 삭제/편집 액션
if (_isSwipingLeft && widget.onDelete != null) {
HapticFeedbackHelper.success();
_animateToOffset(-MediaQuery.of(context).size.width);
Future.delayed(const Duration(milliseconds: 300), () {
widget.onDelete!();
});
// 삭제 확인 다이얼로그 표시
await widget.onDelete!();
// 다이얼로그가 닫힌 후 원위치로 복귀
if (mounted) {
_animateToOffset(0);
}
} else if (!_isSwipingLeft && widget.onEdit != null) {
HapticFeedbackHelper.success();
_animateToOffset(MediaQuery.of(context).size.width);
// 편집 화면으로 이동 전 원위치로 복귀
_animateToOffset(0);
Future.delayed(const Duration(milliseconds: 300), () {
widget.onEdit!();
});
} else {
// 액션이 없는 경우 원위치로 복귀
_animateToOffset(0);
}
} else if (extent > _swipeThreshold) {
// 액션 버튼 표시
HapticFeedbackHelper.lightImpact();
_animateToOffset(_isSwipingLeft ? -_swipeThreshold : _swipeThreshold);
} else {
// 원위치로 복귀
// 40% 미만: 모두 원위치로 복귀
_animateToOffset(0);
}
}
void _animateToOffset(double offset) {
// 애니메이션 컨트롤러 리셋
_controller.stop();
_controller.value = 0;
_animation = Tween<double>(
begin: _dragExtent,
begin: _currentOffset,
end: offset,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutExpo,
));
_controller.forward(from: 0).then((_) {
setState(() {
_dragExtent = offset;
});
});
_controller.forward();
}
@override
@@ -135,9 +193,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _isSwipingLeft
? AppColors.dangerColor
: AppColors.primaryColor,
color: Colors.transparent, // 투명하게 변경
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -148,10 +204,10 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
padding: const EdgeInsets.only(left: 24),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _dragExtent > 40 ? 1.0 : 0.0,
opacity: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.0,
child: AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: _dragExtent > 40 ? 1.0 : 0.5,
scale: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.5,
child: const Icon(
Icons.edit_rounded,
color: Colors.white,
@@ -166,12 +222,12 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
padding: const EdgeInsets.only(right: 24),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _dragExtent.abs() > 40 ? 1.0 : 0.0,
opacity: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.0,
child: AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: _dragExtent.abs() > 40 ? 1.0 : 0.5,
scale: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.5,
child: Icon(
_dragExtent.abs() > _deleteThreshold
_currentOffset.abs() > (_cardWidth * _deleteThresholdPercent)
? Icons.delete_forever_rounded
: Icons.delete_rounded,
color: Colors.white,
@@ -186,33 +242,24 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
),
// 스와이프 가능한 카드
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_animation.value, 0),
child: child,
);
},
child: GestureDetector(
onHorizontalDragStart: _handleDragStart,
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
child: Transform.translate(
offset: Offset(_dragExtent, 0),
child: Transform.scale(
scale: 1.0 - (_dragExtent.abs() / 2000),
child: Transform.rotate(
angle: _dragExtent / 2000,
child: GestureDetector(
onTap: () {
if (_dragExtent.abs() < 10) {
widget.onTap?.call();
}
},
child: SubscriptionCard(
subscription: widget.subscription,
),
GestureDetector(
onHorizontalDragStart: _handleDragStart,
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
child: Transform.translate(
offset: Offset(_currentOffset, 0),
child: Transform.scale(
scale: 1.0 - (_currentOffset.abs() / 2000),
child: Transform.rotate(
angle: _currentOffset / 2000,
child: GestureDetector(
onTap: () {
if (_currentOffset.abs() < 10) {
widget.onTap?.call();
}
},
child: SubscriptionCard(
subscription: widget.subscription,
),
),
),

View File

@@ -39,22 +39,20 @@ class ThemedText extends StatelessWidget {
bool forceLight = false,
bool forceDark = false,
}) {
if (forceLight) return Colors.white;
if (forceDark) return AppColors.textPrimary;
if (forceLight) return AppColors.pureWhite;
if (forceDark) return AppColors.darkNavy;
final brightness = Theme.of(context).brightness;
// 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트
// 글래스모피즘 환경에서는 배경이 밝으므로 어두운 텍스트 사용
if (_isGlassmorphicContext(context)) {
return brightness == Brightness.dark
? Colors.white.withValues(alpha: 0.95)
: AppColors.textPrimary;
return AppColors.darkNavy; // color.md 가이드: 밝은 배경 위 어두운 텍스트
}
// 일반 환경
return brightness == Brightness.dark
? Colors.white
: AppColors.textPrimary;
? AppColors.pureWhite
: AppColors.darkNavy;
}
/// 글래스모피즘 컨텍스트인지 확인

View File

@@ -660,7 +660,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
}
return ClipRRect(
key: ValueKey('local_logo_${_localLogoPath}'),
key: ValueKey('local_logo_$_localLogoPath'),
borderRadius: BorderRadius.circular(widget.size * 0.2),
child: Image.file(
File(_localLogoPath!),