Refactor screens to MVC architecture with modular widgets

- Extract business logic from screens into dedicated controllers
- Split large screen files into smaller, reusable widget components
- Add controllers for AddSubscriptionScreen and DetailScreen
- Create modular widgets for subscription and detail features
- Improve code organization and maintainability
- Remove duplicated code and improve reusability

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-11 00:21:18 +09:00
parent 4731288622
commit 83c5e3d64e
56 changed files with 9092 additions and 4579 deletions

View File

@@ -1,58 +1,79 @@
## 구독관리 앱 글래스모피어즘 색상 가이드 # 구독관리 앱 글래스모피어즘 컬러 & 텍스트 컬러 가이드
**신뢰성, 편안함, 트렌드함**을 모두 잡는 컬러 조합 추천
### 1. 컬러 선정 원칙 구독관리 앱에 글래스모피어즘을 적용할 때, **신뢰성, 편안함, 트렌드함**을 모두 잡으면서도 **텍스트 가독성**을 최우선으로 고려한 컬러 팔레트와 활용법을 안내합니다.
- **신뢰성:** 블루 계열, 그레이, 화이트 등 안정적이고 전문적인 느낌의 색상 ## 1. 컬러 팔레트 제안
- **편안함:** 저채도 파스텔, 연한 블루·민트, 따뜻한 베이지 등 눈에 부담 없는 색상
- **트렌드함:** 그라디언트, 반투명 레이어, 약간의 네온 포인트 등 현대적 감각
### 2. 추천 컬러 팔레트 | 용도 | 컬러명 | Hex 코드 | 설명/느낌 |
|--------------|--------------|--------------|--------------------------|
| 메인 | Deep Blue | #2563eb | 신뢰, 포인트 |
| 서브 | Sky Blue | #60a5fa | 트렌디, 그라디언트 |
| 포인트 | Soft Mint | #38bdf8 | 상쾌함, 포인트 |
| 배경 | Light Gray | #f1f5f9 | 편안함, 밝은 배경 |
| 글래스 효과 | White Glass | #ffffff(투명)| 반투명 글래스 효과 |
| 포인트 | Pink Accent | #f472b6 | 트렌디, 액센트 |
| 그림자 | Shadow Black | rgba(0,0,0,0.08) | 깊이감 부여 |
| 용도 | 추천 색상 예시 (Hex) | 설명 | ## 2. 텍스트 색상 가이드
|--------------|-------------------------------|---------------------------------------|
| 메인 | #2563eb, #60a5fa, #e0e7ef | 신뢰감 주는 블루 계열 그라디언트 |
| 서브 | #f9fafb, #f1f5f9, #f3f4f6 | 밝은 화이트·그레이, 편안한 배경 |
| 포인트 | #38bdf8, #7dd3fc, #f472b6 | 트렌디한 민트, 연핑크, 밝은 블루 |
| 테두리/블러 | rgba(255,255,255,0.3) | 글래스 효과용 반투명 화이트 |
| 그림자 | rgba(0,0,0,0.08) | 부드러운 깊이감 부여 |
### 3. 실전 적용 예시 밝은 배경(예: #f1f5f9, #ffffff(투명)) 위에는 **어두운 텍스트**를,
진한 컬러(예: #2563eb, #38bdf8) 위에는 **밝은 텍스트**를 사용해야 가독성이 좋습니다.
- **배경:** | 배경 컬러 | 추천 텍스트 컬러 | 용도/설명 |
연한 블루(#e0e7ef) 또는 밝은 그레이(#f9fafb) |------------------|----------------------|-----------------------------------|
- **글래스 카드:** | Light Gray (#f1f5f9) | Dark Navy (#1e293b) | 메인 텍스트, 타이틀, 버튼 |
반투명 화이트(예: rgba(255,255,255,0.2)), 블루 그라디언트 테두리 | White Glass (투명) | Deep Blue (#2563eb) | 강조 텍스트, 버튼 |
- **포인트 버튼:** | Deep Blue (#2563eb) | Pure White (#ffffff) | 버튼, 반전 텍스트 |
밝은 민트(#38bdf8) 또는 연핑크(#f472b6) | Sky Blue (#60a5fa) | Navy Gray (#334155) | 서브 텍스트, 부가 설명 |
- **아이콘/텍스트:** | Soft Mint (#38bdf8) | Navy Gray (#334155) | 포인트 텍스트 |
진한 블루(#2563eb), 다크 그레이(#334155) | Pink Accent (#f472b6)| Deep Blue (#2563eb) | 강조, 포인트 텍스트 |
- **그라디언트 예시:**
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2563eb), Color(0xFF60a5fa), Color(0xFFe0e7ef)],
)
### 4. 참고 팁 ## 3. 실전 적용 예시
- 글래스모피어즘은 **투명도·블러**와 함께 **밝고 깨끗한 색상**을 조합하면 신뢰감과 트렌디함을 동시에 줄 수 있습니다. - **배경**: Light Gray (#f1f5f9)
- 포인트 컬러를 너무 강하게 쓰기보다는, 전체적으로 **밝고 부드러운 톤**에 약간의 컬러만 더하는 것이 편안함을 극대화합니다. - **글래스 카드**: White Glass (rgba(255,255,255,0.2)), 테두리 Deep Blue (#2563eb)
- 실제 인기 앱(Reflect, T.RICKS, Coffee 등)도 블루·화이트·민트 계열을 주로 활용합니다. - **메인 텍스트**: Dark Navy (#1e293b)
- **서브/설명 텍스트**: Navy Gray (#334155)
- **버튼 배경**: Deep Blue (#2563eb)
- **버튼 텍스트**: Pure White (#ffffff)
- **포인트/액센트**: Soft Mint (#38bdf8), Pink Accent (#f472b6)
### 5. 컬러 팔레트 예시 ## 4. 그라디언트 및 글래스 효과 예시
| 이름 | Hex 코드 | 용도/느낌 | ```dart
|-------------|------------|-------------------| // Flutter 예시 (Dart)
| Deep Blue | #2563eb | 신뢰, 메인 | LinearGradient(
| Sky Blue | #60a5fa | 트렌드, 그라디언트| begin: Alignment.topLeft,
| Soft Mint | #38bdf8 | 포인트, 상쾌함 | end: Alignment.bottomRight,
| Light Gray | #f1f5f9 | 배경, 편안함 | colors: [
| White Glass | #ffffff(투명도) | 글래스 효과 | Color(0xFF2563eb),
| Pink Accent | #f472b6 | 포인트, 트렌디 | Color(0xFF60a5fa),
Color(0xFFe0e7ef),
],
)
```
- 글래스 카드 배경: rgba(255,255,255,0.2) + blur + border(Deep Blue)
- 텍스트: #1e293b(진한 네이비) 또는 #2563eb(딥블루) 사용
### 6. 마무리 ## 5. 디자인 팁
- **블루+화이트+민트** 조합은 신뢰성, 편안함, 트렌드함을 모두 만족시킵니다. - **텍스트 대비**를 항상 체크하세요.
- 글래스모피어즘 효과와 함께라면, 위 팔레트로 세련되고 현대적인 구독관리 앱 UI를 완성할 수 있습니다. 밝은 배경에는 어두운 텍스트, 진한 배경에는 밝은 텍스트!
- 실제 적용 시, 밝은 배경과 부드러운 그라디언트, 포인트 컬러를 적절히 조합해보세요. - **포인트 컬러**는 버튼, 아이콘, 강조 텍스트에만 제한적으로 사용하면 세련됨이 살아납니다.
- **글래스 효과**는 투명도와 블러, 그리고 경계선 컬러(예: #2563eb, #60a5fa)로 깊이감을 더하세요.
## 6. 컬러/텍스트 조합 요약표
| 배경색 | 텍스트색 | 용도 예시 |
|------------------|------------------|--------------------|
| #f1f5f9 | #1e293b | 메인 타이틀, 내용 |
| #ffffff(투명) | #2563eb | 카드 내 강조 |
| #2563eb | #ffffff | 버튼, 반전 강조 |
| #60a5fa | #334155 | 서브, 설명 |
| #38bdf8 | #334155 | 포인트, 서브텍스트 |
## 결론
- **블루+화이트+민트** 조합과, **밝은 배경+어두운 텍스트** 원칙으로 신뢰성, 편안함, 트렌드함, 가독성 모두 챙길 수 있습니다.
- 실제 앱에 적용할 때는 위 표를 참고해 각 상황별로 텍스트 컬러를 꼭 맞춰주세요.
- 글래스모피어즘 효과와 대비 높은 텍스트 조합으로, 세련되고 사용성 좋은 구독관리 앱을 완성할 수 있습니다.

View File

@@ -0,0 +1,418 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
/// AddSubscriptionScreen의 비즈니스 로직을 관리하는 Controller
class AddSubscriptionController {
final BuildContext context;
// Form Key
final formKey = GlobalKey<FormState>();
// Text Controllers
final serviceNameController = TextEditingController();
final monthlyCostController = TextEditingController();
final nextBillingDateController = TextEditingController();
final websiteUrlController = TextEditingController();
final eventPriceController = TextEditingController();
// Form State
String billingCycle = '월간';
String currency = 'KRW';
DateTime? nextBillingDate;
bool isLoading = false;
String? selectedCategoryId;
// Event State
bool isEventActive = false;
DateTime? eventStartDate = DateTime.now();
DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30));
// Focus Nodes
final serviceNameFocus = FocusNode();
final monthlyCostFocus = FocusNode();
final billingCycleFocus = FocusNode();
final nextBillingDateFocus = FocusNode();
final websiteUrlFocus = FocusNode();
final categoryFocus = FocusNode();
final currencyFocus = FocusNode();
// Animation Controller
AnimationController? animationController;
Animation<double>? fadeAnimation;
Animation<Offset>? slideAnimation;
// Scroll Controller
final ScrollController scrollController = ScrollController();
double scrollOffset = 0;
// UI State
int currentEditingField = -1;
bool isSaveHovered = false;
// Gradient Colors
final List<Color> gradientColors = [
const Color(0xFF3B82F6),
const Color(0xFF0EA5E9),
const Color(0xFF06B6D4),
];
AddSubscriptionController({required this.context});
/// 초기화
void initialize({required TickerProvider vsync}) {
// 결제일 기본값을 오늘 날짜로 설정
nextBillingDate = DateTime.now();
// 서비스명 컨트롤러에 리스너 추가
serviceNameController.addListener(onServiceNameChanged);
// 애니메이션 컨트롤러 초기화
animationController = AnimationController(
vsync: vsync,
duration: const Duration(milliseconds: 800),
);
fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: animationController!,
curve: Curves.easeIn,
));
slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController!,
curve: Curves.easeOut,
));
// 스크롤 리스너
scrollController.addListener(() {
scrollOffset = scrollController.offset;
});
// 애니메이션 시작
animationController!.forward();
}
/// 리소스 정리
void dispose() {
// Controllers
serviceNameController.dispose();
monthlyCostController.dispose();
nextBillingDateController.dispose();
websiteUrlController.dispose();
eventPriceController.dispose();
// Focus Nodes
serviceNameFocus.dispose();
monthlyCostFocus.dispose();
billingCycleFocus.dispose();
nextBillingDateFocus.dispose();
websiteUrlFocus.dispose();
categoryFocus.dispose();
currencyFocus.dispose();
// Animation
animationController?.dispose();
// Scroll
scrollController.dispose();
}
/// 서비스명 변경시 호출
void onServiceNameChanged() {
autoSelectCategory();
}
/// 카테고리 자동 선택
void autoSelectCategory() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories;
final serviceName = serviceNameController.text.toLowerCase();
// 서비스명에 기반한 카테고리 매칭 로직
dynamic matchedCategory;
// 엔터테인먼트 관련 키워드
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓챠') ||
serviceName.contains('티빙') ||
serviceName.contains('웨이브') ||
serviceName.contains('coupang play') ||
serviceName.contains('쿠팡플레이')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '엔터테인먼트',
orElse: () => categories.first,
);
}
// 음악 관련 키워드
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '음악',
orElse: () => categories.first,
);
}
// 생산성 관련 키워드
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '생산성',
orElse: () => categories.first,
);
}
// 게임 관련 키워드
else if (serviceName.contains('xbox') ||
serviceName.contains('playstation') ||
serviceName.contains('nintendo') ||
serviceName.contains('steam') ||
serviceName.contains('게임')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '게임',
orElse: () => categories.first,
);
}
// 교육 관련 키워드
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '교육',
orElse: () => categories.first,
);
}
// 쇼핑 관련 키워드
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '쇼핑',
orElse: () => categories.first,
);
}
if (matchedCategory != null) {
selectedCategoryId = matchedCategory.id;
}
}
/// SMS 스캔
Future<void> scanSMS({required Function setState}) async {
if (kIsWeb) return;
setState(() => isLoading = true);
try {
if (!await SMSService.hasSMSPermission()) {
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),
),
),
);
}
return;
}
}
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),
),
),
);
}
return;
}
final subscription = subscriptions.first;
setState(() {
serviceNameController.text = subscription['serviceName'] ?? '';
// 비용 처리 및 통화 단위 자동 감지
final costValue = subscription['monthlyCost']?.toString() ?? '';
if (costValue.isNotEmpty) {
// 달러 표시가 있거나 소수점이 있으면 달러로 판단
if (costValue.contains('\$') || costValue.contains('.')) {
currency = 'USD';
String numericValue = costValue.replaceAll('\$', '').trim();
if (!numericValue.contains('.')) {
numericValue = '$numericValue.00';
}
final double parsedValue =
double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0;
monthlyCostController.text =
NumberFormat('#,##0.00').format(parsedValue);
} else {
currency = 'KRW';
String numericValue =
costValue.replaceAll('', '').replaceAll(',', '').trim();
final int parsedValue = int.tryParse(numericValue) ?? 0;
monthlyCostController.text =
NumberFormat.decimalPattern().format(parsedValue);
}
} else {
monthlyCostController.text = '';
}
billingCycle = subscription['billingCycle'] ?? '월간';
nextBillingDate = subscription['nextBillingDate'] != null
? DateTime.parse(subscription['nextBillingDate'])
: DateTime.now();
// 서비스명이 있으면 URL 자동 매칭 시도
if (subscription['serviceName'] != null &&
subscription['serviceName'].isNotEmpty) {
final suggestedUrl =
SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']);
if (suggestedUrl != null) {
websiteUrlController.text = suggestedUrl;
}
// 서비스명 기반으로 카테고리 자동 선택
autoSelectCategory();
}
// 애니메이션 재생
animationController!.reset();
animationController!.forward();
});
} 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),
),
),
);
}
} finally {
if (context.mounted) {
setState(() => isLoading = false);
}
}
}
/// 구독 저장
Future<void> saveSubscription({required Function setState}) async {
if (formKey.currentState!.validate() && nextBillingDate != null) {
setState(() {
isLoading = true;
});
try {
// 콤마 제거하고 숫자만 추출
final monthlyCost =
double.parse(monthlyCostController.text.replaceAll(',', ''));
// 이벤트 가격 파싱
double? eventPrice;
if (isEventActive && eventPriceController.text.isNotEmpty) {
eventPrice = double.tryParse(
eventPriceController.text.replaceAll(',', '')
);
}
await Provider.of<SubscriptionProvider>(context, listen: false)
.addSubscription(
serviceName: serviceNameController.text.trim(),
monthlyCost: monthlyCost,
billingCycle: billingCycle,
nextBillingDate: nextBillingDate!,
websiteUrl: websiteUrlController.text.trim(),
categoryId: selectedCategoryId,
currency: currency,
isEventActive: isEventActive,
eventStartDate: eventStartDate,
eventEndDate: eventEndDate,
eventPrice: eventPrice,
);
if (context.mounted) {
Navigator.pop(context, true); // 성공 여부 반환
}
} catch (e) {
setState(() {
isLoading = false;
});
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('저장 중 오류가 발생했습니다: $e'),
backgroundColor: Colors.red,
),
);
}
}
} else {
scrollController.animateTo(
0.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
}

View File

@@ -0,0 +1,422 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/subscription_model.dart';
import '../models/category_model.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController {
final BuildContext context;
final SubscriptionModel subscription;
// Text Controllers
late TextEditingController serviceNameController;
late TextEditingController monthlyCostController;
late TextEditingController websiteUrlController;
late TextEditingController eventPriceController;
// Form State
late String billingCycle;
late DateTime nextBillingDate;
String? selectedCategoryId;
late String currency;
bool isLoading = false;
// Event State
late bool isEventActive;
DateTime? eventStartDate;
DateTime? eventEndDate;
// Focus Nodes
final serviceNameFocus = FocusNode();
final monthlyCostFocus = FocusNode();
final billingCycleFocus = FocusNode();
final nextBillingDateFocus = FocusNode();
final websiteUrlFocus = FocusNode();
final categoryFocus = FocusNode();
final currencyFocus = FocusNode();
// UI State
final ScrollController scrollController = ScrollController();
double scrollOffset = 0;
int currentEditingField = -1;
bool isDeleteHovered = false;
bool isSaveHovered = false;
bool isCancelHovered = false;
// Animation Controller
AnimationController? animationController;
Animation<double>? fadeAnimation;
Animation<Offset>? slideAnimation;
Animation<double>? rotateAnimation;
DetailScreenController({
required this.context,
required this.subscription,
});
/// 초기화
void initialize({required TickerProvider vsync}) {
// Text Controllers 초기화
serviceNameController = TextEditingController(text: subscription.serviceName);
monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString());
websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? '');
eventPriceController = TextEditingController();
// Form State 초기화
billingCycle = subscription.billingCycle;
nextBillingDate = subscription.nextBillingDate;
selectedCategoryId = subscription.categoryId;
currency = subscription.currency;
// Event State 초기화
isEventActive = subscription.isEventActive;
eventStartDate = subscription.eventStartDate;
eventEndDate = subscription.eventEndDate;
// 이벤트 가격 초기화
if (subscription.eventPrice != null) {
if (currency == 'KRW') {
eventPriceController.text = NumberFormat.decimalPattern()
.format(subscription.eventPrice!.toInt());
} else {
eventPriceController.text =
NumberFormat('#,##0.00').format(subscription.eventPrice!);
}
}
// 통화 단위에 따른 금액 표시 형식 조정
_updateMonthlyCostFormat();
// 애니메이션 초기화
animationController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: vsync,
);
fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: animationController!,
curve: Curves.easeInOut,
));
slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController!,
curve: Curves.easeOutCubic,
));
rotateAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: animationController!,
curve: Curves.easeOutCubic,
));
// 애니메이션 시작
animationController!.forward();
// 서비스명 변경 감지 리스너
serviceNameController.addListener(onServiceNameChanged);
// 스크롤 리스너
scrollController.addListener(() {
scrollOffset = scrollController.offset;
});
}
/// 리소스 정리
void dispose() {
// Controllers
serviceNameController.dispose();
monthlyCostController.dispose();
websiteUrlController.dispose();
eventPriceController.dispose();
// Focus Nodes
serviceNameFocus.dispose();
monthlyCostFocus.dispose();
billingCycleFocus.dispose();
nextBillingDateFocus.dispose();
websiteUrlFocus.dispose();
categoryFocus.dispose();
currencyFocus.dispose();
// Animation
animationController?.dispose();
// Scroll
scrollController.dispose();
}
/// 통화 단위에 따른 금액 표시 형식 업데이트
void _updateMonthlyCostFormat() {
if (currency == 'KRW') {
// 원화는 소수점 없이 표시
final intValue = subscription.monthlyCost.toInt();
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
} else {
// 달러는 소수점 2자리까지 표시
monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost);
}
}
/// 서비스명 변경시 카테고리 자동 선택
void onServiceNameChanged() {
autoSelectCategory();
}
/// 카테고리 자동 선택
void autoSelectCategory() {
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categories = categoryProvider.categories;
final serviceName = serviceNameController.text.toLowerCase();
// 서비스명에 기반한 카테고리 매칭 로직
CategoryModel? matchedCategory;
// 엔터테인먼트 관련 키워드
if (serviceName.contains('netflix') ||
serviceName.contains('youtube') ||
serviceName.contains('disney') ||
serviceName.contains('왓챠') ||
serviceName.contains('티빙') ||
serviceName.contains('웨이브') ||
serviceName.contains('coupang play') ||
serviceName.contains('쿠팡플레이')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '엔터테인먼트',
orElse: () => categories.first,
);
}
// 음악 관련 키워드
else if (serviceName.contains('spotify') ||
serviceName.contains('apple music') ||
serviceName.contains('멜론') ||
serviceName.contains('지니') ||
serviceName.contains('플로') ||
serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '음악',
orElse: () => categories.first,
);
}
// 생산성 관련 키워드
else if (serviceName.contains('notion') ||
serviceName.contains('microsoft') ||
serviceName.contains('office') ||
serviceName.contains('google') ||
serviceName.contains('dropbox') ||
serviceName.contains('icloud') ||
serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '생산성',
orElse: () => categories.first,
);
}
// 게임 관련 키워드
else if (serviceName.contains('xbox') ||
serviceName.contains('playstation') ||
serviceName.contains('nintendo') ||
serviceName.contains('steam') ||
serviceName.contains('게임')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '게임',
orElse: () => categories.first,
);
}
// 교육 관련 키워드
else if (serviceName.contains('coursera') ||
serviceName.contains('udemy') ||
serviceName.contains('인프런') ||
serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '교육',
orElse: () => categories.first,
);
}
// 쇼핑 관련 키워드
else if (serviceName.contains('쿠팡') ||
serviceName.contains('coupang') ||
serviceName.contains('amazon') ||
serviceName.contains('네이버') ||
serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere(
(cat) => cat.name == '쇼핑',
orElse: () => categories.first,
);
}
if (matchedCategory != null) {
selectedCategoryId = matchedCategory.id;
}
}
/// 구독 정보 업데이트
Future<void> updateSubscription() async {
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
String? websiteUrl = websiteUrlController.text;
if (websiteUrl.isEmpty) {
websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text);
}
// 구독 정보 업데이트
// 콤마 제거하고 숫자만 추출
double monthlyCost = 0.0;
try {
monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', ''));
} catch (e) {
// 파싱 오류 발생 시 기본값 사용
monthlyCost = subscription.monthlyCost;
}
subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl;
subscription.billingCycle = billingCycle;
subscription.nextBillingDate = nextBillingDate;
subscription.categoryId = selectedCategoryId;
subscription.currency = currency;
// 이벤트 정보 업데이트
subscription.isEventActive = isEventActive;
subscription.eventStartDate = eventStartDate;
subscription.eventEndDate = eventEndDate;
// 이벤트 가격 파싱
if (isEventActive && eventPriceController.text.isNotEmpty) {
try {
subscription.eventPrice =
double.parse(eventPriceController.text.replaceAll(',', ''));
} catch (e) {
subscription.eventPrice = null;
}
} else {
subscription.eventPrice = null;
}
// 구독 업데이트
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)),
),
);
// 변경 사항이 반영될 시간을 주기 위해 짧게 지연 후 결과 반환
await Future.delayed(const Duration(milliseconds: 100));
if (context.mounted) {
Navigator.of(context).pop(true);
}
}
}
/// 구독 삭제
Future<void> deleteSubscription() async {
if (context.mounted) {
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
await provider.deleteSubscription(subscription.id);
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();
}
}
}
/// 해지 페이지 열기
Future<void> openCancellationPage() async {
if (subscription.websiteUrl != null &&
subscription.websiteUrl!.isNotEmpty) {
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,
),
);
}
}
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('웹사이트 정보가 없습니다. 해지는 웹사이트에서 진행해주세요.'),
backgroundColor: Colors.orange,
),
);
}
}
}
/// 카드 색상 가져오기
Color getCardColor() {
// 서비스 이름에 따라 일관된 색상 생성
final int hash = subscription.serviceName.hashCode.abs();
final List<Color> colors = [
const Color(0xFF3B82F6), // 파랑
const Color(0xFF10B981), // 초록
const Color(0xFF8B5CF6), // 보라
const Color(0xFFF59E0B), // 노랑
const Color(0xFFEF4444), // 빨강
const Color(0xFF0EA5E9), // 하늘
const Color(0xFFEC4899), // 분홍
];
return colors[hash % colors.length];
}
/// 그라데이션 가져오기
LinearGradient getGradient(Color baseColor) {
return LinearGradient(
colors: [
baseColor,
baseColor.withValues(alpha: 0.8),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
}
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';

View File

@@ -4,9 +4,6 @@ import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'package:provider/provider.dart';
import 'notification_provider.dart';
import '../navigator_key.dart';
class SubscriptionProvider extends ChangeNotifier { class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox; late Box<SubscriptionModel> _subscriptionBox;
@@ -156,35 +153,6 @@ class SubscriptionProvider extends ChangeNotifier {
} }
} }
Future<void> _scheduleNotifications() async {
final BuildContext? context = navigatorKey.currentContext;
if (context == null) return;
final notificationProvider = Provider.of<NotificationProvider>(
context,
listen: false,
);
if (!notificationProvider.isEnabled ||
!notificationProvider.isPaymentEnabled) {
return;
}
for (final subscription in _subscriptions) {
final notificationDate = subscription.nextBillingDate.subtract(
const Duration(days: 3),
);
if (notificationDate.isAfter(DateTime.now())) {
await NotificationService.scheduleNotification(
id: subscription.id.hashCode,
title: '구독 결제 예정 알림',
body: '${subscription.serviceName}의 결제가 3일 후 예정되어 있습니다.',
scheduledDate: notificationDate,
);
}
}
}
Future<void> clearAllSubscriptions() async { Future<void> clearAllSubscriptions() async {
_isLoading = true; _isLoading = true;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ class AppLockScreen extends StatelessWidget {
onPressed: () async { onPressed: () async {
final appLock = context.read<AppLockProvider>(); final appLock = context.read<AppLockProvider>();
final success = await appLock.authenticate(); final success = await appLock.authenticate();
if (!success) { if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('인증에 실패했습니다. 다시 시도해주세요.'), content: Text('인증에 실패했습니다. 다시 시도해주세요.'),

View File

@@ -1,7 +1,6 @@
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 '../models/category_model.dart';
class CategoryManagementScreen extends StatefulWidget { class CategoryManagementScreen extends StatefulWidget {
const CategoryManagementScreen({super.key}); const CategoryManagementScreen({super.key});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/app_lock_provider.dart'; import '../providers/app_lock_provider.dart';
import '../providers/navigation_provider.dart'; import '../providers/navigation_provider.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
@@ -13,7 +12,6 @@ import 'sms_scan_screen.dart';
import '../utils/animation_controller_helper.dart'; import '../utils/animation_controller_helper.dart';
import '../widgets/floating_navigation_bar.dart'; import '../widgets/floating_navigation_bar.dart';
import '../widgets/glassmorphic_scaffold.dart'; import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/home_content.dart'; import '../widgets/home_content.dart';
class MainScreen extends StatefulWidget { class MainScreen extends StatefulWidget {
@@ -33,7 +31,6 @@ class _MainScreenState extends State<MainScreen>
late AnimationController _waveController; late AnimationController _waveController;
late ScrollController _scrollController; late ScrollController _scrollController;
late FloatingNavBarScrollController _navBarScrollController; late FloatingNavBarScrollController _navBarScrollController;
bool _isNavBarVisible = true;
// 화면 목록 // 화면 목록
late final List<Widget> _screens; late final List<Widget> _screens;
@@ -67,8 +64,8 @@ class _MainScreenState extends State<MainScreen>
_navBarScrollController = FloatingNavBarScrollController( _navBarScrollController = FloatingNavBarScrollController(
scrollController: _scrollController, scrollController: _scrollController,
onHide: () => setState(() => _isNavBarVisible = false), onHide: () {},
onShow: () => setState(() => _isNavBarVisible = true), onShow: () {},
); );
// 화면 목록 초기화 // 화면 목록 초기화
@@ -162,17 +159,18 @@ class _MainScreenState extends State<MainScreen>
// 구독이 성공적으로 추가된 경우 // 구독이 성공적으로 추가된 경우
if (result == true) { if (result == true) {
// 상단에 스낵바 표시 // 상단에 스낵바 표시
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Row( content: const Row(
children: [ children: [
const Icon( Icon(
Icons.check_circle, Icons.check_circle,
color: Colors.white, color: Colors.white,
size: 20, size: 20,
), ),
const SizedBox(width: 12), SizedBox(width: 12),
const Text( Text(
'구독이 추가되었습니다', '구독이 추가되었습니다',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,

View File

@@ -1,24 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../providers/notification_provider.dart'; import '../providers/notification_provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/navigation_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io'; import 'dart:io';
import 'package:path/path.dart' as path;
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import '../screens/sms_scan_screen.dart';
import 'package:url_launcher/url_launcher.dart'; 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/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/glassmorphism_card.dart'; import '../widgets/glassmorphism_card.dart';
import '../widgets/app_navigator.dart';
import '../theme/app_colors.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -76,65 +65,7 @@ class SettingsScreen extends StatelessWidget {
); );
} }
Future<void> _backupData(BuildContext context) async {
try {
final provider = context.read<SubscriptionProvider>();
final subscriptions = provider.subscriptions;
// 임시 디렉토리에 백업 파일 생성
final tempDir = await getTemporaryDirectory();
final backupFile =
File(path.join(tempDir.path, 'submanager_backup.json'));
// 구독 데이터를 JSON 형식으로 저장
final jsonData = subscriptions
.map((sub) => {
'id': sub.id,
'serviceName': sub.serviceName,
'monthlyCost': sub.monthlyCost,
'billingCycle': sub.billingCycle,
'nextBillingDate': sub.nextBillingDate.toIso8601String(),
'isAutoDetected': sub.isAutoDetected,
'repeatCount': sub.repeatCount,
'lastPaymentDate': sub.lastPaymentDate?.toIso8601String(),
})
.toList();
await backupFile.writeAsString(jsonData.toString());
// 파일 공유
await Share.shareXFiles(
[XFile(backupFile.path)],
text: 'SubManager 백업 파일',
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('백업 파일이 생성되었습니다')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('백업 중 오류가 발생했습니다: $e')),
);
}
}
}
// SMS 스캔 화면으로 이동
void _navigateToSmsScan(BuildContext context) async {
final added = await Navigator.push<bool>(
context,
MaterialPageRoute(builder: (context) => const SmsScanScreen()),
);
if (added == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
);
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -455,10 +386,10 @@ class SettingsScreen extends StatelessWidget {
), ),
// 데이터 관리 // 데이터 관리
GlassmorphismCard( const GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Column( child: const Column(
children: [ children: [
// 데이터 백업 기능 비활성화 // 데이터 백업 기능 비활성화
// ListTile( // ListTile(

View File

@@ -7,11 +7,8 @@ import '../models/subscription.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가 import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
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';
class SmsScanScreen extends StatefulWidget { class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key}); const SmsScanScreen({super.key});

View File

@@ -1,14 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../widgets/glassmorphism_card.dart';
import '../routes/app_routes.dart'; import '../routes/app_routes.dart';
import 'app_lock_screen.dart';
import 'main_screen.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});
@@ -289,7 +283,7 @@ class _SplashScreenState extends State<SplashScreen>
BlendMode.srcIn, BlendMode.srcIn,
shaderCallback: shaderCallback:
(bounds) => (bounds) =>
LinearGradient( const LinearGradient(
colors: AppColors colors: AppColors
.blueGradient, .blueGradient,
begin: begin:

View File

@@ -1,6 +1,5 @@
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
/// 환율 정보 서비스 클래스 /// 환율 정보 서비스 클래스

View File

@@ -152,13 +152,6 @@ class NotificationService {
} }
} }
// 알림 서비스가 초기화되었는지 확인하는 메서드
static bool _isInitialized() {
// 웹 플랫폼인 경우 항상 false 반환
if (_isWeb) return false;
// 초기화 플래그 확인
return _initialized;
}
static Future<bool> requestPermission() async { static Future<bool> requestPermission() async {
final result = await _notifications final result = await _notifications
@@ -182,7 +175,7 @@ class NotificationService {
} }
try { try {
final androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
'subscription_channel', 'subscription_channel',
'구독 알림', '구독 알림',
channelDescription: '구독 관련 알림을 보여줍니다.', channelDescription: '구독 관련 알림을 보여줍니다.',
@@ -257,7 +250,7 @@ class NotificationService {
try { try {
final notificationId = subscription.id.hashCode; final notificationId = subscription.id.hashCode;
final androidDetails = AndroidNotificationDetails( const androidDetails = AndroidNotificationDetails(
'subscription_channel', 'subscription_channel',
'구독 알림', '구독 알림',
channelDescription: '구독 만료 알림을 보내는 채널입니다.', channelDescription: '구독 만료 알림을 보내는 채널입니다.',
@@ -265,13 +258,13 @@ class NotificationService {
priority: Priority.high, priority: Priority.high,
); );
final iosDetails = DarwinNotificationDetails( const iosDetails = DarwinNotificationDetails(
presentAlert: true, presentAlert: true,
presentBadge: true, presentBadge: true,
presentSound: true, presentSound: true,
); );
final notificationDetails = NotificationDetails( const notificationDetails = NotificationDetails(
android: androidDetails, android: androidDetails,
iOS: iosDetails, iOS: iosDetails,
); );

View File

@@ -80,7 +80,6 @@ class SmsScanner {
final nextBillingDateStr = sms['nextBillingDate'] as String?; final nextBillingDateStr = sms['nextBillingDate'] as String?;
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨) // 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
final isRecurring = (sms['isRecurring'] as bool?) ?? (repeatCount >= 2);
final message = sms['message'] as String? ?? ''; final message = sms['message'] as String? ?? '';
// 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사 // 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사
@@ -205,79 +204,7 @@ class SmsScanner {
return serviceUrls[serviceName]; return serviceUrls[serviceName];
} }
bool _containsSubscriptionKeywords(String text) {
final keywords = [
'구독',
'결제',
'청구',
'정기',
'자동',
'subscription',
'payment',
'bill',
'invoice'
];
return keywords
.any((keyword) => text.toLowerCase().contains(keyword.toLowerCase()));
}
double? _extractAmount(String text) {
final RegExp amountRegex = RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)');
final match = amountRegex.firstMatch(text);
if (match != null) {
final amountStr = match.group(1)?.replaceAll(',', '');
return double.tryParse(amountStr ?? '');
}
return null;
}
String? _extractServiceName(String text) {
final serviceNames = [
'Netflix',
'Spotify',
'Disney+',
'Apple Music',
'YouTube Premium',
'Amazon Prime',
'Microsoft 365',
'Google One',
'iCloud',
'Dropbox'
];
for (final name in serviceNames) {
if (text.contains(name)) {
return name;
}
}
return null;
}
String _extractBillingCycle(String text) {
if (text.contains('') || text.contains('month')) {
return 'monthly';
} else if (text.contains('') || text.contains('year')) {
return 'yearly';
} else if (text.contains('') || text.contains('week')) {
return 'weekly';
}
return 'monthly'; // 기본값
}
DateTime _extractNextBillingDate(String text) {
final RegExp dateRegex = RegExp(r'(\d{4}[-/]\d{2}[-/]\d{2})');
final match = dateRegex.firstMatch(text);
if (match != null) {
final dateStr = match.group(1);
if (dateStr != null) {
final date = DateTime.tryParse(dateStr);
if (date != null) {
return date;
}
}
}
return DateTime.now().add(const Duration(days: 30)); // 기본값: 30일 후
}
// 메시지에서 통화 단위를 감지하는 함수 // 메시지에서 통화 단위를 감지하는 함수
String _detectCurrency(String message) { String _detectCurrency(String message) {

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스 /// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
class SubscriptionUrlMatcher { class SubscriptionUrlMatcher {

View File

@@ -13,7 +13,7 @@ class AdaptiveTheme {
return ThemeData( return ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: Brightness.dark, brightness: Brightness.dark,
colorScheme: ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: AppColors.primaryColor, primary: AppColors.primaryColor,
onPrimary: Colors.white, onPrimary: Colors.white,
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
@@ -134,11 +134,11 @@ class AdaptiveTheme {
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5), borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.dangerColor, width: 1), borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
), ),
labelStyle: TextStyle( labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7), color: Colors.white.withValues(alpha: 0.7),

View File

@@ -4,7 +4,7 @@ import 'app_colors.dart';
class AppTheme { class AppTheme {
static ThemeData lightTheme = ThemeData( static ThemeData lightTheme = ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.light( colorScheme: const ColorScheme.light(
primary: AppColors.primaryColor, primary: AppColors.primaryColor,
onPrimary: Colors.white, onPrimary: Colors.white,
secondary: AppColors.secondaryColor, secondary: AppColors.secondaryColor,
@@ -24,48 +24,48 @@ class AppTheme {
shadowColor: Colors.black.withValues(alpha: 0.04), shadowColor: Colors.black.withValues(alpha: 0.04),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColors.borderColor, width: 0.5), side: const BorderSide(color: AppColors.borderColor, width: 0.5),
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
), ),
// 앱바 스타일 - 깔끔하고 투명한 디자인 // 앱바 스타일 - 깔끔하고 투명한 디자인
appBarTheme: AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: AppColors.surfaceColor, backgroundColor: AppColors.surfaceColor,
foregroundColor: AppColors.textPrimary, foregroundColor: AppColors.textPrimary,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: false,
titleTextStyle: TextStyle( titleTextStyle: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 22, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: -0.2, letterSpacing: -0.2,
), ),
iconTheme: IconThemeData( iconTheme: const IconThemeData(
color: AppColors.secondaryColor, color: AppColors.secondaryColor,
size: 24, size: 24,
), ),
), ),
// 타이포그래피 - Metronic Tailwind 스타일 // 타이포그래피 - Metronic Tailwind 스타일
textTheme: TextTheme( textTheme: const TextTheme(
// 헤드라인 - 페이지 제목 // 헤드라인 - 페이지 제목
headlineLarge: TextStyle( headlineLarge: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 32, fontSize: 32,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineMedium: TextStyle( headlineMedium: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: -0.5, letterSpacing: -0.5,
height: 1.2, height: 1.2,
), ),
headlineSmall: TextStyle( headlineSmall: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -74,7 +74,7 @@ class AppTheme {
), ),
// 타이틀 - 카드, 섹션 제목 // 타이틀 - 카드, 섹션 제목
titleLarge: TextStyle( titleLarge: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -154,31 +154,31 @@ class AppTheme {
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.borderColor, width: 1), borderSide: const BorderSide(color: AppColors.borderColor, width: 1),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5), borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5),
), ),
errorBorder: OutlineInputBorder( errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.dangerColor, width: 1), borderSide: const BorderSide(color: AppColors.dangerColor, width: 1),
), ),
focusedErrorBorder: OutlineInputBorder( focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.dangerColor, width: 1.5), borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5),
), ),
labelStyle: TextStyle( labelStyle: const TextStyle(
color: AppColors.textSecondary, color: AppColors.textSecondary,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
hintStyle: TextStyle( hintStyle: const TextStyle(
color: AppColors.textMuted, color: AppColors.textMuted,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
), ),
errorStyle: TextStyle( errorStyle: const TextStyle(
color: AppColors.dangerColor, color: AppColors.dangerColor,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@@ -230,7 +230,7 @@ class AppTheme {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
side: BorderSide(color: AppColors.borderColor, width: 1), side: const BorderSide(color: AppColors.borderColor, width: 1),
textStyle: const TextStyle( textStyle: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@@ -282,7 +282,7 @@ class AppTheme {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
side: BorderSide(color: AppColors.borderColor, width: 1.5), side: const BorderSide(color: AppColors.borderColor, width: 1.5),
), ),
// 라디오 버튼 스타일 // 라디오 버튼 스타일
@@ -307,16 +307,16 @@ class AppTheme {
), ),
// 탭바 스타일 // 탭바 스타일
tabBarTheme: TabBarTheme( tabBarTheme: const TabBarTheme(
labelColor: AppColors.primaryColor, labelColor: AppColors.primaryColor,
unselectedLabelColor: AppColors.textSecondary, unselectedLabelColor: AppColors.textSecondary,
indicatorColor: AppColors.primaryColor, indicatorColor: AppColors.primaryColor,
labelStyle: TextStyle( labelStyle: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.1, letterSpacing: 0.1,
), ),
unselectedLabelStyle: TextStyle( unselectedLabelStyle: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
letterSpacing: 0.1, letterSpacing: 0.1,
@@ -324,7 +324,7 @@ class AppTheme {
), ),
// 디바이더 스타일 // 디바이더 스타일
dividerTheme: DividerThemeData( dividerTheme: const DividerThemeData(
color: AppColors.dividerColor, color: AppColors.dividerColor,
thickness: 1, thickness: 1,
space: 16, space: 16,
@@ -342,7 +342,7 @@ class AppTheme {
// 스낵바 스타일 // 스낵바 스타일
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
backgroundColor: AppColors.textPrimary, backgroundColor: AppColors.textPrimary,
contentTextStyle: TextStyle( contentTextStyle: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:collection';
import 'dart:async'; import 'dart:async';
/// 메모리 관리를 위한 헬퍼 클래스 /// 메모리 관리를 위한 헬퍼 클래스

View File

@@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'dart:async'; import 'dart:async';
import 'dart:developer' as developer;
/// 성능 최적화를 위한 유틸리티 클래스 /// 성능 최적화를 위한 유틸리티 클래스
class PerformanceOptimizer { class PerformanceOptimizer {

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart'; import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart'; import '../services/subscription_url_matcher.dart';

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math;
import '../../controllers/add_subscription_controller.dart';
/// 구독 추가 화면의 App Bar
class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget {
final AddSubscriptionController controller;
final double scrollOffset;
final VoidCallback onScanSMS;
const AddSubscriptionAppBar({
super.key,
required this.controller,
required this.scrollOffset,
required this.onScanSMS,
});
@override
Size get preferredSize => const Size.fromHeight(60);
@override
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100));
return Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.1 * appBarOpacity),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 4),
)
]
: null,
),
child: SafeArea(
child: AppBar(
title: Text(
'구독 추가',
style: TextStyle(
fontFamily: 'Montserrat',
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: const Color(0xFF1E293B),
shadows: appBarOpacity > 0.6
? [
Shadow(
color: Colors.black.withOpacity(0.2),
offset: const Offset(0, 1),
blurRadius: 2,
)
]
: null,
),
),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
if (!kIsWeb)
controller.isLoading
? const Padding(
padding: EdgeInsets.only(right: 16.0),
child: Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Color(0xFF3B82F6)),
),
),
),
)
: IconButton(
icon: const FaIcon(
FontAwesomeIcons.message,
size: 20,
color: Color(0xFF3B82F6),
),
onPressed: onScanSMS,
tooltip: 'SMS에서 구독 정보 스캔',
),
],
),
),
);
}
}

View File

@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
/// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
final Function setState;
const AddSubscriptionEventSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
required this.setState,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeIn),
),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: controller.isEventActive
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.2),
width: controller.isEventActive ? 2 : 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: controller.isEventActive,
onChanged: (value) {
setState(() {
controller.isEventActive = value ?? false;
if (!controller.isEventActive) {
// 이벤트 비활성화 시 관련 데이터 초기화
controller.eventStartDate = DateTime.now();
controller.eventEndDate = DateTime.now().add(const Duration(days: 30));
controller.eventPriceController.clear();
} else {
// 이벤트 활성화 시 날짜가 null이면 기본값 설정
controller.eventStartDate ??= DateTime.now();
controller.eventEndDate ??= DateTime.now().add(const Duration(days: 30));
}
});
},
activeColor: const Color(0xFF3B82F6),
),
const Text(
'이벤트/할인 설정',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(width: 8),
Icon(
Icons.local_offer,
size: 20,
color: controller.isEventActive
? const Color(0xFF3B82F6)
: Colors.grey,
),
],
),
// 이벤트 활성화 시 추가 필드 표시
AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: controller.isEventActive ? null : 0,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: controller.isEventActive ? 1.0 : 0.0,
child: Column(
children: [
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
setState(() {
controller.eventStartDate = date;
});
},
onEndDateSelected: (date) {
setState(() {
controller.eventEndDate = date;
});
},
startLabel: '시작일',
endLabel: '종료일',
primaryColor: const Color(0xFF3B82F6),
),
const SizedBox(height: 20),
// 이벤트 가격
CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: '이벤트 가격',
hintText: '할인된 가격을 입력하세요',
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,431 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../../controllers/add_subscription_controller.dart';
import '../../providers/category_provider.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
/// 구독 추가 화면의 폼 섹션
class AddSubscriptionForm extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
final Function setState;
const AddSubscriptionForm({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
required this.setState,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeIn),
),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.4),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
children: [
ShaderMask(
shaderCallback: (bounds) => LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: const Icon(
FontAwesomeIcons.fileLines,
size: 20,
color: Colors.white,
),
),
const SizedBox(width: 12),
const Text(
'서비스 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
),
),
],
),
const SizedBox(height: 24),
// 서비스명 필드
BaseTextField(
controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus,
label: '서비스명',
hintText: '예: Netflix, Spotify',
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.monthlyCostFocus.requestFocus();
},
validator: (value) {
if (value == null || value.isEmpty) {
return '서비스명을 입력해주세요';
}
return null;
},
),
const SizedBox(height: 20),
// 월 지출 및 통화 선택
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: CurrencyInputField(
controller: controller.monthlyCostController,
currency: controller.currency,
label: '월 지출',
focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.billingCycleFocus.requestFocus();
},
validator: (value) {
if (value == null || value.isEmpty) {
return '금액을 입력해주세요';
}
return null;
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'통화',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_CurrencySelector(
currency: controller.currency,
onChanged: (value) {
setState(() {
controller.currency = value;
});
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 주기
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제 주기',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_BillingCycleSelector(
billingCycle: controller.billingCycle,
gradientColors: controller.gradientColors,
onChanged: (value) {
setState(() {
controller.billingCycle = value;
});
},
),
],
),
const SizedBox(height: 20),
// 다음 결제일
DatePickerField(
selectedDate: controller.nextBillingDate ?? DateTime.now(),
onDateSelected: (date) {
setState(() {
controller.nextBillingDate = date;
});
},
label: '다음 결제일',
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: controller.gradientColors[0],
),
const SizedBox(height: 20),
// 웹사이트 URL
BaseTextField(
controller: controller.websiteUrlController,
focusNode: controller.websiteUrlFocus,
label: '웹사이트 URL (선택)',
hintText: 'https://example.com',
keyboardType: TextInputType.url,
prefixIcon: Icon(
Icons.link_rounded,
color: Colors.grey[600],
),
),
const SizedBox(height: 20),
// 카테고리 선택
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'카테고리',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
gradientColors: controller.gradientColors,
onChanged: (categoryId) {
setState(() {
controller.selectedCategoryId = categoryId;
});
},
),
],
);
},
),
],
),
),
),
),
);
}
}
/// 통화 선택기
class _CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const _CurrencySelector({
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_CurrencyOption(
label: '',
value: 'KRW',
isSelected: currency == 'KRW',
onTap: () => onChanged('KRW'),
),
const SizedBox(width: 8),
_CurrencyOption(
label: '\$',
value: 'USD',
isSelected: currency == 'USD',
onTap: () => onChanged('USD'),
),
],
);
}
}
/// 통화 옵션
class _CurrencyOption extends StatelessWidget {
final String label;
final String value;
final bool isSelected;
final VoidCallback onTap;
const _CurrencyOption({
required this.label,
required this.value,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : Colors.grey[600],
),
),
),
),
),
);
}
}
/// 결제 주기 선택기
class _BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final List<Color> gradientColors;
final ValueChanged<String> onChanged;
const _BillingCycleSelector({
required this.billingCycle,
required this.gradientColors,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final cycles = ['월간', '분기별', '반기별', '연간'];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: cycles.map((cycle) {
final isSelected = billingCycle == cycle;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => onChanged(cycle),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
),
),
);
}).toList(),
),
);
}
}
/// 카테고리 선택기
class _CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final List<Color> gradientColors;
final ValueChanged<String?> onChanged;
const _CategorySelector({
required this.categories,
required this.selectedCategoryId,
required this.gradientColors,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((category) {
final isSelected = selectedCategoryId == category.id;
return InkWell(
onTap: () => onChanged(category.id),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected
? gradientColors[0]
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
category.emoji,
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 6),
Text(
category.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
],
),
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
/// 구독 추가 화면의 헤더 섹션
class AddSubscriptionHeader extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const AddSubscriptionHeader({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Container(
margin: const EdgeInsets.only(bottom: 24),
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
gradient: LinearGradient(
colors: controller.gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: controller.gradientColors[0].withOpacity(0.3),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.add_rounded,
size: 32,
color: Colors.white,
),
),
const SizedBox(width: 16),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'새 구독 추가',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
),
),
SizedBox(height: 4),
Text(
'서비스 정보를 입력해주세요',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white70,
),
),
],
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../common/buttons/primary_button.dart';
/// 구독 추가 화면의 저장 버튼
class AddSubscriptionSaveButton extends StatelessWidget {
final AddSubscriptionController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
final Function setState;
const AddSubscriptionSaveButton({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
required this.setState,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.6, 1.0, curve: Curves.easeIn),
),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.6),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic),
)),
child: Padding(
padding: const EdgeInsets.only(bottom: 80),
child: PrimaryButton(
text: '구독 추가하기',
icon: Icons.add_circle_outline,
onPressed: controller.isLoading
? null
: () => controller.saveSubscription(setState: setState),
isLoading: controller.isLoading,
backgroundColor: const Color(0xFF3B82F6),
),
),
),
);
}
}

