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