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

14
CLAUDE.md Normal file
View File

@@ -0,0 +1,14 @@
# Claude 프로젝트 컨텍스트
## 언어 설정
- 모든 답변은 한국어로 제공
- 기술 용어는 영어와 한국어 병기 가능
## 프로젝트 정보
- Flutter 기반 구독 관리 앱 (SubManager)
- 글래스모피즘 디자인 시스템 적용 중
- @doc/color.md의 색상 가이드를 전체 UI에 통일성 있게 적용하는 작업 진행 중
## 현재 작업
- 전체 10개 화면과 50개 이상의 위젯에 통일된 글래스모피즘 스타일 적용
- 색상 시스템 업데이트 및 일관성 있는 UI 구현

View File

@@ -6,6 +6,7 @@ import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/sms_service.dart'; import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller /// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController { class AddSubscriptionController {
@@ -232,21 +233,9 @@ class AddSubscriptionController {
final granted = await SMSService.requestSMSPermission(); final granted = await SMSService.requestSMSPermission();
if (!granted) { if (!granted) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
SnackBar( context: context,
content: const Row( message: 'SMS 권한이 필요합니다.',
children: [
Icon(Icons.error_outline, color: Colors.white),
SizedBox(width: 12),
Expanded(child: Text('SMS 권한이 필요합니다.')),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
); );
} }
return; return;
@@ -256,21 +245,9 @@ class AddSubscriptionController {
final subscriptions = await SMSService.scanSubscriptions(); final subscriptions = await SMSService.scanSubscriptions();
if (subscriptions.isEmpty) { if (subscriptions.isEmpty) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showWarning(
SnackBar( context: context,
content: const Row( message: '구독 관련 SMS를 찾을 수 없습니다.',
children: [
Icon(Icons.info_outline, color: Colors.white),
SizedBox(width: 12),
Expanded(child: Text('구독 관련 SMS를 찾을 수 없습니다.')),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
); );
} }
return; return;
@@ -331,21 +308,9 @@ class AddSubscriptionController {
}); });
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
SnackBar( context: context,
content: Row( message: 'SMS 스캔 중 오류 발생: $e',
children: [
const Icon(Icons.error_outline, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text('SMS 스캔 중 오류 발생: $e')),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
); );
} }
} finally { } finally {
@@ -399,11 +364,9 @@ class AddSubscriptionController {
}); });
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
SnackBar( context: context,
content: Text('저장 중 오류가 발생했습니다: $e'), message: '저장 중 오류가 발생했습니다: $e',
backgroundColor: Colors.red,
),
); );
} }
} }

View File

@@ -7,6 +7,8 @@ import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController { class DetailScreenController {
@@ -313,20 +315,9 @@ class DetailScreenController {
await provider.updateSubscription(subscription); await provider.updateSubscription(subscription);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showSuccess(
SnackBar( context: context,
content: const Row( message: '구독 정보가 업데이트되었습니다.',
children: [
Icon(Icons.check_circle_rounded, color: Colors.white),
SizedBox(width: 12),
Text('구독 정보가 업데이트되었습니다.'),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: const Color(0xFF10B981),
duration: const Duration(seconds: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
); );
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환 // 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
@@ -339,30 +330,31 @@ class DetailScreenController {
/// 구독 삭제 /// 구독 삭제
Future<void> deleteSubscription() async { Future<void> deleteSubscription() async {
if (context.mounted) {
// 삭제 확인 다이얼로그 표시
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: subscription.serviceName,
);
if (!shouldDelete) return;
// 사용자가 확인한 경우에만 삭제 진행
if (context.mounted) { if (context.mounted) {
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider = Provider.of<SubscriptionProvider>(context, listen: false);
await provider.deleteSubscription(subscription.id); await provider.deleteSubscription(subscription.id);
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showSuccess(
SnackBar( context: context,
content: const Row( message: '구독이 삭제되었습니다.',
children: [ icon: Icons.delete_forever_rounded,
Icon(Icons.delete_forever_rounded, color: Colors.white),
SizedBox(width: 12),
Text('구독이 삭제되었습니다.'),
],
),
behavior: SnackBarBehavior.floating,
backgroundColor: const Color(0xFFDC2626),
duration: const Duration(seconds: 2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
); );
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
} }
} }
}
/// 해지 페이지 열기 /// 해지 페이지 열기
Future<void> openCancellationPage() async { Future<void> openCancellationPage() async {
@@ -371,21 +363,17 @@ class DetailScreenController {
final Uri url = Uri.parse(subscription.websiteUrl!); final Uri url = Uri.parse(subscription.websiteUrl!);
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
const SnackBar( context: context,
content: Text('웹사이트를 열 수 없습니다.'), message: '웹사이트를 열 수 없습니다.',
backgroundColor: Colors.red,
),
); );
} }
} }
} else { } else {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showWarning(
const SnackBar( context: context,
content: Text('웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.'), message: '웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.',
backgroundColor: Colors.orange,
),
); );
} }
} }

View File