View File

@@ -1,12 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../screens/main_screen.dart';
import '../screens/analysis_screen.dart';
import '../screens/add_subscription_screen.dart';
import '../screens/detail_screen.dart';
import '../screens/settings_screen.dart';
import '../screens/sms_scan_screen.dart';
import '../screens/category_management_screen.dart'; import '../screens/category_management_screen.dart';
import '../screens/app_lock_screen.dart'; import '../screens/app_lock_screen.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
/// 위험한 액션에 사용되는 Danger 버튼
/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다.
class DangerButton extends StatefulWidget {
final String text;
final VoidCallback? onPressed;
final bool requireConfirmation;
final String? confirmationTitle;
final String? confirmationMessage;
final IconData? icon;
final double? width;
final double height;
final double fontSize;
final EdgeInsetsGeometry? padding;
final double borderRadius;
final bool enableHoverEffect;
const DangerButton({
super.key,
required this.text,
this.onPressed,
this.requireConfirmation = false,
this.confirmationTitle,
this.confirmationMessage,
this.icon,
this.width,
this.height = 60,
this.fontSize = 18,
this.padding,
this.borderRadius = 16,
this.enableHoverEffect = true,
});
@override
State<DangerButton> createState() => _DangerButtonState();
}
class _DangerButtonState extends State<DangerButton> {
bool _isHovered = false;
static const Color _dangerColor = Color(0xFFDC2626);
Future<void> _handlePress() async {
if (widget.requireConfirmation) {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: Text(
widget.confirmationTitle ?? widget.text,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _dangerColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
widget.icon ?? Icons.warning_amber_rounded,
color: _dangerColor,
size: 48,
),
),
const SizedBox(height: 16),
Text(
widget.confirmationMessage ??
'이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
height: 1.5,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: _dangerColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
widget.text,
style: const TextStyle(color: Colors.white),
),
),
],
),
);
if (confirmed == true) {
widget.onPressed?.call();
}
} else {
widget.onPressed?.call();
}
}
@override
Widget build(BuildContext context) {
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.width ?? double.infinity,
height: widget.height,
transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02))
: Matrix4.identity(),
child: ElevatedButton(
onPressed: widget.onPressed != null ? _handlePress : null,
style: ElevatedButton.styleFrom(
backgroundColor: _dangerColor,
foregroundColor: Colors.white,
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),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(
widget.icon,
color: Colors.white,
size: _isHovered ? 24 : 20,
),
const SizedBox(width: 8),
],
Text(
widget.text,
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
);
if (widget.enableHoverEffect) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: button,
);
}
return button;
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
/// 주요 액션에 사용되는 Primary 버튼
/// 저장, 추가, 확인 등의 주요 액션에 사용됩니다.
class PrimaryButton extends StatefulWidget {
final String text;
final VoidCallback? onPressed;
final bool isLoading;
final IconData? icon;
final double? width;
final double height;
final Color? backgroundColor;
final Color? foregroundColor;
final double fontSize;
final EdgeInsetsGeometry? padding;
final double borderRadius;
final bool enableHoverEffect;
const PrimaryButton({
super.key,
required this.text,
this.onPressed,
this.isLoading = false,
this.icon,
this.width,
this.height = 60,
this.backgroundColor,
this.foregroundColor,
this.fontSize = 18,
this.padding,
this.borderRadius = 16,
this.enableHoverEffect = true,
});
@override
State<PrimaryButton> createState() => _PrimaryButtonState();
}
class _PrimaryButtonState extends State<PrimaryButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor;
final effectiveForegroundColor = widget.foregroundColor ?? Colors.white;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.width ?? double.infinity,
height: widget.height,
transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02))
: Matrix4.identity(),
child: ElevatedButton(
onPressed: widget.isLoading ? null : widget.onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: effectiveBackgroundColor,
foregroundColor: effectiveForegroundColor,
shape: RoundedRectangleBorder(
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),
),
child: widget.isLoading
? SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: effectiveForegroundColor,
),
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(
widget.icon,
color: effectiveForegroundColor,
size: _isHovered ? 24 : 20,
),
const SizedBox(width: 8),
],
Text(
widget.text,
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w600,
color: effectiveForegroundColor,
),
),
],
),
),
);
if (widget.enableHoverEffect) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: button,
);
}
return button;
}
}

