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:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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))
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
272
lib/widgets/common/snackbar/app_snackbar.dart
Normal file
272
lib/widgets/common/snackbar/app_snackbar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
182
lib/widgets/dialogs/delete_confirmation_dialog.dart
Normal file
182
lib/widgets/dialogs/delete_confirmation_dialog.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// 글래스모피즘 컨텍스트인지 확인
|
||||
|
||||
@@ -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!),
|
||||
|
||||
Reference in New Issue
Block a user