@@ -5,6 +5,7 @@ import '../widgets/add_subscription/add_subscription_header.dart';
import '../widgets/add_subscription/add_subscription_form.dart'; import '../widgets/add_subscription/add_subscription_form.dart';
import '../widgets/add_subscription/add_subscription_event_section.dart'; import '../widgets/add_subscription/add_subscription_event_section.dart';
import '../widgets/add_subscription/add_subscription_save_button.dart'; import '../widgets/add_subscription/add_subscription_save_button.dart';
import '../theme/app_colors.dart';
/// 새로운 구독을 추가하는 화면 /// 새로운 구독을 추가하는 화면
class AddSubscriptionScreen extends StatefulWidget { class AddSubscriptionScreen extends StatefulWidget {
@@ -44,7 +45,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
_controller.scrollController.addListener(_onScroll); _controller.scrollController.addListener(_onScroll);
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF8FAFC), backgroundColor: AppColors.backgroundColor,
extendBodyBehindAppBar: true, extendBodyBehindAppBar: true,
appBar: AddSubscriptionAppBar( appBar: AddSubscriptionAppBar(
controller: _controller, controller: _controller,

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart'; import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart';
class AppLockScreen extends StatelessWidget { class AppLockScreen extends StatelessWidget {
const AppLockScreen({super.key}); const AppLockScreen({super.key});
@@ -12,25 +13,26 @@ class AppLockScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( Icon(
Icons.lock_outline, Icons.lock_outline,
size: 80, size: 80,
color: Colors.grey, color: AppColors.navyGray,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text( Text(
'앱이 잠겨 있습니다', '앱이 잠겨 있습니다',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.darkNavy,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'생체 인증으로 잠금을 해제하세요', '생체 인증으로 잠금을 해제하세요',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.grey, color: AppColors.navyGray,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -40,8 +42,14 @@ class AppLockScreen extends StatelessWidget {
final success = await appLock.authenticate(); final success = await appLock.authenticate();
if (!success && context.mounted) { if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('인증에 실패했습니다. 다시 시도해주세요.'), content: Text(
'인증에 실패했습니다. 다시 시도해주세요.',
style: TextStyle(
color: AppColors.pureWhite,
),
),
backgroundColor: AppColors.dangerColor,
), ),
); );
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../theme/app_colors.dart';
class CategoryManagementScreen extends StatefulWidget { class CategoryManagementScreen extends StatefulWidget {
const CategoryManagementScreen({super.key}); const CategoryManagementScreen({super.key});
@@ -41,8 +42,13 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('카테고리 관리'), title: Text(
backgroundColor: const Color(0xFF1976D2), '카테고리 관리',
style: TextStyle(
color: AppColors.pureWhite,
),
),
backgroundColor: AppColors.primaryColor,
), ),
body: Consumer<CategoryProvider>( body: Consumer<CategoryProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
@@ -59,8 +65,11 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
children: [ children: [
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '카테고리 이름', labelText: '카테고리 이름',
labelStyle: TextStyle(
color: AppColors.navyGray,
),
), ),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@@ -72,20 +81,23 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedColor, value: _selectedColor,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '색상 선택', labelText: '색상 선택',
labelStyle: TextStyle(
color: AppColors.navyGray,
), ),
items: const [ ),
items: [
DropdownMenuItem( DropdownMenuItem(
value: '#1976D2', child: Text('파란색')), value: '#1976D2', child: Text('파란색', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#4CAF50', child: Text('초록색')), value: '#4CAF50', child: Text('초록색', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#FF9800', child: Text('주황색')), value: '#FF9800', child: Text('주황색', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#F44336', child: Text('빨간색')), value: '#F44336', child: Text('빨간색', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: '#9C27B0', child: Text('보라색')), value: '#9C27B0', child: Text('보라색', style: TextStyle(color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -96,19 +108,22 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _selectedIcon, value: _selectedIcon,
decoration: const InputDecoration( decoration: InputDecoration(
labelText: '아이콘 선택', labelText: '아이콘 선택',
labelStyle: TextStyle(
color: AppColors.navyGray,
), ),
items: const [ ),
items: [
DropdownMenuItem( DropdownMenuItem(
value: 'subscriptions', child: Text('구독')), value: 'subscriptions', child: Text('구독', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem(value: 'movie', child: Text('영화')), DropdownMenuItem(value: 'movie', child: Text('영화', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'music_note', child: Text('음악')), value: 'music_note', child: Text('음악', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'fitness_center', child: Text('운동')), value: 'fitness_center', child: Text('운동', style: TextStyle(color: AppColors.darkNavy))),
DropdownMenuItem( DropdownMenuItem(
value: 'shopping_cart', child: Text('쇼핑')), value: 'shopping_cart', child: Text('쇼핑', style: TextStyle(color: AppColors.darkNavy))),
], ],
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -119,7 +134,12 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _addCategory, onPressed: _addCategory,
child: const Text('카테고리 추가'), child: Text(
'카테고리 추가',
style: TextStyle(
color: AppColors.pureWhite,
),
),
), ),
], ],
), ),
@@ -141,7 +161,12 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
color: Color( color: Color(
int.parse(category.color.replaceAll('#', '0xFF'))), int.parse(category.color.replaceAll('#', '0xFF'))),
), ),
title: Text(category.name), title: Text(
category.name,
style: TextStyle(
color: AppColors.darkNavy,
),
),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.delete), icon: const Icon(Icons.delete),
onPressed: () async { onPressed: () async {

View File

@@ -6,6 +6,7 @@ import '../widgets/detail/detail_form_section.dart';
import '../widgets/detail/detail_event_section.dart'; import '../widgets/detail/detail_event_section.dart';
import '../widgets/detail/detail_url_section.dart'; import '../widgets/detail/detail_url_section.dart';
import '../widgets/detail/detail_action_buttons.dart'; import '../widgets/detail/detail_action_buttons.dart';
import '../theme/app_colors.dart';
/// 구독 상세 정보를 표시하고 편집할 수 있는 화면 /// 구독 상세 정보를 표시하고 편집할 수 있는 화면
class DetailScreen extends StatefulWidget { class DetailScreen extends StatefulWidget {
@@ -46,7 +47,7 @@ class _DetailScreenState extends State<DetailScreen>
final baseColor = _controller.getCardColor(); final baseColor = _controller.getCardColor();
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF5F5F7), backgroundColor: AppColors.backgroundColor,
body: CustomScrollView( body: CustomScrollView(
controller: _controller.scrollController, controller: _controller.scrollController,
slivers: [ slivers: [

View File

@@ -166,7 +166,7 @@ class _MainScreenState extends State<MainScreen>
children: [ children: [
Icon( Icon(
Icons.check_circle, Icons.check_circle,
color: Colors.white, color: AppColors.pureWhite,
size: 20, size: 20,
), ),
SizedBox(width: 12), SizedBox(width: 12),
@@ -175,11 +175,12 @@ class _MainScreenState extends State<MainScreen>
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.pureWhite,
), ),
), ),
], ],
), ),
backgroundColor: const Color(0xFF10B981), // 초록색 backgroundColor: AppColors.successColor,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only( margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, // 상단 여백 top: MediaQuery.of(context).padding.top + 16, // 상단 여백
@@ -219,19 +220,9 @@ class _MainScreenState extends State<MainScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final navigationProvider = context.watch<NavigationProvider>(); final navigationProvider = context.watch<NavigationProvider>();
final hour = DateTime.now().hour;
List<Color> backgroundGradient;
// 시간대별 배경 그라디언트 설정 // 메인 그라데이션 사용
if (hour >= 6 && hour < 10) { List<Color> backgroundGradient = AppColors.mainGradient;
backgroundGradient = AppColors.morningGradient;
} else if (hour >= 10 && hour < 17) {
backgroundGradient = AppColors.dayGradient;
} else if (hour >= 17 && hour < 20) {
backgroundGradient = AppColors.eveningGradient;
} else {
backgroundGradient = AppColors.nightGradient;
}
// 현재 인덱스가 유효한지 확인 // 현재 인덱스가 유효한지 확인
int currentIndex = navigationProvider.currentIndex; int currentIndex = navigationProvider.currentIndex;

View File

@@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../providers/theme_provider.dart'; import '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart'; import '../theme/adaptive_theme.dart';
import '../widgets/glassmorphism_card.dart'; import '../widgets/glassmorphism_card.dart';
import '../theme/app_colors.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -24,13 +25,13 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2) ? AppColors.primaryColor.withValues(alpha: 0.2)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: isSelected color: isSelected
? Theme.of(context).colorScheme.primary ? AppColors.primaryColor
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), : AppColors.textSecondary.withValues(alpha: 0.5),
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
), ),
), ),
@@ -43,8 +44,8 @@ class SettingsScreen extends StatelessWidget {
? Icons.radio_button_checked ? Icons.radio_button_checked
: Icons.radio_button_unchecked, : Icons.radio_button_unchecked,
color: isSelected color: isSelected
? Theme.of(context).colorScheme.primary ? AppColors.primaryColor
: Theme.of(context).colorScheme.outline, : AppColors.textSecondary,
size: 24, size: 24,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -54,8 +55,8 @@ class SettingsScreen extends StatelessWidget {
fontSize: 14, fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected color: isSelected
? Theme.of(context).colorScheme.primary ? AppColors.primaryColor
: Theme.of(context).colorScheme.onSurface, : AppColors.textPrimary,
), ),
), ),
], ],
@@ -187,8 +188,14 @@ class SettingsScreen extends StatelessWidget {
provider.setEnabled(true); provider.setEnabled(true);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( SnackBar(
content: Text('알림 권한이 거부되었습니다'), content: Text(
'알림 권한이 거부되었습니다',
style: TextStyle(
color: AppColors.pureWhite,
),
),
backgroundColor: AppColors.dangerColor,
), ),
); );
} }
@@ -449,7 +456,15 @@ class SettingsScreen extends StatelessWidget {
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('스토어를 열 수 없습니다')), SnackBar(
content: Text(
'스토어를 열 수 없습니다',
style: TextStyle(
color: AppColors.pureWhite,
),
),
backgroundColor: AppColors.dangerColor,
),
); );
} }
} }