View File

@@ -0,0 +1,203 @@
import 'package:flutter/material.dart';
/// 부차적인 액션에 사용되는 Secondary 버튼
/// 취소, 되돌아가기, 부가 옵션 등에 사용됩니다.
class SecondaryButton extends StatefulWidget {
final String text;
final VoidCallback? onPressed;
final IconData? icon;
final double? width;
final double height;
final Color? borderColor;
final Color? textColor;
final double fontSize;
final EdgeInsetsGeometry? padding;
final double borderRadius;
final double borderWidth;
final bool enableHoverEffect;
const SecondaryButton({
super.key,
required this.text,
this.onPressed,
this.icon,
this.width,
this.height = 56,
this.borderColor,
this.textColor,
this.fontSize = 16,
this.padding,
this.borderRadius = 16,
this.borderWidth = 1.5,
this.enableHoverEffect = true,
});
@override
State<SecondaryButton> createState() => _SecondaryButtonState();
}
class _SecondaryButtonState extends State<SecondaryButton> {
bool _isHovered = false;
@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);
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: widget.width,
height: widget.height,
transform: widget.enableHoverEffect && _isHovered
? (Matrix4.identity()..scale(1.02))
: Matrix4.identity(),
child: OutlinedButton(
onPressed: widget.onPressed,
style: OutlinedButton.styleFrom(
foregroundColor: effectiveTextColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(widget.borderRadius),
),
side: BorderSide(
color: _isHovered
? effectiveBorderColor.withOpacity(0.4)
: effectiveBorderColor,
width: widget.borderWidth,
),
padding: widget.padding ?? const EdgeInsets.symmetric(
vertical: 12,
horizontal: 24,
),
backgroundColor: _isHovered
? theme.colorScheme.onSurface.withOpacity(0.05)
: Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(
widget.icon,
color: effectiveTextColor,
size: 20,
),
const SizedBox(width: 8),
],
Text(
widget.text,
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w500,
color: effectiveTextColor,
),
),
],
),
),
);
if (widget.enableHoverEffect) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: button,
);
}
return button;
}
}
/// 텍스트 링크 스타일의 버튼
/// 간단한 액션이나 링크에 사용됩니다.
class TextLinkButton extends StatefulWidget {
final String text;
final VoidCallback? onPressed;
final IconData? icon;
final Color? color;
final double fontSize;
final bool enableHoverEffect;
const TextLinkButton({
super.key,
required this.text,
this.onPressed,
this.icon,
this.color,
this.fontSize = 14,
this.enableHoverEffect = true,
});
@override
State<TextLinkButton> createState() => _TextLinkButtonState();
}
class _TextLinkButtonState extends State<TextLinkButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveColor = widget.color ?? theme.colorScheme.primary;
Widget button = AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: _isHovered
? theme.colorScheme.onSurface.withOpacity(0.05)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: TextButton(
onPressed: widget.onPressed,
style: TextButton.styleFrom(
foregroundColor: effectiveColor,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.icon != null) ...[
Icon(
widget.icon,
size: 18,
color: effectiveColor,
),
const SizedBox(width: 6),
],
Text(
widget.text,
style: TextStyle(
fontSize: widget.fontSize,
fontWeight: FontWeight.w500,
color: effectiveColor,
decoration: _isHovered
? TextDecoration.underline
: TextDecoration.none,
),
),
],
),
),
);
if (widget.enableHoverEffect) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: button,
);
}
return button;
}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
/// 섹션별 컨텐츠를 감싸는 기본 카드 위젯
/// 폼 섹션, 정보 표시 섹션 등에 사용됩니다.
class SectionCard extends StatelessWidget {
final String? title;
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final Color? backgroundColor;
final double borderRadius;
final List<BoxShadow>? boxShadow;
final Border? border;
final double? height;
final double? width;
final VoidCallback? onTap;
const SectionCard({
super.key,
this.title,
required this.child,
this.padding,
this.margin,
this.backgroundColor,
this.borderRadius = 20,
this.boxShadow,
this.border,
this.height,
this.width,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveBackgroundColor = backgroundColor ?? Colors.white;
final effectiveShadow = boxShadow ?? [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 4),
),
];
Widget card = Container(
height: height,
width: width,
margin: margin,
decoration: BoxDecoration(
color: effectiveBackgroundColor,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: effectiveShadow,
border: border,
),
child: Padding(
padding: padding ?? const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null) ...[
Text(
title!,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 16),
],
child,
],
),
),
);
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: card,
);
}
return card;
}
}
/// 투명한 배경의 섹션 카드
/// 어두운 배경 위에서 사용하기 적합합니다.
class TransparentSectionCard extends StatelessWidget {
final String? title;
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double opacity;
final double borderRadius;
final Color? borderColor;
final VoidCallback? onTap;
const TransparentSectionCard({
super.key,
this.title,
required this.child,
this.padding,
this.margin,
this.opacity = 0.15,
this.borderRadius = 16,
this.borderColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
Widget card = Container(
margin: margin,
decoration: BoxDecoration(
color: Colors.white.withOpacity(opacity),
borderRadius: BorderRadius.circular(borderRadius),
border: borderColor != null
? Border.all(color: borderColor!, width: 1)
: null,
),
child: Padding(
padding: padding ?? const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (title != null) ...[
Text(
title!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white.withOpacity(0.9),
),
),
const SizedBox(height: 12),
],
child,
],
),
),
);
if (onTap != null) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: card,
);
}
return card;
}
}
/// 정보 표시용 카드
/// 읽기 전용 정보를 표시할 때 사용합니다.
class InfoCard extends StatelessWidget {
final String label;
final String value;
final IconData? icon;
final Color? iconColor;
final Color? backgroundColor;
final EdgeInsetsGeometry? padding;
final double borderRadius;
const InfoCard({
super.key,
required this.label,
required this.value,
this.icon,
this.iconColor,
this.backgroundColor,
this.padding,
this.borderRadius = 12,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: padding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor ?? theme.colorScheme.surface,
borderRadius: BorderRadius.circular(borderRadius),
),
child: Row(
children: [
if (icon != null) ...[
Icon(
icon,
size: 24,
color: iconColor ?? theme.colorScheme.primary,
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 4),
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
/// 확인 다이얼로그 위젯
/// 사용자에게 중요한 작업을 확인받을 때 사용합니다.
class ConfirmationDialog extends StatelessWidget {
final String title;
final String? message;
final Widget? content;
final String confirmText;
final String cancelText;
final VoidCallback? onConfirm;
final VoidCallback? onCancel;
final Color? confirmColor;
final IconData? icon;
final Color? iconColor;
final double iconSize;
const ConfirmationDialog({
super.key,
required this.title,
this.message,
this.content,
this.confirmText = '확인',
this.cancelText = '취소',
this.onConfirm,
this.onCancel,
this.confirmColor,
this.icon,
this.iconColor,
this.iconSize = 48,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectiveConfirmColor = confirmColor ?? theme.primaryColor;
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
title: Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: (iconColor ?? effectiveConfirmColor).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: iconColor ?? effectiveConfirmColor,
size: iconSize,
),
),
const SizedBox(height: 16),
],
if (content != null)
content!
else if (message != null)
Text(
message!,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
height: 1.5,
),
),
],
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
onCancel?.call();
},
child: Text(cancelText),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop(true);
onConfirm?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: effectiveConfirmColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
confirmText,
style: const TextStyle(color: Colors.white),
),
),
],
);
}
/// 다이얼로그를 표시하고 결과를 반환하는 정적 메서드
static Future<bool?> show({
required BuildContext context,
required String title,
String? message,
Widget? content,
String confirmText = '확인',
String cancelText = '취소',
Color? confirmColor,
IconData? icon,
Color? iconColor,
double iconSize = 48,
}) {
return showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => ConfirmationDialog(
title: title,
message: message,
content: content,
confirmText: confirmText,
cancelText: cancelText,
confirmColor: confirmColor,
icon: icon,
iconColor: iconColor,
iconSize: iconSize,
),
);
}
}
/// 성공 다이얼로그
class SuccessDialog extends StatelessWidget {
final String title;
final String? message;
final String buttonText;
final VoidCallback? onPressed;
const SuccessDialog({
super.key,
required this.title,
this.message,
this.buttonText = '확인',
this.onPressed,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_circle,
color: Colors.green,
size: 64,
),
),
const SizedBox(height: 24),
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (message != null) ...[
const SizedBox(height: 12),
Text(
message!,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
],
),
actions: [
Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onPressed?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: Text(
buttonText,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
),
],
);
}
static Future<void> show({
required BuildContext context,
required String title,
String? message,
String buttonText = '확인',
VoidCallback? onPressed,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => SuccessDialog(
title: title,
message: message,
buttonText: buttonText,
onPressed: onPressed,
),
);
}
}
/// 에러 다이얼로그
class ErrorDialog extends StatelessWidget {
final String title;
final String? message;
final String buttonText;
final VoidCallback? onPressed;
const ErrorDialog({
super.key,
required this.title,
this.message,
this.buttonText = '확인',
this.onPressed,
});
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.error_outline,
color: Colors.red,
size: 64,
),
),
const SizedBox(height: 24),
Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
if (message != null) ...[
const SizedBox(height: 12),
Text(
message!,
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
],
],
),
actions: [
Center(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
onPressed?.call();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(
horizontal: 32,
vertical: 12,
),
),
child: Text(
buttonText,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
),
),
),
),
],
);
}
static Future<void> show({
required BuildContext context,
required String title,
String? message,
String buttonText = '확인',
VoidCallback? onPressed,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => ErrorDialog(
title: title,
message: message,
buttonText: buttonText,
onPressed: onPressed,
),
);
}
}

