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,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;
}
}