feat(flutter): 공통 위젯 시안 맞춤 — 모달 배경 흐림·누름 효과·휠 직접 입력·제거 확인창

시안 전체 점검에서 여러 화면에 걸쳐 나온 차이를 공통 위젯에서 한 번에 고쳤다.

- 시트·확인창 뒤 화면을 시안(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) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 01:24:24 +09:00
parent 03cb0224d6
commit d9a4eba9a3
15 changed files with 541 additions and 98 deletions
+3 -3
View File
@@ -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);
}
}
@@ -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<void> 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);
}
/// 보관함에서 다시 불러온다 — 지운 일정은 돌아오지 않는다
@@ -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),
),
],
),
@@ -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<double> 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<T> extends ModalBottomSheetRoute<T> {
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<T> extends DialogRoute<T> {
BlurredDialogRoute({required super.context, required super.builder, super.themes})
: super(barrierColor: AppColors.scrim);
@override
Widget buildModalBarrier() => _BlurredBarrier(animation: animation!, child: super.buildModalBarrier());
}
/// [showDialog] 대신 — 앱 전체(루트) 위에 흐린 가림막과 함께 띄운다
Future<T?> showBlurredDialog<T>(BuildContext context, {required WidgetBuilder builder}) {
final navigator = Navigator.of(context, rootNavigator: true);
return navigator.push(
BlurredDialogRoute<T>(
context: context,
builder: builder,
themes: InheritedTheme.capture(from: context, to: navigator.context),
),
);
}
+11
View File
@@ -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});
+83 -32
View File
@@ -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<bool> onChanged;
final String label;
@override
State<HeartToggle> createState() => _HeartToggleState();
}
class _HeartToggleState extends State<HeartToggle> {
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;
}
+15 -1
View File
@@ -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<Pressable> {
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,
]);
+8 -1
View File
@@ -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,
+119 -5
View File
@@ -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<bool> showRupConfirm(
Color iconColor = AppColors.ink2,
Color? iconBackground,
RupButtonVariant? confirmVariant,
bool tightTop = false,
}) async {
final result = await showDialog<bool>(
context: context,
useRootNavigator: true,
barrierColor: AppColors.scrim,
final result = await showBlurredDialog<bool>(
context,
builder: (context) {
final textTheme = Theme.of(context).textTheme;
final row = layout == RupConfirmLayout.row;
@@ -56,7 +58,8 @@ Future<bool> 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<bool> 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<bool> showRupRemoveConfirm(
BuildContext context, {
required String title,
required String message,
String confirmLabel = '제거하기',
String cancelLabel = '취소',
}) async {
final result = await showBlurredDialog<bool>(
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),
),
);
}
}
@@ -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<T> 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<T> onChanged;
/// 선택지마다 앞에 붙는 아이콘 (15, 굵기 2, 오른쪽 5)
final List<RupIconName>? icons;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodyMedium!.copyWith(
@@ -283,6 +293,7 @@ class RupSegmented<T> 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<T> 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<T> 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,
),
),
),
],
),
),
),
),
+8 -5
View File
@@ -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<T?> showRupSheet<T>(
bool flushBottom = false,
required WidgetBuilder builder,
}) {
return showModalBottomSheet<T>(
context: context,
useRootNavigator: true,
final navigator = Navigator.of(context, rootNavigator: true);
final localizations = MaterialLocalizations.of(context);
return navigator.push(BlurredSheetRoute<T>(
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<T?> showRupSheet<T>(
flushBottom: flushBottom,
child: builder(context),
),
);
));
}
class _RupSheetBody extends StatelessWidget {
+21 -7
View File
@@ -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<ToastMessage?> {
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,
),
+31 -7
View File
@@ -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,
@@ -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<int>(
@@ -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);
}
+106 -5
View File
@@ -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<T> extends StatefulWidget {
@@ -23,6 +28,7 @@ class WheelColumn<T> extends StatefulWidget {
this.rows = _kDefaultRows,
this.showBand = true,
this.loop = false,
this.parseInput,
});
final List<T> items;
@@ -40,6 +46,10 @@ class WheelColumn<T> extends StatefulWidget {
/// 끝이 없이 돌아가는 열 — 시·분 (시안 loop)
final bool loop;
/// 있으면 가운데(선택된) 줄을 눌러 숫자를 직접 입력한다 — 시·분 (시안 editable).
/// 입력한 글자를 값으로 바꾸고, 범위 밖이면 가장 가까운 값으로 맞춰 돌려준다
final T? Function(String input)? parseInput;
@override
State<WheelColumn<T>> createState() => _WheelColumnState<T>();
}
@@ -55,6 +65,10 @@ class _WheelColumnState<T> extends State<WheelColumn<T>> {
/// 굴린 위치 (끝없는 열이면 목록 길이를 넘는다 — 항목은 나머지로 구한다)
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<T> extends State<WheelColumn<T>> {
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<T> extends State<WheelColumn<T>> {
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<T> extends State<WheelColumn<T>> {
),
// 위아래 흐림 (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<T> extends State<WheelColumn<T>> {
}
}
/// 가운데 줄 위에 겹치는 숫자 입력 칸 (시안: 테두리 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();