View File

@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
/// 로딩 오버레이 위젯
/// 비동기 작업 중 화면을 덮는 로딩 인디케이터를 표시합니다.
class LoadingOverlay extends StatelessWidget {
final bool isLoading;
final Widget child;
final String? message;
final Color? backgroundColor;
final Color? indicatorColor;
final double opacity;
const LoadingOverlay({
super.key,
required this.isLoading,
required this.child,
this.message,
this.backgroundColor,
this.indicatorColor,
this.opacity = 0.7,
});
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
if (isLoading)
Container(
color: (backgroundColor ?? Colors.black).withOpacity(opacity),
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: indicatorColor ?? Theme.of(context).primaryColor,
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
),
],
);
}
}
/// 로딩 다이얼로그
/// 모달 형태의 로딩 인디케이터를 표시합니다.
class LoadingDialog {
static Future<void> show({
required BuildContext context,
String? message,
Color? barrierColor,
bool barrierDismissible = false,
}) {
return showDialog(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: barrierColor ?? Colors.black54,
builder: (context) => WillPopScope(
onWillPop: () async => barrierDismissible,
child: Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
color: Theme.of(context).primaryColor,
),
if (message != null) ...[
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
),
);
}
static void hide(BuildContext context) {
Navigator.of(context).pop();
}
}
/// 커스텀 로딩 인디케이터
/// 다양한 스타일의 로딩 애니메이션을 제공합니다.
class CustomLoadingIndicator extends StatefulWidget {
final double size;
final Color? color;
final LoadingStyle style;
const CustomLoadingIndicator({
super.key,
this.size = 50,
this.color,
this.style = LoadingStyle.circular,
});
@override
State<CustomLoadingIndicator> createState() => _CustomLoadingIndicatorState();
}
class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat();
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final effectiveColor = widget.color ?? Theme.of(context).primaryColor;
switch (widget.style) {
case LoadingStyle.circular:
return SizedBox(
width: widget.size,
height: widget.size,
child: CircularProgressIndicator(
color: effectiveColor,
strokeWidth: 3,
),
);
case LoadingStyle.dots:
return SizedBox(
width: widget.size,
height: widget.size / 3,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(3, (index) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final delay = index * 0.2;
final value = (_animation.value - delay).clamp(0.0, 1.0);
return Container(
width: widget.size / 5,
height: widget.size / 5,
decoration: BoxDecoration(
color: effectiveColor.withOpacity(0.3 + value * 0.7),
shape: BoxShape.circle,
),
);
},
);
}),
),
);
case LoadingStyle.pulse:
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withOpacity(0.3),
),
child: Center(
child: Container(
width: widget.size * (0.3 + _animation.value * 0.5),
height: widget.size * (0.3 + _animation.value * 0.5),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: effectiveColor.withOpacity(1 - _animation.value),
),
),
),
);
},
);
}
}
}
enum LoadingStyle {
circular,
dots,
pulse,
}

