generated from ttipo/project-base
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:
@@ -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),
|
||||
];
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user