feat(flutter): 입력 폼·시트·토스트 등 공통 위젯 추가

등록·일정 등 이후 화면이 함께 쓰는 시안 컴포넌트.
- 입력칸·선택 칸·2지선다·태그 칩 (테두리는 컨테이너가 그려 시안 치수 46 과 일치)
- 바텀시트(바닥까지 채우는 버튼 포함)·가운데 확인창·토스트(전역, 2초)
- 휠 선택 열 + 생년월일 선택 시트, 카드·점선 테두리·섹션 제목·구분 띠·하위 화면 헤더
- 날짜 표기(9월 28일 · 2021.09.14) · 을/를 조사 순수 함수

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-28 19:35:23 +09:00
parent e880f121b0
commit 544281ed8e
13 changed files with 1476 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import 'package:flutter/painting.dart';
import 'package:tma_app/core/constants/app_colors.dart';
/// 시안 그림자 토큰 (--shadow-sm / --shadow-md)
abstract final class AppShadows {
/// 카드 기본
static const List<BoxShadow> sm = [
BoxShadow(color: AppColors.shadowWarm, offset: Offset(0, 1), blurRadius: 2),
BoxShadow(color: AppColors.shadowWarmSoft, offset: Offset(0, 2), blurRadius: 8),
];
/// 떠 있는 요소 (토스트 등)
static const List<BoxShadow> md = [
BoxShadow(color: AppColors.shadowMd, offset: Offset(0, 4), blurRadius: 14),
BoxShadow(color: AppColors.shadowMdSoft, offset: Offset(0, 1), blurRadius: 4),
];
}
+32
View File
@@ -0,0 +1,32 @@
// 날짜 표기 — 시안 app-data.jsx 의 fmtDate · fmtYMD · DOW_KR 과 같은 형식.
// 날짜는 시각 없이 "그 날" 로만 다룬다.
const List<String> _kWeekdaysKo = ['월', '화', '수', '목', '금', '토', '일'];
String _pad2(int n) => n.toString().padLeft(2, '0');
/// 시각을 버린 날짜
DateTime dateOnly(DateTime d) => DateTime(d.year, d.month, d.day);
/// 2021.09.14
String formatYmd(DateTime d) => '${d.year}.${_pad2(d.month)}.${_pad2(d.day)}';
/// 2021 . 09 . 14 — 등록 화면 생년월일 칸
String formatYmdSpaced(DateTime d) => '${d.year} . ${_pad2(d.month)} . ${_pad2(d.day)}';
/// 9월 28일
String formatMonthDay(DateTime d) => '${d.month}월 ${d.day}일';
/// 일 · 월 · … · 토 (한 글자)
String weekdayKo(DateTime d) => _kWeekdaysKo[d.weekday - 1];
/// 해당 월의 일 수 (윤년 반영)
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
/// ISO 날짜 문자열(2021-09-14) ↔ DateTime — 저장·전송용
String toIsoDate(DateTime d) => '${d.year}-${_pad2(d.month)}-${_pad2(d.day)}';
DateTime parseIsoDate(String iso) {
final parts = iso.split('-').map(int.parse).toList();
return DateTime(parts[0], parts[1], parts[2]);
}
+18
View File
@@ -0,0 +1,18 @@
/// 한글 음절 범위 (가 ~ 힣)
const int _kHangulStart = 0xAC00;
const int _kHangulEnd = 0xD7A3;
const int _kJongseongCount = 28;
/// 마지막 글자에 받침이 있는지 — 한글이 아니면 false (예: 영문 이름은 '를' 로 붙인다)
bool hasFinalConsonant(String word) {
if (word.isEmpty) return false;
final code = word.runes.last;
if (code < _kHangulStart || code > _kHangulEnd) return false;
return (code - _kHangulStart) % _kJongseongCount != 0;
}
/// 목적격 조사 — 콩이를 · 초코를 · 보리를 / 루비를 · 사랑을
String withObjectParticle(String word) => '$word${hasFinalConsonant(word) ? '을' : '를'}';
/// 주격 조사 — 콩이가 / 사랑이
String withSubjectParticle(String word) => '$word${hasFinalConsonant(word) ? '이' : '가'}';
@@ -0,0 +1,81 @@
import 'dart:ui' show PathMetric;
import 'package:flutter/widgets.dart';
/// CSS `border: dashed` — 둥근 사각형 테두리를 점선으로 그린다.
class DashedBorderBox extends StatelessWidget {
const DashedBorderBox({
super.key,
required this.child,
required this.color,
required this.radius,
this.strokeWidth = 1.5,
this.backgroundColor,
});
final Widget child;
final Color color;
final double radius;
final double strokeWidth;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
return CustomPaint(
foregroundPainter: _DashedRRectPainter(
color: color,
radius: radius,
strokeWidth: strokeWidth,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(radius),
),
child: child,
),
);
}
}
class _DashedRRectPainter extends CustomPainter {
_DashedRRectPainter({
required this.color,
required this.radius,
required this.strokeWidth,
});
final Color color;
final double radius;
final double strokeWidth;
// 브라우저(Chrome) 점선과 비슷한 비율 — 선 굵기의 2배 길이, 2배 간격
double get _dash => strokeWidth * 2;
double get _gap => strokeWidth * 2;
@override
void paint(Canvas canvas, Size size) {
final inset = strokeWidth / 2;
final rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(inset, inset, size.width - strokeWidth, size.height - strokeWidth),
Radius.circular(radius - inset),
);
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth;
final path = Path()..addRRect(rrect);
for (final PathMetric metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
final end = (distance + _dash).clamp(0.0, metric.length);
canvas.drawPath(metric.extractPath(distance, end), paint);
distance += _dash + _gap;
}
}
}
@override
bool shouldRepaint(_DashedRRectPainter old) =>
old.color != color || old.radius != radius || old.strokeWidth != strokeWidth;
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
import 'package:tma_app/shared/widgets/wheel_picker.dart';
/// 생년월일 선택 범위 — 시안: 올해부터 30년 전까지
const int _kYearSpan = 30;
/// 시안 DatePicker — 연·월·일 휠 3개 + 바닥까지 채운 "선택" 버튼.
/// 고르지 않고 닫으면 null.
Future<DateTime?> showBirthDatePicker(
BuildContext context, {
DateTime? initial,
String title = '생년월일 선택',
String hint = '위아래로 돌려서 맞춰주세요',
}) {
final now = DateTime.now();
final start = initial ?? DateTime(now.year - 1);
return showRupSheet<DateTime>(
context,
title: title,
flushBottom: true,
builder: (context) => _DatePickerBody(
initial: start,
firstYear: now.year - _kYearSpan,
lastYear: now.year,
hint: hint,
),
);
}
class _DatePickerBody extends StatefulWidget {
const _DatePickerBody({
required this.initial,
required this.firstYear,
required this.lastYear,
required this.hint,
});
final DateTime initial;
final int firstYear;
final int lastYear;
final String hint;
@override
State<_DatePickerBody> createState() => _DatePickerBodyState();
}
class _DatePickerBodyState extends State<_DatePickerBody> {
late int _y = widget.initial.year.clamp(widget.firstYear, widget.lastYear);
late int _m = widget.initial.month;
late int _d = widget.initial.day;
String _pad(int n) => n.toString().padLeft(2, '0');
@override
Widget build(BuildContext context) {
final years = [for (var y = widget.firstYear; y <= widget.lastYear; y++) y];
final dayCount = daysInMonth(_y, _m);
// 달이 바뀌어 날짜가 넘치면 그 달의 마지막 날로 맞춘다
final day = _d > dayCount ? dayCount : _d;
final picked = DateTime(_y, _m, day);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
widget.hint,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12.5,
color: AppColors.muted,
),
),
),
Row(
children: [
Expanded(
child: WheelColumn<int>(
items: years,
value: _y,
suffix: '년',
labelOf: (y) => '$y',
onChanged: (y) => setState(() => _y = y),
),
),
const SizedBox(width: 4),
Expanded(
child: WheelColumn<int>(
items: [for (var m = 1; m <= 12; m++) m],
value: _m,
suffix: '월',
labelOf: _pad,
onChanged: (m) => setState(() => _m = m),
),
),
const SizedBox(width: 4),
Expanded(
child: WheelColumn<int>(
// 연·월이 바뀌면 일 목록 길이가 달라지므로 새로 만든다
key: ValueKey('$_y-$_m'),
items: [for (var d = 1; d <= dayCount; d++) d],
value: day,
suffix: '일',
labelOf: _pad,
onChanged: (d) => setState(() => _d = d),
),
),
],
),
RupSheetBleedButton(
label: '${formatYmd(picked)} 선택',
onPressed: () => Navigator.of(context).pop(picked),
),
],
);
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
/// 시안 `.rup-card` — 흰 면 + 얇은 선 + 옅은 그림자
class RupCard extends StatelessWidget {
const RupCard({
super.key,
required this.child,
this.padding,
this.onTap,
this.color = AppColors.surface,
this.borderColor = AppColors.line2,
this.shadow = true,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final VoidCallback? onTap;
final Color color;
final Color borderColor;
final bool shadow;
@override
Widget build(BuildContext context) {
final card = Container(
padding: padding,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: borderColor),
boxShadow: shadow ? AppShadows.sm : null,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.card - 1),
child: child,
),
);
if (onTap == null) return card;
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: card,
),
);
}
}
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
const double _kDialogMaxWidth = 300;
/// 가운데 확인창 (시안 `<Sheet center>` + 카드) — 확인하면 true
Future<bool> showRupConfirm(
BuildContext context, {
required String title,
required String message,
required String confirmLabel,
String cancelLabel = '취소',
bool destructive = false,
}) async {
final result = await showDialog<bool>(
context: context,
useRootNavigator: true,
barrierColor: AppColors.scrim,
builder: (context) => Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.all(AppSpacing.xl),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _kDialogMaxWidth),
child: Container(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 18),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: AppColors.line2),
boxShadow: AppShadows.sm,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
title,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.sm),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: AppColors.muted),
),
const SizedBox(height: 18),
RupButton(
label: confirmLabel,
large: true,
variant: destructive ? RupButtonVariant.danger : RupButtonVariant.primary,
onPressed: () => Navigator.of(context).pop(true),
),
const SizedBox(height: AppSpacing.sm),
TextButton(
onPressed: () => Navigator.of(context).pop(false),
style: TextButton.styleFrom(foregroundColor: AppColors.muted),
child: Text(
cancelLabel,
style: Theme.of(context).textTheme.labelMedium!.copyWith(color: AppColors.muted),
),
),
],
),
),
),
),
);
return result ?? false;
}
@@ -0,0 +1,442 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/shared/widgets/dashed_border_box.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
/// 입력칸 공통 치수 — 시안 .rup-input { padding: 13px 14px; border: 1.5px; radius 10 }
const EdgeInsets _kInputPadding = EdgeInsets.symmetric(horizontal: 14, vertical: 13);
const double _kInputBorder = 1.5;
const double _kInputFontSize = 15;
const double _kTextareaMinHeight = 84;
/// 줄 높이 — 브라우저 line-height: normal(SUITE 15px ≈ 17px)에 맞춰 입력칸 높이 46 을 만든다
const double _kInputLineHeight = 1.15;
const double _kTextareaLineHeight = 1.5;
TextStyle _inputText(BuildContext context) => Theme.of(context)
.textTheme
.bodyLarge!
.copyWith(fontSize: _kInputFontSize, height: _kInputLineHeight);
BoxDecoration _inputDecoration({required bool error, bool focused = false}) {
return BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: error
? AppColors.error
: focused
? AppColors.primary
: AppColors.line,
width: _kInputBorder,
),
);
}
/// 라벨 + 입력 + 오류 문구 묶음 (.rup-field)
class RupField extends StatelessWidget {
const RupField({
super.key,
required this.label,
required this.child,
this.labelSuffix,
this.errorText,
});
final String label;
/// 라벨 옆 보조 설명 — 예: (주의가 필요한 항목)
final String? labelSuffix;
final Widget child;
/// null 이 아니면 입력칸 아래에 붉게 보여 준다
final String? errorText;
@override
Widget build(BuildContext context) {
final labelStyle = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.ink2,
height: 1.2,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 7),
child: Text.rich(
TextSpan(
text: label,
children: [
if (labelSuffix != null)
TextSpan(
text: ' $labelSuffix',
style: const TextStyle(color: AppColors.muted, fontWeight: FontWeight.w500),
),
],
),
style: labelStyle,
),
),
child,
if (errorText != null)
Padding(
padding: const EdgeInsets.fromLTRB(2, 6, 2, 0),
child: Text(
errorText!,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}
/// 시안 `.rup-input` / `.rup-textarea`.
/// 테두리·여백은 [RupPickerField] 와 같은 컨테이너가 그리고, 안의 입력은 장식 없이 둔다
/// (InputDecoration 의 내부 여백 계산이 시안과 달라 높이·글자 위치가 어긋나기 때문).
class RupTextField extends StatefulWidget {
const RupTextField({
super.key,
required this.controller,
this.hintText,
this.hasError = false,
this.keyboardType,
this.inputFormatters,
this.onChanged,
this.onSubmitted,
this.textInputAction,
this.multiline = false,
this.autofocus = false,
this.maxLength,
this.prefix,
this.suffix,
this.focusNode,
});
final TextEditingController controller;
final String? hintText;
final bool hasError;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final TextInputAction? textInputAction;
/// true 면 여러 줄 메모칸(.rup-textarea, 최소 높이 84)
final bool multiline;
final bool autofocus;
final int? maxLength;
/// 입력 앞뒤에 붙는 아이콘·버튼 (검색 돋보기, 지우기 등)
final Widget? prefix;
final Widget? suffix;
final FocusNode? focusNode;
@override
State<RupTextField> createState() => _RupTextFieldState();
}
class _RupTextFieldState extends State<RupTextField> {
FocusNode? _ownFocus;
FocusNode get _focus => widget.focusNode ?? (_ownFocus ??= FocusNode());
@override
void initState() {
super.initState();
_focus.addListener(_onFocusChange);
}
@override
void dispose() {
_focus.removeListener(_onFocusChange);
_ownFocus?.dispose();
super.dispose();
}
// 포커스되면 테두리가 주황으로 바뀐다
void _onFocusChange() => setState(() {});
@override
Widget build(BuildContext context) {
final base = _inputText(context);
final style = widget.multiline ? base.copyWith(height: _kTextareaLineHeight) : base;
final field = TextField(
controller: widget.controller,
focusNode: _focus,
autofocus: widget.autofocus,
keyboardType: widget.multiline ? TextInputType.multiline : widget.keyboardType,
textInputAction: widget.textInputAction,
inputFormatters: [
if (widget.maxLength != null) LengthLimitingTextInputFormatter(widget.maxLength),
...?widget.inputFormatters,
],
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
minLines: widget.multiline ? 3 : 1,
maxLines: widget.multiline ? null : 1,
style: style,
cursorColor: AppColors.primary,
decoration: InputDecoration.collapsed(
hintText: widget.hintText,
hintStyle: style.copyWith(color: AppColors.muted),
),
);
return GestureDetector(
// 테두리 안 어디를 눌러도 입력이 시작되게
onTap: _focus.requestFocus,
child: Container(
constraints: widget.multiline
? const BoxConstraints(minHeight: _kTextareaMinHeight)
: null,
padding: _kInputPadding,
decoration: _inputDecoration(error: widget.hasError, focused: _focus.hasFocus),
child: Row(
crossAxisAlignment:
widget.multiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [
?widget.prefix,
Expanded(child: field),
?widget.suffix,
],
),
),
);
}
}
/// 입력칸 모양의 눌러서 고르는 칸 — 종·생년월일·단위 선택
class RupPickerField extends StatelessWidget {
const RupPickerField({
super.key,
required this.onTap,
required this.child,
this.hasError = false,
this.trailing,
this.semanticLabel,
});
final VoidCallback onTap;
final Widget child;
final bool hasError;
final Widget? trailing;
final String? semanticLabel;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: semanticLabel,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: _kInputPadding,
decoration: _inputDecoration(error: hasError),
child: DefaultTextStyle(
style: _inputText(context),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Row(
children: [
Expanded(child: child),
if (trailing != null) ...[const SizedBox(width: AppSpacing.xs), trailing!],
],
),
),
),
),
),
);
}
}
/// 두 개 중 하나를 고르는 버튼 (.rup-seg.rup-seg--compact) — 성별·중성화
class RupSegmented<T> extends StatelessWidget {
const RupSegmented({
super.key,
required this.options,
required this.value,
required this.onChanged,
});
final List<(T, String)> options;
final T value;
final ValueChanged<T> onChanged;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
height: 1.2,
);
return Row(
children: [
for (final (index, (option, label)) in options.indexed) ...[
if (index > 0) const SizedBox(width: AppSpacing.sm),
Expanded(
child: Semantics(
button: true,
selected: option == value,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => onChanged(option),
child: AnimatedContainer(
duration: AppDurations.fast,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 11),
alignment: Alignment.center,
decoration: BoxDecoration(
color: option == value ? AppColors.primary50 : AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: option == value ? AppColors.primary : AppColors.line,
width: _kInputBorder,
),
),
child: Text(
label,
style: base.copyWith(
color: option == value ? AppColors.primary600 : AppColors.ink2,
),
),
),
),
),
),
),
],
],
);
}
}
/// 켜고 끄는 태그 칩 (.rup-tag-chip) — 알레르기 등
class RupTagChip extends StatelessWidget {
const RupTagChip({
super.key,
required this.label,
required this.selected,
required this.onTap,
this.onDelete,
});
final String label;
final bool selected;
final VoidCallback onTap;
/// 지정하면 오른쪽에 × (메뉴에서 삭제)
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
final color = selected ? AppColors.primary600 : AppColors.muted;
return Semantics(
button: true,
selected: selected,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: AppDurations.fast,
padding: EdgeInsets.fromLTRB(12, 8, onDelete == null ? 12 : 6, 8),
decoration: BoxDecoration(
color: selected ? AppColors.surface2 : AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(
color: selected ? AppColors.primary100 : AppColors.line,
width: _kInputBorder,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label, style: _chipText(context, color)),
if (onDelete != null)
Semantics(
button: true,
label: '$label 메뉴에서 삭제',
child: GestureDetector(
onTap: onDelete,
child: Container(
width: 17,
height: 17,
margin: const EdgeInsets.only(left: 4),
decoration: const BoxDecoration(
color: Color(0x1A000000),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: const RupIcon(
RupIconName.x,
size: 10,
stroke: 2.6,
color: AppColors.ink2,
),
),
),
),
],
),
),
),
),
);
}
}
/// 점선 "직접 추가" 칩
class RupAddChip extends StatelessWidget {
const RupAddChip({super.key, required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: DashedBorderBox(
color: AppColors.line,
radius: AppRadius.card,
backgroundColor: AppColors.surface,
child: Padding(
// 일반 칩(여백 8·12 + 테두리 1.5)과 크기를 맞춘다 — 점선은 레이아웃 공간을 차지하지 않는다
padding: const EdgeInsets.symmetric(horizontal: 13.5, vertical: 9.5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupIcon(RupIconName.plus, size: 13, stroke: 2.4, color: AppColors.primary600),
const SizedBox(width: 6),
Text(label, style: _chipText(context, AppColors.primary600)),
],
),
),
),
),
),
);
}
}
TextStyle _chipText(BuildContext context, Color color) =>
Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: color,
height: 1.2,
);
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
/// 하위 화면 상단 — 뒤로 + 가운데 제목 + 아래 구분선 (시안 등록·수정 화면 헤더)
class RupNavHeader extends StatelessWidget {
const RupNavHeader({super.key, required this.title, required this.onBack, this.subtitle});
final String title;
final String? subtitle;
final VoidCallback onBack;
/// 오른쪽을 비워 제목을 가운데 맞춘다 (시안: 30)
static const double _sideSlot = 30;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(top: 2, bottom: AppSpacing.lg),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.line2)),
),
child: Row(
children: [
SizedBox(
width: _sideSlot,
child: Pressable(
onTap: onBack,
pressedScale: .9,
semanticLabel: '뒤로',
child: const Padding(
padding: EdgeInsets.all(AppSpacing.xs),
child: RupIcon(RupIconName.chevL, size: 22, color: AppColors.ink),
),
),
),
Expanded(
child: Column(
children: [
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 19,
fontWeight: FontWeight.w600,
height: 1.2,
color: AppColors.ink,
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
subtitle!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 13,
color: AppColors.muted,
),
),
),
],
),
),
const SizedBox(width: _sideSlot),
],
),
);
}
}
+144
View File
@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show OverflowBoxFit;
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
/// 시트 등장 곡선·시간 — 시안 rup-rise .28s cubic-bezier(.2,.8,.25,1)
const Duration _kSheetDuration = Duration(milliseconds: 280);
const Cubic _kSheetCurve = Cubic(.2, .8, .25, 1);
const double _kSheetRadius = 18;
const double _kSheetMaxHeightFactor = .9;
const double _kSheetBottomPad = 40;
/// 시안 `<Sheet>` — 아래에서 올라오는 시트. 탭바 위까지 덮는다.
/// [flushBottom] 이면 아래 여백 없이 [RupSheetBleedButton] 을 바닥까지 붙인다.
Future<T?> showRupSheet<T>(
BuildContext context, {
String? title,
bool flushBottom = false,
required WidgetBuilder builder,
}) {
return showModalBottomSheet<T>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: AppColors.bg,
barrierColor: AppColors.scrim,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(_kSheetRadius)),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * _kSheetMaxHeightFactor,
),
sheetAnimationStyle: const AnimationStyle(
duration: _kSheetDuration,
curve: _kSheetCurve,
),
builder: (context) => _RupSheetBody(
title: title,
flushBottom: flushBottom,
child: builder(context),
),
);
}
class _RupSheetBody extends StatelessWidget {
const _RupSheetBody({required this.title, required this.flushBottom, required this.child});
final String? title;
final bool flushBottom;
final Widget child;
@override
Widget build(BuildContext context) {
return Padding(
// 키보드가 올라오면 그만큼 밀어 올린다 (검색 입력 등)
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: Padding(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
10,
AppSpacing.xl,
flushBottom ? 0 : _kSheetBottomPad,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 38,
height: 5,
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.line,
borderRadius: BorderRadius.circular(AppRadius.pill),
),
),
),
if (title != null)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Text(
title!,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
),
),
Flexible(child: child),
],
),
),
);
}
}
/// 시트 바닥까지 채우는 주 버튼 (.rup-btn--bleed) — [showRupSheet] 의 flushBottom 과 함께 쓴다
class RupSheetBleedButton extends StatelessWidget {
const RupSheetBleedButton({super.key, required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.paddingOf(context).bottom;
return Padding(
padding: const EdgeInsets.only(top: 18),
child: Semantics(
button: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onPressed,
// 시트의 좌우 여백(20)을 넘어 가장자리까지 채운다
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth + AppSpacing.xl * 2;
return OverflowBox(
minWidth: width,
maxWidth: width,
fit: OverflowBoxFit.deferToChild,
child: Container(
color: AppColors.primary,
padding: EdgeInsets.only(
top: AppSpacing.lg,
bottom: bottomInset > 26 ? bottomInset : 26,
),
alignment: Alignment.center,
child: Text(
label,
style: AppTypography.button(16, color: Colors.white),
),
),
);
},
),
),
),
),
);
}
}
+124
View File
@@ -0,0 +1,124 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
/// 토스트가 떠 있는 시간 — 시안 Toast 2초
const Duration _kToastVisible = Duration(seconds: 2);
const Duration _kToastAnim = Duration(milliseconds: 300);
/// 화면 아래에서 토스트까지 — 시안 .rup-toast { bottom: 118px } (탭바 위)
const double _kToastBottom = 118;
class ToastMessage {
const ToastMessage(this.text, this.icon, this.serial);
final String text;
final RupIconName icon;
/// 같은 문구를 연달아 띄워도 다시 나타나게 하는 구분값
final int serial;
}
/// 앱 전역 토스트 — `ref.read(toastProvider.notifier).show('등록 완료!')`
class ToastNotifier extends Notifier<ToastMessage?> {
Timer? _timer;
int _serial = 0;
@override
ToastMessage? build() {
ref.onDispose(() => _timer?.cancel());
return null;
}
void show(String text, {RupIconName icon = RupIconName.check}) {
_timer?.cancel();
state = ToastMessage(text, icon, ++_serial);
_timer = Timer(_kToastVisible, () => state = null);
}
}
final toastProvider = NotifierProvider<ToastNotifier, ToastMessage?>(ToastNotifier.new);
/// 라우터 위에 토스트를 겹쳐 그린다 — MaterialApp.router 의 builder 에서 감싼다.
class ToastHost extends ConsumerWidget {
const ToastHost({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final toast = ref.watch(toastProvider);
return Stack(
children: [
child,
Positioned(
left: 0,
right: 0,
bottom: _kToastBottom,
child: IgnorePointer(
child: Center(
child: AnimatedSwitcher(
duration: _kToastAnim,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween(begin: const Offset(0, .25), end: Offset.zero)
.animate(animation),
child: child,
),
),
child: toast == null
? const SizedBox.shrink()
: _ToastBubble(key: ValueKey(toast.serial), message: toast),
),
),
),
),
],
);
}
}
class _ToastBubble extends StatelessWidget {
const _ToastBubble({super.key, required this.message});
final ToastMessage message;
@override
Widget build(BuildContext context) {
return Semantics(
liveRegion: true,
child: Material(
type: MaterialType.transparency,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface2,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.line),
boxShadow: AppShadows.md,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
RupIcon(message.icon, size: 16, stroke: 2.2, color: AppColors.ink2),
const SizedBox(width: 8),
Text(
message.text,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
color: AppColors.ink2,
fontWeight: FontWeight.w600,
height: 1.2,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
/// 섹션 제목 줄 — 왼쪽 제목, 오른쪽 "더보기 ›" 같은 보조 링크나 설명 (시안 .rup-section-title)
class SectionTitle extends StatelessWidget {
const SectionTitle({
super.key,
required this.title,
this.trailing,
this.onTrailingTap,
this.sub = false,
});
final String title;
/// 오른쪽 문구 — [onTrailingTap] 이 있으면 › 가 붙은 링크가 된다
final String? trailing;
final VoidCallback? onTrailingTap;
/// 소제목(.is-sub) — 본문 서체 14, 흐린 색
final bool sub;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final titleStyle = sub
? textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.muted,
height: 1.2,
)
: textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600);
final trailingStyle = textTheme.bodySmall!.copyWith(
fontSize: 13,
color: AppColors.muted,
height: 1.2,
);
return Row(
children: [
Expanded(child: Text(title, style: titleStyle)),
if (trailing != null)
onTrailingTap == null
? Text(trailing!, style: trailingStyle)
: Semantics(
button: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTrailingTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(trailing!, style: trailingStyle),
const SizedBox(width: 2),
const RupIcon(RupIconName.chevR, size: 13, color: AppColors.muted),
],
),
),
),
),
),
],
);
}
}
/// 빈 상태 문구 (시안 .rup-empty)
class EmptyMessage extends StatelessWidget {
const EmptyMessage(this.text, {super.key, this.padding = 28});
final String text;
final double padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.all(padding),
child: Text(
text,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
color: AppColors.muted,
height: 1.25,
),
),
);
}
}
/// 화면 폭 전체를 가로지르는 구분 띠 (시안 .rup-divider-band)
class DividerBand extends StatelessWidget {
const DividerBand({super.key});
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(top: 34, bottom: 22),
child: SizedBox(height: 8, width: double.infinity, child: ColoredBox(color: AppColors.sand)),
);
}
}
@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
/// 시안 WheelColumn 치수 — 한 줄 40, 기본 5줄 노출
const double kWheelItemExtent = 40;
const int _kDefaultRows = 5;
const double _kSelectedFont = 21;
const double _kIdleFont = 17;
const double _kIdleOpacity = .45;
/// 위아래로 돌려 값을 고르는 열 (시안 WheelColumn).
/// 가운데 줄이 선택값이며, 다른 줄을 누르면 그 줄로 굴러가 선택된다.
class WheelColumn<T> extends StatefulWidget {
const WheelColumn({
super.key,
required this.items,
required this.value,
required this.onChanged,
required this.labelOf,
this.suffix,
this.rows = _kDefaultRows,
this.showBand = true,
});
final List<T> items;
final T value;
final ValueChanged<T> onChanged;
final String Function(T) labelOf;
/// 선택값 오른쪽에 붙는 단위 — 년·월·일
final String? suffix;
final int rows;
/// 가운데 선택 띠를 이 열이 직접 그릴지 (여러 열이 한 띠를 공유하면 false)
final bool showBand;
@override
State<WheelColumn<T>> createState() => _WheelColumnState<T>();
}
class _WheelColumnState<T> extends State<WheelColumn<T>> {
late final FixedExtentScrollController _controller = FixedExtentScrollController(
initialItem: _indexOf(widget.value),
);
late int _selected = _indexOf(widget.value);
int _indexOf(T value) {
final i = widget.items.indexOf(value);
return i < 0 ? 0 : i;
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _select(int index) {
setState(() => _selected = index);
final value = widget.items[index];
if (value != widget.value) widget.onChanged(value);
}
@override
Widget build(BuildContext context) {
final height = kWheelItemExtent * widget.rows;
final bandTop = kWheelItemExtent * (widget.rows - 1) / 2;
final selectedLabel = widget.labelOf(widget.items[_selected]);
return SizedBox(
height: height,
child: Stack(
children: [
if (widget.showBand)
Positioned(
left: AppSpacing.xs,
right: AppSpacing.xs,
top: bandTop,
height: kWheelItemExtent,
child: const _SelectionBand(),
),
ListWheelScrollView.useDelegate(
controller: _controller,
itemExtent: kWheelItemExtent,
// 시안은 평평한 목록이다 — 원통 왜곡이 거의 없도록 지름을 크게 둔다
diameterRatio: 100,
physics: const FixedExtentScrollPhysics(),
onSelectedItemChanged: _select,
childDelegate: ListWheelChildBuilderDelegate(
childCount: widget.items.length,
builder: (context, index) {
final on = index == _selected;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _controller.animateToItem(
index,
duration: AppDurations.fade,
curve: Curves.easeOutCubic,
),
child: Center(
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 100),
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: on ? _kSelectedFont : _kIdleFont,
height: 1,
color: on
? AppColors.primary600
: AppColors.muted.withValues(alpha: _kIdleOpacity),
),
child: Text(widget.labelOf(widget.items[index])),
),
),
);
},
),
),
// 위아래 흐림 (bg → 투명 28% · 72% → bg)
const IgnorePointer(child: _EdgeFade()),
if (widget.suffix != null)
Positioned(
top: bandTop,
height: kWheelItemExtent,
left: 0,
right: 0,
child: IgnorePointer(
child: LayoutBuilder(
builder: (context, constraints) => Stack(
children: [
Positioned(
// 시안: 가운데 + 글자 수 × 6 + 5
left: constraints.maxWidth / 2 + selectedLabel.length * 6 + 5,
top: 0,
bottom: 0,
child: Center(
child: Text(
widget.suffix!,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.ink2,
),
),
),
),
],
),
),
),
),
],
),
);
}
}
class _SelectionBand extends StatelessWidget {
const _SelectionBand();
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: AppColors.primary50,
borderRadius: BorderRadius.circular(12),
),
);
}
}
class _EdgeFade extends StatelessWidget {
const _EdgeFade();
@override
Widget build(BuildContext context) {
return const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, .28, .72, 1],
colors: [AppColors.bg, Color(0x00FFFDFB), Color(0x00FFFDFB), AppColors.bg],
),
),
child: SizedBox.expand(),
);
}
}