View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// 공통 텍스트 필드 위젯
/// 프로젝트 전체에서 일관된 스타일의 텍스트 입력 필드를 제공합니다.
class BaseTextField extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final String? label;
final String? hintText;
final TextInputAction? textInputAction;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final Function()? onTap;
final Function()? onEditingComplete;
final Function(String)? onChanged;
final String? Function(String?)? validator;
final bool enabled;
final Widget? prefixIcon;
final String? prefixText;
final Widget? suffixIcon;
final bool obscureText;
final int? maxLines;
final int? minLines;
final bool readOnly;
final TextStyle? style;
final EdgeInsetsGeometry? contentPadding;
final Color? fillColor;
final Color? cursorColor;
const BaseTextField({
super.key,
required this.controller,
this.focusNode,
this.label,
this.hintText,
this.textInputAction = TextInputAction.next,
this.keyboardType,
this.inputFormatters,
this.onTap,
this.onEditingComplete,
this.onChanged,
this.validator,
this.enabled = true,
this.prefixIcon,
this.prefixText,
this.suffixIcon,
this.obscureText = false,
this.maxLines = 1,
this.minLines,
this.readOnly = false,
this.style,
this.contentPadding,
this.fillColor,
this.cursorColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null) ...[
Text(
label!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
],
TextFormField(
controller: controller,
focusNode: focusNode,
textInputAction: textInputAction,
keyboardType: keyboardType,
inputFormatters: inputFormatters,
onTap: onTap,
onEditingComplete: onEditingComplete,
onChanged: onChanged,
validator: validator,
enabled: enabled,
obscureText: obscureText,
maxLines: maxLines,
minLines: minLines,
readOnly: readOnly,
cursorColor: cursorColor ?? theme.primaryColor,
style: style ?? TextStyle(
fontSize: 16,
color: theme.colorScheme.onSurface,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
prefixIcon: prefixIcon,
prefixText: prefixText,
suffixIcon: suffixIcon,
filled: true,
fillColor: fillColor ?? Colors.white,
contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.primaryColor,
width: 2,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 1,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'base_text_field.dart';
/// 통화 입력 필드 위젯
/// 원화(KRW)와 달러(USD)를 지원하며 자동 포맷팅을 제공합니다.
class CurrencyInputField extends StatefulWidget {
final TextEditingController controller;
final String currency; // 'KRW' or 'USD'
final String? label;
final String? hintText;
final Function(double?)? onChanged;
final String? Function(String?)? validator;
final FocusNode? focusNode;
final TextInputAction? textInputAction;
final Function()? onEditingComplete;
final bool enabled;
const CurrencyInputField({
super.key,
required this.controller,
required this.currency,
this.label,
this.hintText,
this.onChanged,
this.validator,
this.focusNode,
this.textInputAction,
this.onEditingComplete,
this.enabled = true,
});
@override
State<CurrencyInputField> createState() => _CurrencyInputFieldState();
}
class _CurrencyInputFieldState extends State<CurrencyInputField> {
late TextEditingController _formattedController;
@override
void initState() {
super.initState();
_formattedController = TextEditingController();
_updateFormattedValue();
widget.controller.addListener(_onControllerChanged);
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
_formattedController.dispose();
super.dispose();
}
void _onControllerChanged() {
_updateFormattedValue();
}
void _updateFormattedValue() {
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
if (value != null) {
_formattedController.text = _formatCurrency(value);
} else {
_formattedController.text = '';
}
}
String _formatCurrency(double value) {
if (widget.currency == 'KRW') {
return NumberFormat.decimalPattern().format(value.toInt());
} else {
return NumberFormat('#,##0.00').format(value);
}
}
double? _parseValue(String text) {
final cleanText = text.replaceAll(',', '').replaceAll('', '').replaceAll('\$', '').trim();
return double.tryParse(cleanText);
}
String get _prefixText {
return widget.currency == 'KRW' ? '' : '\$ ';
}
String get _defaultHintText {
return widget.currency == 'KRW' ? '금액을 입력하세요' : 'Enter amount';
}
@override
Widget build(BuildContext context) {
return BaseTextField(
controller: _formattedController,
focusNode: widget.focusNode,
label: widget.label,
hintText: widget.hintText ?? _defaultHintText,
textInputAction: widget.textInputAction,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')),
],
prefixText: _prefixText,
onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled,
onChanged: (value) {
final parsedValue = _parseValue(value);
if (parsedValue != null) {
widget.controller.text = parsedValue.toString();
widget.onChanged?.call(parsedValue);
} else {
widget.controller.text = '';
widget.onChanged?.call(null);
}
// 포맷팅 업데이트
if (parsedValue != null) {
final formattedText = _formatCurrency(parsedValue);
if (formattedText != value) {
_formattedController.value = TextEditingValue(
text: formattedText,
selection: TextSelection.collapsed(offset: formattedText.length),
);
}
}
},
validator: widget.validator ?? (value) {
if (value == null || value.isEmpty) {
return '금액을 입력해주세요';
}
final parsedValue = _parseValue(value);
if (parsedValue == null || parsedValue <= 0) {
return '올바른 금액을 입력해주세요';
}
return null;
},
);
}
}

View File

@@ -0,0 +1,263 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
/// 날짜 선택 필드 위젯
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
class DatePickerField extends StatelessWidget {
final DateTime selectedDate;
final Function(DateTime) onDateSelected;
final String label;
final String? hintText;
final DateTime? firstDate;
final DateTime? lastDate;
final bool enabled;
final FocusNode? focusNode;
final Color? backgroundColor;
final EdgeInsetsGeometry? contentPadding;
final String? dateFormat;
final Color? primaryColor;
const DatePickerField({
super.key,
required this.selectedDate,
required this.onDateSelected,
required this.label,
this.hintText,
this.firstDate,
this.lastDate,
this.enabled = true,
this.focusNode,
this.backgroundColor,
this.contentPadding,
this.dateFormat,
this.primaryColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
final effectiveDateFormat = dateFormat ?? 'yyyy년 MM월 dd일';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 8),
InkWell(
focusNode: focusNode,
onTap: enabled ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: selectedDate,
firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)),
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null && picked != selectedDate) {
onDateSelected(picked);
}
} : null,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: contentPadding ?? const EdgeInsets.all(16),
decoration: BoxDecoration(
color: backgroundColor ?? Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.transparent,
),
),
child: Row(
children: [
Expanded(
child: Text(
DateFormat(effectiveDateFormat).format(selectedDate),
style: TextStyle(
fontSize: 16,
color: enabled
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
),
Icon(
Icons.calendar_today,
size: 20,
color: enabled
? theme.colorScheme.onSurface.withOpacity(0.6)
: theme.colorScheme.onSurface.withOpacity(0.3),
),
],
),
),
),
],
);
}
}
/// 날짜 범위 선택 필드 위젯
/// 시작일과 종료일을 선택할 수 있는 필드입니다.
class DateRangePickerField extends StatelessWidget {
final DateTime? startDate;
final DateTime? endDate;
final Function(DateTime?) onStartDateSelected;
final Function(DateTime?) onEndDateSelected;
final String startLabel;
final String endLabel;
final bool enabled;
final Color? primaryColor;
const DateRangePickerField({
super.key,
required this.startDate,
required this.endDate,
required this.onStartDateSelected,
required this.onEndDateSelected,
this.startLabel = '시작일',
this.endLabel = '종료일',
this.enabled = true,
this.primaryColor,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _DateRangeItem(
date: startDate,
label: startLabel,
enabled: enabled,
primaryColor: primaryColor,
onDateSelected: onStartDateSelected,
firstDate: DateTime.now().subtract(const Duration(days: 365)),
lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)),
),
),
const SizedBox(width: 12),
Expanded(
child: _DateRangeItem(
date: endDate,
label: endLabel,
enabled: enabled && startDate != null,
primaryColor: primaryColor,
onDateSelected: onEndDateSelected,
firstDate: startDate ?? DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
),
),
],
);
}
}
class _DateRangeItem extends StatelessWidget {
final DateTime? date;
final String label;
final bool enabled;
final Color? primaryColor;
final Function(DateTime?) onDateSelected;
final DateTime firstDate;
final DateTime lastDate;
const _DateRangeItem({
required this.date,
required this.label,
required this.enabled,
required this.primaryColor,
required this.onDateSelected,
required this.firstDate,
required this.lastDate,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final effectivePrimaryColor = primaryColor ?? theme.primaryColor;
return InkWell(
onTap: enabled ? () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: date ?? DateTime.now(),
firstDate: firstDate,
lastDate: lastDate,
builder: (BuildContext context, Widget? child) {
return Theme(
data: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: effectivePrimaryColor,
onPrimary: Colors.white,
surface: Colors.white,
onSurface: Colors.black,
),
dialogBackgroundColor: Colors.white,
),
child: child!,
);
},
);
if (picked != null) {
onDateSelected(picked);
}
} : null,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 4),
Text(
date != null
? DateFormat('MM/dd').format(date!)
: '선택',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: date != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.4),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.dart';
import '../common/buttons/primary_button.dart';
/// 상세 화면 액션 버튼 섹션
/// 저장 버튼을 포함하는 섹션입니다.
class DetailActionButtons extends StatelessWidget {
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailActionButtons({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.8),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Padding(
padding: const EdgeInsets.only(bottom: 80),
child: PrimaryButton(
text: '변경사항 저장',
icon: Icons.save_rounded,
onPressed: controller.updateSubscription,
isLoading: controller.isLoading,
backgroundColor: baseColor,
),
),
),
);
}
}

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
/// 이벤트 가격 섹션
/// 할인 이벤트 정보를 관리하는 섹션입니다.
class DetailEventSection extends StatelessWidget {
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailEventSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.8),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.local_offer_rounded,
color: baseColor,
size: 24,
),
),
const SizedBox(width: 12),
const Text(
'이벤트 가격',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
],
),
// 이벤트 활성화 스위치
Switch.adaptive(
value: controller.isEventActive,
onChanged: (value) {
controller.isEventActive = value;
if (!value) {
// 이벤트 비활성화시 관련 정보 초기화
controller.eventStartDate = null;
controller.eventEndDate = null;
controller.eventPriceController.clear();
}
},
activeColor: baseColor,
),
],
),
// 이벤트 활성화시 표시될 필드들
if (controller.isEventActive) ...[
const SizedBox(height: 20),
// 이벤트 설명
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.info_outline_rounded,
color: Colors.blue[700],
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'할인 또는 프로모션 가격을 설정하세요',
style: TextStyle(
fontSize: 14,
color: Colors.blue[700],
),
),
),
],
),
),
const SizedBox(height: 20),
// 이벤트 기간
DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
onStartDateSelected: (date) {
controller.eventStartDate = date;
},
onEndDateSelected: (date) {
controller.eventEndDate = date;
},
startLabel: '시작일',
endLabel: '종료일',
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 이벤트 가격
CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: '이벤트 가격',
hintText: '할인된 가격을 입력하세요',
),
const SizedBox(height: 16),
// 할인율 표시
if (controller.eventPriceController.text.isNotEmpty)
_DiscountBadge(
originalPrice: controller.subscription.monthlyCost,
eventPrice: double.tryParse(
controller.eventPriceController.text.replaceAll(',', '')
) ?? 0,
currency: controller.currency,
),
],
],
),
),
),
),
);
}
}
/// 할인율 배지
class _DiscountBadge extends StatelessWidget {
final double originalPrice;
final double eventPrice;
final String currency;
const _DiscountBadge({
required this.originalPrice,
required this.eventPrice,
required this.currency,
});
@override
Widget build(BuildContext context) {
if (eventPrice >= originalPrice || eventPrice <= 0) {
return const SizedBox.shrink();
}
final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round();
final discountAmount = originalPrice - eventPrice;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.green.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$discountPercentage% 할인',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: 12),
Text(
currency == 'KRW'
? '${discountAmount.toInt().toString()}원 절약'
: '\$${discountAmount.toStringAsFixed(2)} 절약',
style: TextStyle(
color: Colors.green[700],
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart';
import '../../providers/category_provider.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
/// 상세 화면 폼 섹션
/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다.
class DetailFormSection extends StatelessWidget {
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailFormSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.6),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 서비스명 필드
BaseTextField(
controller: controller.serviceNameController,
focusNode: controller.serviceNameFocus,
label: '서비스명',
hintText: '예: Netflix, Spotify',
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.monthlyCostFocus.requestFocus();
},
),
const SizedBox(height: 20),
// 월 지출 및 통화 선택
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: CurrencyInputField(
controller: controller.monthlyCostController,
currency: controller.currency,
label: '월 지출',
focusNode: controller.monthlyCostFocus,
textInputAction: TextInputAction.next,
onEditingComplete: () {
controller.billingCycleFocus.requestFocus();
},
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'통화',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_CurrencySelector(
currency: controller.currency,
onChanged: (value) {
controller.currency = value;
// 통화 변경시 금액 포맷 업데이트
if (value == 'KRW') {
final amount = double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '')
);
if (amount != null) {
controller.monthlyCostController.text =
amount.toInt().toString();
}
}
},
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 주기
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제 주기',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_BillingCycleSelector(
billingCycle: controller.billingCycle,
baseColor: baseColor,
onChanged: (value) {
controller.billingCycle = value;
},
),
],
),
const SizedBox(height: 20),
// 다음 결제일
DatePickerField(
selectedDate: controller.nextBillingDate,
onDateSelected: (date) {
controller.nextBillingDate = date;
},
label: '다음 결제일',
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365 * 2)),
primaryColor: baseColor,
),
const SizedBox(height: 20),
// 카테고리 선택
Consumer<CategoryProvider>(
builder: (context, categoryProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'카테고리',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
_CategorySelector(
categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor,
onChanged: (categoryId) {
controller.selectedCategoryId = categoryId;
},
),
],
);
},
),
],
),
),
),
),
);
}
}
/// 통화 선택기
class _CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> onChanged;
const _CurrencySelector({
required this.currency,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
_CurrencyOption(
label: '',
value: 'KRW',
isSelected: currency == 'KRW',
onTap: () => onChanged('KRW'),
),
const SizedBox(width: 8),
_CurrencyOption(
label: '\$',
value: 'USD',
isSelected: currency == 'USD',
onTap: () => onChanged('USD'),
),
],
);
}
}
/// 통화 옵션
class _CurrencyOption extends StatelessWidget {
final String label;
final String value;
final bool isSelected;
final VoidCallback onTap;
const _CurrencyOption({
required this.label,
required this.value,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).primaryColor
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isSelected ? Colors.white : Colors.grey[600],
),
),
),
),
),
);
}
}
/// 결제 주기 선택기
class _BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final Color baseColor;
final ValueChanged<String> onChanged;
const _BillingCycleSelector({
required this.billingCycle,
required this.baseColor,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final cycles = ['매월', '분기별', '반기별', '매년'];
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: cycles.map((cycle) {
final isSelected = billingCycle == cycle;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: InkWell(
onTap: () => onChanged(cycle),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
cycle,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
),
),
);
}).toList(),
),
);
}
}
/// 카테고리 선택기
class _CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final Color baseColor;
final ValueChanged<String?> onChanged;
const _CategorySelector({
required this.categories,
required this.selectedCategoryId,
required this.baseColor,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 8,
children: categories.map((category) {
final isSelected = selectedCategoryId == category.id;
return InkWell(
onTap: () => onChanged(category.id),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration(
color: isSelected ? baseColor : Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
category.emoji,
style: const TextStyle(fontSize: 16),
),
const SizedBox(width: 6),
Text(
category.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: isSelected ? Colors.white : Colors.grey[700],
),
),
],
),
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,247 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart';
import '../website_icon.dart';
/// 상세 화면 상단 헤더 섹션
/// 서비스 아이콘, 이름, 결제 정보를 표시합니다.
class DetailHeaderSection extends StatelessWidget {
final SubscriptionModel subscription;
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
final Animation<double> rotateAnimation;
const DetailHeaderSection({
super.key,
required this.subscription,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
required this.rotateAnimation,
});
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
final gradient = controller.getGradient(baseColor);
return Container(
height: 320,
decoration: BoxDecoration(gradient: gradient),
child: Stack(
children: [
// 배경 패턴
Positioned(
top: -50,
right: -50,
child: RotationTransition(
turns: rotateAnimation,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.1),
),
),
),
),
Positioned(
bottom: -30,
left: -30,
child: Container(
width: 150,
height: 150,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.08),
),
),
),
// 콘텐츠
SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
color: Colors.white,
),
onPressed: () => Navigator.of(context).pop(),
),
IconButton(
icon: const Icon(
Icons.delete_outline_rounded,
color: Colors.white,
),
onPressed: controller.deleteSubscription,
),
],
),
const Spacer(),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 서비스 아이콘과 이름
Row(
children: [
Hero(
tag: 'icon_${subscription.id}',
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: WebsiteIcon(
url: subscription.websiteUrl,
serviceName: subscription.serviceName,
size: 48,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subscription.serviceName,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
shadows: [
Shadow(
color: Colors.black26,
offset: Offset(0, 2),
blurRadius: 4,
),
],
),
),
const SizedBox(height: 4),
Text(
'${subscription.billingCycle} 결제',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.8),
),
),
],
),
),
],
),
const SizedBox(height: 20),
// 결제 정보 카드
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
label: '다음 결제일',
value: DateFormat('yyyy년 MM월 dd일')
.format(subscription.nextBillingDate),
),
_InfoColumn(
label: '월 지출',
value: NumberFormat.currency(
locale: subscription.currency == 'KRW'
? 'ko_KR'
: 'en_US',
symbol: subscription.currency == 'KRW'
? ''
: '\$',
decimalDigits:
subscription.currency == 'KRW' ? 0 : 2,
).format(subscription.monthlyCost),
alignment: CrossAxisAlignment.end,
),
],
),
),
],
),
),
),
],
),
),
),
],
),
);
}
}
/// 정보 표시 컬럼
class _InfoColumn extends StatelessWidget {
final String label;
final String value;
final CrossAxisAlignment alignment;
const _InfoColumn({
required this.label,
required this.value,
this.alignment = CrossAxisAlignment.start,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: alignment,
children: [
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.8),
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
);
}
}

