diff --git a/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart b/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart index 7e45a86..64eaeb5 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_compose_page.dart @@ -68,7 +68,8 @@ class _DiaryComposePageState extends ConsumerState { photoPath: _photo, ); toast.show('기록을 남겼어요'); - if (mounted) context.pop(); + // 남겼음을 알려 다이어리가 피드 보기로 돌아가게 한다 + if (mounted) context.pop(true); } finally { if (mounted) setState(() => _saving = false); } diff --git a/flutter/lib/features/diary/presentation/pages/diary_page.dart b/flutter/lib/features/diary/presentation/pages/diary_page.dart index 818d043..a103552 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_page.dart @@ -52,7 +52,7 @@ enum FeedMode { /// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (추억 보관함에서 열 때) const double _kTabBottomGap = 30; -/// 추억 보관함 안에 끼워 열 때 머리가 위아래로 2씩 좁다 (시안 embedded margin 2px 0 16px) +/// 다이어리 머리가 위아래로 2씩 좁다 (시안 embedded margin 2px 0 16px — 기록 탭·보관함 모두) const double _kEmbeddedTrim = 2; /// 다이어리 안 — 기록 피드(모두·사진·글) / 달력 보기, 검색, 오늘 쓰기 @@ -92,7 +92,13 @@ class _DiaryPageState extends ConsumerState { ref.read(toastProvider.notifier).show('오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요'); return; } - unawaited(context.push(AppRoutes.diaryWrite(widget.diaryId))); + unawaited(_openCompose()); + } + + /// 기록하기 — 남기고 돌아오면 달력이 아니라 피드로 보여 준다 + Future _openCompose() async { + final saved = await context.push(AppRoutes.diaryWrite(widget.diaryId)); + if (saved == true && mounted) setState(() => _calendar = false); } @override @@ -119,7 +125,7 @@ class _DiaryPageState extends ConsumerState { keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.fromLTRB( AppSpacing.xl, - AppSpacing.xs - (archived ? _kEmbeddedTrim : 0), + AppSpacing.xs - _kEmbeddedTrim, AppSpacing.xl, safeBottom + (archived ? _kTabBottomGap : _kFabGap + _kFabSize), ), @@ -129,7 +135,7 @@ class _DiaryPageState extends ConsumerState { subtitle: '기록 ${posts.length}개', subtitleLeading: archived ? const RupGlyphIcon(RupGlyph.memory, size: 12, color: AppColors.memory) : null, displayFont: true, - bottomGap: archived ? 18 - _kEmbeddedTrim : 18, + bottomGap: 18 - _kEmbeddedTrim, onBack: () => context.pop(), trailing: archived ? null @@ -184,13 +190,13 @@ class _DiaryPageState extends ConsumerState { return [ Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), - child: SectionTitle(title: _mode.title, sub: true), + child: SectionTitle(title: _mode.title, sub: true, subDisplayFont: true), ), const SizedBox(height: AppSpacing.md), if (posts.isEmpty) RupCard( child: searching - ? const EmptyMessage('검색 결과가 없어요', padding: 40) + ? const EmptyMessage('검색 결과가 없어요') : _NoPosts(onWrite: wroteToday ? null : () => _write(false)), ) else if (_mode == FeedMode.image && shown.isEmpty) @@ -324,11 +330,33 @@ class _HeaderIconButton extends StatelessWidget { } /// 검색칸 — 돋보기 + 지우기 -class _SearchField extends StatelessWidget { +/// 입력 중이면 주황 테두리 (시안 .rup-input:focus) +class _SearchField extends StatefulWidget { const _SearchField({required this.controller}); final TextEditingController controller; + @override + State<_SearchField> createState() => _SearchFieldState(); +} + +class _SearchFieldState extends State<_SearchField> { + final _focus = FocusNode(); + + TextEditingController get controller => widget.controller; + + @override + void initState() { + super.initState(); + _focus.addListener(() => setState(() {})); + } + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final style = Theme.of(context).textTheme.bodyLarge!.copyWith(fontSize: 14, height: 1.2); @@ -338,7 +366,7 @@ class _SearchField extends StatelessWidget { decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(AppRadius.card), - border: Border.all(color: AppColors.line, width: 1.5), + border: Border.all(color: _focus.hasFocus ? AppColors.primary : AppColors.line, width: 1.5), ), child: Row( children: [ @@ -347,6 +375,7 @@ class _SearchField extends StatelessWidget { Expanded( child: TextField( controller: controller, + focusNode: _focus, style: style, cursorColor: AppColors.primary, textInputAction: TextInputAction.search, diff --git a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart index d1737e4..f2e330f 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart @@ -39,13 +39,10 @@ class DiarySettingsPage extends ConsumerWidget { final String diaryId; Future _delete(BuildContext context, WidgetRef ref, Diary diary) async { - final ok = await showRupConfirm( + final ok = await showRupRemoveConfirm( context, title: '다이어리를 제거할까요?', message: '다이어리를 삭제하면 다시 복구할 수 없으며,\n이때까지 적은 기록이 전부 삭제돼요.', - confirmLabel: '제거하기', - destructive: true, - layout: RupConfirmLayout.row, ); if (!ok || !context.mounted) return; // 먼저 책장으로 돌아가 지워진 다이어리를 그리지 않게 한다 @@ -143,7 +140,7 @@ class DiarySettingsPage extends ConsumerWidget { ], ), ), - const SizedBox(width: AppSpacing.sm), + const SizedBox(width: 13), HeartToggle( value: diary.notifies(row.notice), label: row.title, diff --git a/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart index 480631a..9afdf23 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart @@ -121,8 +121,9 @@ class _PolaroidPostState extends State { Widget build(BuildContext context) { final post = widget.post; final photo = post.photoPath; + // 시안 margin 4px 2px 28px — 위 4 는 앞 요소의 아래 여백(제목 12, 앞 기록 28)과 겹쳐 사라진다 return Padding( - padding: const EdgeInsets.fromLTRB(2, 4, 2, 28), + padding: const EdgeInsets.fromLTRB(2, 0, 2, 28), child: Stack( clipBehavior: Clip.none, children: [ @@ -189,10 +190,40 @@ class _Tape extends StatelessWidget { ), boxShadow: [BoxShadow(color: Color(0x12000000), blurRadius: 3, offset: Offset(0, 1))], ), + // 시안: 양옆 1px 흰 점선 (rgba(255,255,255,.55)) + foregroundDecoration: const _DashedSides(), ); } } +/// 좌우 가장자리에 세로 점선 — CSS border-left/right: 1px dashed +class _DashedSides extends Decoration { + const _DashedSides(); + + @override + BoxPainter createBoxPainter([VoidCallback? onChanged]) => _DashedSidesPainter(); +} + +class _DashedSidesPainter extends BoxPainter { + static const double _dash = 3; + static const Color _color = Color(0x8CFFFFFF); + + @override + void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) { + final size = configuration.size; + if (size == null) return; + final paint = Paint() + ..color = _color + ..strokeWidth = 1; + final bottom = offset.dy + size.height; + for (final x in [offset.dx + .5, offset.dx + size.width - .5]) { + for (var y = offset.dy; y < bottom; y += _dash * 2) { + canvas.drawLine(Offset(x, y), Offset(x, y + _dash > bottom ? bottom : y + _dash), paint); + } + } + } +} + /// "댓글 N" — 누르면 댓글을 펼친다 class _CommentToggle extends ConsumerWidget { const _CommentToggle({required this.postId, required this.open, required this.onTap}); @@ -282,6 +313,7 @@ class _CommentsState extends ConsumerState<_Comments> { message: '삭제한 댓글은 다시 볼 수 없어요.', confirmLabel: '삭제하기', layout: RupConfirmLayout.row, + tightTop: true, ); if (ok) await ref.read(diaryProvider.notifier).deleteComment(c.id); } @@ -328,14 +360,18 @@ class _CommentsState extends ConsumerState<_Comments> { ], ], ), - const SizedBox(height: 5), + // 시안: 수정칸 위 5, 댓글 글 위 6 + SizedBox(height: _editingId == c.id ? 5 : 6), if (_editingId == c.id) Row( children: [ - Expanded(child: _CommentInput(controller: _edit, autofocus: true, onSubmit: _saveEdit)), + Expanded( + child: _CommentInput(controller: _edit, autofocus: true, compact: true, onSubmit: _saveEdit), + ), const SizedBox(width: 6), _SoftIconButton( label: '수정 저장', + horizontal: 13, onTap: () => unawaited(_saveEdit()), child: const RupIcon(RupIconName.check, size: 16, stroke: 2.4, color: AppColors.primary600), ), @@ -357,13 +393,11 @@ class _CommentsState extends ConsumerState<_Comments> { child: _CommentInput(controller: _draft, hint: '댓글 달기…', onSubmit: _add), ), const SizedBox(width: AppSpacing.sm), - Opacity( - opacity: _draft.text.trim().isEmpty ? .5 : 1, - child: _SoftIconButton( - label: '등록', - onTap: () => unawaited(_add()), - child: Text('등록', style: _ui(context, 13, FontWeight.w700, AppColors.primary600)), - ), + // 시안 .rup-btn — 제목 서체 800, 비어 있어도 모양은 그대로 (누르면 아무 일도 없다) + _SoftIconButton( + label: '등록', + onTap: () => unawaited(_add()), + child: Text('등록', style: AppTypography.button(13, color: AppColors.primary600)), ), ], ), @@ -387,12 +421,15 @@ class _SmallAction extends StatelessWidget { /// 연주황 작은 버튼 (.rup-btn--soft) — 등록 · ✓ class _SoftIconButton extends StatelessWidget { - const _SoftIconButton({required this.label, required this.onTap, required this.child}); + const _SoftIconButton({required this.label, required this.onTap, required this.child, this.horizontal = 14}); final String label; final VoidCallback onTap; final Widget child; + /// 좌우 여백 — 등록 14, 수정 저장 13 + final double horizontal; + @override Widget build(BuildContext context) { return Semantics( @@ -402,7 +439,7 @@ class _SoftIconButton extends StatelessWidget { child: GestureDetector( onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), + padding: EdgeInsets.symmetric(horizontal: horizontal, vertical: 9), decoration: BoxDecoration( color: AppColors.primary50, borderRadius: BorderRadius.circular(AppRadius.pill), @@ -415,34 +452,64 @@ class _SoftIconButton extends StatelessWidget { } } -/// 댓글 입력칸 — 50자까지 -class _CommentInput extends StatelessWidget { - const _CommentInput({required this.controller, required this.onSubmit, this.hint, this.autofocus = false}); +/// 댓글 입력칸 — 50자까지. 입력 중이면 주황 테두리 (시안 .rup-input:focus) +class _CommentInput extends StatefulWidget { + const _CommentInput({ + required this.controller, + required this.onSubmit, + this.hint, + this.autofocus = false, + this.compact = false, + }); final TextEditingController controller; final Future Function() onSubmit; final String? hint; final bool autofocus; + /// 댓글 수정칸 — 여백 8/11, 모서리 9 (새 댓글은 9/12, 10) + final bool compact; + + @override + State<_CommentInput> createState() => _CommentInputState(); +} + +class _CommentInputState extends State<_CommentInput> { + final _focus = FocusNode(); + + @override + void initState() { + super.initState(); + _focus.addListener(() => setState(() {})); + } + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final style = _ui(context, 13.5, FontWeight.w400, AppColors.ink, height: 1.2); + final compact = widget.compact; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + padding: EdgeInsets.symmetric(horizontal: compact ? 11 : 12, vertical: compact ? 8 : 9), decoration: BoxDecoration( color: AppColors.surface, - borderRadius: BorderRadius.circular(AppRadius.card), - border: Border.all(color: AppColors.line, width: 1.5), + borderRadius: BorderRadius.circular(compact ? 9 : AppRadius.card), + border: Border.all(color: _focus.hasFocus ? AppColors.primary : AppColors.line, width: 1.5), ), child: TextField( - controller: controller, - autofocus: autofocus, + controller: widget.controller, + focusNode: _focus, + autofocus: widget.autofocus, maxLength: kCommentMaxLength, style: style, cursorColor: AppColors.primary, textInputAction: TextInputAction.send, - onSubmitted: (_) => unawaited(onSubmit()), - decoration: InputDecoration.collapsed(hintText: hint, hintStyle: style.copyWith(color: AppColors.muted)) + onSubmitted: (_) => unawaited(widget.onSubmit()), + decoration: InputDecoration.collapsed(hintText: widget.hint, hintStyle: style.copyWith(color: AppColors.muted)) .copyWith(counterText: ''), ), ); diff --git a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart index 43f994f..3039200 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -131,7 +132,10 @@ Future showAddDiarySheet(BuildContext context, WidgetRef ref) { iconBackground: AppColors.accent100, title: '공유 다이어리 참여하기', description: '초대 코드로 함께 쓰는 다이어리에 참여해요', - onTap: () => ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users), + onTap: () { + Navigator.of(sheetContext).pop(); + ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users); + }, ), ], ), @@ -261,6 +265,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> { children: [ RupSegmented( options: const [(false, '개인 다이어리'), (true, '공유 다이어리')], + icons: const [RupIconName.user, RupIconName.users], value: false, onChanged: (shared) { if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users); @@ -304,6 +309,8 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> { label: '책갈피 리본 색상', child: RibbonPicker(color: _ribbon, onPick: (c) => setState(() => _ribbon = c), onCustom: _pickCustomRibbon), ), + // 시안 .rup-field 아래 16 (바닥 버튼 위 18 과 겹치지 않는다) + const SizedBox(height: AppSpacing.lg), RupSheetBleedButton( label: _editing ? '수정 완료' : '다이어리 만들기', onPressed: () => unawaited(_submit()), @@ -324,10 +331,14 @@ class RibbonPicker extends StatelessWidget { static const double _size = 36; - static const _rainbow = SweepGradient(colors: [ - Color(0xFFFF7500), Color(0xFFFF7AA0), Color(0xFF8E7CF0), Color(0xFF4DA6E0), - Color(0xFF3FB58C), Color(0xFFFFD43B), Color(0xFFFF7500), - ]); + // CSS conic-gradient 는 12시에서 시작한다 (Flutter 기본은 3시) + static const _rainbow = SweepGradient( + transform: GradientRotation(-math.pi / 2), + colors: [ + Color(0xFFFF7500), Color(0xFFFF7AA0), Color(0xFF8E7CF0), Color(0xFF4DA6E0), + Color(0xFF3FB58C), Color(0xFFFFD43B), Color(0xFFFF7500), + ], + ); @override Widget build(BuildContext context) { @@ -384,11 +395,17 @@ class RibbonPicker extends StatelessWidget { alignment: Alignment.center, child: custom ? check(color) - : const RupIcon( - RupIconName.plus, - size: 16, - stroke: 2.6, - color: Colors.white, + : const DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0x33000000), offset: Offset(0, 1), blurRadius: 2)], + ), + child: RupIcon( + RupIconName.plus, + size: 16, + stroke: 2.6, + color: Colors.white, + ), ), ), ), @@ -439,6 +456,9 @@ Future showPostMenuSheet(BuildContext context) { /// 지우기·나가기 등 위험한 동작의 색 (시안 #E05A4A) const Color kDangerTone = Color(0xFFE05A4A); +/// 기록 수정 글칸 높이 (시안 minHeight 120 — 작성 화면과 같다) +const double _kEditTextMinHeight = 120; + /// 기록 글 수정 — 저장하면 새 글, 닫으면 null Future showEditPostSheet(BuildContext context, String initial) { return showRupSheet( @@ -477,8 +497,10 @@ class _EditPostBodyState extends State<_EditPostBody> { const SizedBox(height: AppSpacing.sm), RupField( label: '오늘 하루는 어떠셨나요?', - child: RupTextField(controller: _text, multiline: true), + labelGap: AppSpacing.md, + child: RupTextField(controller: _text, multiline: true, minHeight: _kEditTextMinHeight), ), + const SizedBox(height: AppSpacing.lg), RupSheetBleedButton( label: '수정 완료', // 비우면 원래 글을 그대로 둔다 (시안)