feat: 폼 필드 컴포넌트 분리 및 구독 카드 인터랙션 개선
- billing_cycle_selector, category_selector, currency_selector 컴포넌트 분리 - 구독 카드 클릭 이슈 해결을 위한 리팩토링 - SMS 스캔 화면 UI/UX 개선 및 기능 강화 - 상세 화면 컨트롤러 로직 개선 - 알림 서비스 및 구독 URL 매칭 기능 추가 - CLAUDE.md 프로젝트 가이드라인 대폭 확장 - 전반적인 코드 구조 개선 및 타입 안정성 강화 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,14 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
|
||||
),
|
||||
child: SafeArea(
|
||||
child: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
Icons.chevron_left,
|
||||
size: 28,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(
|
||||
'구독 추가',
|
||||
style: TextStyle(
|
||||
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
/// 구독 추가 화면의 이벤트/할인 섹션
|
||||
class AddSubscriptionEventSection extends StatelessWidget {
|
||||
@@ -37,56 +38,67 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
)),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: AppColors.glassCard,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: controller.isEventActive
|
||||
? const Color(0xFF3B82F6)
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
width: controller.isEventActive ? 2 : 1,
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Checkbox(
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: controller.gradientColors[0].withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.local_offer_rounded,
|
||||
color: controller.gradientColors[0],
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'이벤트 가격',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: controller.isEventActive,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
controller.isEventActive = value ?? false;
|
||||
controller.isEventActive = value;
|
||||
if (!controller.isEventActive) {
|
||||
// 이벤트 비활성화 시 관련 데이터 초기화
|
||||
controller.eventStartDate = DateTime.now();
|
||||
controller.eventEndDate = DateTime.now().add(const Duration(days: 30));
|
||||
controller.eventStartDate = null;
|
||||
controller.eventEndDate = null;
|
||||
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,
|
||||
activeColor: controller.gradientColors[0],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -101,6 +113,39 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
// 이벤트 설명
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.infoColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.infoColor.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: AppColors.infoColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'할인 또는 프로모션 가격을 설정하세요',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 이벤트 기간
|
||||
DateRangePickerField(
|
||||
@@ -109,6 +154,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
onStartDateSelected: (date) {
|
||||
setState(() {
|
||||
controller.eventStartDate = date;
|
||||
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||
if (date != null && controller.eventEndDate == null) {
|
||||
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||
}
|
||||
});
|
||||
},
|
||||
onEndDateSelected: (date) {
|
||||
@@ -118,7 +167,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
||||
},
|
||||
startLabel: '시작일',
|
||||
endLabel: '종료일',
|
||||
primaryColor: const Color(0xFF3B82F6),
|
||||
primaryColor: controller.gradientColors[0],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
|
||||
@@ -6,6 +6,11 @@ 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';
|
||||
import '../common/form_fields/currency_selector.dart';
|
||||
import '../common/form_fields/billing_cycle_selector.dart';
|
||||
import '../common/form_fields/category_selector.dart';
|
||||
import '../glassmorphism_card.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
|
||||
/// 구독 추가 화면의 폼 섹션
|
||||
class AddSubscriptionForm extends StatelessWidget {
|
||||
@@ -39,12 +44,8 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
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: GlassmorphismCard(
|
||||
backgroundColor: AppColors.glassCard,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
@@ -134,8 +135,9 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CurrencySelector(
|
||||
CurrencySelector(
|
||||
currency: controller.currency,
|
||||
isGlassmorphism: true,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
controller.currency = value;
|
||||
@@ -161,9 +163,10 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_BillingCycleSelector(
|
||||
BillingCycleSelector(
|
||||
billingCycle: controller.billingCycle,
|
||||
gradientColors: controller.gradientColors,
|
||||
baseColor: controller.gradientColors[0],
|
||||
isGlassmorphism: true,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
controller.billingCycle = value;
|
||||
@@ -217,10 +220,11 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CategorySelector(
|
||||
CategorySelector(
|
||||
categories: categoryProvider.categories,
|
||||
selectedCategoryId: controller.selectedCategoryId,
|
||||
gradientColors: controller.gradientColors,
|
||||
baseColor: controller.gradientColors[0],
|
||||
isGlassmorphism: true,
|
||||
onChanged: (categoryId) {
|
||||
setState(() {
|
||||
controller.selectedCategoryId = categoryId;
|
||||
@@ -240,192 +244,3 @@ class AddSubscriptionForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 통화 선택기
|
||||
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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
category.icon,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ class AnalysisBadge extends StatelessWidget {
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 0),
|
||||
FutureBuilder<String>(
|
||||
future: CurrencyUtil.formatAmount(
|
||||
subscription.monthlyCost,
|
||||
|
||||
@@ -42,12 +42,18 @@ class AppNavigator {
|
||||
|
||||
/// 구독 상세 화면으로 네비게이션
|
||||
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
|
||||
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
await Navigator.of(context).pushNamed(
|
||||
AppRoutes.subscriptionDetail,
|
||||
arguments: subscription,
|
||||
);
|
||||
try {
|
||||
await Navigator.of(context).pushNamed(
|
||||
AppRoutes.subscriptionDetail,
|
||||
arguments: subscription,
|
||||
);
|
||||
print('DetailScreen 네비게이션 성공');
|
||||
} catch (e) {
|
||||
print('DetailScreen 네비게이션 오류: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// SMS 스캔 화면으로 네비게이션
|
||||
|
||||
@@ -4,17 +4,19 @@ import 'package:intl/intl.dart';
|
||||
/// 카테고리별 구독 그룹의 헤더 위젯
|
||||
///
|
||||
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
|
||||
/// 참고: 여러 통화 단위가 혼합된 경우 간단히 원화 표시 형식을 사용합니다.
|
||||
/// 통화별로 구분하여 표시하며, 혼재된 경우 각각 표시합니다.
|
||||
class CategoryHeaderWidget extends StatelessWidget {
|
||||
final String categoryName;
|
||||
final int subscriptionCount;
|
||||
final double totalCost;
|
||||
final double totalCostUSD;
|
||||
final double totalCostKRW;
|
||||
|
||||
const CategoryHeaderWidget({
|
||||
Key? key,
|
||||
required this.categoryName,
|
||||
required this.subscriptionCount,
|
||||
required this.totalCost,
|
||||
required this.totalCostUSD,
|
||||
required this.totalCostKRW,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -36,7 +38,7 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0).format(totalCost)}',
|
||||
_buildCostDisplay(),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -55,4 +57,44 @@ class CategoryHeaderWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 통화별 합계를 표시하는 문자열을 생성합니다.
|
||||
String _buildCostDisplay() {
|
||||
final parts = <String>[];
|
||||
|
||||
// 개수는 항상 표시
|
||||
parts.add('$subscriptionCount개');
|
||||
|
||||
// 통화 부분을 별도로 처리
|
||||
final currencyParts = <String>[];
|
||||
|
||||
// 달러가 있는 경우
|
||||
if (totalCostUSD > 0) {
|
||||
final formatter = NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
);
|
||||
currencyParts.add(formatter.format(totalCostUSD));
|
||||
}
|
||||
|
||||
// 원화가 있는 경우
|
||||
if (totalCostKRW > 0) {
|
||||
final formatter = NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
);
|
||||
currencyParts.add(formatter.format(totalCostKRW));
|
||||
}
|
||||
|
||||
// 통화가 하나 이상 있는 경우
|
||||
if (currencyParts.isNotEmpty) {
|
||||
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
|
||||
final currencyDisplay = currencyParts.join(' + ');
|
||||
parts.add(currencyDisplay);
|
||||
}
|
||||
|
||||
return parts.join(' · ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ class BaseTextField extends StatelessWidget {
|
||||
prefixText: prefixText,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: fillColor ?? AppColors.glassBackground,
|
||||
fillColor: fillColor ?? AppColors.surfaceColorAlt,
|
||||
contentPadding: contentPadding ?? const EdgeInsets.all(16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -119,8 +119,8 @@ class BaseTextField extends StatelessWidget {
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.textSecondary,
|
||||
width: 1,
|
||||
color: AppColors.borderColor.withValues(alpha: 0.7),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
|
||||
101
lib/widgets/common/form_fields/billing_cycle_selector.dart
Normal file
101
lib/widgets/common/form_fields/billing_cycle_selector.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// 결제 주기 선택 위젯
|
||||
/// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다.
|
||||
class BillingCycleSelector extends StatelessWidget {
|
||||
final String billingCycle;
|
||||
final ValueChanged<String> onChanged;
|
||||
final Color? baseColor;
|
||||
final List<Color>? gradientColors;
|
||||
final bool isGlassmorphism;
|
||||
|
||||
const BillingCycleSelector({
|
||||
super.key,
|
||||
required this.billingCycle,
|
||||
required this.onChanged,
|
||||
this.baseColor,
|
||||
this.gradientColors,
|
||||
this.isGlassmorphism = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시
|
||||
final cycles = isGlassmorphism
|
||||
? ['매월', '분기별', '반기별', '매년']
|
||||
: ['월간', '분기별', '반기별', '연간'];
|
||||
|
||||
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: _getBackgroundColor(isSelected),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: _getBorder(isSelected),
|
||||
),
|
||||
child: Text(
|
||||
cycle,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getTextColor(isSelected),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getBackgroundColor(bool isSelected) {
|
||||
if (!isSelected) {
|
||||
return isGlassmorphism
|
||||
? AppColors.backgroundColor
|
||||
: Colors.grey.withValues(alpha: 0.1);
|
||||
}
|
||||
|
||||
if (baseColor != null) {
|
||||
return baseColor!;
|
||||
}
|
||||
|
||||
if (gradientColors != null && gradientColors!.isNotEmpty) {
|
||||
return gradientColors![0];
|
||||
}
|
||||
|
||||
return const Color(0xFF3B82F6);
|
||||
}
|
||||
|
||||
Border? _getBorder(bool isSelected) {
|
||||
if (isSelected || !isGlassmorphism) {
|
||||
return null;
|
||||
}
|
||||
return Border.all(
|
||||
color: AppColors.borderColor.withValues(alpha: 0.5),
|
||||
width: 1.5,
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTextColor(bool isSelected) {
|
||||
if (isSelected) {
|
||||
return Colors.white;
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.darkNavy
|
||||
: Colors.grey[700]!;
|
||||
}
|
||||
}
|
||||
132
lib/widgets/common/form_fields/category_selector.dart
Normal file
132
lib/widgets/common/form_fields/category_selector.dart
Normal file
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// 카테고리 선택 위젯
|
||||
/// 구독 서비스의 카테고리를 선택할 수 있습니다.
|
||||
class CategorySelector extends StatelessWidget {
|
||||
final List<dynamic> categories;
|
||||
final String? selectedCategoryId;
|
||||
final ValueChanged<String?> onChanged;
|
||||
final Color? baseColor;
|
||||
final List<Color>? gradientColors;
|
||||
final bool isGlassmorphism;
|
||||
|
||||
const CategorySelector({
|
||||
super.key,
|
||||
required this.categories,
|
||||
required this.selectedCategoryId,
|
||||
required this.onChanged,
|
||||
this.baseColor,
|
||||
this.gradientColors,
|
||||
this.isGlassmorphism = false,
|
||||
});
|
||||
|
||||
@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: _getBackgroundColor(isSelected),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: _getBorder(isSelected),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getCategoryIcon(category),
|
||||
size: 18,
|
||||
color: _getTextColor(isSelected),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
category.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _getTextColor(isSelected),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getCategoryIcon(dynamic category) {
|
||||
// 카테고리명에 따른 아이콘 반환
|
||||
switch (category.name) {
|
||||
case '음악':
|
||||
return Icons.music_note_rounded;
|
||||
case 'OTT(동영상)':
|
||||
return Icons.movie_filter_rounded;
|
||||
case '저장/클라우드':
|
||||
return Icons.cloud_outlined;
|
||||
case '통신 · 인터넷 · TV':
|
||||
return Icons.wifi_rounded;
|
||||
case '생활/라이프스타일':
|
||||
return Icons.home_outlined;
|
||||
case '쇼핑/이커머스':
|
||||
return Icons.shopping_cart_outlined;
|
||||
case '프로그래밍':
|
||||
return Icons.code_rounded;
|
||||
case '협업/오피스':
|
||||
return Icons.business_center_outlined;
|
||||
case 'AI 서비스':
|
||||
return Icons.smart_toy_outlined;
|
||||
case '기타':
|
||||
default:
|
||||
return Icons.category_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
Color _getBackgroundColor(bool isSelected) {
|
||||
if (!isSelected) {
|
||||
return isGlassmorphism
|
||||
? AppColors.backgroundColor
|
||||
: Colors.grey.withValues(alpha: 0.1);
|
||||
}
|
||||
|
||||
if (baseColor != null) {
|
||||
return baseColor!;
|
||||
}
|
||||
|
||||
if (gradientColors != null && gradientColors!.isNotEmpty) {
|
||||
return gradientColors![0];
|
||||
}
|
||||
|
||||
return const Color(0xFF3B82F6);
|
||||
}
|
||||
|
||||
Border? _getBorder(bool isSelected) {
|
||||
if (isSelected || !isGlassmorphism) {
|
||||
return null;
|
||||
}
|
||||
return Border.all(
|
||||
color: AppColors.borderColor.withValues(alpha: 0.5),
|
||||
width: 1.5,
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTextColor(bool isSelected) {
|
||||
if (isSelected) {
|
||||
return Colors.white;
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.darkNavy
|
||||
: Colors.grey[700]!;
|
||||
}
|
||||
}
|
||||
@@ -36,33 +36,62 @@ class CurrencyInputField extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
late TextEditingController _formattedController;
|
||||
late FocusNode _focusNode;
|
||||
bool _isFormatted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_formattedController = TextEditingController();
|
||||
_updateFormattedValue();
|
||||
widget.controller.addListener(_onControllerChanged);
|
||||
_focusNode = widget.focusNode ?? FocusNode();
|
||||
_focusNode.addListener(_onFocusChanged);
|
||||
|
||||
// 초기값이 있으면 포맷팅 적용
|
||||
if (widget.controller.text.isNotEmpty) {
|
||||
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
|
||||
if (value != null) {
|
||||
widget.controller.text = _formatCurrency(value);
|
||||
_isFormatted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.controller.removeListener(_onControllerChanged);
|
||||
_formattedController.dispose();
|
||||
if (widget.focusNode == null) {
|
||||
_focusNode.dispose();
|
||||
} else {
|
||||
_focusNode.removeListener(_onFocusChanged);
|
||||
}
|
||||
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 = '';
|
||||
void _onFocusChanged() {
|
||||
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
|
||||
// 포커스를 잃었을 때 포맷팅 적용
|
||||
final value = _parseValue(widget.controller.text);
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
widget.controller.text = _formatCurrency(value);
|
||||
_isFormatted = true;
|
||||
});
|
||||
}
|
||||
} else if (_focusNode.hasFocus && _isFormatted) {
|
||||
// 포커스를 받았을 때 포맷팅 제거
|
||||
final value = _parseValue(widget.controller.text);
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
if (widget.currency == 'KRW') {
|
||||
widget.controller.text = value.toInt().toString();
|
||||
} else {
|
||||
widget.controller.text = value.toString();
|
||||
}
|
||||
_isFormatted = false;
|
||||
});
|
||||
// 커서를 끝으로 이동
|
||||
widget.controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: widget.controller.text.length),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,38 +119,42 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseTextField(
|
||||
controller: _formattedController,
|
||||
focusNode: widget.focusNode,
|
||||
controller: widget.controller,
|
||||
focusNode: _focusNode,
|
||||
label: widget.label,
|
||||
hintText: widget.hintText ?? _defaultHintText,
|
||||
textInputAction: widget.textInputAction,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')),
|
||||
FilteringTextInputFormatter.allow(
|
||||
widget.currency == 'KRW'
|
||||
? RegExp(r'[0-9]')
|
||||
: RegExp(r'[0-9.]')
|
||||
),
|
||||
if (widget.currency == 'USD')
|
||||
// USD의 경우 소수점 이하 2자리까지만 허용
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
final text = newValue.text;
|
||||
if (text.isEmpty) return newValue;
|
||||
|
||||
final parts = text.split('.');
|
||||
if (parts.length > 2) {
|
||||
// 소수점이 2개 이상인 경우 거부
|
||||
return oldValue;
|
||||
}
|
||||
if (parts.length == 2 && parts[1].length > 2) {
|
||||
// 소수점 이하가 2자리를 초과하는 경우 거부
|
||||
return oldValue;
|
||||
}
|
||||
return newValue;
|
||||
}),
|
||||
],
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
widget.onChanged?.call(parsedValue);
|
||||
},
|
||||
validator: widget.validator ?? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
|
||||
117
lib/widgets/common/form_fields/currency_selector.dart
Normal file
117
lib/widgets/common/form_fields/currency_selector.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// 통화 선택 위젯
|
||||
/// KRW(원화)와 USD(달러) 중 선택할 수 있습니다.
|
||||
class CurrencySelector extends StatelessWidget {
|
||||
final String currency;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool isGlassmorphism;
|
||||
|
||||
const CurrencySelector({
|
||||
super.key,
|
||||
required this.currency,
|
||||
required this.onChanged,
|
||||
this.isGlassmorphism = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
_CurrencyOption(
|
||||
label: '₩',
|
||||
value: 'KRW',
|
||||
isSelected: currency == 'KRW',
|
||||
onTap: () => onChanged('KRW'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_CurrencyOption(
|
||||
label: '\$',
|
||||
value: 'USD',
|
||||
isSelected: currency == 'USD',
|
||||
onTap: () => onChanged('USD'),
|
||||
isGlassmorphism: isGlassmorphism,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 통화 옵션 버튼
|
||||
class _CurrencyOption extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
final bool isGlassmorphism;
|
||||
|
||||
const _CurrencyOption({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
required this.isGlassmorphism,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _getBackgroundColor(theme),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: _getBorder(),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _getTextColor(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getBackgroundColor(ThemeData theme) {
|
||||
if (isSelected) {
|
||||
return isGlassmorphism
|
||||
? theme.primaryColor
|
||||
: const Color(0xFF3B82F6);
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.surfaceColorAlt
|
||||
: Colors.grey.withValues(alpha: 0.1);
|
||||
}
|
||||
|
||||
Border? _getBorder() {
|
||||
if (isSelected || !isGlassmorphism) {
|
||||
return null;
|
||||
}
|
||||
return Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 1.5,
|
||||
);
|
||||
}
|
||||
|
||||
Color _getTextColor() {
|
||||
if (isSelected) {
|
||||
return Colors.white;
|
||||
}
|
||||
return isGlassmorphism
|
||||
? AppColors.navyGray
|
||||
: Colors.grey[600]!;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../../theme/app_colors.dart';
|
||||
|
||||
/// 날짜 선택 필드 위젯
|
||||
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
|
||||
@@ -47,7 +48,7 @@ class DatePickerField extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.onSurface,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -82,10 +83,11 @@ class DatePickerField extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: contentPadding ?? const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? Colors.white,
|
||||
color: backgroundColor ?? AppColors.surfaceColorAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.transparent,
|
||||
color: AppColors.borderColor.withValues(alpha: 0.7),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
@@ -96,8 +98,8 @@ class DatePickerField extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: enabled
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -105,8 +107,8 @@ class DatePickerField extends StatelessWidget {
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: enabled
|
||||
? theme.colorScheme.onSurface.withValues(alpha: 0.6)
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
? AppColors.navyGray
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -227,8 +229,12 @@ class _DateRangeItem extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor.withValues(alpha: 0.7),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -237,7 +243,7 @@ class _DateRangeItem extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -249,8 +255,8 @@ class _DateRangeItem extends StatelessWidget {
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: date != null
|
||||
? theme.colorScheme.onSurface
|
||||
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -161,10 +161,10 @@ class AppSnackBar {
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: showAtTop
|
||||
? EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 16,
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: MediaQuery.of(context).size.height - 120,
|
||||
bottom: MediaQuery.of(context).size.height - 100,
|
||||
)
|
||||
: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -222,10 +222,10 @@ class AppSnackBar {
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: showAtTop
|
||||
? EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 16,
|
||||
top: MediaQuery.of(context).padding.top,
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: MediaQuery.of(context).size.height - 120,
|
||||
bottom: MediaQuery.of(context).size.height - 100,
|
||||
)
|
||||
: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../controllers/detail_screen_controller.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../common/form_fields/currency_input_field.dart';
|
||||
import '../common/form_fields/date_picker_field.dart';
|
||||
|
||||
@@ -19,9 +21,11 @@ class DetailEventSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseColor = controller.getCardColor();
|
||||
return Consumer<DetailScreenController>(
|
||||
builder: (context, controller, child) {
|
||||
final baseColor = controller.getCardColor();
|
||||
|
||||
return FadeTransition(
|
||||
return FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
@@ -31,11 +35,21 @@ class DetailEventSection extends StatelessWidget {
|
||||
parent: controller.animationController!,
|
||||
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
|
||||
)),
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassCard,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -66,6 +80,7 @@ class DetailEventSection extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -93,14 +108,18 @@ class DetailEventSection extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
color: AppColors.infoColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.infoColor.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Colors.blue[700],
|
||||
color: AppColors.infoColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -109,7 +128,8 @@ class DetailEventSection extends StatelessWidget {
|
||||
'할인 또는 프로모션 가격을 설정하세요',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue[700],
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -123,6 +143,10 @@ class DetailEventSection extends StatelessWidget {
|
||||
endDate: controller.eventEndDate,
|
||||
onStartDateSelected: (date) {
|
||||
controller.eventStartDate = date;
|
||||
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||
if (date != null && controller.eventEndDate == null) {
|
||||
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||
}
|
||||
},
|
||||
onEndDateSelected: (date) {
|
||||
controller.eventEndDate = date;
|
||||
@@ -138,6 +162,18 @@ class DetailEventSection extends StatelessWidget {
|
||||
currency: controller.currency,
|
||||
label: '이벤트 가격',
|
||||
hintText: '할인된 가격을 입력하세요',
|
||||
validator: controller.isEventActive
|
||||
? (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '이벤트 가격을 입력해주세요';
|
||||
}
|
||||
final price = double.tryParse(value.replaceAll(',', ''));
|
||||
if (price == null || price <= 0) {
|
||||
return '올바른 가격을 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 할인율 표시
|
||||
@@ -156,6 +192,8 @@ class DetailEventSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +247,7 @@ class _DiscountBadge extends StatelessWidget {
|
||||
? '₩${discountAmount.toInt().toString()}원 절약'
|
||||
: '\$${discountAmount.toStringAsFixed(2)} 절약',
|
||||
style: TextStyle(
|
||||
color: Colors.green[700],
|
||||
color: const Color(0xFF15803D),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../controllers/detail_screen_controller.dart';
|
||||
import '../../providers/category_provider.dart';
|
||||
import '../../theme/app_colors.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';
|
||||
import '../common/form_fields/currency_selector.dart';
|
||||
import '../common/form_fields/billing_cycle_selector.dart';
|
||||
import '../common/form_fields/category_selector.dart';
|
||||
|
||||
/// 상세 화면 폼 섹션
|
||||
/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다.
|
||||
@@ -22,9 +26,11 @@ class DetailFormSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseColor = controller.getCardColor();
|
||||
return Consumer<DetailScreenController>(
|
||||
builder: (context, controller, child) {
|
||||
final baseColor = controller.getCardColor();
|
||||
|
||||
return FadeTransition(
|
||||
return FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
@@ -34,11 +40,21 @@ class DetailFormSection extends StatelessWidget {
|
||||
parent: controller.animationController!,
|
||||
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
|
||||
)),
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassCard,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -86,11 +102,13 @@ class DetailFormSection extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CurrencySelector(
|
||||
CurrencySelector(
|
||||
currency: controller.currency,
|
||||
isGlassmorphism: true,
|
||||
onChanged: (value) {
|
||||
controller.currency = value;
|
||||
// 통화 변경시 금액 포맷 업데이트
|
||||
@@ -121,12 +139,14 @@ class DetailFormSection extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_BillingCycleSelector(
|
||||
BillingCycleSelector(
|
||||
billingCycle: controller.billingCycle,
|
||||
baseColor: baseColor,
|
||||
isGlassmorphism: true,
|
||||
onChanged: (value) {
|
||||
controller.billingCycle = value;
|
||||
},
|
||||
@@ -159,13 +179,15 @@ class DetailFormSection extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_CategorySelector(
|
||||
CategorySelector(
|
||||
categories: categoryProvider.categories,
|
||||
selectedCategoryId: controller.selectedCategoryId,
|
||||
baseColor: baseColor,
|
||||
isGlassmorphism: true,
|
||||
onChanged: (categoryId) {
|
||||
controller.selectedCategoryId = categoryId;
|
||||
},
|
||||
@@ -180,191 +202,8 @@ class DetailFormSection extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 통화 선택기
|
||||
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.withValues(alpha: 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.withValues(alpha: 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.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
category.icon,
|
||||
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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../../models/subscription_model.dart';
|
||||
import '../../controllers/detail_screen_controller.dart';
|
||||
@@ -24,10 +25,12 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseColor = controller.getCardColor();
|
||||
final gradient = controller.getGradient(baseColor);
|
||||
return Consumer<DetailScreenController>(
|
||||
builder: (context, controller, child) {
|
||||
final baseColor = controller.getCardColor();
|
||||
final gradient = controller.getGradient(baseColor);
|
||||
|
||||
return Container(
|
||||
return Container(
|
||||
height: 320,
|
||||
decoration: BoxDecoration(gradient: gradient),
|
||||
child: Stack(
|
||||
@@ -118,8 +121,8 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: WebsiteIcon(
|
||||
url: subscription.websiteUrl,
|
||||
serviceName: subscription.serviceName,
|
||||
url: controller.websiteUrlController.text,
|
||||
serviceName: controller.serviceNameController.text,
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
@@ -131,7 +134,7 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
subscription.serviceName,
|
||||
controller.serviceNameController.text,
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
@@ -148,7 +151,7 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${subscription.billingCycle} 결제',
|
||||
'${controller.billingCycle} 결제',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -174,20 +177,22 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
_InfoColumn(
|
||||
label: '다음 결제일',
|
||||
value: DateFormat('yyyy년 MM월 dd일')
|
||||
.format(subscription.nextBillingDate),
|
||||
.format(controller.nextBillingDate),
|
||||
),
|
||||
_InfoColumn(
|
||||
label: '월 지출',
|
||||
value: NumberFormat.currency(
|
||||
locale: subscription.currency == 'KRW'
|
||||
locale: controller.currency == 'KRW'
|
||||
? 'ko_KR'
|
||||
: 'en_US',
|
||||
symbol: subscription.currency == 'KRW'
|
||||
symbol: controller.currency == 'KRW'
|
||||
? '₩'
|
||||
: '\$',
|
||||
decimalDigits:
|
||||
subscription.currency == 'KRW' ? 0 : 2,
|
||||
).format(subscription.monthlyCost),
|
||||
controller.currency == 'KRW' ? 0 : 2,
|
||||
).format(double.tryParse(
|
||||
controller.monthlyCostController.text.replaceAll(',', '')
|
||||
) ?? 0),
|
||||
alignment: CrossAxisAlignment.end,
|
||||
),
|
||||
],
|
||||
@@ -204,6 +209,8 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../controllers/detail_screen_controller.dart';
|
||||
import '../../theme/app_colors.dart';
|
||||
import '../common/form_fields/base_text_field.dart';
|
||||
import '../common/buttons/secondary_button.dart';
|
||||
|
||||
@@ -31,11 +32,21 @@ class DetailUrlSection extends StatelessWidget {
|
||||
parent: controller.animationController!,
|
||||
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
|
||||
)),
|
||||
child: Card(
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.glassCard,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.shadowBlack,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
@@ -63,6 +74,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -78,7 +90,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
keyboardType: TextInputType.url,
|
||||
prefixIcon: Icon(
|
||||
Icons.link_rounded,
|
||||
color: Colors.grey[600],
|
||||
color: AppColors.navyGray,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -89,10 +101,10 @@ class DetailUrlSection extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
color: AppColors.warningColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Colors.orange.withValues(alpha: 0.3),
|
||||
color: AppColors.warningColor.withValues(alpha: 0.4),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -103,7 +115,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: Colors.orange[700],
|
||||
color: AppColors.warningColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -112,7 +124,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.orange[700],
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -122,7 +134,8 @@ class DetailUrlSection extends StatelessWidget {
|
||||
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
@@ -131,7 +144,7 @@ class DetailUrlSection extends StatelessWidget {
|
||||
text: '해지 페이지로 이동',
|
||||
icon: Icons.open_in_new_rounded,
|
||||
onPressed: controller.openCancellationPage,
|
||||
color: Colors.orange[700],
|
||||
color: AppColors.warningColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -144,14 +157,18 @@ class DetailUrlSection extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
color: AppColors.infoColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.infoColor.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.auto_fix_high_rounded,
|
||||
color: Colors.blue[700],
|
||||
color: AppColors.infoColor,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -160,7 +177,8 @@ class DetailUrlSection extends StatelessWidget {
|
||||
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue[700],
|
||||
color: AppColors.darkNavy,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -68,23 +68,12 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
right: 20,
|
||||
height: 88,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 100 * (1 - _animation.value)),
|
||||
child: Opacity(
|
||||
opacity: _animation.value,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 차단 레이어 - 크기 명시
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 글래스모피즘 레이어
|
||||
GlassmorphismCard(
|
||||
child: GlassmorphismCard(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
borderRadius: 24,
|
||||
@@ -123,8 +112,6 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -174,10 +174,17 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapUp: (details) {
|
||||
_handleTapUp(details);
|
||||
// onTap 콜백 실행
|
||||
if (widget.onTap != null) {
|
||||
print('[AnimatedGlassmorphismCard] onTap 콜백 실행');
|
||||
widget.onTap!();
|
||||
}
|
||||
},
|
||||
onTapCancel: _handleTapCancel,
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
@@ -191,6 +198,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
borderRadius: widget.borderRadius,
|
||||
blur: _blurAnimation.value,
|
||||
opacity: widget.opacity,
|
||||
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
|
||||
child: widget.child,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../widgets/native_ad_widget.dart';
|
||||
import '../widgets/main_summary_card.dart';
|
||||
import '../widgets/subscription_list_widget.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/glassmorphic_app_bar.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class HomeContent extends StatelessWidget {
|
||||
@@ -67,10 +66,10 @@ class HomeContent extends StatelessWidget {
|
||||
controller: scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
const GlassmorphicSliverAppBar(
|
||||
title: '홈',
|
||||
pinned: true,
|
||||
expandedHeight: kToolbarHeight,
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||
),
|
||||
),
|
||||
const SliverToBoxAdapter(
|
||||
child: NativeAdWidget(key: ValueKey('home_ad')),
|
||||
@@ -105,7 +104,9 @@ class HomeContent extends StatelessWidget {
|
||||
parent: slideController, curve: Curves.easeOutCubic)),
|
||||
child: Text(
|
||||
'나의 구독 서비스',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
color: AppColors.darkNavy,
|
||||
),
|
||||
),
|
||||
),
|
||||
SlideTransition(
|
||||
@@ -143,7 +144,7 @@ class HomeContent extends StatelessWidget {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100 + MediaQuery.of(context).padding.bottom,
|
||||
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -37,185 +37,196 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 24,
|
||||
blur: 15,
|
||||
backgroundColor: AppColors.glassCard,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: AppColors.mainGradient.map((color) => color.withValues(alpha: 0.2)).toList(),
|
||||
borderRadius: 24,
|
||||
blur: 15,
|
||||
backgroundColor: AppColors.glassCard,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: AppColors.mainGradient
|
||||
.map((color) => color.withValues(alpha: 0.2))
|
||||
.toList(),
|
||||
),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder,
|
||||
width: 1,
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 180,
|
||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||
),
|
||||
border: Border.all(
|
||||
color: AppColors.glassBorder,
|
||||
width: 1,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 180,
|
||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 애니메이션 웨이브 배경
|
||||
Positioned.fill(
|
||||
child: AnimatedWaveBackground(
|
||||
controller: waveController,
|
||||
pulseController: pulseController,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 애니메이션 웨이브 배경
|
||||
Positioned.fill(
|
||||
child: AnimatedWaveBackground(
|
||||
controller: waveController,
|
||||
pulseController: pulseController,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'이번 달 총 구독 비용',
|
||||
style: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'이번 달 총 구독 비용',
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'원',
|
||||
style: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '연간 구독 비용',
|
||||
value: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(yearlyCost)}원',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '총 구독 서비스',
|
||||
value: '$totalSubscriptions개',
|
||||
),
|
||||
],
|
||||
),
|
||||
// 이벤트 절약액 표시
|
||||
if (activeEvents > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.2),
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 14,
|
||||
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'이벤트 할인 중',
|
||||
style: TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' 절약 ($activeEvents개)',
|
||||
style: TextStyle(
|
||||
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'원',
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '예상 연간 구독 비용',
|
||||
value: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(yearlyCost)}원',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '총 구독 서비스',
|
||||
value: '$totalSubscriptions개',
|
||||
),
|
||||
],
|
||||
),
|
||||
// 이벤트 절약액 표시
|
||||
if (activeEvents > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.2),
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.primaryColor
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 14,
|
||||
color: AppColors
|
||||
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'이벤트 할인 중',
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: AppColors
|
||||
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' 절약 ($activeEvents개)',
|
||||
style: TextStyle(
|
||||
color: AppColors
|
||||
.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -236,7 +247,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -245,7 +256,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import 'website_icon.dart';
|
||||
import 'app_navigator.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
@@ -8,10 +10,12 @@ import 'glassmorphism_card.dart';
|
||||
|
||||
class SubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SubscriptionCard({
|
||||
super.key,
|
||||
required this.subscription,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -190,6 +194,34 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
return false;
|
||||
}
|
||||
|
||||
// 카테고리별 그라데이션 색상 생성
|
||||
List<Color> _getCategoryGradientColors(BuildContext context) {
|
||||
try {
|
||||
if (widget.subscription.categoryId == null) {
|
||||
return AppColors.blueGradient;
|
||||
}
|
||||
|
||||
final categoryProvider = context.watch<CategoryProvider>();
|
||||
final category = categoryProvider.getCategoryById(widget.subscription.categoryId!);
|
||||
|
||||
if (category == null) {
|
||||
return AppColors.blueGradient;
|
||||
}
|
||||
|
||||
final categoryColor = Color(
|
||||
int.parse(category.color.replaceAll('#', '0xFF'))
|
||||
);
|
||||
|
||||
return [
|
||||
categoryColor,
|
||||
categoryColor.withValues(alpha: 0.8),
|
||||
];
|
||||
} catch (e) {
|
||||
// 색상 파싱 실패 시 기본 파란색 그라데이션 반환
|
||||
return AppColors.blueGradient;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -200,53 +232,39 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _onHover(true),
|
||||
onExit: (_) => _onHover(false),
|
||||
child: AnimatedBuilder(
|
||||
animation: _hoverController,
|
||||
builder: (context, child) {
|
||||
final scale = 1.0 + (0.02 * _hoverController.value);
|
||||
child: AnimatedGlassmorphismCard(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: 16,
|
||||
blur: _isHovering ? 15 : 10,
|
||||
width: double.infinity, // 전체 너비를 차지하도록 설정
|
||||
onTap: widget.onTap ?? () async {
|
||||
await AppNavigator.toDetail(context, widget.subscription);
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
// 그라데이션 상단 바 효과
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: widget.subscription.isCurrentlyInEvent
|
||||
? [
|
||||
const Color(0xFFFF6B6B),
|
||||
const Color(0xFFFF8787),
|
||||
]
|
||||
: isNearBilling
|
||||
? AppColors.amberGradient
|
||||
: _getCategoryGradientColors(context),
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
await AppNavigator.toDetail(context, widget.subscription);
|
||||
},
|
||||
splashColor: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
highlightColor: AppColors.primaryColor.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: AnimatedGlassmorphismCard(
|
||||
onTap: () {}, // onTap은 이미 InkWell에서 처리됨
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: 16,
|
||||
blur: _isHovering ? 15 : 10,
|
||||
width: double.infinity, // 전체 너비를 차지하도록 설정
|
||||
child: Column(
|
||||
children: [
|
||||
// 그라데이션 상단 바 효과
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: widget.subscription.isCurrentlyInEvent
|
||||
? [
|
||||
const Color(0xFFFF6B6B),
|
||||
const Color(0xFFFF8787),
|
||||
]
|
||||
: isNearBilling
|
||||
? AppColors.amberGradient
|
||||
: AppColors.blueGradient,
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 서비스 아이콘
|
||||
@@ -304,7 +322,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
@@ -526,15 +544,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -42,10 +42,8 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
child: CategoryHeaderWidget(
|
||||
categoryName: category,
|
||||
subscriptionCount: subscriptions.length,
|
||||
totalCost: subscriptions.fold(
|
||||
0.0,
|
||||
(sum, sub) => sum + sub.monthlyCost,
|
||||
),
|
||||
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
|
||||
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
|
||||
),
|
||||
),
|
||||
// 카테고리별 구독 목록
|
||||
@@ -87,10 +85,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
child: SwipeableSubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
onTap: () {
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onEdit: () {
|
||||
// 편집 화면으로 이동
|
||||
print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onDelete: () async {
|
||||
@@ -111,7 +106,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showSuccess(
|
||||
AppSnackBar.showError(
|
||||
context: context,
|
||||
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.',
|
||||
icon: Icons.delete_forever_rounded,
|
||||
@@ -134,6 +129,13 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 특정 통화의 총 합계를 계산합니다.
|
||||
double _calculateTotalByCurrency(List<SubscriptionModel> subscriptions, String currency) {
|
||||
return subscriptions
|
||||
.where((sub) => sub.currency == currency)
|
||||
.fold(0.0, (sum, sub) => sum + sub.monthlyCost);
|
||||
}
|
||||
}
|
||||
|
||||
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../utils/haptic_feedback_helper.dart';
|
||||
import 'subscription_card.dart';
|
||||
@@ -8,7 +9,7 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
final VoidCallback? onEdit;
|
||||
final Future<void> Function()? onDelete;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
|
||||
const SwipeableSubscriptionCard({
|
||||
super.key,
|
||||
required this.subscription,
|
||||
@@ -18,23 +19,34 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState();
|
||||
State<SwipeableSubscriptionCard> createState() =>
|
||||
_SwipeableSubscriptionCardState();
|
||||
}
|
||||
|
||||
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// 상수 정의
|
||||
static const double _tapTolerance = 20.0; // 탭 허용 범위
|
||||
static const double _actionThresholdPercent = 0.15;
|
||||
static const double _deleteThresholdPercent = 0.40;
|
||||
static const int _tapDurationMs = 500;
|
||||
static const double _velocityThreshold = 800.0;
|
||||
// static const double _animationDuration = 300.0;
|
||||
|
||||
// 애니메이션 관련
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _animation;
|
||||
double _dragStartX = 0;
|
||||
double _currentOffset = 0; // 현재 카드의 실제 위치
|
||||
bool _isDragging = false; // 드래그 중인지 여부
|
||||
|
||||
// 제스처 추적
|
||||
Offset? _startPosition;
|
||||
DateTime? _startTime;
|
||||
bool _isValidTap = true;
|
||||
|
||||
// 상태 관리
|
||||
double _currentOffset = 0;
|
||||
bool _isSwipingLeft = false;
|
||||
bool _hapticTriggered = false;
|
||||
double _screenWidth = 0;
|
||||
double _cardWidth = 0; // 카드의 실제 너비 (margin 제외)
|
||||
|
||||
static const double _actionThresholdPercent = 0.15; // 15%에서 액션 버튼 표시
|
||||
static const double _deleteThresholdPercent = 0.40; // 40%에서 삭제/편집 실행
|
||||
double _cardWidth = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -50,128 +62,128 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutExpo,
|
||||
));
|
||||
|
||||
// 애니메이션 상태 리스너 추가
|
||||
_controller.addStatusListener(_onAnimationStatusChanged);
|
||||
|
||||
// 애니메이션 리스너 추가
|
||||
_controller.addListener(_onAnimationUpdate);
|
||||
|
||||
_controller.addListener(() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_currentOffset = _animation.value;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_screenWidth = MediaQuery.of(context).size.width;
|
||||
_cardWidth = _screenWidth - 32; // 좌우 margin 16px씩 제외
|
||||
_cardWidth = MediaQuery.of(context).size.width - 32;
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SwipeableSubscriptionCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 위젯이 업데이트될 때 카드를 원위치로 복귀
|
||||
if (oldWidget.subscription.id != widget.subscription.id) {
|
||||
_controller.stop();
|
||||
setState(() {
|
||||
_currentOffset = 0;
|
||||
_isDragging = false;
|
||||
});
|
||||
_resetCard();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.removeListener(_onAnimationUpdate);
|
||||
_controller.removeStatusListener(_onAnimationStatusChanged);
|
||||
_controller.stop();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onAnimationUpdate() {
|
||||
if (!_isDragging) {
|
||||
setState(() {
|
||||
_currentOffset = _animation.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onAnimationStatusChanged(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed && !_isDragging) {
|
||||
setState(() {
|
||||
_currentOffset = _animation.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
_dragStartX = details.localPosition.dx;
|
||||
// 제스처 핸들러
|
||||
void _handlePanStart(DragStartDetails details) {
|
||||
_startPosition = details.localPosition;
|
||||
_startTime = DateTime.now();
|
||||
_isValidTap = true;
|
||||
_hapticTriggered = false;
|
||||
_isDragging = true;
|
||||
_controller.stop(); // 진행 중인 애니메이션 중지
|
||||
_controller.stop();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
final delta = details.localPosition.dx - _dragStartX;
|
||||
void _handlePanUpdate(DragUpdateDetails details) {
|
||||
final currentPosition = details.localPosition;
|
||||
final delta = currentPosition.dx - _startPosition!.dx;
|
||||
final distance = (currentPosition - _startPosition!).distance;
|
||||
|
||||
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
|
||||
if (distance > _tapTolerance) {
|
||||
_isValidTap = false;
|
||||
}
|
||||
|
||||
// 카드 이동
|
||||
setState(() {
|
||||
_currentOffset = delta;
|
||||
_isSwipingLeft = delta < 0;
|
||||
});
|
||||
|
||||
// 햅틱 피드백 트리거 (카드 너비의 15%)
|
||||
final actionThreshold = _cardWidth * _actionThresholdPercent;
|
||||
if (!_hapticTriggered && _currentOffset.abs() > actionThreshold) {
|
||||
_hapticTriggered = true;
|
||||
HapticFeedbackHelper.mediumImpact();
|
||||
}
|
||||
|
||||
// 삭제 임계값에 도달했을 때 강한 햅틱 (카드 너비의 40%)
|
||||
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
||||
if (_currentOffset.abs() > deleteThreshold && _hapticTriggered) {
|
||||
HapticFeedbackHelper.heavyImpact();
|
||||
_hapticTriggered = false; // 반복 방지
|
||||
}
|
||||
|
||||
// 햅틱 피드백
|
||||
_triggerHapticFeedback();
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) async {
|
||||
_isDragging = false;
|
||||
void _handlePanEnd(DragEndDetails details) {
|
||||
final duration = DateTime.now().difference(_startTime!);
|
||||
final velocity = details.velocity.pixelsPerSecond.dx;
|
||||
|
||||
// 탭/스와이프 처리 분기
|
||||
|
||||
// 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리
|
||||
if (_isValidTap &&
|
||||
duration.inMilliseconds < _tapDurationMs &&
|
||||
_currentOffset.abs() < _tapTolerance) {
|
||||
_processTap();
|
||||
return;
|
||||
}
|
||||
|
||||
// 스와이프 처리
|
||||
_processSwipe(velocity);
|
||||
}
|
||||
|
||||
// 헬퍼 메서드
|
||||
void _processTap() {
|
||||
if (widget.onTap != null) {
|
||||
widget.onTap!();
|
||||
}
|
||||
_animateToOffset(0);
|
||||
}
|
||||
|
||||
void _processSwipe(double velocity) {
|
||||
final extent = _currentOffset.abs();
|
||||
|
||||
// 카드 너비의 40% 계산
|
||||
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
||||
|
||||
if (extent > deleteThreshold || velocity.abs() > 800) {
|
||||
// 40% 이상 스와이프 시 삭제/편집 액션
|
||||
if (_isSwipingLeft && widget.onDelete != null) {
|
||||
|
||||
if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) {
|
||||
// 삭제 실행
|
||||
if (widget.onDelete != null) {
|
||||
HapticFeedbackHelper.success();
|
||||
// 삭제 확인 다이얼로그 표시
|
||||
await widget.onDelete!();
|
||||
// 다이얼로그가 닫힌 후 원위치로 복귀
|
||||
if (mounted) {
|
||||
_animateToOffset(0);
|
||||
}
|
||||
} else if (!_isSwipingLeft && widget.onEdit != null) {
|
||||
HapticFeedbackHelper.success();
|
||||
// 편집 화면으로 이동 전 원위치로 복귀
|
||||
_animateToOffset(0);
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
widget.onEdit!();
|
||||
widget.onDelete!().then((_) {
|
||||
if (mounted) {
|
||||
_animateToOffset(0);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 액션이 없는 경우 원위치로 복귀
|
||||
_animateToOffset(0);
|
||||
}
|
||||
} else {
|
||||
// 40% 미만: 모두 원위치로 복귀
|
||||
// 원위치 복귀
|
||||
_animateToOffset(0);
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerHapticFeedback() {
|
||||
final actionThreshold = _cardWidth * _actionThresholdPercent;
|
||||
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
||||
final absOffset = _currentOffset.abs();
|
||||
|
||||
if (!_hapticTriggered && absOffset > actionThreshold) {
|
||||
_hapticTriggered = true;
|
||||
HapticFeedbackHelper.mediumImpact();
|
||||
} else if (_hapticTriggered && absOffset > deleteThreshold) {
|
||||
HapticFeedbackHelper.heavyImpact();
|
||||
_hapticTriggered = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _animateToOffset(double offset) {
|
||||
// 애니메이션 컨트롤러 리셋
|
||||
_controller.stop();
|
||||
_controller.value = 0;
|
||||
|
||||
_animation = Tween<double>(
|
||||
begin: _currentOffset,
|
||||
end: offset,
|
||||
@@ -179,94 +191,97 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||
parent: _controller,
|
||||
curve: Curves.easeOutExpo,
|
||||
));
|
||||
|
||||
_controller.forward();
|
||||
|
||||
_controller.forward(from: 0);
|
||||
}
|
||||
|
||||
void _resetCard() {
|
||||
_controller.stop();
|
||||
setState(() {
|
||||
_currentOffset = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// 빌드 메서드
|
||||
Widget _buildActionButtons() {
|
||||
return Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (!_isSwipingLeft) _buildDeleteIcon(true),
|
||||
if (_isSwipingLeft) _buildDeleteIcon(false),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeleteIcon(bool isLeft) {
|
||||
final showIcon = _currentOffset.abs() > (_cardWidth * 0.10);
|
||||
final isDeleteThreshold =
|
||||
_currentOffset.abs() > (_cardWidth * _deleteThresholdPercent);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: isLeft ? 24 : 0,
|
||||
right: isLeft ? 0 : 24,
|
||||
),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: showIcon ? 1.0 : 0.0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
scale: showIcon ? 1.0 : 0.5,
|
||||
child: Icon(
|
||||
isDeleteThreshold
|
||||
? Icons.delete_forever_rounded
|
||||
: Icons.delete_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCard() {
|
||||
return Transform.translate(
|
||||
offset: Offset(_currentOffset, 0),
|
||||
child: Transform.scale(
|
||||
scale: 1.0 - (_currentOffset.abs() / 2000),
|
||||
child: Transform.rotate(
|
||||
angle: _currentOffset / 2000,
|
||||
child: SubscriptionCard(
|
||||
subscription: widget.subscription,
|
||||
onTap: widget.onTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 웹과 모바일 모두 동일한 스와이프 기능 제공
|
||||
return Stack(
|
||||
children: [
|
||||
// 배경 액션 버튼들
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.transparent, // 투명하게 변경
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 편집 버튼 (오른쪽 스와이프)
|
||||
if (!_isSwipingLeft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 24),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
scale: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.5,
|
||||
child: const Icon(
|
||||
Icons.edit_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 삭제 버튼 (왼쪽 스와이프)
|
||||
if (_isSwipingLeft)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
opacity: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.0,
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
scale: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.5,
|
||||
child: Icon(
|
||||
_currentOffset.abs() > (_cardWidth * _deleteThresholdPercent)
|
||||
? Icons.delete_forever_rounded
|
||||
: Icons.delete_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 스와이프 가능한 카드
|
||||
_buildActionButtons(),
|
||||
GestureDetector(
|
||||
onHorizontalDragStart: _handleDragStart,
|
||||
onHorizontalDragUpdate: _handleDragUpdate,
|
||||
onHorizontalDragEnd: _handleDragEnd,
|
||||
child: Transform.translate(
|
||||
offset: Offset(_currentOffset, 0),
|
||||
child: Transform.scale(
|
||||
scale: 1.0 - (_currentOffset.abs() / 2000),
|
||||
child: Transform.rotate(
|
||||
angle: _currentOffset / 2000,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (_currentOffset.abs() < 10) {
|
||||
widget.onTap?.call();
|
||||
}
|
||||
},
|
||||
child: SubscriptionCard(
|
||||
subscription: widget.subscription,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanStart: _handlePanStart,
|
||||
onPanUpdate: _handlePanUpdate,
|
||||
onPanEnd: _handlePanEnd,
|
||||
child: _buildCard(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user