View File

@@ -0,0 +1,178 @@
import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.dart';
import '../common/form_fields/base_text_field.dart';
import '../common/buttons/secondary_button.dart';
/// 웹사이트 URL 섹션
/// 서비스 웹사이트 URL과 해지 관련 정보를 관리하는 섹션입니다.
class DetailUrlSection extends StatelessWidget {
final DetailScreenController controller;
final Animation<double> fadeAnimation;
final Animation<Offset> slideAnimation;
const DetailUrlSection({
super.key,
required this.controller,
required this.fadeAnimation,
required this.slideAnimation,
});
@override
Widget build(BuildContext context) {
final baseColor = controller.getCardColor();
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 0.8),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)),
child: Card(
elevation: 1,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 헤더
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: baseColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.language_rounded,
color: baseColor,
size: 24,
),
),
const SizedBox(width: 12),
const Text(
'웹사이트 정보',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 20),
// URL 입력 필드
BaseTextField(
controller: controller.websiteUrlController,
focusNode: controller.websiteUrlFocus,
label: '웹사이트 URL',
hintText: 'https://example.com',
keyboardType: TextInputType.url,
prefixIcon: Icon(
Icons.link_rounded,
color: Colors.grey[600],
),
),
// 해지 안내 섹션
if (controller.subscription.websiteUrl != null &&
controller.subscription.websiteUrl!.isNotEmpty) ...[
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline_rounded,
color: Colors.orange[700],
size: 20,
),
const SizedBox(width: 8),
Text(
'해지 안내',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.orange[700],
),
),
],
),
const SizedBox(height: 8),
Text(
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.',
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
height: 1.5,
),
),
const SizedBox(height: 12),
TextLinkButton(
text: '해지 페이지로 이동',
icon: Icons.open_in_new_rounded,
onPressed: controller.openCancellationPage,
color: Colors.orange[700],
),
],
),
),
],
// URL 자동 매칭 정보
if (controller.websiteUrlController.text.isEmpty) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.auto_fix_high_rounded,
color: Colors.blue[700],
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다',
style: TextStyle(
fontSize: 14,
color: Colors.blue[700],
),
),
),
],
),
),
],
],
),
),
),
),
);
}
}

