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:
95
lib/widgets/add_subscription/add_subscription_app_bar.dart
Normal file
95
lib/widgets/add_subscription/add_subscription_app_bar.dart
Normal 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에서 구독 정보 스캔',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
142
lib/widgets/add_subscription/add_subscription_event_section.dart
Normal file
142
lib/widgets/add_subscription/add_subscription_event_section.dart
Normal 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: '할인된 가격을 입력하세요',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
431
lib/widgets/add_subscription/add_subscription_form.dart
Normal file
431
lib/widgets/add_subscription/add_subscription_form.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/widgets/add_subscription/add_subscription_header.dart
Normal file
88
lib/widgets/add_subscription/add_subscription_header.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/app_lock_screen.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
|
||||
173
lib/widgets/common/buttons/danger_button.dart
Normal file
173
lib/widgets/common/buttons/danger_button.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
112
lib/widgets/common/buttons/primary_button.dart
Normal file
112
lib/widgets/common/buttons/primary_button.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
203
lib/widgets/common/buttons/secondary_button.dart
Normal file
203
lib/widgets/common/buttons/secondary_button.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
229
lib/widgets/common/cards/section_card.dart
Normal file
229
lib/widgets/common/cards/section_card.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
353
lib/widgets/common/dialogs/confirmation_dialog.dart
Normal file
353
lib/widgets/common/dialogs/confirmation_dialog.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
238
lib/widgets/common/dialogs/loading_overlay.dart
Normal file
238
lib/widgets/common/dialogs/loading_overlay.dart
Normal 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,
|
||||
}
|
||||
145
lib/widgets/common/form_fields/base_text_field.dart
Normal file
145
lib/widgets/common/form_fields/base_text_field.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
138
lib/widgets/common/form_fields/currency_input_field.dart
Normal file
138
lib/widgets/common/form_fields/currency_input_field.dart
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
263
lib/widgets/common/form_fields/date_picker_field.dart
Normal file
263
lib/widgets/common/form_fields/date_picker_field.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
lib/widgets/detail/detail_action_buttons.dart
Normal file
46
lib/widgets/detail/detail_action_buttons.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
221
lib/widgets/detail/detail_event_section.dart
Normal file
221
lib/widgets/detail/detail_event_section.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
370
lib/widgets/detail/detail_form_section.dart
Normal file
370
lib/widgets/detail/detail_form_section.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
247
lib/widgets/detail/detail_header_section.dart
Normal file
247
lib/widgets/detail/detail_header_section.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
178
lib/widgets/detail/detail_url_section.dart
Normal file
178
lib/widgets/detail/detail_url_section.dart
Normal 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],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -137,7 +137,7 @@ class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
|
||||
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
|
||||
}
|
||||
|
||||
return Column(
|
||||
return const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:ui';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
@@ -62,8 +61,6 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../theme/app_colors.dart';
|
||||
import 'glassmorphic_app_bar.dart';
|
||||
import 'floating_navigation_bar.dart';
|
||||
|
||||
/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드
|
||||
@@ -79,7 +78,6 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
||||
void _setupScrollListener() {
|
||||
_scrollController?.addListener(() {
|
||||
final currentScroll = _scrollController!.position.pixels;
|
||||
final maxScroll = _scrollController!.position.maxScrollExtent;
|
||||
|
||||
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
|
||||
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {
|
||||
|
||||
@@ -126,7 +126,6 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _blurAnimation;
|
||||
bool _isPressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -160,23 +159,14 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
setState(() {
|
||||
_isPressed = true;
|
||||
});
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
setState(() {
|
||||
_isPressed = false;
|
||||
});
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
setState(() {
|
||||
_isPressed = false;
|
||||
});
|
||||
_controller.reverse();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import '../widgets/subscription_list_widget.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/glassmorphic_app_bar.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
|
||||
class HomeContent extends StatelessWidget {
|
||||
final AnimationController fadeController;
|
||||
@@ -73,8 +72,8 @@ class HomeContent extends StatelessWidget {
|
||||
pinned: true,
|
||||
expandedHeight: kToolbarHeight,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: NativeAdWidget(key: const ValueKey('home_ad')),
|
||||
const SliverToBoxAdapter(
|
||||
child: NativeAdWidget(key: ValueKey('home_ad')),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SlideTransition(
|
||||
@@ -119,14 +118,14 @@ class HomeContent extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
'${provider.subscriptions.length}개',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14,
|
||||
color: AppColors.primaryColor,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/format_helper.dart';
|
||||
import 'animated_wave_background.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
|
||||
class SpringAnimationWidget extends StatefulWidget {
|
||||
@@ -44,14 +43,6 @@ class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
// 스프링 시뮬레이션
|
||||
final simulation = SpringSimulation(
|
||||
widget.spring,
|
||||
0.0,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
|
||||
// 오프셋 애니메이션
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
begin: widget.initialOffset ?? const Offset(0, 50),
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../models/subscription_model.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import 'website_icon.dart';
|
||||
import 'app_navigator.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class SubscriptionCard extends StatefulWidget {
|
||||
@@ -27,9 +22,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _hoverController;
|
||||
bool _isHovering = false;
|
||||
final double _initialElevation = 1.0;
|
||||
final double _hoveredElevation = 3.0;
|
||||
late SubscriptionProvider _subscriptionProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -40,12 +32,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_subscriptionProvider =
|
||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -221,10 +207,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
child: AnimatedBuilder(
|
||||
animation: _hoverController,
|
||||
builder: (context, child) {
|
||||
final elevation = _initialElevation +
|
||||
(_hoveredElevation - _initialElevation) *
|
||||
_hoverController.value;
|
||||
|
||||
final scale = 1.0 + (0.02 * _hoverController.value);
|
||||
|
||||
return Transform.scale(
|
||||
@@ -337,10 +319,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFFF6B6B),
|
||||
const Color(0xFFFF8787),
|
||||
Color(0xFFFF6B6B),
|
||||
Color(0xFFFF8787),
|
||||
],
|
||||
),
|
||||
borderRadius:
|
||||
@@ -349,12 +331,12 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 11,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
SizedBox(width: 3),
|
||||
Text(
|
||||
'이벤트',
|
||||
style: TextStyle(
|
||||
@@ -386,7 +368,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
),
|
||||
child: Text(
|
||||
widget.subscription.billingCycle,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -424,7 +406,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
decimalDigits: 0,
|
||||
).format(widget
|
||||
.subscription.monthlyCost),
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -555,7 +537,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
if (widget.subscription.eventEndDate != null) ...[
|
||||
Text(
|
||||
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../widgets/subscription_card.dart';
|
||||
import '../widgets/category_header_widget.dart';
|
||||
import '../widgets/swipeable_subscription_card.dart';
|
||||
import '../widgets/staggered_list_animation.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import '../widgets/animated_page_transitions.dart';
|
||||
import '../widgets/app_navigator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
@@ -62,8 +59,8 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
itemBuilder: (context, subIndex) {
|
||||
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||
final delay = 0.05 * subIndex;
|
||||
final animationBegin = 0.2;
|
||||
final animationEnd = 1.0;
|
||||
const animationBegin = 0.2;
|
||||
const animationEnd = 1.0;
|
||||
final intervalStart = delay;
|
||||
final intervalEnd = intervalStart + 0.4;
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../models/subscription_model.dart';
|
||||
import '../utils/haptic_feedback_helper.dart';
|
||||
import 'subscription_card.dart';
|
||||
|
||||
@@ -43,7 +43,6 @@ class ThemedText extends StatelessWidget {
|
||||
if (forceDark) return AppColors.textPrimary;
|
||||
|
||||
final brightness = Theme.of(context).brightness;
|
||||
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
|
||||
// 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트
|
||||
if (_isGlassmorphicContext(context)) {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
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 'dart:convert';
|
||||
import 'dart:io';
|
||||
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 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
@@ -57,7 +52,7 @@ class FaviconCache {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 로드 오류: $e');
|
||||
// 파비콘 캐시 로드 오류
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +63,7 @@ class FaviconCache {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('favicon_$serviceKey', logoUrl);
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 저장 오류: $e');
|
||||
// 파비콘 캐시 저장 오류
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +75,7 @@ class FaviconCache {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 삭제 오류: $e');
|
||||
// 파비콘 캐시 삭제 오류
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +85,7 @@ class FaviconCache {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('local_favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('로컬 파비콘 경로 로드 오류: $e');
|
||||
// 로컬 파비콘 경로 로드 오류
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -102,39 +97,14 @@ class FaviconCache {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('local_favicon_$serviceKey', filePath);
|
||||
} catch (e) {
|
||||
print('로컬 파비콘 경로 저장 오류: $e');
|
||||
// 로컬 파비콘 경로 저장 오류
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구글 파비콘 API 서비스
|
||||
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 생성
|
||||
static String getFaviconUrl(String domain, int size) {
|
||||
@@ -167,7 +137,7 @@ class GoogleFaviconService {
|
||||
static String getBase64PlaceholderIcon(String serviceName, Color color) {
|
||||
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
|
||||
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 디코딩 후 이미지 로드 시 문제 발생)
|
||||
final svgContent =
|
||||
@@ -207,15 +177,12 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
bool _isLoading = true;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
// 각 인스턴스에 대한 고유 식별자 추가
|
||||
final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
// 서비스와 URL 조합으로 캐시 키 생성
|
||||
String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}';
|
||||
// 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용)
|
||||
String? _previousServiceKey;
|
||||
// 로드 시작된 시점
|
||||
DateTime? _loadStartTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -231,15 +198,9 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
CurvedAnimation(
|
||||
parent: _animationController, curve: Curves.easeOutCubic));
|
||||
|
||||
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut));
|
||||
|
||||
// 초기 _previousServiceKey 설정
|
||||
_previousServiceKey = _serviceKey;
|
||||
|
||||
// 로드 시작 시간 기록
|
||||
_loadStartTime = DateTime.now();
|
||||
|
||||
// 최초 로딩
|
||||
_loadFaviconWithCache();
|
||||
}
|
||||
@@ -263,7 +224,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
|
||||
// 이미 로딩 중인지 확인
|
||||
if (!FaviconCache.markAsLoading(_serviceKey)) {
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
cachedLogo = FaviconCache.getFromMemory(_serviceKey);
|
||||
if (cachedLogo != null) {
|
||||
setState(() {
|
||||
@@ -312,7 +273,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
|
||||
// 2. 이미 로딩 중인지 확인
|
||||
if (!FaviconCache.markAsLoading(_serviceKey)) {
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
|
||||
if (localPath != null) {
|
||||
final file = File(localPath);
|
||||
@@ -344,12 +305,9 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
// 서비스명이나 URL이 변경된 경우에만 다시 로드
|
||||
final currentServiceKey = _serviceKey;
|
||||
if (_previousServiceKey != currentServiceKey) {
|
||||
print('서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey');
|
||||
// 서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey
|
||||
_previousServiceKey = currentServiceKey;
|
||||
|
||||
// 로드 시작 시간 기록
|
||||
_loadStartTime = DateTime.now();
|
||||
|
||||
// 변경된 서비스 정보로 파비콘 로드
|
||||
_loadFaviconWithCache();
|
||||
}
|
||||
@@ -472,7 +430,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
print('DuckDuckGo 파비콘 API 요청 실패: $e');
|
||||
// DuckDuckGo 파비콘 API 요청 실패
|
||||
// 실패 시 백업 방법으로 진행
|
||||
}
|
||||
|
||||
@@ -501,7 +459,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
} catch (e) {
|
||||
print('웹용 파비콘 가져오기 오류: $e');
|
||||
// 웹용 파비콘 가져오기 오류
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -579,7 +537,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
} catch (e) {
|
||||
print('앱용 파비콘 다운로드 오류: $e');
|
||||
// 앱용 파비콘 다운로드 오류
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
@@ -610,7 +568,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
boxShadow: widget.isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _getColorFromName().withAlpha(76), // 약 0.3 알파값
|
||||
color: _getColorFromName().withValues(alpha: 0.3), // 약 0.3 알파값
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 4),
|
||||
@@ -643,7 +601,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primaryColor.withAlpha(179)),
|
||||
AppColors.primaryColor.withValues(alpha: 0.7)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -684,7 +642,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primaryColor.withAlpha(179)),
|
||||
AppColors.primaryColor.withValues(alpha: 0.7)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -726,7 +684,7 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
color,
|
||||
color.withAlpha(204), // 약 0.8 알파값
|
||||
color.withValues(alpha: 0.8), // 약 0.8 알파값
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
|
||||
Reference in New Issue
Block a user