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

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

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

🤖 Generated with Claude Code

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

View File

@@ -6,6 +6,7 @@ import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController {
@@ -232,21 +233,9 @@ class AddSubscriptionController {
final granted = await SMSService.requestSMSPermission();
if (!granted) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
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),
),
),
AppSnackBar.showError(
context: context,
message: 'SMS 권한이 필요합니다.',
);
}
return;
@@ -256,21 +245,9 @@ class AddSubscriptionController {
final subscriptions = await SMSService.scanSubscriptions();
if (subscriptions.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Row(
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),
),
),
AppSnackBar.showWarning(
context: context,
message: '구독 관련 SMS를 찾을 수 없습니다.',
);
}
return;
@@ -331,21 +308,9 @@ class AddSubscriptionController {
});
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
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),
),
),
AppSnackBar.showError(
context: context,
message: 'SMS 스캔 중 오류 발생: $e',
);
}
} finally {
@@ -399,11 +364,9 @@ class AddSubscriptionController {
});
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('저장 중 오류가 발생했습니다: $e'),
backgroundColor: Colors.red,
),
AppSnackBar.showError(
context: context,
message: '저장 중 오류가 발생했습니다: $e',
);
}
}

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart';
import '../widgets/glassmorphism_card.dart';
import '../theme/app_colors.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -24,13 +25,13 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)
? AppColors.primaryColor.withValues(alpha: 0.2)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
? AppColors.primaryColor
: AppColors.textSecondary.withValues(alpha: 0.5),
width: isSelected ? 2 : 1,
),
),
@@ -43,8 +44,8 @@ class SettingsScreen extends StatelessWidget {
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
? AppColors.primaryColor
: AppColors.textSecondary,
size: 24,
),
const SizedBox(height: 6),
@@ -54,8 +55,8 @@ class SettingsScreen extends StatelessWidget {
fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
? AppColors.primaryColor
: AppColors.textPrimary,
),
),
],
@@ -187,8 +188,14 @@ class SettingsScreen extends StatelessWidget {
provider.setEnabled(true);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('알림 권한이 거부되었습니다'),
SnackBar(
content: Text(
'알림 권한이 거부되었습니다',
style: TextStyle(
color: AppColors.pureWhite,
),
),
backgroundColor: AppColors.dangerColor,
),
);
}
@@ -449,7 +456,15 @@ class SettingsScreen extends StatelessWidget {
} catch (e) {
if (context.mounted) {
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 '../widgets/glassmorphism_card.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 {
const SmsScanScreen({super.key});
@@ -352,41 +357,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 성공 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
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,
),
AppSnackBar.showSuccess(
context: context,
message: '${subscription.serviceName} 구독이 추가되었습니다.',
);
}
@@ -395,12 +368,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
} catch (e) {
print('구독 추가 중 오류 발생: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('구독 추가 중 오류가 발생했습니다: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
AppSnackBar.showError(
context: context,
message: '구독 추가 중 오류가 발생했습니다: $e',
);
// 오류가 있어도 다음 구독으로 이동
@@ -411,6 +381,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 현재 구독 건너뛰기
void _skipCurrentSubscription() {
final subscription = _scannedSubscriptions[_currentIndex];
if (mounted) {
AppSnackBar.showInfo(
context: context,
message: '${subscription.serviceName} 구독을 건너뛰었습니다.',
icon: Icons.skip_next_rounded,
);
}
_moveToNextSubscription();
}
@@ -434,12 +414,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
navigationProvider.updateCurrentIndex(0);
// 완료 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('모든 구독이 처리되었습니다.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
AppSnackBar.showSuccess(
context: context,
message: '모든 구독이 처리되었습니다.',
);
}
@@ -530,15 +507,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 로딩 상태 UI
Widget _buildLoadingState() {
return const Center(
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
ThemedText('SMS 메시지를 스캔 중입니다...'),
SizedBox(height: 8),
ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7),
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppColors.primaryColor),
),
const SizedBox(height: 16),
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회 이상 결제된 구독 서비스 찾기',
fontSize: 20,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 16),
const Padding(
@@ -572,16 +552,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
textAlign: TextAlign.center,
opacity: 0.7,
forceDark: true,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
PrimaryButton(
text: '스캔 시작하기',
icon: Icons.search_rounded,
onPressed: _scanSms,
icon: const Icon(Icons.search),
label: const Text('스캔 시작하기'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
width: 200,
height: 56,
backgroundColor: AppColors.primaryColor,
),
],
),
@@ -591,9 +572,10 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 구독 표시 상태 UI
Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) {
return const Center(
child: ThemedText('모든 구독 처리 완료'),
);
// 처리 완료 후 초기 상태로 복귀
_scannedSubscriptions = [];
_currentIndex = 0;
return _buildInitialState(); // 스캔 버튼이 있는 초기 화면으로 돌아감
}
final subscription = _scannedSubscriptions[_currentIndex];
@@ -609,7 +591,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: Colors.grey.withValues(alpha: 0.2),
backgroundColor: AppColors.navyGray.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
@@ -618,6 +600,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 24),
@@ -632,6 +615,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'다음 구독을 찾았습니다',
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 24),
// 서비스명
@@ -639,12 +623,14 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'서비스명',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
subscription.serviceName,
fontSize: 22,
fontWeight: FontWeight.bold,
forceDark: true,
),
const SizedBox(height: 16),
@@ -659,6 +645,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'월 비용',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
@@ -675,6 +662,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
).format(subscription.monthlyCost),
fontSize: 18,
fontWeight: FontWeight.bold,
forceDark: true,
),
],
),
@@ -687,6 +675,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'반복 횟수',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
@@ -713,12 +702,14 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'결제 주기',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
subscription.billingCycle,
fontSize: 16,
fontWeight: FontWeight.w500,
forceDark: true,
),
],
),
@@ -731,12 +722,14 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'결제일',
fontWeight: FontWeight.w500,
opacity: 0.7,
forceDark: true,
),
const SizedBox(height: 4),
ThemedText(
_getNextBillingText(subscription.nextBillingDate),
fontSize: 14,
fontWeight: FontWeight.w500,
forceDark: true,
),
],
),
@@ -746,17 +739,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드 추가/수정
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _websiteUrlController,
decoration: const InputDecoration(
labelText: '웹사이트 URL (자동 추출됨)',
hintText: '웹사이트 URL을 수정하거나 비워두세요',
prefixIcon: Icon(Icons.language),
border: OutlineInputBorder(),
),
BaseTextField(
controller: _websiteUrlController,
label: '웹사이트 URL (자동 추출됨)',
hintText: '웹사이트 URL을 수정하거나 비워두세요',
prefixIcon: Icon(
Icons.language,
color: AppColors.navyGray,
),
style: TextStyle(
color: AppColors.darkNavy,
),
fillColor: AppColors.pureWhite.withValues(alpha: 0.8),
),
const SizedBox(height: 32),
@@ -764,22 +758,18 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
Row(
children: [
Expanded(
child: OutlinedButton(
child: SecondaryButton(
text: '건너뛰기',
onPressed: _skipCurrentSubscription,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('건너뛰기'),
height: 48,
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
child: PrimaryButton(
text: '추가하기',
onPressed: _addCurrentSubscription,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('추가하기'),
height: 48,
),
),
],

View File

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

View File

@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
class AppColors {
// 메인 컬러 (Metronic Tailwind 스타일)
static const primaryColor = Color(0xFF3B82F6); // 메트로닉 블루
static const secondaryColor = Color(0xFF64748B); // 슬레이트 600
static const successColor = Color(0xFF10B981); // 그린
static const primaryColor = Color(0xFF2563EB); // 블루
static const secondaryColor = Color(0xFF60A5FA); // 스카이 블루
static const successColor = Color(0xFF38BDF8); // 소프트 민트
static const infoColor = Color(0xFF6366F1); // 인디고
static const warningColor = Color(0xFFF59E0B); // 앰버
static const dangerColor = Color(0xFFEF4444); // 레드
static const dangerColor = Color(0xFFF472B6); // 핑크 액센트
// 배경색
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
@@ -17,18 +17,24 @@ class AppColors {
// 텍스트 컬러
static const textPrimary = Color(0xFF1E293B); // 슬레이트 800
static const textSecondary = Color(0xFF64748B); // 슬레이트 600
static const textMuted = Color(0xFF94A3B8); // 슬레이트 400
static const darkNavy = Color(0xFF1E293B); // 메인 텍스트 (color.md 가이드)
static const textSecondary = Color(0xFF334155); // 네이비 그레이
static const navyGray = Color(0xFF334155); // 서브 텍스트 (color.md 가이드)
static const textMuted = Color(0xFF334155); // 네이비 그레이
static const textLight = Color(0xFFFFFFFF); // 화이트
static const pureWhite = Color(0xFFFFFFFF); // 버튼 텍스트용 (color.md 가이드)
// 보더 & 디바이더
static const borderColor = 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 = [
Color(0xFF3B82F6),
Color(0xFF2563EB)
Color(0xFF2563EB), // 딥 블루
Color(0xFF60A5FA) // 스카이 블루
];
static const List<Color> tealGradient = [
Color(0xFF14B8A6),
@@ -48,10 +54,10 @@ class AppColors {
];
// Glassmorphism 효과를 위한 색상
static const glassSurface = Color(0x0FFFFFFF); // 매우 연한 흰색 (6% opacity)
static const glassBackground = Color(0x1AFFFFFF); // 연한 흰색 (10% opacity)
static const glassSurface = Color(0x33FFFFFF); // 화이트 글래스 (20% opacity)
static const glassBackground = 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)
// 다크 모드용 Glassmorphism 색상
@@ -62,8 +68,8 @@ class AppColors {
// 백드롭 블러 효과를 위한 그라디언트
static const List<Color> glassGradient = [
Color(0x33FFFFFF), // 20% white
Color(0x1AFFFFFF), // 10% white
Color(0x0FFFFFFF), // 6% white
];
static const List<Color> glassGradientDark = [
@@ -71,6 +77,18 @@ class AppColors {
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 = [
Color(0xFFFED7AA), // 따뜻한 오렌지

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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