View File

@@ -137,7 +137,7 @@ class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환 return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
} }
return Column( return const Column(
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성 // 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'dart:ui';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'glassmorphism_card.dart'; import 'glassmorphism_card.dart';
@@ -62,8 +61,6 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return AnimatedBuilder( return AnimatedBuilder(
animation: _animation, animation: _animation,
builder: (context, child) { builder: (context, child) {

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'dart:math' as math; import 'dart:math' as math;
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'glassmorphic_app_bar.dart';
import 'floating_navigation_bar.dart'; import 'floating_navigation_bar.dart';
/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드 /// 글래스모피즘 디자인이 적용된 통일된 스캐폴드
@@ -79,7 +78,6 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
void _setupScrollListener() { void _setupScrollListener() {
_scrollController?.addListener(() { _scrollController?.addListener(() {
final currentScroll = _scrollController!.position.pixels; final currentScroll = _scrollController!.position.pixels;
final maxScroll = _scrollController!.position.maxScrollExtent;
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김 // 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) { if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {

View File

@@ -126,7 +126,6 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
late AnimationController _controller; late AnimationController _controller;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
late Animation<double> _blurAnimation; late Animation<double> _blurAnimation;
bool _isPressed = false;
@override @override
void initState() { void initState() {
@@ -160,23 +159,14 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
} }
void _handleTapDown(TapDownDetails details) { void _handleTapDown(TapDownDetails details) {
setState(() {
_isPressed = true;
});
_controller.forward(); _controller.forward();
} }
void _handleTapUp(TapUpDetails details) { void _handleTapUp(TapUpDetails details) {
setState(() {
_isPressed = false;
});
_controller.reverse(); _controller.reverse();
} }
void _handleTapCancel() { void _handleTapCancel() {
setState(() {
_isPressed = false;
});
_controller.reverse(); _controller.reverse();
} }

View File

@@ -9,7 +9,6 @@ import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart'; import '../widgets/empty_state_widget.dart';
import '../widgets/glassmorphic_app_bar.dart'; import '../widgets/glassmorphic_app_bar.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../routes/app_routes.dart';
class HomeContent extends StatelessWidget { class HomeContent extends StatelessWidget {
final AnimationController fadeController; final AnimationController fadeController;
@@ -73,8 +72,8 @@ class HomeContent extends StatelessWidget {
pinned: true, pinned: true,
expandedHeight: kToolbarHeight, expandedHeight: kToolbarHeight,
), ),
SliverToBoxAdapter( const SliverToBoxAdapter(
child: NativeAdWidget(key: const ValueKey('home_ad')), child: NativeAdWidget(key: ValueKey('home_ad')),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SlideTransition( child: SlideTransition(
@@ -119,14 +118,14 @@ class HomeContent extends StatelessWidget {
children: [ children: [
Text( Text(
'${provider.subscriptions.length}', '${provider.subscriptions.length}',
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.primaryColor, color: AppColors.primaryColor,
), ),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Icon( const Icon(
Icons.arrow_forward_ios, Icons.arrow_forward_ios,
size: 14, size: 14,
color: AppColors.primaryColor, color: AppColors.primaryColor,

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../utils/format_helper.dart';
import 'animated_wave_background.dart'; import 'animated_wave_background.dart';
import 'glassmorphism_card.dart'; import 'glassmorphism_card.dart';

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/physics.dart'; import 'package:flutter/physics.dart';
import 'dart:math' as math;
/// 물리 기반 스프링 애니메이션을 적용하는 위젯 /// 물리 기반 스프링 애니메이션을 적용하는 위젯
class SpringAnimationWidget extends StatefulWidget { class SpringAnimationWidget extends StatefulWidget {
@@ -44,14 +43,6 @@ class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
); );
// 스프링 시뮬레이션
final simulation = SpringSimulation(
widget.spring,
0.0,
1.0,
0.0,
);
// 오프셋 애니메이션 // 오프셋 애니메이션
_offsetAnimation = Tween<Offset>( _offsetAnimation = Tween<Offset>(
begin: widget.initialOffset ?? const Offset(0, 50), begin: widget.initialOffset ?? const Offset(0, 50),

View File

@@ -1,14 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'dart:math' as math;
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../screens/detail_screen.dart';
import 'website_icon.dart'; import 'website_icon.dart';
import 'app_navigator.dart'; import 'app_navigator.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import 'glassmorphism_card.dart'; import 'glassmorphism_card.dart';
class SubscriptionCard extends StatefulWidget { class SubscriptionCard extends StatefulWidget {
@@ -27,9 +22,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late AnimationController _hoverController; late AnimationController _hoverController;
bool _isHovering = false; bool _isHovering = false;
final double _initialElevation = 1.0;
final double _hoveredElevation = 3.0;
late SubscriptionProvider _subscriptionProvider;
@override @override
void initState() { void initState() {
@@ -40,12 +32,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
); );
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
_subscriptionProvider =
Provider.of<SubscriptionProvider>(context, listen: false);
}
@override @override
void dispose() { void dispose() {
@@ -221,10 +207,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _hoverController, animation: _hoverController,
builder: (context, child) { builder: (context, child) {
final elevation = _initialElevation +
(_hoveredElevation - _initialElevation) *
_hoverController.value;
final scale = 1.0 + (0.02 * _hoverController.value); final scale = 1.0 + (0.02 * _hoverController.value);
return Transform.scale( return Transform.scale(
@@ -337,10 +319,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vertical: 3, vertical: 3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: const LinearGradient(
colors: [ colors: [
const Color(0xFFFF6B6B), Color(0xFFFF6B6B),
const Color(0xFFFF8787), Color(0xFFFF8787),
], ],
), ),
borderRadius: borderRadius:
@@ -349,12 +331,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon( Icon(
Icons.local_offer_rounded, Icons.local_offer_rounded,
size: 11, size: 11,
color: Colors.white, color: Colors.white,
), ),
const SizedBox(width: 3), SizedBox(width: 3),
Text( Text(
'이벤트', '이벤트',
style: TextStyle( style: TextStyle(
@@ -386,7 +368,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
), ),
child: Text( child: Text(
widget.subscription.billingCycle, widget.subscription.billingCycle,
style: TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -424,7 +406,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
decimalDigits: 0, decimalDigits: 0,
).format(widget ).format(widget
.subscription.monthlyCost), .subscription.monthlyCost),
style: TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.textSecondary, color: AppColors.textSecondary,
@@ -555,7 +537,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
if (widget.subscription.eventEndDate != null) ...[ if (widget.subscription.eventEndDate != null) ...[
Text( Text(
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음', '${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음',
style: TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 11,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),

View File

@@ -1,11 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../widgets/subscription_card.dart';
import '../widgets/category_header_widget.dart'; import '../widgets/category_header_widget.dart';
import '../widgets/swipeable_subscription_card.dart'; import '../widgets/swipeable_subscription_card.dart';
import '../widgets/staggered_list_animation.dart'; import '../widgets/staggered_list_animation.dart';
import '../screens/detail_screen.dart';
import '../widgets/animated_page_transitions.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';
@@ -62,8 +59,8 @@ class SubscriptionListWidget extends StatelessWidget {
itemBuilder: (context, subIndex) { itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록) // 각 구독의 지연값 계산 (순차적으로 나타나도록)
final delay = 0.05 * subIndex; final delay = 0.05 * subIndex;
final animationBegin = 0.2; const animationBegin = 0.2;
final animationEnd = 1.0; const animationEnd = 1.0;
final intervalStart = delay; final intervalStart = delay;
final intervalEnd = intervalStart + 0.4; final intervalEnd = intervalStart + 0.4;

View File

@@ -1,6 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;
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';

View File

@@ -43,7 +43,6 @@ class ThemedText extends StatelessWidget {
if (forceDark) return AppColors.textPrimary; if (forceDark) return AppColors.textPrimary;
final brightness = Theme.of(context).brightness; final brightness = Theme.of(context).brightness;
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
// 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트 // 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트
if (_isGlassmorphicContext(context)) { if (_isGlassmorphicContext(context)) {

View File

@@ -1,16 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:octo_image/octo_image.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:html/parser.dart' as html_parser;
import 'package:html/dom.dart' as html_dom;
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
@@ -57,7 +52,7 @@ class FaviconCache {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getString('favicon_$serviceKey'); return prefs.getString('favicon_$serviceKey');
} catch (e) { } catch (e) {
print('파비콘 캐시 로드 오류: $e'); // 파비콘 캐시 로드 오류
return null; return null;
} }
} }
@@ -68,7 +63,7 @@ class FaviconCache {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString('favicon_$serviceKey', logoUrl); await prefs.setString('favicon_$serviceKey', logoUrl);
} catch (e) { } catch (e) {
print('파비콘 캐시 저장 오류: $e'); // 파비콘 캐시 저장 오류
} }
} }
@@ -80,7 +75,7 @@ class FaviconCache {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove('favicon_$serviceKey'); await prefs.remove('favicon_$serviceKey');
} catch (e) { } catch (e) {
print('파비콘 캐시 삭제 오류: $e'); // 파비콘 캐시 삭제 오류
} }
} }
@@ -90,7 +85,7 @@ class FaviconCache {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
return prefs.getString('local_favicon_$serviceKey'); return prefs.getString('local_favicon_$serviceKey');
} catch (e) { } catch (e) {
print('로컬 파비콘 경로 로드 오류: $e'); // 로컬 파비콘 경로 로드 오류
return null; return null;
} }
} }
@@ -102,39 +97,14 @@ class FaviconCache {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString('local_favicon_$serviceKey', filePath); await prefs.setString('local_favicon_$serviceKey', filePath);
} catch (e) { } catch (e) {
print('로컬 파비콘 경로 저장 오류: $e'); // 로컬 파비콘 경로 저장 오류
} }
} }
} }
// 구글 파비콘 API 서비스 // 구글 파비콘 API 서비스
class GoogleFaviconService { class GoogleFaviconService {
// CORS 프록시 서버 목록
static final List<String> _corsProxies = [
'https://corsproxy.io/?',
'https://api.allorigins.win/raw?url=',
'https://cors-anywhere.herokuapp.com/',
];
// 현재 사용 중인 프록시 인덱스
static int _currentProxyIndex = 0;
// 프록시를 사용하여 URL 생성
static String _getProxiedUrl(String url) {
// 앱 환경에서는 프록시 없이 직접 URL 반환
if (!kIsWeb) {
return url;
}
// 웹 환경에서는 CORS 프록시 사용
final proxy = _corsProxies[_currentProxyIndex];
_currentProxyIndex =
(_currentProxyIndex + 1) % _corsProxies.length; // 다음 요청은 다른 프록시 사용
// URL 인코딩
final encodedUrl = Uri.encodeComponent(url);
return '$proxy$encodedUrl';
}
// 구글 파비콘 API URL 생성 // 구글 파비콘 API URL 생성
static String getFaviconUrl(String domain, int size) { static String getFaviconUrl(String domain, int size) {
@@ -167,7 +137,7 @@ class GoogleFaviconService {
static String getBase64PlaceholderIcon(String serviceName, Color color) { static String getBase64PlaceholderIcon(String serviceName, Color color) {
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시) // 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?'; final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?';
final colorHex = color.value.toRadixString(16).padLeft(8, '0').substring(2); final colorHex = color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2);
// 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생) // 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생)
final svgContent = final svgContent =
@@ -207,15 +177,12 @@ class _WebsiteIconState extends State<WebsiteIcon>
bool _isLoading = true; bool _isLoading = true;
late AnimationController _animationController; late AnimationController _animationController;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
late Animation<double> _opacityAnimation;
// 각 인스턴스에 대한 고유 식별자 추가 // 각 인스턴스에 대한 고유 식별자 추가
final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString(); final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString();
// 서비스와 URL 조합으로 캐시 키 생성 // 서비스와 URL 조합으로 캐시 키 생성
String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}'; String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}';
// 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용) // 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용)
String? _previousServiceKey; String? _previousServiceKey;
// 로드 시작된 시점
DateTime? _loadStartTime;
@override @override
void initState() { void initState() {
@@ -231,15 +198,9 @@ class _WebsiteIconState extends State<WebsiteIcon>
CurvedAnimation( CurvedAnimation(
parent: _animationController, curve: Curves.easeOutCubic)); parent: _animationController, curve: Curves.easeOutCubic));
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeOut));
// 초기 _previousServiceKey 설정 // 초기 _previousServiceKey 설정
_previousServiceKey = _serviceKey; _previousServiceKey = _serviceKey;
// 로드 시작 시간 기록
_loadStartTime = DateTime.now();
// 최초 로딩 // 최초 로딩
_loadFaviconWithCache(); _loadFaviconWithCache();
} }
@@ -263,7 +224,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
// 이미 로딩 중인지 확인 // 이미 로딩 중인지 확인
if (!FaviconCache.markAsLoading(_serviceKey)) { if (!FaviconCache.markAsLoading(_serviceKey)) {
await Future.delayed(Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
cachedLogo = FaviconCache.getFromMemory(_serviceKey); cachedLogo = FaviconCache.getFromMemory(_serviceKey);
if (cachedLogo != null) { if (cachedLogo != null) {
setState(() { setState(() {
@@ -312,7 +273,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
// 2. 이미 로딩 중인지 확인 // 2. 이미 로딩 중인지 확인
if (!FaviconCache.markAsLoading(_serviceKey)) { if (!FaviconCache.markAsLoading(_serviceKey)) {
await Future.delayed(Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 500));
localPath = await FaviconCache.getLocalFaviconPath(_serviceKey); localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
if (localPath != null) { if (localPath != null) {
final file = File(localPath); final file = File(localPath);
@@ -344,12 +305,9 @@ class _WebsiteIconState extends State<WebsiteIcon>
// 서비스명이나 URL이 변경된 경우에만 다시 로드 // 서비스명이나 URL이 변경된 경우에만 다시 로드
final currentServiceKey = _serviceKey; final currentServiceKey = _serviceKey;
if (_previousServiceKey != currentServiceKey) { if (_previousServiceKey != currentServiceKey) {
print('서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey'); // 서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey
_previousServiceKey = currentServiceKey; _previousServiceKey = currentServiceKey;
// 로드 시작 시간 기록
_loadStartTime = DateTime.now();
// 변경된 서비스 정보로 파비콘 로드 // 변경된 서비스 정보로 파비콘 로드
_loadFaviconWithCache(); _loadFaviconWithCache();
} }
@@ -472,7 +430,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
return; return;
} }
} catch (e) { } catch (e) {
print('DuckDuckGo 파비콘 API 요청 실패: $e'); // DuckDuckGo 파비콘 API 요청 실패
// 실패 시 백업 방법으로 진행 // 실패 시 백업 방법으로 진행
} }
@@ -501,7 +459,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
FaviconCache.cancelLoading(_serviceKey); FaviconCache.cancelLoading(_serviceKey);
} catch (e) { } catch (e) {
print('웹용 파비콘 가져오기 오류: $e'); // 웹용 파비콘 가져오기 오류
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
@@ -579,7 +537,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
FaviconCache.cancelLoading(_serviceKey); FaviconCache.cancelLoading(_serviceKey);
} catch (e) { } catch (e) {
print('앱용 파비콘 다운로드 오류: $e'); // 앱용 파비콘 다운로드 오류
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
@@ -610,7 +568,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
boxShadow: widget.isHovered boxShadow: widget.isHovered
? [ ? [
BoxShadow( BoxShadow(
color: _getColorFromName().withAlpha(76), // 약 0.3 알파값 color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값
blurRadius: 12, blurRadius: 12,
spreadRadius: 0, spreadRadius: 0,
offset: const Offset(0, 4), offset: const Offset(0, 4),
@@ -643,7 +601,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withAlpha(179)), AppColors.primaryColor.withValues(alpha: 0.7)),
), ),
), ),
), ),
@@ -684,7 +642,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>( valueColor: AlwaysStoppedAnimation<Color>(
AppColors.primaryColor.withAlpha(179)), AppColors.primaryColor.withValues(alpha: 0.7)),
), ),
), ),
), ),
@@ -726,7 +684,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
color, color,
color.withAlpha(204), // 약 0.8 알파값 color.withValues(alpha: 0.8), // 약 0.8 알파값
], ],
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,