Refactor screens to MVC architecture with modular widgets

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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';

View File

@@ -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),

View File

@@ -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,
),

View File

@@ -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;

View File

@@ -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';

View File

@@ -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)) {

View File

@@ -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,