From d9a4eba9a333f42e166075e4c1a8ef8e142d1a5b Mon Sep 17 00:00:00 2001 From: TtiPo Date: Tue, 29 Sep 2026 01:24:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=EA=B3=B5=ED=86=B5=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=8B=9C=EC=95=88=20=EB=A7=9E=EC=B6=A4=20=E2=80=94?= =?UTF-8?q?=20=EB=AA=A8=EB=8B=AC=20=EB=B0=B0=EA=B2=BD=20=ED=9D=90=EB=A6=BC?= =?UTF-8?q?=C2=B7=EB=88=84=EB=A6=84=20=ED=9A=A8=EA=B3=BC=C2=B7=ED=9C=A0=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=9E=85=EB=A0=A5=C2=B7=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=EC=B0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시안 전체 점검에서 여러 화면에 걸쳐 나온 차이를 공통 위젯에서 한 번에 고쳤다. - 시트·확인창 뒤 화면을 시안(backdrop-filter blur)처럼 흐린다. showModalBottomSheet·showDialog 대신 BlurredSheetRoute·showBlurredDialog 로 띄우고, 흐림은 등장 애니메이션을 따라간다. - 탭바 배경 흐림에 채도(saturate 1.6)를 더하고, 탭 라벨은 본문 서체 12/800 으로 맞췄다. - 누름 효과: Pressable 에 누름 밝기를 두어 인트로 흰 버튼은 살짝 줄고 어두워진다(.99·밝기 .94). 하트 토글은 누르는 동안 손잡이가 움직이고 안쪽 그림자가 생긴다. - 시간·날짜 휠의 가운데 숫자를 누르면 직접 입력할 수 있다 (범위를 넘으면 가장 가까운 값). - 일정·다이어리 제거용 확인창(showRupRemoveConfirm)과 추억 보관함 토스트 색(ToastTone.memory) 추가. - 버튼 줄 높이를 제목 서체 normal(1.10)로, 빈 상태 여백을 .rup-empty(위아래 40·좌우 20)로 맞추고, 화면별로 필요한 선택지(작은 섹션 링크, 라벨 간격, 아이콘 달린 선택 버튼, 흐림 불투명도)를 열었다. Co-Authored-By: Claude Opus 5.5 (1M context) --- flutter/lib/core/theme/app_typography.dart | 6 +- .../providers/archive_actions.dart | 3 +- .../presentation/widgets/rup_tab_bar.dart | 29 ++-- flutter/lib/shared/widgets/blurred_modal.dart | 67 ++++++++++ flutter/lib/shared/widgets/grayscale.dart | 11 ++ flutter/lib/shared/widgets/heart_toggle.dart | 115 +++++++++++----- flutter/lib/shared/widgets/pressable.dart | 16 ++- flutter/lib/shared/widgets/rup_button.dart | 9 +- flutter/lib/shared/widgets/rup_dialog.dart | 124 +++++++++++++++++- .../lib/shared/widgets/rup_form_fields.dart | 54 ++++++-- flutter/lib/shared/widgets/rup_sheet.dart | 13 +- flutter/lib/shared/widgets/rup_toast.dart | 28 +++- flutter/lib/shared/widgets/section_title.dart | 38 +++++- .../lib/shared/widgets/time_picker_sheet.dart | 15 ++- flutter/lib/shared/widgets/wheel_picker.dart | 111 +++++++++++++++- 15 files changed, 541 insertions(+), 98 deletions(-) create mode 100644 flutter/lib/shared/widgets/blurred_modal.dart diff --git a/flutter/lib/core/theme/app_typography.dart b/flutter/lib/core/theme/app_typography.dart index 255314e..d52482b 100644 --- a/flutter/lib/core/theme/app_typography.dart +++ b/flutter/lib/core/theme/app_typography.dart @@ -71,13 +71,13 @@ abstract final class AppTypography { // 13 — 작은 버튼·링크 labelMedium: _ui(13, FontWeight.w600), // 12 — 하단 탭 라벨 (.rup-tab 은 제목 서체 800) - labelSmall: _display(12, FontWeight.w800), + labelSmall: _ui(12, FontWeight.w800), ); /// `.rup-btn` · 선택 버튼 — 시안은 모든 버튼을 제목 서체 800 으로 통일한다 static TextStyle button(double size, {bool thin = false, Color? color}) { - // 제목 서체의 기본 줄 높이가 커서 버튼이 시안보다 높아지므로 줄 높이를 고정한다 - return _display(size, thin ? FontWeight.w600 : FontWeight.w800, height: 1.2) + // 시안 .rup-btn 은 제목 서체 + line-height normal (브라우저 실측 1.10) + return _display(size, thin ? FontWeight.w600 : FontWeight.w800, height: displayNormalHeight) .copyWith(color: color); } } diff --git a/flutter/lib/features/archive/presentation/providers/archive_actions.dart b/flutter/lib/features/archive/presentation/providers/archive_actions.dart index e8dc097..281ba2d 100644 --- a/flutter/lib/features/archive/presentation/providers/archive_actions.dart +++ b/flutter/lib/features/archive/presentation/providers/archive_actions.dart @@ -2,7 +2,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tma_app/features/pet/domain/pet_archive.dart'; import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart'; import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart'; -import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; import 'package:tma_app/shared/widgets/rup_toast.dart'; /// 추억 보관함 — 옮기기 / 다시 불러오기 @@ -16,7 +15,7 @@ class ArchiveActions { Future archive(String petId, PetArchive archive) async { await _ref.read(petListProvider.notifier).archivePet(petId, archive); await _ref.read(scheduleProvider.notifier).dropTarget(petId); - _ref.read(toastProvider.notifier).show('추억보관함에 보관했어요.', icon: RupIconName.memory); + _ref.read(toastProvider.notifier).show('추억보관함에 보관했어요.', tone: ToastTone.memory); } /// 보관함에서 다시 불러온다 — 지운 일정은 돌아오지 않는다 diff --git a/flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart b/flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart index 411b724..4398f37 100644 --- a/flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart +++ b/flutter/lib/features/shell/presentation/widgets/rup_tab_bar.dart @@ -3,12 +3,13 @@ import 'dart:ui' show ImageFilter; 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/features/shell/domain/app_tab.dart'; +import 'package:tma_app/shared/widgets/grayscale.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; -/// 시안 .rup-tabbar 의 뒤 배경 흐림 정도 (blur 18px → 표준편차 9) -const double _kBlurSigma = 9; +/// 시안 .rup-tabbar — backdrop-filter: blur(18px) saturate(160%). CSS blur 값이 곧 표준편차다 +const double _kBlurSigma = 18; +const double _kSaturate = 1.6; const double _kActiveStroke = 2.2; const double _kInactiveStroke = 1.9; @@ -27,7 +28,10 @@ class RupTabBar extends StatelessWidget { Widget build(BuildContext context) { return ClipRect( child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: _kBlurSigma, sigmaY: _kBlurSigma), + filter: ImageFilter.compose( + outer: saturateFilter(_kSaturate), + inner: ImageFilter.blur(sigmaX: _kBlurSigma, sigmaY: _kBlurSigma), + ), child: DecoratedBox( decoration: const BoxDecoration( color: AppColors.tabBarBg, @@ -64,21 +68,6 @@ class RupTabBar extends StatelessWidget { } } -/// 제목 서체의 가운뎃점(·)은 폭이 넓고 왼쪽에 붙어 있다. -/// 브라우저는 문장부호 간격을 자동으로 줄이지만 Flutter 는 그대로 그리므로 -/// 가운뎃점만 본문 서체로 바꿔 "일정·기록" 처럼 붙여 보이게 한다. -TextSpan _narrowDots(String label) { - final parts = label.split('·'); - return TextSpan( - children: [ - for (final (i, part) in parts.indexed) ...[ - if (i > 0) const TextSpan(text: '·', style: TextStyle(fontFamily: AppFonts.ui)), - TextSpan(text: part), - ], - ], - ); -} - class _TabItem extends StatelessWidget { const _TabItem({required this.tab, required this.active, required this.onTap}); @@ -124,7 +113,7 @@ class _TabItem extends StatelessWidget { color: color, letterSpacing: 0, ), - child: Text.rich(_narrowDots(tab.label), maxLines: 1, softWrap: false), + child: Text(tab.label, maxLines: 1, softWrap: false), ), ], ), diff --git a/flutter/lib/shared/widgets/blurred_modal.dart b/flutter/lib/shared/widgets/blurred_modal.dart new file mode 100644 index 0000000..d64533d --- /dev/null +++ b/flutter/lib/shared/widgets/blurred_modal.dart @@ -0,0 +1,67 @@ +import 'dart:ui' show ImageFilter; + +import 'package:flutter/material.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; + +/// 시트·확인창 뒤 화면 흐림 — 시안 .rup-overlay { backdrop-filter: blur(2px) } +const double _kBarrierBlur = 2; + +/// 가림막 뒤 화면을 등장 정도에 맞춰 흐리게 한다 +class _BlurredBarrier extends StatelessWidget { + const _BlurredBarrier({required this.animation, required this.child}); + + final Animation animation; + final Widget child; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: animation, + builder: (context, child) { + final sigma = _kBarrierBlur * animation.value; + return BackdropFilter(filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), child: child); + }, + child: child, + ); + } +} + +/// 아래에서 올라오는 시트 — 가림막 뒤를 흐린다 +class BlurredSheetRoute extends ModalBottomSheetRoute { + BlurredSheetRoute({ + required super.builder, + required super.isScrollControlled, + super.capturedThemes, + super.barrierLabel, + super.backgroundColor, + super.shape, + super.clipBehavior, + super.constraints, + super.modalBarrierColor, + super.sheetAnimationStyle, + }); + + @override + Widget buildModalBarrier() => _BlurredBarrier(animation: animation!, child: super.buildModalBarrier()); +} + +/// 가운데 확인창 — 가림막 뒤를 흐린다 +class BlurredDialogRoute extends DialogRoute { + BlurredDialogRoute({required super.context, required super.builder, super.themes}) + : super(barrierColor: AppColors.scrim); + + @override + Widget buildModalBarrier() => _BlurredBarrier(animation: animation!, child: super.buildModalBarrier()); +} + +/// [showDialog] 대신 — 앱 전체(루트) 위에 흐린 가림막과 함께 띄운다 +Future showBlurredDialog(BuildContext context, {required WidgetBuilder builder}) { + final navigator = Navigator.of(context, rootNavigator: true); + return navigator.push( + BlurredDialogRoute( + context: context, + builder: builder, + themes: InheritedTheme.capture(from: context, to: navigator.context), + ), + ); +} diff --git a/flutter/lib/shared/widgets/grayscale.dart b/flutter/lib/shared/widgets/grayscale.dart index 076f1d1..8d0b8ce 100644 --- a/flutter/lib/shared/widgets/grayscale.dart +++ b/flutter/lib/shared/widgets/grayscale.dart @@ -16,6 +16,17 @@ ColorFilter grayscaleFilter(double amount) { ]); } +/// CSS `saturate(amount)` 와 같은 색 행렬 — 1 이면 그대로, 1 보다 크면 진하게 +ColorFilter saturateFilter(double amount) { + final s = amount; + return ColorFilter.matrix([ + _kR + (1 - _kR) * s, _kG - _kG * s, _kB - _kB * s, 0, 0, // + _kR - _kR * s, _kG + (1 - _kG) * s, _kB - _kB * s, 0, 0, // + _kR - _kR * s, _kG - _kG * s, _kB + (1 - _kB) * s, 0, 0, // + 0, 0, 0, 1, 0, + ]); +} + /// 빛바랜 사진 — 추억 보관함의 아이 사진에 쓴다 class Grayscale extends StatelessWidget { const Grayscale({super.key, required this.amount, required this.child}); diff --git a/flutter/lib/shared/widgets/heart_toggle.dart b/flutter/lib/shared/widgets/heart_toggle.dart index f30f3ab..a98b97a 100644 --- a/flutter/lib/shared/widgets/heart_toggle.dart +++ b/flutter/lib/shared/widgets/heart_toggle.dart @@ -6,59 +6,82 @@ const double _kWidth = 44; const double _kHeight = 22; const double _kInset = 3.5; const double _kShift = 22; +const double _kRadius = 20; const Duration _kSlide = Duration(milliseconds: 850); const Cubic _kSlideCurve = Cubic(.4, .8, .35, 1); const Color _kOffTrack = Color(0xFFECE7DF); -/// 설정 켜고 끄기 (시안 HeartToggle) -class HeartToggle extends StatelessWidget { +/// 트랙 안쪽 그림자 — 시안 box-shadow: 0 0 6px 0 rgba(0,0,0,.05) inset +const Color _kInnerShadow = Color(0x0D000000); +const double _kInnerShadowBlur = 6; + +/// 설정 켜고 끄기 (시안 HeartToggle) — 누르는 동안 흰 판이 가운데로 모인다 +class HeartToggle extends StatefulWidget { const HeartToggle({super.key, required this.value, required this.onChanged, required this.label}); final bool value; final ValueChanged onChanged; final String label; + @override + State createState() => _HeartToggleState(); +} + +class _HeartToggleState extends State { + bool _pressed = false; + + void _press(bool v) { + if (_pressed != v) setState(() => _pressed = v); + } + @override Widget build(BuildContext context) { const innerWidth = _kWidth - _kInset * 2; + final value = widget.value; return Semantics( container: true, toggled: value, - label: label, + label: widget.label, button: true, excludeSemantics: true, child: GestureDetector( - onTap: () => onChanged(!value), - child: AnimatedContainer( - duration: const Duration(milliseconds: 500), - width: _kWidth, - height: _kHeight, - padding: const EdgeInsets.all(_kInset), - decoration: BoxDecoration( - color: value ? AppColors.primary : _kOffTrack, - borderRadius: BorderRadius.circular(20), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Stack( - clipBehavior: Clip.hardEdge, - children: [ - AnimatedPositioned( - duration: _kSlide, - curve: _kSlideCurve, - left: value ? _kShift : -_kShift, - top: 0, - bottom: 0, - width: innerWidth, - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: const [BoxShadow(color: Color(0x24000000), blurRadius: 8, spreadRadius: 2)], + onTap: () => widget.onChanged(!value), + onTapDown: (_) => _press(true), + onTapUp: (_) => _press(false), + onTapCancel: () => _press(false), + child: CustomPaint( + foregroundPainter: const _InnerShadowPainter(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + width: _kWidth, + height: _kHeight, + padding: const EdgeInsets.all(_kInset), + decoration: BoxDecoration( + color: value ? AppColors.primary : _kOffTrack, + borderRadius: BorderRadius.circular(_kRadius), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(_kRadius), + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + AnimatedPositioned( + duration: _kSlide, + curve: _kSlideCurve, + left: _pressed ? 0 : (value ? _kShift : -_kShift), + top: 0, + bottom: 0, + width: innerWidth, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(_kRadius), + boxShadow: const [BoxShadow(color: Color(0x24000000), blurRadius: 8, spreadRadius: 2)], + ), ), ), - ), - ], + ], + ), ), ), ), @@ -66,3 +89,31 @@ class HeartToggle extends StatelessWidget { ); } } + +/// 둥근 트랙 안쪽 가장자리를 살짝 어둡게 — CSS inset 그림자 +class _InnerShadowPainter extends CustomPainter { + const _InnerShadowPainter(); + + @override + void paint(Canvas canvas, Size size) { + final rrect = RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kRadius)); + // 트랙 바깥을 두른 띠를 흐리게 그리고 트랙 안으로 잘라 내면 안쪽 그림자가 된다 + final ring = Path() + ..fillType = PathFillType.evenOdd + ..addRect((Offset.zero & size).inflate(_kInnerShadowBlur * 2)) + ..addRRect(rrect); + canvas + ..save() + ..clipRRect(rrect) + ..drawPath( + ring, + Paint() + ..color = _kInnerShadow + ..maskFilter = MaskFilter.blur(BlurStyle.normal, Shadow.convertRadiusToSigma(_kInnerShadowBlur)), + ) + ..restore(); + } + + @override + bool shouldRepaint(_InnerShadowPainter oldDelegate) => false; +} diff --git a/flutter/lib/shared/widgets/pressable.dart b/flutter/lib/shared/widgets/pressable.dart index 92efa7c..5b35d0f 100644 --- a/flutter/lib/shared/widgets/pressable.dart +++ b/flutter/lib/shared/widgets/pressable.dart @@ -9,6 +9,7 @@ class Pressable extends StatefulWidget { required this.child, required this.onTap, this.pressedScale = 0.95, + this.pressedBrightness = 1, this.semanticLabel, this.behavior = HitTestBehavior.opaque, }); @@ -17,6 +18,9 @@ class Pressable extends StatefulWidget { final VoidCallback? onTap; final double pressedScale; + /// 누르는 동안 밝기 — 시안 filter: brightness(.94) 등 (1 이면 그대로) + final double pressedBrightness; + /// 스크린리더용 이름. 없으면 자식의 텍스트를 그대로 읽는다. final String? semanticLabel; final HitTestBehavior behavior; @@ -55,10 +59,20 @@ class _PressableState extends State { scale: _pressed ? widget.pressedScale : 1, duration: AppDurations.press, curve: Curves.easeOut, - child: widget.child, + child: _pressed && widget.pressedBrightness != 1 + ? ColorFiltered(colorFilter: _brightness(widget.pressedBrightness), child: widget.child) + : widget.child, ), ), ), ); } } + +/// CSS brightness(k) — 색 채널을 k 배로 +ColorFilter _brightness(double k) => ColorFilter.matrix([ + k, 0, 0, 0, 0, // + 0, k, 0, 0, 0, // + 0, 0, k, 0, 0, // + 0, 0, 0, 1, 0, + ]); diff --git a/flutter/lib/shared/widgets/rup_button.dart b/flutter/lib/shared/widgets/rup_button.dart index 1af2560..b49470c 100644 --- a/flutter/lib/shared/widgets/rup_button.dart +++ b/flutter/lib/shared/widgets/rup_button.dart @@ -103,6 +103,7 @@ class RupButton extends StatelessWidget { this.thin = false, this.expand = true, this.dimmed = false, + this.dimmedOpacity = .5, this.height, this.leading, }); @@ -123,6 +124,9 @@ class RupButton extends StatelessWidget { /// 누를 수는 있지만 아직 조건이 안 된 상태를 흐리게 보여 준다 (등록 버튼 등) final bool dimmed; + /// 흐리게 보일 때 불투명도 — 시안이 화면마다 다르다 (등록 .5 / 일정 저장 .45) + final double dimmedOpacity; + /// 높이를 시안 값으로 고정해야 하는 자리(인트로 CTA 46 등)에서만 지정 final double? height; final Widget? leading; @@ -168,9 +172,12 @@ class RupButton extends StatelessWidget { ); return Opacity( - opacity: onPressed == null || dimmed ? 0.5 : 1, + opacity: onPressed == null || dimmed ? dimmedOpacity : 1, child: Pressable( onTap: onPressed, + // 주황 화면 위 흰 버튼(인트로)은 살짝만 줄고 어두워진다 + pressedScale: variant == RupButtonVariant.light ? .99 : .95, + pressedBrightness: variant == RupButtonVariant.light ? .94 : 1, child: Container( height: height, width: expand ? double.infinity : null, diff --git a/flutter/lib/shared/widgets/rup_dialog.dart b/flutter/lib/shared/widgets/rup_dialog.dart index 4a5cc95..2a64a00 100644 --- a/flutter/lib/shared/widgets/rup_dialog.dart +++ b/flutter/lib/shared/widgets/rup_dialog.dart @@ -2,7 +2,10 @@ 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/core/theme/app_typography.dart'; +import 'package:tma_app/shared/widgets/blurred_modal.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/pressable.dart'; import 'package:tma_app/shared/widgets/rup_button.dart'; const double _kDialogMaxWidth = 300; @@ -30,11 +33,10 @@ Future showRupConfirm( Color iconColor = AppColors.ink2, Color? iconBackground, RupButtonVariant? confirmVariant, + bool tightTop = false, }) async { - final result = await showDialog( - context: context, - useRootNavigator: true, - barrierColor: AppColors.scrim, + final result = await showBlurredDialog( + context, builder: (context) { final textTheme = Theme.of(context).textTheme; final row = layout == RupConfirmLayout.row; @@ -56,7 +58,8 @@ Future showRupConfirm( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (row) const SizedBox(height: AppSpacing.xs), + // 시안 row 확인창은 아이콘·제목 위에 4 를 둔다 (댓글 삭제처럼 없는 곳은 tightTop) + if (row && !tightTop) const SizedBox(height: AppSpacing.xs), if (icon != null || glyph != null) Center( child: Container( @@ -135,3 +138,114 @@ Future showRupConfirm( ); return result ?? false; } + +// 제거 확인창 치수 — 시안 일정·다이어리 제거 (maxWidth 306, padding 26/20/20, 모서리 22) +const double _kRemoveMaxWidth = 306; +const double _kRemoveRadius = 22; +const double _kRemoveButtonRadius = 14; + +/// 제거 확인창 — 일정·다이어리를 지울 때의 전용 모양 (제목 서체 제목, 모서리 버튼 두 개). 확인하면 true +Future showRupRemoveConfirm( + BuildContext context, { + required String title, + required String message, + String confirmLabel = '제거하기', + String cancelLabel = '취소', +}) async { + final result = await showBlurredDialog( + context, + builder: (context) { + final textTheme = Theme.of(context).textTheme; + final button = textTheme.bodyMedium!.copyWith(fontSize: 14, height: AppTypography.normalHeight); + return Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.all(AppSpacing.xl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _kRemoveMaxWidth), + child: Container( + padding: const EdgeInsets.fromLTRB(AppSpacing.xl, 26, AppSpacing.xl, AppSpacing.xl), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(_kRemoveRadius), + border: Border.all(color: AppColors.line2), + boxShadow: AppShadows.sm, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: textTheme.titleMedium!.copyWith( + fontSize: 17.5, + fontWeight: FontWeight.w600, + letterSpacing: -.175, + height: AppTypography.displayNormalHeight, + ), + ), + const SizedBox(height: AppSpacing.md), + Text( + message, + textAlign: TextAlign.center, + style: textTheme.bodySmall!.copyWith(fontSize: 13, height: 1.6, color: AppColors.muted), + ), + const SizedBox(height: AppSpacing.xl), + Row( + children: [ + Expanded( + child: _RemoveButton( + label: cancelLabel, + style: button.copyWith(fontWeight: FontWeight.w700, color: AppColors.ink2), + border: AppColors.line, + onTap: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: _RemoveButton( + label: confirmLabel, + style: button.copyWith(fontWeight: FontWeight.w800, color: Colors.white), + fill: AppColors.danger, + onTap: () => Navigator.of(context).pop(true), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + return result ?? false; +} + +class _RemoveButton extends StatelessWidget { + const _RemoveButton({required this.label, required this.style, required this.onTap, this.fill, this.border}); + + final String label; + final TextStyle style; + final VoidCallback onTap; + final Color? fill; + final Color? border; + + @override + Widget build(BuildContext context) { + return Pressable( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 13), + alignment: Alignment.center, + decoration: BoxDecoration( + color: fill, + borderRadius: BorderRadius.circular(_kRemoveButtonRadius), + border: border == null ? null : Border.all(color: border!, width: 1.5), + ), + child: Text(label, style: style), + ), + ); + } +} diff --git a/flutter/lib/shared/widgets/rup_form_fields.dart b/flutter/lib/shared/widgets/rup_form_fields.dart index 34e05e0..2fff738 100644 --- a/flutter/lib/shared/widgets/rup_form_fields.dart +++ b/flutter/lib/shared/widgets/rup_form_fields.dart @@ -2,6 +2,7 @@ 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/core/theme/app_typography.dart'; import 'package:tma_app/shared/widgets/dashed_border_box.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; @@ -43,10 +44,14 @@ class RupField extends StatelessWidget { required this.child, this.labelSuffix, this.errorText, + this.labelGap = 7, }); final String label; + /// 라벨 아래 간격 — 시안이 칸마다 덮어쓰는 곳이 있다 (기록 수정 12) + final double labelGap; + /// 라벨 옆 보조 설명 — 예: (주의가 필요한 항목) final String? labelSuffix; final Widget child; @@ -66,7 +71,7 @@ class RupField extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.only(bottom: 7), + padding: EdgeInsets.only(bottom: labelGap), child: Text.rich( TextSpan( text: label, @@ -263,19 +268,24 @@ class RupPickerField extends StatelessWidget { } } -/// 두 개 중 하나를 고르는 버튼 (.rup-seg.rup-seg--compact) — 성별·중성화 +/// 두 개 중 하나를 고르는 버튼 (.rup-seg) — 기본은 컴팩트 변형(성별·중성화). +/// [icons] 를 주면 기본 변형 — 제목 서체 14 / 800, 여백 12, 앞에 아이콘 (다이어리 종류) class RupSegmented extends StatelessWidget { const RupSegmented({ super.key, required this.options, required this.value, required this.onChanged, + this.icons, }); final List<(T, String)> options; final T value; final ValueChanged onChanged; + /// 선택지마다 앞에 붙는 아이콘 (15, 굵기 2, 오른쪽 5) + final List? icons; + @override Widget build(BuildContext context) { final base = Theme.of(context).textTheme.bodyMedium!.copyWith( @@ -283,6 +293,7 @@ class RupSegmented extends StatelessWidget { fontWeight: FontWeight.w600, height: 1.2, ); + final icons = this.icons; return Row( children: [ for (final (index, (option, label)) in options.indexed) ...[ @@ -297,7 +308,9 @@ class RupSegmented extends StatelessWidget { onTap: () => onChanged(option), child: AnimatedContainer( duration: AppDurations.fast, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 11), + padding: icons == null + ? const EdgeInsets.symmetric(horizontal: 8, vertical: 11) + : const EdgeInsets.all(AppSpacing.md), alignment: Alignment.center, decoration: BoxDecoration( color: option == value ? AppColors.primary50 : AppColors.surface, @@ -307,12 +320,35 @@ class RupSegmented extends StatelessWidget { width: _kInputBorder, ), ), - child: Text( - label, - style: base.copyWith( - color: option == value ? AppColors.primary600 : AppColors.ink2, - ), - ), + child: icons == null + ? Text( + label, + style: base.copyWith( + color: option == value ? AppColors.primary600 : AppColors.ink2, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + RupIcon( + icons[index], + size: 15, + stroke: 2, + color: option == value ? AppColors.primary600 : AppColors.ink2, + ), + const SizedBox(width: 5), + Flexible( + child: Text( + label, + maxLines: 1, + style: AppTypography.button( + 14, + color: option == value ? AppColors.primary600 : AppColors.ink2, + ), + ), + ), + ], + ), ), ), ), diff --git a/flutter/lib/shared/widgets/rup_sheet.dart b/flutter/lib/shared/widgets/rup_sheet.dart index 9be6730..ce4a49f 100644 --- a/flutter/lib/shared/widgets/rup_sheet.dart +++ b/flutter/lib/shared/widgets/rup_sheet.dart @@ -3,6 +3,7 @@ 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'; +import 'package:tma_app/shared/widgets/blurred_modal.dart'; /// 시트 등장 곡선·시간 — 시안 rup-rise .28s cubic-bezier(.2,.8,.25,1) const Duration _kSheetDuration = Duration(milliseconds: 280); @@ -19,12 +20,14 @@ Future showRupSheet( bool flushBottom = false, required WidgetBuilder builder, }) { - return showModalBottomSheet( - context: context, - useRootNavigator: true, + final navigator = Navigator.of(context, rootNavigator: true); + final localizations = MaterialLocalizations.of(context); + return navigator.push(BlurredSheetRoute( + capturedThemes: InheritedTheme.capture(from: context, to: navigator.context), + barrierLabel: localizations.scrimOnTapHint(localizations.bottomSheetLabel), isScrollControlled: true, backgroundColor: AppColors.bg, - barrierColor: AppColors.scrim, + modalBarrierColor: AppColors.scrim, clipBehavior: Clip.antiAlias, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(_kSheetRadius)), @@ -41,7 +44,7 @@ Future showRupSheet( flushBottom: flushBottom, child: builder(context), ), - ); + )); } class _RupSheetBody extends StatelessWidget { diff --git a/flutter/lib/shared/widgets/rup_toast.dart b/flutter/lib/shared/widgets/rup_toast.dart index 416de97..bd081a5 100644 --- a/flutter/lib/shared/widgets/rup_toast.dart +++ b/flutter/lib/shared/widgets/rup_toast.dart @@ -13,11 +13,18 @@ const Duration _kToastAnim = Duration(milliseconds: 300); /// 화면 아래에서 토스트까지 — 시안 .rup-toast { bottom: 118px } (탭바 위) const double _kToastBottom = 118; +/// 토스트 색 — 기본(크림) / 추억 보관함(보라, 시안 .rup-toast--memory) +enum ToastTone { normal, memory } + +/// 추억 보관함 토스트 색 (시안: 바탕 memory-50, 테두리 memory+흰 68%, 글자 #6E5A86) +const Color _kMemoryToastText = Color(0xFF6E5A86); + class ToastMessage { - const ToastMessage(this.text, this.icon, this.serial); + const ToastMessage(this.text, this.icon, this.serial, {this.tone = ToastTone.normal}); final String text; final RupIconName icon; + final ToastTone tone; /// 같은 문구를 연달아 띄워도 다시 나타나게 하는 구분값 final int serial; @@ -34,9 +41,9 @@ class ToastNotifier extends Notifier { return null; } - void show(String text, {RupIconName icon = RupIconName.check}) { + void show(String text, {RupIconName icon = RupIconName.check, ToastTone tone = ToastTone.normal}) { _timer?.cancel(); - state = ToastMessage(text, icon, ++_serial); + state = ToastMessage(text, icon, ++_serial, tone: tone); _timer = Timer(_kToastVisible, () => state = null); } } @@ -90,6 +97,8 @@ class _ToastBubble extends StatelessWidget { @override Widget build(BuildContext context) { + final memory = message.tone == ToastTone.memory; + final ink = memory ? _kMemoryToastText : AppColors.ink2; return Semantics( liveRegion: true, child: Material( @@ -97,22 +106,27 @@ class _ToastBubble extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), decoration: BoxDecoration( - color: AppColors.surface2, + color: memory ? AppColors.memory50 : AppColors.surface2, borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.line), + border: Border.all( + color: memory ? Color.lerp(AppColors.memory, Colors.white, .68)! : AppColors.line, + ), boxShadow: AppShadows.md, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - RupIcon(message.icon, size: 16, stroke: 2.2, color: AppColors.ink2), + if (memory) + const RupGlyphIcon(RupGlyph.memory, size: 16, color: AppColors.memory) + else + RupIcon(message.icon, size: 16, stroke: 2.2, color: ink), const SizedBox(width: 8), // 시안은 한 줄(nowrap)이지만, 화면보다 긴 안내는 넘치지 않게 줄을 바꾼다 Flexible( child: Text( message.text, style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: AppColors.ink2, + color: ink, fontWeight: FontWeight.w600, height: 1.2, ), diff --git a/flutter/lib/shared/widgets/section_title.dart b/flutter/lib/shared/widgets/section_title.dart index 944bacc..31dca81 100644 --- a/flutter/lib/shared/widgets/section_title.dart +++ b/flutter/lib/shared/widgets/section_title.dart @@ -6,6 +6,9 @@ import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; const double _kTitleLinkPad = 2; +/// 빈 상태 위아래 여백 (시안 .rup-empty) +const double _kEmptyPadV = 40; + /// 섹션 제목 줄 — 왼쪽 제목, 오른쪽 "더보기 ›" 같은 보조 링크나 설명 (시안 .rup-section-title) class SectionTitle extends StatelessWidget { const SectionTitle({ @@ -14,6 +17,8 @@ class SectionTitle extends StatelessWidget { this.trailing, this.onTrailingTap, this.sub = false, + this.subDisplayFont = false, + this.smallTrailing = false, }); final String title; @@ -25,10 +30,23 @@ class SectionTitle extends StatelessWidget { /// 소제목(.is-sub) — 본문 서체 14, 흐린 색 final bool sub; + /// 소제목을 제목 서체로 — 시안에서 인라인으로 본문 서체를 주지 않은 곳(다이어리 보기 이름) + final bool subDisplayFont; + + /// 오른쪽 링크를 작게 — 12 / 600, › 와 간격 1 (홈 "아이 상세보기") + final bool smallTrailing; + @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final titleStyle = sub + final titleStyle = sub && subDisplayFont + ? textTheme.titleMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.muted, + height: AppTypography.displayNormalHeight, + ) + : sub ? textTheme.bodyMedium!.copyWith( fontSize: 14, fontWeight: FontWeight.w600, @@ -38,7 +56,8 @@ class SectionTitle extends StatelessWidget { // 줄 높이를 지정하지 않으면 본문 기본값(1.6)을 물려받아 시안 h2(글자 높이 그대로)보다 줄이 커진다 : textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, height: AppTypography.displayNormalHeight); final trailingStyle = textTheme.bodySmall!.copyWith( - fontSize: 13, + fontSize: smallTrailing ? 12 : 13, + fontWeight: smallTrailing ? FontWeight.w600 : null, color: AppColors.muted, height: 1.2, ); @@ -63,7 +82,7 @@ class SectionTitle extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text(trailing!, style: trailingStyle), - const SizedBox(width: 2), + SizedBox(width: smallTrailing ? 1 : 2), const RupIcon(RupIconName.chevR, size: 13, color: AppColors.muted), ], ), @@ -76,17 +95,22 @@ class SectionTitle extends StatelessWidget { } } -/// 빈 상태 문구 (시안 .rup-empty) +/// 빈 상태 문구 (시안 .rup-empty — 여백 40/20) class EmptyMessage extends StatelessWidget { - const EmptyMessage(this.text, {super.key, this.padding = 28}); + const EmptyMessage(this.text, {super.key, this.padding}); final String text; - final double padding; + + /// 시안이 인라인으로 준 사방 여백 — 없으면 .rup-empty 기본(위아래 40, 좌우 20) + final double? padding; @override Widget build(BuildContext context) { + final all = padding; return Padding( - padding: EdgeInsets.all(padding), + padding: all == null + ? const EdgeInsets.symmetric(vertical: _kEmptyPadV, horizontal: AppSpacing.xl) + : EdgeInsets.all(all), child: Text( text, textAlign: TextAlign.center, diff --git a/flutter/lib/shared/widgets/time_picker_sheet.dart b/flutter/lib/shared/widgets/time_picker_sheet.dart index 902f5bd..a714b22 100644 --- a/flutter/lib/shared/widgets/time_picker_sheet.dart +++ b/flutter/lib/shared/widgets/time_picker_sheet.dart @@ -131,12 +131,18 @@ class _TimePickerBodyState extends State<_TimePickerBody> { rows: _kRows, showBand: false, loop: true, + parseInput: (s) => _clampInput(s, 1, 12), onChanged: (h) => _change(() => _h12 = h), ), ), const Text( ':', - style: TextStyle(fontFamily: AppFonts.display, fontSize: 24, color: AppColors.ink2), + style: TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w800, + fontSize: 24, + color: AppColors.ink2, + ), ), Expanded( child: WheelColumn( @@ -147,6 +153,7 @@ class _TimePickerBodyState extends State<_TimePickerBody> { rows: _kRows, showBand: false, loop: true, + parseInput: (s) => _clampInput(s, 0, 59), onChanged: (m) => _change(() => _m = m), ), ), @@ -160,3 +167,9 @@ class _TimePickerBodyState extends State<_TimePickerBody> { ); } } + +/// 휠 직접 입력 — 숫자가 아니면 null, 범위 밖이면 가장 가까운 값 +int? _clampInput(String input, int min, int max) { + final n = int.tryParse(input); + return n?.clamp(min, max); +} diff --git a/flutter/lib/shared/widgets/wheel_picker.dart b/flutter/lib/shared/widgets/wheel_picker.dart index aaa962e..18427da 100644 --- a/flutter/lib/shared/widgets/wheel_picker.dart +++ b/flutter/lib/shared/widgets/wheel_picker.dart @@ -1,4 +1,5 @@ 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/core/theme/app_typography.dart'; @@ -10,6 +11,10 @@ const double _kSelectedFont = 21; const double _kIdleFont = 17; const double _kIdleOpacity = .45; +/// 직접 입력 칸 (시안 WheelColumn editable — 가운데 줄을 누르면 키패드) +const int _kInputMaxDigits = 2; +const double _kInputRadius = 12; + /// 위아래로 돌려 값을 고르는 열 (시안 WheelColumn). /// 가운데 줄이 선택값이며, 다른 줄을 누르면 그 줄로 굴러가 선택된다. class WheelColumn extends StatefulWidget { @@ -23,6 +28,7 @@ class WheelColumn extends StatefulWidget { this.rows = _kDefaultRows, this.showBand = true, this.loop = false, + this.parseInput, }); final List items; @@ -40,6 +46,10 @@ class WheelColumn extends StatefulWidget { /// 끝이 없이 돌아가는 열 — 시·분 (시안 loop) final bool loop; + /// 있으면 가운데(선택된) 줄을 눌러 숫자를 직접 입력한다 — 시·분 (시안 editable). + /// 입력한 글자를 값으로 바꾸고, 범위 밖이면 가장 가까운 값으로 맞춰 돌려준다 + final T? Function(String input)? parseInput; + @override State> createState() => _WheelColumnState(); } @@ -55,6 +65,10 @@ class _WheelColumnState extends State> { /// 굴린 위치 (끝없는 열이면 목록 길이를 넘는다 — 항목은 나머지로 구한다) late int _selected = _indexOf(widget.value); + final _input = TextEditingController(); + final _inputFocus = FocusNode(); + bool _editing = false; + int get _count => widget.items.length; T _itemAt(int index) => widget.items[index % _count]; @@ -65,12 +79,44 @@ class _WheelColumnState extends State> { return widget.loop ? _count * _kLoopLaps + base : base; } + @override + void initState() { + super.initState(); + // 칸 밖을 누르거나 키보드를 닫으면 입력한 값으로 맞춘다 + _inputFocus.addListener(() { + if (!_inputFocus.hasFocus && _editing) _commitInput(); + }); + } + @override void dispose() { _controller.dispose(); + _input.dispose(); + _inputFocus.dispose(); super.dispose(); } + void _openInput() { + final label = widget.labelOf(_itemAt(_selected)); + _input + ..text = label + ..selection = TextSelection(baseOffset: 0, extentOffset: label.length); + setState(() => _editing = true); + _inputFocus.requestFocus(); + } + + void _commitInput() { + final value = widget.parseInput?.call(_input.text); + setState(() => _editing = false); + if (value == null) return; + final i = widget.items.indexOf(value); + if (i < 0) return; + // 끝없는 열이면 지금 돌고 있는 바퀴 안에서 맞춘다 + final target = widget.loop ? _selected - _selected % _count + i : i; + _controller.jumpToItem(target); + _select(target); + } + void _select(int index) { setState(() => _selected = index); final value = _itemAt(index); @@ -108,16 +154,20 @@ class _WheelColumnState extends State> { final on = index == _selected; return GestureDetector( behavior: HitTestBehavior.opaque, - onTap: () => _controller.animateToItem( - index, - duration: AppDurations.fade, - curve: Curves.easeOutCubic, - ), + onTap: () => on && widget.parseInput != null + ? _openInput() + : _controller.animateToItem( + index, + duration: AppDurations.fade, + curve: Curves.easeOutCubic, + ), child: Center( child: AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 100), style: TextStyle( fontFamily: AppFonts.display, + // 시안: 인라인 제목 서체 → 굵기 800 (styles.css [style*=font-display]) + fontWeight: FontWeight.w800, fontSize: on ? _kSelectedFont : _kIdleFont, height: 1, color: on @@ -133,6 +183,14 @@ class _WheelColumnState extends State> { ), // 위아래 흐림 (bg → 투명 28% · 72% → bg) const IgnorePointer(child: _EdgeFade()), + if (_editing) + Positioned( + left: 2, + right: 2, + top: bandTop, + height: kWheelItemExtent, + child: _WheelInput(controller: _input, focusNode: _inputFocus, onDone: _commitInput), + ), if (widget.suffix != null) Positioned( top: bandTop, @@ -169,6 +227,49 @@ class _WheelColumnState extends State> { } } +/// 가운데 줄 위에 겹치는 숫자 입력 칸 (시안: 테두리 1.5 primary-600, 모서리 12, 제목 서체 21) +class _WheelInput extends StatelessWidget { + const _WheelInput({required this.controller, required this.focusNode, required this.onDone}); + + final TextEditingController controller; + final FocusNode focusNode; + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.bg, + borderRadius: BorderRadius.circular(_kInputRadius), + border: Border.all(color: AppColors.primary600, width: 1.5), + ), + child: TextField( + controller: controller, + focusNode: focusNode, + autofocus: true, + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(_kInputMaxDigits), + ], + cursorColor: AppColors.primary600, + style: const TextStyle( + fontFamily: AppFonts.display, + fontWeight: FontWeight.w800, + fontSize: _kSelectedFont, + height: 1, + color: AppColors.primary600, + ), + decoration: const InputDecoration.collapsed(hintText: null), + onSubmitted: (_) => onDone(), + ), + ); + } +} + class _SelectionBand extends StatelessWidget { const _SelectionBand();