feat(ui): 레트로 위젯 확장

- RetroDialog: 레트로 스타일 다이얼로그 위젯 추가
- RetroButton: 다양한 크기/스타일 옵션 추가
- retro_widgets.dart에 export 추가
This commit is contained in:
JiWoong Sul
2025-12-30 19:03:34 +09:00
parent 2486d84d63
commit 4d9042451c
3 changed files with 472 additions and 25 deletions

View File

@@ -31,6 +31,7 @@ class RetroButton extends StatefulWidget {
class _RetroButtonState extends State<RetroButton> {
bool _isPressed = false;
bool _isHovered = false;
bool get _isEnabled => widget.onPressed != null;
@@ -41,6 +42,12 @@ class _RetroButtonState extends State<RetroButton> {
? RetroColors.buttonPrimaryPressed
: RetroColors.buttonSecondaryPressed;
}
// 호버 시 밝아지는 효과
if (_isHovered) {
return widget.isPrimary
? RetroColors.buttonPrimary.withValues(alpha: 0.9)
: RetroColors.buttonSecondary.withValues(alpha: 0.9);
}
return widget.isPrimary
? RetroColors.buttonPrimary
: RetroColors.buttonSecondary;
@@ -48,43 +55,64 @@ class _RetroButtonState extends State<RetroButton> {
Color get _borderTopLeft {
if (_isPressed) return RetroColors.panelBorderOuter;
// 호버 시 골드 테두리
if (_isHovered && _isEnabled) return RetroColors.gold;
return RetroColors.panelBorderInner;
}
Color get _borderBottomRight {
if (_isPressed) return RetroColors.panelBorderInner;
// 호버 시 골드 테두리
if (_isHovered && _isEnabled) return RetroColors.goldDark;
return RetroColors.panelBorderOuter;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _isEnabled ? (_) => setState(() => _isPressed = true) : null,
onTapUp: _isEnabled ? (_) => setState(() => _isPressed = false) : null,
onTapCancel: _isEnabled ? () => setState(() => _isPressed = false) : null,
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 50),
padding: widget.padding,
decoration: BoxDecoration(
color: _backgroundColor,
border: Border(
top: BorderSide(color: _borderTopLeft, width: 2),
left: BorderSide(color: _borderTopLeft, width: 2),
bottom: BorderSide(color: _borderBottomRight, width: 2),
right: BorderSide(color: _borderBottomRight, width: 2),
return MouseRegion(
onEnter: _isEnabled ? (_) => setState(() => _isHovered = true) : null,
onExit: _isEnabled ? (_) => setState(() => _isHovered = false) : null,
cursor: _isEnabled ? SystemMouseCursors.click : SystemMouseCursors.basic,
child: GestureDetector(
onTapDown: _isEnabled ? (_) => setState(() => _isPressed = true) : null,
onTapUp: _isEnabled ? (_) => setState(() => _isPressed = false) : null,
onTapCancel: _isEnabled ? () => setState(() => _isPressed = false) : null,
onTap: widget.onPressed,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: widget.padding,
decoration: BoxDecoration(
color: _backgroundColor,
border: Border(
top: BorderSide(color: _borderTopLeft, width: 2),
left: BorderSide(color: _borderTopLeft, width: 2),
bottom: BorderSide(color: _borderBottomRight, width: 2),
right: BorderSide(color: _borderBottomRight, width: 2),
),
// 호버 시 글로우 효과
boxShadow: _isHovered && _isEnabled
? [
BoxShadow(
color: RetroColors.gold.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 1,
),
]
: null,
),
),
transform: _isPressed
? Matrix4.translationValues(1, 1, 0)
: Matrix4.identity(),
child: DefaultTextStyle(
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: _isEnabled ? RetroColors.textLight : RetroColors.textDisabled,
transform: _isPressed
? Matrix4.translationValues(1, 1, 0)
: Matrix4.identity(),
child: DefaultTextStyle(
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: _isEnabled
? (_isHovered ? RetroColors.gold : RetroColors.textLight)
: RetroColors.textDisabled,
),
child: widget.child,
),
child: widget.child,
),
),
);
@@ -148,3 +176,118 @@ class RetroIconButton extends StatelessWidget {
);
}
}
/// 레트로 호버 효과 리스트 아이템
/// 웹/데스크톱에서 마우스 호버 시 하이라이트 효과
class RetroHoverItem extends StatefulWidget {
const RetroHoverItem({
super.key,
required this.child,
this.onTap,
this.isSelected = false,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
});
final Widget child;
final VoidCallback? onTap;
final bool isSelected;
final EdgeInsets padding;
@override
State<RetroHoverItem> createState() => _RetroHoverItemState();
}
class _RetroHoverItemState extends State<RetroHoverItem> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
final isHighlighted = _isHovered || widget.isSelected;
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: widget.onTap != null
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
padding: widget.padding,
decoration: BoxDecoration(
color: isHighlighted
? RetroColors.panelBgLight.withValues(alpha: 0.5)
: Colors.transparent,
border: isHighlighted
? Border(
left: BorderSide(
color: widget.isSelected
? RetroColors.gold
: RetroColors.gold.withValues(alpha: 0.5),
width: 2,
),
)
: null,
),
child: widget.child,
),
),
);
}
}
/// 레트로 호버 패널
/// 웹/데스크톱에서 패널 호버 시 미세 확대 및 글로우 효과
class RetroHoverPanel extends StatefulWidget {
const RetroHoverPanel({
super.key,
required this.child,
this.onTap,
this.enableScale = true,
});
final Widget child;
final VoidCallback? onTap;
final bool enableScale;
@override
State<RetroHoverPanel> createState() => _RetroHoverPanelState();
}
class _RetroHoverPanelState extends State<RetroHoverPanel> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
cursor: widget.onTap != null
? SystemMouseCursors.click
: SystemMouseCursors.basic,
child: GestureDetector(
onTap: widget.onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
transform: widget.enableScale && _isHovered
? Matrix4.diagonal3Values(1.01, 1.01, 1.0)
: Matrix4.identity(),
transformAlignment: Alignment.center,
decoration: BoxDecoration(
boxShadow: _isHovered
? [
BoxShadow(
color: RetroColors.gold.withValues(alpha: 0.2),
blurRadius: 12,
spreadRadius: 2,
),
]
: null,
),
child: widget.child,
),
),
);
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/shared/retro_colors.dart';
/// 레트로 스타일 다이얼로그 베이스 위젯
///
/// 8-bit RPG 스타일의 다이얼로그 프레임
class RetroDialog extends StatelessWidget {
const RetroDialog({
super.key,
required this.title,
required this.child,
this.titleIcon,
this.maxWidth = 500,
this.maxHeight = 600,
this.accentColor = RetroColors.gold,
});
final String title;
final Widget child;
final String? titleIcon;
final double maxWidth;
final double maxHeight;
final Color accentColor;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: BoxConstraints(maxWidth: maxWidth, maxHeight: maxHeight),
decoration: BoxDecoration(
color: RetroColors.panelBg,
border: Border(
top: BorderSide(color: accentColor, width: 3),
left: BorderSide(color: accentColor, width: 3),
bottom: const BorderSide(color: RetroColors.panelBorderOuter, width: 3),
right: const BorderSide(color: RetroColors.panelBorderOuter, width: 3),
),
boxShadow: [
BoxShadow(
color: accentColor.withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 헤더 바
_buildHeader(context),
// 본문
Flexible(child: child),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: accentColor.withValues(alpha: 0.2),
border: Border(
bottom: BorderSide(color: accentColor, width: 2),
),
),
child: Row(
children: [
if (titleIcon != null) ...[
Text(
titleIcon!,
style: TextStyle(fontSize: 14, color: accentColor),
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: accentColor,
letterSpacing: 1,
),
),
),
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
border: Border.all(color: RetroColors.textDisabled, width: 1),
),
child: const Text(
'X',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: RetroColors.textDisabled,
),
),
),
),
],
),
);
}
}
/// 레트로 스타일 탭 바
class RetroTabBar extends StatelessWidget {
const RetroTabBar({
super.key,
required this.controller,
required this.tabs,
this.accentColor = RetroColors.gold,
});
final TabController controller;
final List<String> tabs;
final Color accentColor;
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: RetroColors.panelBorderOuter, width: 2),
),
),
child: TabBar(
controller: controller,
isScrollable: tabs.length > 3,
indicator: BoxDecoration(
color: accentColor.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(color: accentColor, width: 2),
),
),
labelColor: accentColor,
unselectedLabelColor: RetroColors.textDisabled,
labelStyle: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
),
unselectedLabelStyle: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
),
dividerColor: Colors.transparent,
tabs: tabs.map((t) => Tab(text: t.toUpperCase())).toList(),
),
);
}
}
/// 레트로 스타일 섹션 헤더
class RetroSectionHeader extends StatelessWidget {
const RetroSectionHeader({
super.key,
required this.title,
this.icon,
this.accentColor = RetroColors.gold,
});
final String title;
final String? icon;
final Color accentColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
if (icon != null) ...[
Text(
icon!,
style: TextStyle(fontSize: 12, color: accentColor),
),
const SizedBox(width: 6),
],
Text(
title.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: accentColor,
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 2,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
accentColor,
accentColor.withValues(alpha: 0.3),
Colors.transparent,
],
),
),
),
),
],
),
);
}
}
/// 레트로 스타일 정보 박스
class RetroInfoBox extends StatelessWidget {
const RetroInfoBox({
super.key,
required this.content,
this.backgroundColor,
});
final String content;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: backgroundColor ?? RetroColors.deepBrown,
border: Border.all(color: RetroColors.panelBorderOuter, width: 1),
),
child: Text(
content,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textLight,
height: 1.8,
),
),
);
}
}
/// 레트로 스타일 통계 행
class RetroStatRow extends StatelessWidget {
const RetroStatRow({
super.key,
required this.label,
required this.value,
this.highlight = false,
this.highlightColor = RetroColors.gold,
});
final String label;
final String value;
final bool highlight;
final Color highlightColor;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textDisabled,
),
),
Container(
padding: highlight
? const EdgeInsets.symmetric(horizontal: 6, vertical: 2)
: null,
decoration: highlight
? BoxDecoration(
color: highlightColor.withValues(alpha: 0.2),
border: Border.all(color: highlightColor, width: 1),
)
: null,
child: Text(
value,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 9,
color: highlight ? highlightColor : RetroColors.textLight,
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

View File

@@ -4,5 +4,6 @@ library;
export 'pixel_border_painter.dart';
export 'retro_button.dart';
export 'retro_dialog.dart';
export 'retro_panel.dart';
export 'retro_progress_bar.dart';