From 544281ed8eb7b4de44b03badaaa8d934c1eb2df4 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Mon, 28 Sep 2026 19:35:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EC=9E=85=EB=A0=A5=20=ED=8F=BC?= =?UTF-8?q?=C2=B7=EC=8B=9C=ED=8A=B8=C2=B7=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=93=B1=20=EA=B3=B5=ED=86=B5=20=EC=9C=84=EC=A0=AF=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 등록·일정 등 이후 화면이 함께 쓰는 시안 컴포넌트. - 입력칸·선택 칸·2지선다·태그 칩 (테두리는 컨테이너가 그려 시안 치수 46 과 일치) - 바텀시트(바닥까지 채우는 버튼 포함)·가운데 확인창·토스트(전역, 2초) - 휠 선택 열 + 생년월일 선택 시트, 카드·점선 테두리·섹션 제목·구분 띠·하위 화면 헤더 - 날짜 표기(9월 28일 · 2021.09.14) · 을/를 조사 순수 함수 Co-Authored-By: Claude Opus 5.5 (1M context) --- flutter/lib/core/theme/app_shadows.dart | 17 + flutter/lib/core/utils/date_format.dart | 32 ++ flutter/lib/core/utils/korean.dart | 18 + .../lib/shared/widgets/dashed_border_box.dart | 81 ++++ .../lib/shared/widgets/date_picker_sheet.dart | 122 +++++ flutter/lib/shared/widgets/rup_card.dart | 50 ++ flutter/lib/shared/widgets/rup_dialog.dart | 74 +++ .../lib/shared/widgets/rup_form_fields.dart | 442 ++++++++++++++++++ .../lib/shared/widgets/rup_nav_header.dart | 74 +++ flutter/lib/shared/widgets/rup_sheet.dart | 144 ++++++ flutter/lib/shared/widgets/rup_toast.dart | 124 +++++ flutter/lib/shared/widgets/section_title.dart | 109 +++++ flutter/lib/shared/widgets/wheel_picker.dart | 189 ++++++++ 13 files changed, 1476 insertions(+) create mode 100644 flutter/lib/core/theme/app_shadows.dart create mode 100644 flutter/lib/core/utils/date_format.dart create mode 100644 flutter/lib/core/utils/korean.dart create mode 100644 flutter/lib/shared/widgets/dashed_border_box.dart create mode 100644 flutter/lib/shared/widgets/date_picker_sheet.dart create mode 100644 flutter/lib/shared/widgets/rup_card.dart create mode 100644 flutter/lib/shared/widgets/rup_dialog.dart create mode 100644 flutter/lib/shared/widgets/rup_form_fields.dart create mode 100644 flutter/lib/shared/widgets/rup_nav_header.dart create mode 100644 flutter/lib/shared/widgets/rup_sheet.dart create mode 100644 flutter/lib/shared/widgets/rup_toast.dart create mode 100644 flutter/lib/shared/widgets/section_title.dart create mode 100644 flutter/lib/shared/widgets/wheel_picker.dart diff --git a/flutter/lib/core/theme/app_shadows.dart b/flutter/lib/core/theme/app_shadows.dart new file mode 100644 index 0000000..5efec14 --- /dev/null +++ b/flutter/lib/core/theme/app_shadows.dart @@ -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 sm = [ + BoxShadow(color: AppColors.shadowWarm, offset: Offset(0, 1), blurRadius: 2), + BoxShadow(color: AppColors.shadowWarmSoft, offset: Offset(0, 2), blurRadius: 8), + ]; + + /// 떠 있는 요소 (토스트 등) + static const List md = [ + BoxShadow(color: AppColors.shadowMd, offset: Offset(0, 4), blurRadius: 14), + BoxShadow(color: AppColors.shadowMdSoft, offset: Offset(0, 1), blurRadius: 4), + ]; +} diff --git a/flutter/lib/core/utils/date_format.dart b/flutter/lib/core/utils/date_format.dart new file mode 100644 index 0000000..180f5cd --- /dev/null +++ b/flutter/lib/core/utils/date_format.dart @@ -0,0 +1,32 @@ +// 날짜 표기 — 시안 app-data.jsx 의 fmtDate · fmtYMD · DOW_KR 과 같은 형식. +// 날짜는 시각 없이 "그 날" 로만 다룬다. + +const List _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]); +} diff --git a/flutter/lib/core/utils/korean.dart b/flutter/lib/core/utils/korean.dart new file mode 100644 index 0000000..4ed9b30 --- /dev/null +++ b/flutter/lib/core/utils/korean.dart @@ -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) ? '이' : '가'}'; diff --git a/flutter/lib/shared/widgets/dashed_border_box.dart b/flutter/lib/shared/widgets/dashed_border_box.dart new file mode 100644 index 0000000..4f74a25 --- /dev/null +++ b/flutter/lib/shared/widgets/dashed_border_box.dart @@ -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; +} diff --git a/flutter/lib/shared/widgets/date_picker_sheet.dart b/flutter/lib/shared/widgets/date_picker_sheet.dart new file mode 100644 index 0000000..3bffb47 --- /dev/null +++ b/flutter/lib/shared/widgets/date_picker_sheet.dart @@ -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 showBirthDatePicker( + BuildContext context, { + DateTime? initial, + String title = '생년월일 선택', + String hint = '위아래로 돌려서 맞춰주세요', +}) { + final now = DateTime.now(); + final start = initial ?? DateTime(now.year - 1); + return showRupSheet( + 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( + items: years, + value: _y, + suffix: '년', + labelOf: (y) => '$y', + onChanged: (y) => setState(() => _y = y), + ), + ), + const SizedBox(width: 4), + Expanded( + child: WheelColumn( + 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( + // 연·월이 바뀌면 일 목록 길이가 달라지므로 새로 만든다 + 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), + ), + ], + ); + } +} diff --git a/flutter/lib/shared/widgets/rup_card.dart b/flutter/lib/shared/widgets/rup_card.dart new file mode 100644 index 0000000..d770769 --- /dev/null +++ b/flutter/lib/shared/widgets/rup_card.dart @@ -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, + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/rup_dialog.dart b/flutter/lib/shared/widgets/rup_dialog.dart new file mode 100644 index 0000000..21528c8 --- /dev/null +++ b/flutter/lib/shared/widgets/rup_dialog.dart @@ -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; + +/// 가운데 확인창 (시안 `` + 카드) — 확인하면 true +Future showRupConfirm( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, + String cancelLabel = '취소', + bool destructive = false, +}) async { + final result = await showDialog( + 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; +} diff --git a/flutter/lib/shared/widgets/rup_form_fields.dart b/flutter/lib/shared/widgets/rup_form_fields.dart new file mode 100644 index 0000000..5884fc2 --- /dev/null +++ b/flutter/lib/shared/widgets/rup_form_fields.dart @@ -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? inputFormatters; + final ValueChanged? onChanged; + final ValueChanged? 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 createState() => _RupTextFieldState(); +} + +class _RupTextFieldState extends State { + 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 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 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, + ); diff --git a/flutter/lib/shared/widgets/rup_nav_header.dart b/flutter/lib/shared/widgets/rup_nav_header.dart new file mode 100644 index 0000000..a383d2d --- /dev/null +++ b/flutter/lib/shared/widgets/rup_nav_header.dart @@ -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), + ], + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/rup_sheet.dart b/flutter/lib/shared/widgets/rup_sheet.dart new file mode 100644 index 0000000..911377a --- /dev/null +++ b/flutter/lib/shared/widgets/rup_sheet.dart @@ -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; + +/// 시안 `` — 아래에서 올라오는 시트. 탭바 위까지 덮는다. +/// [flushBottom] 이면 아래 여백 없이 [RupSheetBleedButton] 을 바닥까지 붙인다. +Future showRupSheet( + BuildContext context, { + String? title, + bool flushBottom = false, + required WidgetBuilder builder, +}) { + return showModalBottomSheet( + 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), + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/rup_toast.dart b/flutter/lib/shared/widgets/rup_toast.dart new file mode 100644 index 0000000..033d876 --- /dev/null +++ b/flutter/lib/shared/widgets/rup_toast.dart @@ -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 { + 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.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, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/section_title.dart b/flutter/lib/shared/widgets/section_title.dart new file mode 100644 index 0000000..d3b50eb --- /dev/null +++ b/flutter/lib/shared/widgets/section_title.dart @@ -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)), + ); + } +} diff --git a/flutter/lib/shared/widgets/wheel_picker.dart b/flutter/lib/shared/widgets/wheel_picker.dart new file mode 100644 index 0000000..e3da94a --- /dev/null +++ b/flutter/lib/shared/widgets/wheel_picker.dart @@ -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 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 items; + final T value; + final ValueChanged onChanged; + final String Function(T) labelOf; + + /// 선택값 오른쪽에 붙는 단위 — 년·월·일 + final String? suffix; + final int rows; + + /// 가운데 선택 띠를 이 열이 직접 그릴지 (여러 열이 한 띠를 공유하면 false) + final bool showBand; + + @override + State> createState() => _WheelColumnState(); +} + +class _WheelColumnState extends State> { + 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(), + ); + } +}