View File

@@ -9,6 +9,11 @@ import '../services/subscription_url_matcher.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가 import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
import '../widgets/glassmorphism_card.dart'; import '../widgets/glassmorphism_card.dart';
import '../widgets/themed_text.dart'; import '../widgets/themed_text.dart';
import '../theme/app_colors.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../widgets/common/buttons/primary_button.dart';
import '../widgets/common/buttons/secondary_button.dart';
import '../widgets/common/form_fields/base_text_field.dart';
class SmsScanScreen extends StatefulWidget { class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key}); const SmsScanScreen({super.key});
@@ -352,41 +357,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 성공 메시지 표시 // 성공 메시지 표시
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showSuccess(
SnackBar( context: context,
content: Row( message: '${subscription.serviceName} 구독이 추가되었습니다.',
children: [
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${subscription.serviceName} 구독이 추가되었습니다.',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: const Color(0xFF10B981), // 초록색
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
dismissDirection: DismissDirection.horizontal,
),
); );
} }
@@ -395,12 +368,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
} catch (e) { } catch (e) {
print('구독 추가 중 오류 발생: $e'); print('구독 추가 중 오류 발생: $e');
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
SnackBar( context: context,
content: Text('구독 추가 중 오류가 발생했습니다: $e'), message: '구독 추가 중 오류가 발생했습니다: $e',
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
); );
// 오류가 있어도 다음 구독으로 이동 // 오류가 있어도 다음 구독으로 이동
@@ -411,6 +381,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 현재 구독 건너뛰기 // 현재 구독 건너뛰기
void _skipCurrentSubscription() { void _skipCurrentSubscription() {
final subscription = _scannedSubscriptions[_currentIndex];
if (mounted) {
AppSnackBar.showInfo(
context: context,
message: '${subscription.serviceName} 구독을 건너뛰었습니다.',
icon: Icons.skip_next_rounded,
);
}
_moveToNextSubscription(); _moveToNextSubscription();
} }
@@ -434,12 +414,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
navigationProvider.updateCurrentIndex(0); navigationProvider.updateCurrentIndex(0);
// 완료 메시지 표시 // 완료 메시지 표시
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showSuccess(
const SnackBar( context: context,
content: Text('모든 구독이 처리되었습니다.'), message: '모든 구독이 처리되었습니다.',
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
); );
} }
@@ -530,15 +507,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 로딩 상태 UI // 로딩 상태 UI
Widget _buildLoadingState() { Widget _buildLoadingState() {
return const Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
CircularProgressIndicator(), CircularProgressIndicator(
SizedBox(height: 16), valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
ThemedText('SMS 메시지를 스캔 중입니다...'), ),
SizedBox(height: 8), const SizedBox(height: 16),
ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7), const ThemedText('SMS 메시지를 스캔 중입니다...', forceDark: true),
const SizedBox(height: 8),
const ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7, forceDark: true),
], ],
), ),
); );
@@ -564,6 +543,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'2회 이상 결제된 구독 서비스 찾기', '2회 이상 결제된 구독 서비스 찾기',
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Padding( const Padding(
@@ -572,16 +552,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.', '문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
ElevatedButton.icon( PrimaryButton(
text: '스캔 시작하기',
icon: Icons.search_rounded,
onPressed: _scanSms, onPressed: _scanSms,
icon: const Icon(Icons.search), width: 200,
label: const Text('스캔 시작하기'), height: 56,
style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryColor,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
), ),
], ],
), ),
@@ -591,9 +572,10 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 구독 표시 상태 UI // 구독 표시 상태 UI
Widget _buildSubscriptionState() { Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) { if (_currentIndex >= _scannedSubscriptions.length) {
return const Center( // 처리 완료 후 초기 상태로 복귀
child: ThemedText('모든 구독 처리 완료'), _scannedSubscriptions = [];
); _currentIndex = 0;
return _buildInitialState(); // 스캔 버튼이 있는 초기 화면으로 돌아감
} }
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
@@ -609,7 +591,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 진행 상태 표시 // 진행 상태 표시
LinearProgressIndicator( LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length, value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: Colors.grey.withValues(alpha: 0.2), backgroundColor: AppColors.navyGray.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary), Theme.of(context).colorScheme.primary),
), ),
@@ -618,6 +600,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'${_currentIndex + 1}/${_scannedSubscriptions.length}', '${_currentIndex + 1}/${_scannedSubscriptions.length}',
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -632,6 +615,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'다음 구독을 찾았습니다', '다음 구독을 찾았습니다',
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true,
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// 서비스명 // 서비스명
@@ -639,12 +623,14 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'서비스명', '서비스명',
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
subscription.serviceName, subscription.serviceName,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -659,6 +645,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'월 비용', '월 비용',
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
@@ -675,6 +662,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
).format(subscription.monthlyCost), ).format(subscription.monthlyCost),
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
forceDark: true,
), ),
], ],
), ),
@@ -687,6 +675,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'반복 횟수', '반복 횟수',
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
@@ -713,12 +702,14 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'결제 주기', '결제 주기',
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
subscription.billingCycle, subscription.billingCycle,
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
forceDark: true,
), ),
], ],
), ),
@@ -731,12 +722,14 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'결제일', '결제일',
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
opacity: 0.7, opacity: 0.7,
forceDark: true,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
ThemedText( ThemedText(
_getNextBillingText(subscription.nextBillingDate), _getNextBillingText(subscription.nextBillingDate),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
forceDark: true,
), ),
], ],
), ),
@@ -746,17 +739,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24), const SizedBox(height: 24),
// 웹사이트 URL 입력 필드 추가/수정 // 웹사이트 URL 입력 필드 추가/수정
Padding( BaseTextField(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _websiteUrlController, controller: _websiteUrlController,
decoration: const InputDecoration( label: '웹사이트 URL (자동 추출됨)',
labelText: '웹사이트 URL (자동 추출됨)',
hintText: '웹사이트 URL을 수정하거나 비워두세요', hintText: '웹사이트 URL을 수정하거나 비워두세요',
prefixIcon: Icon(Icons.language), prefixIcon: Icon(
border: OutlineInputBorder(), Icons.language,
color: AppColors.navyGray,
), ),
style: TextStyle(
color: AppColors.darkNavy,
), ),
fillColor: AppColors.pureWhite.withValues(alpha: 0.8),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -764,22 +758,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
Row( Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton( child: SecondaryButton(
text: '건너뛰기',
onPressed: _skipCurrentSubscription, onPressed: _skipCurrentSubscription,
style: OutlinedButton.styleFrom( height: 48,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('건너뛰기'),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: ElevatedButton( child: PrimaryButton(
text: '추가하기',
onPressed: _addCurrentSubscription, onPressed: _addCurrentSubscription,
style: ElevatedButton.styleFrom( height: 48,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('추가하기'),
), ),
), ),
], ],

View File

@@ -135,7 +135,7 @@ class _SplashScreenState extends State<SplashScreen>
// 글래스모피즘 오버레이 // 글래스모피즘 오버레이
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05), color: AppColors.pureWhite.withValues(alpha: 0.05),
), ),
), ),
Stack( Stack(
@@ -188,8 +188,8 @@ class _SplashScreenState extends State<SplashScreen>
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: RadialGradient( gradient: RadialGradient(
colors: [ colors: [
Colors.white.withValues(alpha: 0.1), AppColors.pureWhite.withValues(alpha: 0.1),
Colors.white.withValues(alpha: 0.0), AppColors.pureWhite.withValues(alpha: 0.0),
], ],
stops: const [0.2, 1.0], stops: const [0.2, 1.0],
), ),
@@ -208,8 +208,8 @@ class _SplashScreenState extends State<SplashScreen>
shape: BoxShape.circle, shape: BoxShape.circle,
gradient: RadialGradient( gradient: RadialGradient(
colors: [ colors: [
Colors.white.withValues(alpha: 0.07), AppColors.pureWhite.withValues(alpha: 0.07),
Colors.white.withValues(alpha: 0.0), AppColors.pureWhite.withValues(alpha: 0.0),
], ],
stops: const [0.4, 1.0], stops: const [0.4, 1.0],
), ),
@@ -250,23 +250,22 @@ class _SplashScreenState extends State<SplashScreen>
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
Colors.white AppColors.pureWhite
.withValues(alpha: 0.2), .withValues(alpha: 0.2),
Colors.white AppColors.pureWhite
.withValues(alpha: 0.1), .withValues(alpha: 0.1),
], ],
), ),
borderRadius: borderRadius:
BorderRadius.circular(30), BorderRadius.circular(30),
border: Border.all( border: Border.all(
color: Colors.white color: AppColors.pureWhite
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
width: 1.5, width: 1.5,
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black color: AppColors.shadowBlack,
.withValues(alpha: 0.1),
spreadRadius: 0, spreadRadius: 0,
blurRadius: 30, blurRadius: 30,
offset: const Offset(0, 10), offset: const Offset(0, 10),
@@ -323,12 +322,12 @@ class _SplashScreenState extends State<SplashScreen>
), ),
); );
}, },
child: const Text( child: Text(
'SubManager', 'SubManager',
style: TextStyle( style: TextStyle(
fontSize: 36, fontSize: 36,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: AppColors.pureWhite,
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
), ),
@@ -349,11 +348,11 @@ class _SplashScreenState extends State<SplashScreen>
), ),
); );
}, },
child: const Text( child: Text(
'구독 서비스 관리를 더 쉽게', '구독 서비스 관리를 더 쉽게',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: Colors.white70, color: AppColors.pureWhite.withValues(alpha: 0.7),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
@@ -374,17 +373,17 @@ class _SplashScreenState extends State<SplashScreen>
height: 60, height: 60,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1), color: AppColors.pureWhite.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
border: Border.all( border: Border.all(
color: color:
Colors.white.withValues(alpha: 0.2), AppColors.pureWhite.withValues(alpha: 0.2),
width: 1, width: 1,
), ),
), ),
child: const CircularProgressIndicator( child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
Colors.white), AppColors.pureWhite),
strokeWidth: 3, strokeWidth: 3,
), ),
), ),
@@ -401,11 +400,11 @@ class _SplashScreenState extends State<SplashScreen>
padding: const EdgeInsets.only(bottom: 24.0), padding: const EdgeInsets.only(bottom: 24.0),
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: const Text( child: Text(
'© 2023 CClabs. All rights reserved.', '© 2023 CClabs. All rights reserved.',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.white60, color: AppColors.pureWhite.withValues(alpha: 0.6),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),

View File

@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
class AppColors { class AppColors {
// 메인 컬러 (Metronic Tailwind 스타일) // 메인 컬러 (Metronic Tailwind 스타일)
static const primaryColor = Color(0xFF3B82F6); // 메트로닉 블루 static const primaryColor = Color(0xFF2563EB); // 블루
static const secondaryColor = Color(0xFF64748B); // 슬레이트 600 static const secondaryColor = Color(0xFF60A5FA); // 스카이 블루
static const successColor = Color(0xFF10B981); // 그린 static const successColor = Color(0xFF38BDF8); // 소프트 민트
static const infoColor = Color(0xFF6366F1); // 인디고 static const infoColor = Color(0xFF6366F1); // 인디고
static const warningColor = Color(0xFFF59E0B); // 앰버 static const warningColor = Color(0xFFF59E0B); // 앰버
static const dangerColor = Color(0xFFEF4444); // 레드 static const dangerColor = Color(0xFFF472B6); // 핑크 액센트
// 배경색 // 배경색
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100 static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
@@ -17,18 +17,24 @@ class AppColors {
// 텍스트 컬러 // 텍스트 컬러
static const textPrimary = Color(0xFF1E293B); // 슬레이트 800 static const textPrimary = Color(0xFF1E293B); // 슬레이트 800
static const textSecondary = Color(0xFF64748B); // 슬레이트 600 static const darkNavy = Color(0xFF1E293B); // 메인 텍스트 (color.md 가이드)
static const textMuted = Color(0xFF94A3B8); // 슬레이트 400 static const textSecondary = Color(0xFF334155); // 네이비 그레이
static const navyGray = Color(0xFF334155); // 서브 텍스트 (color.md 가이드)
static const textMuted = Color(0xFF334155); // 네이비 그레이
static const textLight = Color(0xFFFFFFFF); // 화이트 static const textLight = Color(0xFFFFFFFF); // 화이트
static const pureWhite = Color(0xFFFFFFFF); // 버튼 텍스트용 (color.md 가이드)
// 보더 & 디바이더 // 보더 & 디바이더
static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200 static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200
static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200 static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200
// 그림자 (color.md 가이드)
static const shadowBlack = Color(0x14000000); // rgba(0,0,0,0.08) - 8% opacity
// 그라데이션 컬러 - 다양한 효과를 위한 조합 // 그라데이션 컬러 - 다양한 효과를 위한 조합
static const List<Color> blueGradient = [ static const List<Color> blueGradient = [
Color(0xFF3B82F6), Color(0xFF2563EB), // 딥 블루
Color(0xFF2563EB) Color(0xFF60A5FA) // 스카이 블루
]; ];
static const List<Color> tealGradient = [ static const List<Color> tealGradient = [
Color(0xFF14B8A6), Color(0xFF14B8A6),
@@ -48,10 +54,10 @@ class AppColors {
]; ];
// Glassmorphism 효과를 위한 색상 // Glassmorphism 효과를 위한 색상
static const glassSurface = Color(0x0FFFFFFF); // 매우 연한 흰색 (6% opacity) static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
static const glassBackground = Color(0x1AFFFFFF); // 연한 흰색 (10% opacity) static const glassBackground = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity) static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity)
static const glassBorder = Color(0x4DFFFFFF); // 반투명 테두리 (30% opacity) static const glassBorder = Color(0xFF2563EB); // 딥 블루 테두리
static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity) static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity)
// 다크 모드용 Glassmorphism 색상 // 다크 모드용 Glassmorphism 색상
@@ -62,8 +68,8 @@ class AppColors {
// 백드롭 블러 효과를 위한 그라디언트 // 백드롭 블러 효과를 위한 그라디언트
static const List<Color> glassGradient = [ static const List<Color> glassGradient = [
Color(0x33FFFFFF), // 20% white
Color(0x1AFFFFFF), // 10% white Color(0x1AFFFFFF), // 10% white
Color(0x0FFFFFFF), // 6% white
]; ];
static const List<Color> glassGradientDark = [ static const List<Color> glassGradientDark = [
@@ -71,6 +77,18 @@ class AppColors {
Color(0x0F000000), // 6% black Color(0x0F000000), // 6% black
]; ];
// 메인 그라데이션
static const List<Color> mainGradient = [
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA), // 스카이 블루
Color(0xFFE0E7EF), // 라이트 그레이
];
static const List<Color> accentGradient = [
Color(0xFF38BDF8), // 소프트 민트
Color(0xFF60A5FA), // 스카이 블루
];
// 시간대별 배경 그라디언트 // 시간대별 배경 그라디언트
static const List<Color> morningGradient = [ static const List<Color> morningGradient = [
Color(0xFFFED7AA), // 따뜻한 오렌지 Color(0xFFFED7AA), // 따뜻한 오렌지

View File

@@ -17,22 +17,22 @@ class AppTheme {
// 기본 배경색 // 기본 배경색
scaffoldBackgroundColor: AppColors.backgroundColor, scaffoldBackgroundColor: AppColors.backgroundColor,
// 카드 스타일 - 부드러운 그림자, 둥근 모서리 // 카드 스타일 - 글래스모피즘 효과
cardTheme: CardTheme( cardTheme: CardTheme(
color: AppColors.cardColor, color: AppColors.glassCard,
elevation: 1, elevation: 0,
shadowColor: Colors.black.withValues(alpha: 0.04), shadowColor: AppColors.shadowBlack,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: AppColors.borderColor, width: 0.5), side: const BorderSide(color: AppColors.glassBorder, width: 1),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
), ),
// 앱바 스타일 - 깔끔하고 투명한 디자인 // 앱바 스타일 - 글래스모피즘 디자인
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surfaceColor, backgroundColor: Colors.transparent,
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
@@ -43,7 +43,7 @@ class AppTheme {
letterSpacing: -0.2, letterSpacing: -0.2,
), ),
iconTheme: const IconThemeData( iconTheme: const IconThemeData(
color: AppColors.secondaryColor, color: AppColors.primaryColor,
size: 24, size: 24,
), ),
), ),
@@ -52,21 +52,21 @@ class AppTheme {
textTheme: const TextTheme( textTheme: const TextTheme(
// 헤드라인 - 페이지 제목 // 헤드라인 - 페이지 제목
headlineLarge: const TextStyle( headlineLarge: const TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineMedium: const TextStyle( headlineMedium: const TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineSmall: const TextStyle( headlineSmall: const TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.25, letterSpacing: -0.25,
@@ -75,21 +75,21 @@ class AppTheme {
// 타이틀 - 카드, 섹션 제목 // 타이틀 - 카드, 섹션 제목
titleLarge: const TextStyle( titleLarge: const TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
height: 1.4, height: 1.4,
), ),
titleMedium: TextStyle( titleMedium: TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.1, letterSpacing: -0.1,
height: 1.4, height: 1.4,
), ),
titleSmall: TextStyle( titleSmall: TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0, letterSpacing: 0,
@@ -98,21 +98,21 @@ class AppTheme {
// 본문 텍스트 // 본문 텍스트
bodyLarge: TextStyle( bodyLarge: TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.5, height: 1.5,
), ),
bodyMedium: TextStyle( bodyMedium: TextStyle(
color: AppColors.textSecondary, color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.5, height: 1.5,
), ),
bodySmall: TextStyle( bodySmall: TextStyle(
color: AppColors.textMuted, color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
letterSpacing: 0.2, letterSpacing: 0.2,
@@ -121,21 +121,21 @@ class AppTheme {
// 라벨 텍스트 // 라벨 텍스트
labelLarge: TextStyle( labelLarge: TextStyle(
color: AppColors.textPrimary, color: AppColors.darkNavy, // color.md 가이드: 메인 텍스트
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
height: 1.4, height: 1.4,
), ),
labelMedium: TextStyle( labelMedium: TextStyle(
color: AppColors.textSecondary, color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.2, letterSpacing: 0.2,
height: 1.4, height: 1.4,
), ),
labelSmall: TextStyle( labelSmall: TextStyle(
color: AppColors.textMuted, color: AppColors.navyGray, // color.md 가이드: 서브 텍스트
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.2, letterSpacing: 0.2,
@@ -143,10 +143,10 @@ class AppTheme {
), ),
), ),
// 입력 필드 스타일 - 깔끔하고 현대적인 디자인 // 입력 필드 스타일 - 글래스모피즘 디자인
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: AppColors.surfaceColorAlt, fillColor: AppColors.glassBackground,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -154,7 +154,7 @@ class AppTheme {
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.borderColor, width: 1), borderSide: const BorderSide(color: AppColors.textSecondary, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@@ -224,13 +224,13 @@ class AppTheme {
// 아웃라인 버튼 스타일 // 아웃라인 버튼 스타일
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.primaryColor,
minimumSize: const Size(0, 48), minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
side: const BorderSide(color: AppColors.borderColor, width: 1), side: const BorderSide(color: AppColors.secondaryColor, width: 1),
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -265,7 +265,7 @@ class AppTheme {
}), }),
trackColor: MaterialStateProperty.resolveWith<Color>((states) { trackColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) { if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor.withValues(alpha: 0.5); return AppColors.secondaryColor.withValues(alpha: 0.5);
} }
return AppColors.borderColor; return AppColors.borderColor;
}), }),
@@ -282,7 +282,7 @@ class AppTheme {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
side: const BorderSide(color: AppColors.borderColor, width: 1.5), side: const BorderSide(color: AppColors.secondaryColor, width: 1.5),
), ),
// 라디오 버튼 스타일 // 라디오 버튼 스타일
@@ -291,16 +291,16 @@ class AppTheme {
if (states.contains(MaterialState.selected)) { if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor; return AppColors.primaryColor;
} }
return AppColors.borderColor; return AppColors.textSecondary;
}), }),
), ),
// 슬라이더 스타일 // 슬라이더 스타일
sliderTheme: SliderThemeData( sliderTheme: SliderThemeData(
activeTrackColor: AppColors.primaryColor, activeTrackColor: AppColors.primaryColor,
inactiveTrackColor: AppColors.borderColor, inactiveTrackColor: AppColors.textSecondary,
thumbColor: AppColors.primaryColor, thumbColor: AppColors.primaryColor,
overlayColor: AppColors.primaryColor.withValues(alpha: 0.2), overlayColor: AppColors.primaryColor.withValues(alpha: 0.3),
trackHeight: 4, trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20), overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯 /// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget { class AnalysisBadge extends StatelessWidget {
@@ -23,7 +24,7 @@ class AnalysisBadge extends StatelessWidget {
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.pureWhite,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: borderColor, color: borderColor,
@@ -31,7 +32,7 @@ class AnalysisBadge extends StatelessWidget {
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.5), color: AppColors.shadowBlack,
blurRadius: 10, blurRadius: 10,
spreadRadius: 2, spreadRadius: 2,
), ),
@@ -48,7 +49,7 @@ class AnalysisBadge extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 8, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.black87, color: AppColors.darkNavy,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -68,7 +69,7 @@ class AnalysisBadge extends StatelessWidget {
displayText, displayText,
style: const TextStyle( style: const TextStyle(
fontSize: 7, 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 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart'; import '../../providers/subscription_provider.dart';
import '../../services/currency_util.dart'; import '../../services/currency_util.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart'; import '../glassmorphism_card.dart';
import '../themed_text.dart'; import '../themed_text.dart';
@@ -73,7 +74,7 @@ class EventAnalysisCard extends StatelessWidget {
const FaIcon( const FaIcon(
FontAwesomeIcons.fire, FontAwesomeIcons.fire,
size: 12, size: 12,
color: Colors.white, color: AppColors.pureWhite,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
@@ -81,7 +82,7 @@ class EventAnalysisCard extends StatelessWidget {
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: Colors.white, color: AppColors.pureWhite,
), ),
), ),
], ],
@@ -159,10 +160,10 @@ class EventAnalysisCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05), color: AppColors.darkNavy.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: Colors.white.withValues(alpha: 0.1), color: AppColors.darkNavy.withValues(alpha: 0.1),
), ),
), ),
child: Row( child: Row(
@@ -194,7 +195,7 @@ class EventAnalysisCard extends StatelessWidget {
fontSize: 12, fontSize: 12,
decoration: TextDecoration decoration: TextDecoration
.lineThrough, .lineThrough,
color: Colors.grey, color: AppColors.navyGray,
), ),
); );
} }
@@ -205,7 +206,7 @@ class EventAnalysisCard extends StatelessWidget {
const Icon( const Icon(
Icons.arrow_forward, Icons.arrow_forward,
size: 12, size: 12,
color: Colors.grey, color: AppColors.navyGray,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
FutureBuilder<String>( FutureBuilder<String>(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ class LoadingOverlay extends StatelessWidget {
child, child,
if (isLoading) if (isLoading)
Container( Container(
color: (backgroundColor ?? Colors.black).withOpacity(opacity), color: (backgroundColor ?? Colors.black).withValues(alpha: opacity),
child: Center( child: Center(
child: Container( child: Container(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -36,7 +36,7 @@ class LoadingOverlay extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.1), color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 4), offset: const Offset(0, 4),
), ),
@@ -193,7 +193,7 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
width: widget.size / 5, width: widget.size / 5,
height: widget.size / 5, height: widget.size / 5,
decoration: BoxDecoration( decoration: BoxDecoration(
color: effectiveColor.withOpacity(0.3 + value * 0.7), color: effectiveColor.withValues(alpha: 0.3 + value * 0.7),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
); );
@@ -212,7 +212,7 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
height: widget.size, height: widget.size,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: effectiveColor.withOpacity(0.3), color: effectiveColor.withValues(alpha: 0.3),
), ),
child: Center( child: Center(
child: Container( child: Container(
@@ -220,7 +220,7 @@ class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
height: widget.size * (0.3 + _animation.value * 0.5), height: widget.size * (0.3 + _animation.value * 0.5),
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, 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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../../../theme/app_colors.dart';
/// 공통 텍스트 필드 위젯 /// 공통 텍스트 필드 위젯
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다. /// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
@@ -68,7 +69,7 @@ class BaseTextField extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface, color: AppColors.textSecondary,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -91,18 +92,18 @@ class BaseTextField extends StatelessWidget {
cursorColor: cursorColor ?? theme.primaryColor, cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? TextStyle( style: style ?? TextStyle(
fontSize: 16, fontSize: 16,
color: theme.colorScheme.onSurface, color: AppColors.textPrimary,
), ),
decoration: InputDecoration( decoration: InputDecoration(
hintText: hintText, hintText: hintText,
hintStyle: TextStyle( hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6), color: AppColors.textMuted,
), ),
prefixIcon: prefixIcon, prefixIcon: prefixIcon,
prefixText: prefixText, prefixText: prefixText,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
filled: true, filled: true,
fillColor: fillColor ?? Colors.white, fillColor: fillColor ?? AppColors.glassBackground,
contentPadding: contentPadding ?? const EdgeInsets.all(16), contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -117,7 +118,10 @@ class BaseTextField extends StatelessWidget {
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none, borderSide: BorderSide(
color: AppColors.textSecondary,
width: 1,
),
), ),
disabledBorder: OutlineInputBorder( disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),

View File

@@ -68,7 +68,6 @@ class DatePickerField extends StatelessWidget {
surface: Colors.white, surface: Colors.white,
onSurface: Colors.black, onSurface: Colors.black,
), ),
dialogBackgroundColor: Colors.white,
), ),
child: child!, child: child!,
); );
@@ -98,7 +97,7 @@ class DatePickerField extends StatelessWidget {
fontSize: 16, fontSize: 16,
color: enabled color: enabled
? theme.colorScheme.onSurface ? 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, Icons.calendar_today,
size: 20, size: 20,
color: enabled color: enabled
? theme.colorScheme.onSurface.withOpacity(0.6) ? theme.colorScheme.onSurface.withValues(alpha: 0.6)
: theme.colorScheme.onSurface.withOpacity(0.3), : theme.colorScheme.onSurface.withValues(alpha: 0.3),
), ),
], ],
), ),
@@ -214,7 +213,6 @@ class _DateRangeItem extends StatelessWidget {
surface: Colors.white, surface: Colors.white,
onSurface: Colors.black, onSurface: Colors.black,
), ),
dialogBackgroundColor: Colors.white,
), ),
child: child!, child: child!,
); );
@@ -239,7 +237,7 @@ class _DateRangeItem extends StatelessWidget {
label, label,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6), color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -252,7 +250,7 @@ class _DateRangeItem extends StatelessWidget {
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: date != null color: date != null
? theme.colorScheme.onSurface ? 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( decoration: BoxDecoration(
color: isSelected color: isSelected
? Theme.of(context).primaryColor ? Theme.of(context).primaryColor
: Colors.grey.withOpacity(0.1), : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Center( child: Center(
@@ -291,7 +291,7 @@ class _BillingCycleSelector extends StatelessWidget {
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withOpacity(0.1), color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Text( child: Text(
@@ -341,14 +341,14 @@ class _CategorySelector extends StatelessWidget {
vertical: 10, vertical: 10,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withOpacity(0.1), color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
category.emoji, category.icon,
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
const SizedBox(width: 6), 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 'dart:math' as math;
import 'glassmorphism_card.dart'; import 'glassmorphism_card.dart';
import 'themed_text.dart'; import 'themed_text.dart';
import '../theme/app_colors.dart';
/// 구독이 없을 때 표시되는 빈 화면 위젯 /// 구독이 없을 때 표시되는 빈 화면 위젯
/// ///
@@ -49,14 +50,14 @@ class EmptyStateWidget extends StatelessWidget {
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: const LinearGradient(
colors: [Color(0xFF3B82F6), Color(0xFF2563EB)], colors: AppColors.blueGradient,
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: const Color(0xFF3B82F6).withValues(alpha: 0.3), color: AppColors.primaryColor.withValues(alpha: 0.3),
spreadRadius: 0, spreadRadius: 0,
blurRadius: 16, blurRadius: 16,
offset: const Offset(0, 8), offset: const Offset(0, 8),
@@ -66,7 +67,7 @@ class EmptyStateWidget extends StatelessWidget {
child: const Icon( child: const Icon(
Icons.subscriptions_outlined, Icons.subscriptions_outlined,
size: 48, size: 48,
color: Colors.white, color: AppColors.pureWhite,
), ),
), ),
); );
@@ -100,7 +101,7 @@ class EmptyStateWidget extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
elevation: 4, elevation: 4,
backgroundColor: const Color(0xFF3B82F6), backgroundColor: AppColors.primaryColor,
), ),
onPressed: () { onPressed: () {
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
@@ -112,7 +113,7 @@ class EmptyStateWidget extends StatelessWidget {
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.5, letterSpacing: 0.5,
color: Colors.white, color: AppColors.pureWhite,
), ),
), ),
), ),

View File

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

View File

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

View File

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

View File

@@ -105,17 +105,8 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
return widget.backgroundGradient!; return widget.backgroundGradient!;
} }
// 시간대별 기본 그라디언트 // 디폴트 그라디언트
final hour = DateTime.now().hour; return AppColors.mainGradient;
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;
}
} }
@override @override
@@ -166,7 +157,11 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, 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( return CustomPaint(
painter: WavePainter( painter: WavePainter(
animation: _waveController, 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 progress = animation.value;
final y = (particle.y + progress * particle.speed) % 1.0; 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( canvas.drawCircle(
Offset(particle.x * size.width, y * size.height), Offset(particle.x * size.width, y * size.height),
particle.size, particle.size,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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