10 Commits

Author SHA1 Message Date
ttipo 1f7693898c fix(flutter): 마이페이지·아이 정보·로그인 시안 점검 반영
- 반려동물 정보에서 뒤로 가면 마이페이지 첫 화면으로 간다 (시안 동작).
  명함 칩은 명함이 있으면 펫 명함 탭으로, 없으면 명함 관리로 간다.
- 사진이 없는 칸은 시안 바탕색으로 채우고, 아이 이름은 제목 서체 800 으로 맞췄다 (보관함 화면 포함).
- 아이 정보를 수정하면 그 아이를 선택한 채로 돌아온다. 삭제하기는 시안처럼 글자 버튼으로.
- 로그인: 인사말은 화면 위에서 96, 버튼 묶음은 아래에서 76 (상태바·홈 인디케이터가 크면 비켜 준다).
  소셜 로그인 버튼은 누르면 크기는 그대로 두고 어두워진다.
- 온보딩 그림, 프로필 수정·몸무게 단위 시트 간격을 시안 값으로 맞췄다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:25 +09:00
ttipo 0c16763943 fix(flutter): 다이어리 시안 점검 반영
- 기록을 남기고 돌아오면 달력이 아니라 피드로 보여 준다.
- 기록 카드: 폴라로이드 위치, 테이프 양옆 흰 점선, 댓글 입력·수정칸 여백을 시안대로 맞추고
  입력 중이면 주황 테두리를 둔다. 등록 버튼은 제목 서체 800 으로.
- 기록 수정 시트: 글칸 최소 높이 120(작성 화면과 같게), 라벨 간격, 종류 선택에 아이콘을 붙였다.
- 다이어리에 참여하면 시트를 닫고, 표지 리본·그림자와 무지개 색 시작 위치(12시)를 맞췄다.
- 검색창 포커스 테두리, 다이어리 머리 여백을 시안 값으로. 다이어리 제거는 전용 확인창으로.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:25 +09:00
ttipo e6f5cd0720 feat(flutter): 펫 명함 이미지를 앨범에 저장
공유 시트의 '이미지로 저장'과 QR 창의 'QR 이미지 저장'이 시안처럼 휴대폰 앨범에 저장한다.
지금까지는 공유 창을 띄웠다. 웹은 앨범이 없으므로 공유 창을 그대로 쓴다.

- 저장은 AlbumSaver 뒤에 두고(gal), 테스트에서는 가짜 저장기로 바꿔 끼운다.
- 사진 접근을 거절해 저장하지 못하면 권한을 확인하라고 알린다.
- QR 창은 배경 흐림·테두리를 시안대로, 저장하면 QR 창과 공유 시트를 함께 닫는다.
- 코드 복사 안내 문구를 시안에 맞췄다.

권한: Android 는 10 미만에서만 저장 권한을 요청하고(maxSdkVersion 29), iOS 는 사진 추가 안내 문구를 넣었다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 4173c64af8 fix(flutter): 펫 명함 화면 시안 점검 반영
- 새 명함은 그 아이의 고유색 한 가지로 시작한다 (CardStyle.forPet — 직접 선택 칸이 켜진다).
- 명함 이름이 길어도 윗줄(이름·성별·공유 버튼)이 깨지지 않게 하고, 공유 버튼에 누름 효과를 넣었다.
- 꾸미기에서 저장하고 돌아오면 상세히 보기로 돌아온다.
- 빈 명함 칸 누름 바탕, '기본' 칸은 골라도 그림자 없음, 명함 받기 안내 아이콘(QR) 등
  상세·관리·목록 화면의 간격과 줄 높이를 시안 값으로 맞췄다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo 81af93a813 fix(flutter): 홈·일정 시안 점검 반영과 우리 아이 순서 바꾸기
홈
- 우리 아이를 꾹 눌러(320ms) 끌면 순서가 바뀌고 저장된다 (PetRepository.reorder).
  보관함으로 간 아이는 목록에서 빠지므로 원래 순서대로 뒤에 둔다.
- 오늘 할 일 진행 막대 끝을 둥글게, 아이 사진 겹침의 글자 아래 여백(inline-flex)을 반영하고
  긴 일정 제목은 줄을 바꾼다.
- 최근 다이어리 칸을 누르면 시안처럼 일정·기록 탭의 다이어리 목록으로 간다.

일정
- 하루 종일이 켜진 채 기간을 하루 2회로 정하면 회차 시각(08:00·20:00)이 저장되지 않던 문제를 고쳤다.
- 예약 분류를 고른 뒤 같은 일반 일정 칩을 다시 누르면 분류 선택이 풀린다.
- 주기 직접 설정은 2주마다로 시작하고, 매일·평일을 골랐다 돌아와도 단위·횟수가 남는다.
- 12개월 보기에서는 일정 목록을 숨기고, 달력 점 묶음을 칸 가운데에 둔다.
- 저장 버튼 흐림 .45, 일정 제거는 전용 확인창으로.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 01:24:24 +09:00
ttipo d9a4eba9a3 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>
2026-09-29 01:24:24 +09:00
ttipo 03cb0224d6 feat(flutter): 추억 보관함 — 보관하기·다시 보기·다시 불러오기
반려동물 정보에서 아이를 추억 보관함으로 옮기고(함께한 기간·메모), 마이페이지의 보관함에서
메모리얼 사진·추억 기록과 원래 정보·펫 명함·다이어리(읽기 전용)를 다시 보며, 다시 불러올 수 있게 했다.

보관 기록은 반려동물에 붙여 저장한다(Pet.archive) — 서버가 생기면 보관/복구 요청으로 바꾼다.
보관하면 그 아이의 일정은 지우고(여러 아이 일정은 그 아이만 뗀다), 홈·나의 반려동물·펫 명함·일정 대상에서
빼며, 그 아이만 담은 다이어리는 책장·홈 최근 기록·기록 알림과 다이어리 수 한도에서 뺀다.

화면은 petListProvider 를 직접 보고 목록 확장(active/archived)으로 거른다 — 걸러 낸 목록을
파생 프로바이더로 두면 Riverpod 3.3 의 일시정지 해제 때 markNeedsBuild 오류가 난다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:09:45 +09:00
ttipo 795509be1b fix(flutter): Riverpod 3.3.2 로 올리고 파생 프로바이더가 알림기만 보도록 정리
Riverpod 3 은 가려진 화면의 구독을 멈췄다가 다시 보일 때 빌드 도중에 풀면서 파생값을 다시 계산한다.
3.2.1 에서는 이때 멈춤 횟수 assertion 이 깨졌다 (3.3.2 에서 수정 — pubspec.lock 만 바뀐다).

3.3.2 에도 파생 프로바이더가 다른 파생 프로바이더에 알리면 markNeedsBuild 오류가 나는 경로가 남아 있다.
이를 고친 3.4 는 Dart 3.12 가 필요해 올릴 수 없으므로, 명함·알림의 파생값을 알림기에서 바로 계산하게 바꿔
파생 프로바이더끼리 잇지 않는다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:09:09 +09:00
ttipo 877c833f7d fix(flutter): 사진 없는 아바타·다이어리 표지에 시안 바탕색 반영
시안의 사진 칸(image-slot)은 사진이 없을 때 rgba(0,0,0,.04) 바탕 위에 기본 그림을 올린다.
아이 아바타는 바탕이 비어 있었고, 다이어리 표지는 고유색이 시안보다 밝게 보였다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 00:08:19 +09:00
ttipo 66fb329747 feat(flutter): 알림 화면과 홈 종 새 알림 표시 구현
시안의 알림 화면(오늘·어제·최근 7일 묶음)과 홈 종 버튼의 새 알림 점을 옮겼다.
서버가 아직 없어 공유 다이어리의 새 글·댓글 알림은 구조만 두고,
기기에서 만들 수 있는 기록 알림(저녁 8시까지 오늘 기록이 없는 다이어리)부터 보여 준다.
다이어리별 기록 알림 스위치와 마이페이지 일정·다이어리 알림 설정을 따른다.

시각에 따라 달라지는 동작을 테스트할 수 있게 clockProvider 로 현재 시각을 주입하고,
앱을 켜 둔 채 8시·자정이 지나도 알림이 맞게 바뀌도록 그 시각에 다시 계산한다.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 23:26:58 +09:00
96 changed files with 4531 additions and 714 deletions
@@ -1,8 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 명함 이미지를 앨범에 저장 (Android 10 이하만 필요 — gal) -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
<application
android:label="tma_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:requestLegacyExternalStorage="true">
<activity
android:name=".MainActivity"
android:exported="true"
+2
View File
@@ -8,6 +8,8 @@
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Tma App</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>펫 명함 이미지를 사진 보관함에 저장합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>우리 아이 프로필 사진을 등록하기 위해 사진 보관함에 접근합니다.</string>
<key>CFBundleExecutable</key>
+65
View File
@@ -4,6 +4,12 @@ import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/archive/presentation/pages/archive_list_page.dart';
import 'package:tma_app/features/archive/presentation/pages/archived_cards_page.dart';
import 'package:tma_app/features/archive/presentation/pages/archived_diaries_page.dart';
import 'package:tma_app/features/archive/presentation/pages/archived_pet_info_page.dart';
import 'package:tma_app/features/archive/presentation/pages/archived_pet_page.dart';
import 'package:tma_app/features/archive/presentation/pages/pet_archive_move_page.dart';
import 'package:tma_app/features/auth/presentation/pages/login_page.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_compose_page.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
@@ -16,6 +22,7 @@ import 'package:tma_app/features/mypage/presentation/pages/my_page.dart';
import 'package:tma_app/features/mypage/presentation/pages/my_pets_page.dart';
import 'package:tma_app/features/mypage/presentation/pages/policy_doc_page.dart';
import 'package:tma_app/features/mypage/presentation/pages/settings_page.dart';
import 'package:tma_app/features/notifications/presentation/pages/notifications_page.dart';
import 'package:tma_app/features/onboarding/presentation/pages/onboarding_page.dart';
import 'package:tma_app/features/pet/presentation/pages/pet_info_page.dart';
import 'package:tma_app/features/pet/presentation/pages/pet_register_page.dart';
@@ -97,6 +104,11 @@ final appRouterProvider = Provider<GoRouter>((ref) {
parentNavigatorKey: rootKey,
builder: (context, state) => EventEditorPage.edit(eventId: state.uri.queryParameters['id'] ?? ''),
),
GoRoute(
path: AppRoutes.notifications,
parentNavigatorKey: rootKey,
builder: (context, state) => const NotificationsPage(),
),
GoRoute(
path: AppRoutes.diaryPattern,
parentNavigatorKey: rootKey,
@@ -181,6 +193,59 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: ':petId',
builder: (context, state) =>
PetInfoPage(petId: state.pathParameters['petId']!),
routes: [
// 추억 보관함으로 옮기기 — 탭바 없이 전체 화면
GoRoute(
path: 'archive',
parentNavigatorKey: rootKey,
builder: (context, state) =>
PetArchiveMovePage(petId: state.pathParameters['petId']!),
),
],
),
],
),
GoRoute(
path: 'archive',
builder: (context, state) => const ArchiveListPage(),
routes: [
GoRoute(
path: ':petId',
builder: (context, state) =>
ArchivedPetPage(petId: state.pathParameters['petId']!),
routes: [
GoRoute(
path: 'info',
builder: (context, state) =>
ArchivedPetInfoPage(petId: state.pathParameters['petId']!),
),
GoRoute(
path: 'cards',
builder: (context, state) =>
ArchivedCardsPage(petId: state.pathParameters['petId']!),
routes: [
GoRoute(
path: ':cardId',
builder: (context, state) => ArchivedCardDetailPage(
petId: state.pathParameters['petId']!,
cardId: state.pathParameters['cardId']!,
),
),
],
),
GoRoute(
path: 'diaries',
builder: (context, state) =>
ArchivedDiariesPage(petId: state.pathParameters['petId']!),
routes: [
GoRoute(
path: ':diaryId',
builder: (context, state) =>
DiaryPage(diaryId: state.pathParameters['diaryId']!, archived: true),
),
],
),
],
),
],
),
+15
View File
@@ -18,6 +18,18 @@ abstract final class AppRoutes {
// 마이페이지 하위 화면 (탭바 유지)
static const String myPets = '/mypage/pets';
static String petInfo(String petId) => '/mypage/pets/$petId';
/// 추억 보관함으로 옮기기 — 탭바 없이 전체 화면
static String petArchive(String petId) => '/mypage/pets/$petId/archive';
// 추억 보관함 (탭바 유지)
static const String archive = '/mypage/archive';
static String archivedPet(String petId) => '/mypage/archive/$petId';
static String archivedPetInfo(String petId) => '/mypage/archive/$petId/info';
static String archivedPetCards(String petId) => '/mypage/archive/$petId/cards';
static String archivedPetCard(String petId, String cardId) => '/mypage/archive/$petId/cards/$cardId';
static String archivedPetDiaries(String petId) => '/mypage/archive/$petId/diaries';
static String archivedPetDiary(String petId, String diaryId) => '/mypage/archive/$petId/diaries/$diaryId';
static const String settings = '/mypage/settings';
static String policy(PolicyDoc doc) => '/mypage/settings/policy/${doc.name}';
@@ -32,6 +44,9 @@ abstract final class AppRoutes {
static String eventCreate(DateTime date) => '$eventCreatePath?date=${toIsoDate(date)}';
static String eventEdit(String eventId) => '$eventEditPath?id=${Uri.encodeQueryComponent(eventId)}';
// 알림 — 홈 종에서 연다 (탭바 없이 전체 화면)
static const String notifications = '/notifications';
// 다이어리 — 열면 탭바 없이 전체 화면
static const String diaryPattern = '/diaries/:diaryId';
static String diary(String id) => '/diaries/$id';
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:gal/gal.dart';
import 'package:share_plus/share_plus.dart';
/// 이미지를 기기 앨범(사진 보관함)에 저장한다 — 테스트에서 바꿔 끼울 수 있게 계약으로 둔다
abstract interface class AlbumSaver {
/// 저장했으면 true — 권한을 거절했거나 실패하면 false
Future<bool> savePng(Uint8List bytes, String name);
}
/// gal 로 앨범에 저장한다. 웹에는 앨범이 없어 파일 내려받기(공유)로 대신한다
class GalAlbumSaver implements AlbumSaver {
const GalAlbumSaver();
@override
Future<bool> savePng(Uint8List bytes, String name) async {
if (kIsWeb) {
await SharePlus.instance.share(
ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: '$name.png')]),
);
return true;
}
try {
if (!await Gal.hasAccess() && !await Gal.requestAccess()) return false;
await Gal.putImageBytes(bytes, name: name);
return true;
} on GalException {
return false;
}
}
}
final albumSaverProvider = Provider<AlbumSaver>((ref) => const GalAlbumSaver());
+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);
}
}
+4
View File
@@ -0,0 +1,4 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 지금 시각 — 테스트에서 특정 시각(예: 저녁 8시)을 재현할 수 있게 주입한다
final clockProvider = Provider<DateTime Function()>((ref) => DateTime.now);
+3
View File
@@ -14,5 +14,8 @@ bool hasFinalConsonant(String word) {
/// 목적격 조사 — 콩이를 · 초코를 · 보리를 / 루비를 · 사랑을
String withObjectParticle(String word) => '$word${hasFinalConsonant(word) ? '을' : '를'}';
/// 공동격 조사 — 콩이와 / 사랑과
String withAndParticle(String word) => '$word${hasFinalConsonant(word) ? '과' : '와'}';
/// 주격 조사 — 콩이가 / 사랑이
String withSubjectParticle(String word) => '$word${hasFinalConsonant(word) ? '이' : '가'}';
@@ -0,0 +1,184 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart';
import 'package:tma_app/shared/widgets/grayscale.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kNormal = AppTypography.normalHeight;
/// 목록의 아이 사진은 조금 더 바랜다 (시안 grayscale(.35) · opacity .92)
const double _kRowFade = .35;
const double _kRowOpacity = .92;
/// 추억 보관함 — 보관된 아이 목록
class ArchiveListPage extends ConsumerWidget {
const ArchiveListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).archived;
final book = ref.watch(diaryBookProvider);
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(
title: '추억 보관함',
subtitle: '${pets.length}마리',
titleLeading: const MemoryMark(size: 18),
bottomGap: AppSpacing.lg,
onBack: () => context.pop(),
),
),
ScreenPadding(
child: Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, 14),
child: Text(
'먼저 떠난 아이를 삭제하지 않고 보관해요. 아이를 누르면 함께한 다이어리와 펫 명함을 다시 볼 수 있어요.',
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.55),
),
),
),
ScreenPadding(
child: RupCard(
child: pets.isEmpty
? const _Empty()
: Column(
children: [
for (final (i, p) in pets.indexed)
_ArchivedPetRow(
pet: p,
postCount: book.postCountOf(p.id),
first: i == 0,
onTap: () => unawaited(context.push(AppRoutes.archivedPet(p.id))),
),
],
),
),
),
const SizedBox(height: AppSpacing.md),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl, vertical: 34),
child: Opacity(
opacity: .8,
child: Column(
children: [
const Opacity(opacity: .7, child: MemoryMark(size: 30)),
const SizedBox(height: AppSpacing.sm),
Text(
'보관된 아이가 없어요',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.memory,
height: _kNormal,
),
),
],
),
),
);
}
}
/// 보관된 아이 한 줄 — 바랜 사진 · 이름 · 종 · 기록 수 · 함께한 기간
class _ArchivedPetRow extends StatelessWidget {
const _ArchivedPetRow({required this.pet, required this.postCount, required this.first, required this.onTap});
final Pet pet;
final int postCount;
final bool first;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final archive = pet.archive;
final kind = pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}';
return Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 13),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
children: [
Opacity(
opacity: _kRowOpacity,
child: Grayscale(amount: _kRowFade, child: PetAvatar(pet: pet, size: 46)),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: textTheme.titleSmall!.copyWith(fontSize: 15.5, height: _kNormal),
),
Text(
'$kind · 기록 $postCount개',
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.memory, height: _kNormal),
),
if (archive != null) ...[
const SizedBox(height: 3),
Row(
children: [
RupIcon(
RupIconName.calendar,
size: 12,
stroke: 2,
color: AppColors.memory.withValues(alpha: .8),
),
const SizedBox(width: AppSpacing.xs),
Flexible(
child: Text(
'${formatYmd(archive.from)} ~ ${formatYmd(archive.to)}',
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal),
),
),
],
),
],
],
),
),
RupIcon(RupIconName.chevR, size: 17, color: AppColors.memory.withValues(alpha: .7)),
],
),
),
),
);
}
}
@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/pet_id_card.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이의 펫 명함 — 간략 명함 목록, 누르면 상세
class ArchivedCardsPage extends ConsumerWidget {
const ArchivedCardsPage({super.key, required this.petId});
final String petId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final cards = ref.watch(cardsOfPetProvider(pet.id));
return TabSubPage(
children: [
ScreenPadding(
child: ArchiveSubHeader(
title: '펫 명함',
petName: pet.name,
// 시안: 안내 문구가 머리 아래 18 에서 8 을 당겨 붙는다
bottomGap: cards.isEmpty ? 18 : 18 - AppSpacing.sm,
),
),
if (cards.isEmpty)
const ScreenPadding(child: RupCard(child: EmptyMessage('만든 펫 명함이 없어요')))
else ...[
ScreenPadding(
child: Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, 14),
child: Text(
'명함을 누르면 상세 펫 명함을 볼 수 있어요.',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12.5,
color: AppColors.muted,
height: 1.55,
),
),
),
),
for (final (i, c) in cards.indexed) ...[
if (i > 0) const SizedBox(height: AppSpacing.md),
ScreenPadding(
child: GestureDetector(
onTap: () => unawaited(context.push(AppRoutes.archivedPetCard(pet.id, c.id))),
child: NameCard(pet: pet, style: c.style),
),
),
],
],
const SizedBox(height: 14),
],
);
}
}
/// 보관된 아이의 펫 명함 상세 — 보기만 한다 (공유·수정·대표 지정 없음)
class ArchivedCardDetailPage extends ConsumerWidget {
const ArchivedCardDetailPage({super.key, required this.petId, required this.cardId});
final String petId;
final String cardId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final card = ref.watch(cardsOfPetProvider(pet.id)).where((c) => c.id == cardId).firstOrNull;
return TabSubPage(
children: [
ScreenPadding(child: ArchiveSubHeader(title: pet.name, petName: pet.name)),
if (card == null)
const ScreenPadding(child: RupCard(child: EmptyMessage('만든 펫 명함이 없어요')))
else
ScreenPadding(child: PetIdCard(pet: pet, style: card.style)),
const SizedBox(height: 14),
],
);
}
}
@@ -0,0 +1,55 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/diary/presentation/widgets/diary_cover.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이와 함께한 다이어리 — 표지를 누르면 읽기 전용 피드
class ArchivedDiariesPage extends ConsumerWidget {
const ArchivedDiariesPage({super.key, required this.petId});
final String petId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final book = ref.watch(diaryBookProvider);
final diaries = book.diariesOf(pet.id);
return TabSubPage(
children: [
ScreenPadding(
child: ArchiveSubHeader(
title: diaries.isEmpty ? '다이어리' : '다이어리 · ${diaries.length}권',
petName: pet.name,
),
),
ScreenPadding(
child: diaries.isEmpty
? const RupCard(child: EmptyMessage('함께한 다이어리가 없어요'))
: DiaryGrid(
children: [
for (final d in diaries)
DiaryCoverTile(
diary: d,
postCount: book.countOf(d.id),
showTargets: false,
onTap: () => unawaited(context.push(AppRoutes.archivedPetDiary(pet.id, d.id))),
),
],
),
),
const SizedBox(height: 14),
],
);
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_avatar.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_info_sections.dart';
import 'package:tma_app/shared/widgets/grayscale.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이의 원래 정보 — 기본·건강 정보를 읽기 전용으로
class ArchivedPetInfoPage extends ConsumerWidget {
const ArchivedPetInfoPage({super.key, required this.petId});
final String petId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
if (pet == null || !pet.isArchived) return const ArchiveMissingPage();
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(child: ArchiveSubHeader(title: '${pet.name}의 정보', petName: pet.name)),
ScreenPadding(
child: RupCard(
padding: const EdgeInsets.all(18),
child: Row(
children: [
Grayscale(amount: kArchiveFade, child: PetAvatar(pet: pet, size: 64)),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w800,
fontSize: 22,
height: 1,
color: AppColors.ink,
),
),
const SizedBox(height: 5),
Text(
pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}',
style: textTheme.bodySmall!.copyWith(
fontSize: 13,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
],
),
),
],
),
),
),
...petInfoSections(pet),
const SizedBox(height: AppSpacing.xs),
],
);
}
}
@@ -0,0 +1,412 @@
import 'dart:async';
import 'dart:ui' show ImageFilter;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/archive/presentation/providers/archive_actions.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/domain/pet.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/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kNormal = AppTypography.normalHeight;
const double _kHeroMinHeight = 200;
/// 사진 위 글씨가 읽히게 아래로 갈수록 어두워지는 막 (시안 linear-gradient)
const List<Color> _kHeroShade = [Color(0x0D000000), Color(0x6B000000), Color(0xC7000000)];
const List<double> _kHeroShadeStops = [.3, .62, 1];
const List<Shadow> _kHeroTextShadow = [Shadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 1))];
/// 보관된 아이 — 메모리얼 사진, 추억 기록, 둘러보기, 다시 불러오기
class ArchivedPetPage extends ConsumerWidget {
const ArchivedPetPage({super.key, required this.petId});
final String petId;
Future<void> _restore(BuildContext context, WidgetRef ref) async {
await ref.read(archiveActionsProvider).restore(petId);
if (context.mounted) context.go(AppRoutes.archive);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final pet = ref.watch(petByIdProvider(petId));
final archive = pet?.archive;
if (pet == null || archive == null) return const ArchiveMissingPage();
final cardCount = ref.watch(cardsOfPetProvider(pet.id)).length;
final textTheme = Theme.of(context).textTheme;
return TabSubPage(
children: [
ScreenPadding(
child: SubPageHeader(
title: pet.name,
subtitle: '추억 보관함',
titleLeading: const MemoryMark(size: 17),
onBack: () => context.pop(),
),
),
ScreenPadding(child: _MemorialHero(pet: pet, archive: archive)),
const DividerBand(),
ScreenPadding(child: _MemoryRecord(archive: archive)),
const DividerBand(top: 18),
const ScreenPadding(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '둘러보기'),
),
),
const SizedBox(height: 10),
ScreenPadding(
child: RupCard(
child: Column(
children: [
_BrowseRow(
first: true,
icon: const RupIcon(RupIconName.paw, size: 18, color: AppColors.memory),
title: '원래 정보',
sub: '기본·건강 정보를 다시 봐요.',
onTap: () => unawaited(context.push(AppRoutes.archivedPetInfo(pet.id))),
),
_BrowseRow(
icon: const RupGlyphIcon(RupGlyph.petCard, size: 19, color: AppColors.memory),
title: '펫 명함',
sub: cardCount > 0 ? '함께한 펫 명함 $cardCount개' : '만든 펫 명함이 없어요',
onTap: () => unawaited(context.push(AppRoutes.archivedPetCards(pet.id))),
),
_BrowseRow(
icon: const RupGlyphIcon(RupGlyph.diary, size: 17, color: AppColors.memory),
title: '다이어리',
sub: '함께한 날들을 다시 봐요.',
onTap: () => unawaited(context.push(AppRoutes.archivedPetDiaries(pet.id))),
),
],
),
),
),
const SizedBox(height: 44),
Center(
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => unawaited(_restore(context, ref)),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupIcon(RupIconName.repeat, size: 17, stroke: 2.2, color: AppColors.memory),
const SizedBox(width: 6),
Text(
'${pet.name} 다시 불러오기',
style: textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.memory,
height: _kNormal,
),
),
],
),
),
),
),
const SizedBox(height: AppSpacing.md),
],
);
}
}
/// 메모리얼 머리 — 바랜 사진 위에 이름과 함께한 기간
class _MemorialHero extends StatelessWidget {
const _MemorialHero({required this.pet, required this.archive});
final Pet pet;
final PetArchive archive;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final kind = pet.breed.isEmpty ? pet.species : '${pet.species} · ${pet.breed}';
return Container(
constraints: const BoxConstraints(minHeight: _kHeroMinHeight),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(AppRadius.card), boxShadow: AppShadows.sm),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned.fill(child: ArchivedPhoto(pet: pet)),
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: _kHeroShade,
stops: _kHeroShadeStops,
),
),
),
),
Container(
constraints: const BoxConstraints(minHeight: _kHeroMinHeight),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
alignment: Alignment.bottomLeft,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pet.name,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w800,
fontSize: 25,
height: 1.1,
color: Colors.white,
shadows: _kHeroTextShadow,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
kind,
style: textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: .92),
height: _kNormal,
shadows: const [Shadow(color: Color(0x66000000), blurRadius: 8, offset: Offset(0, 1))],
),
),
const SizedBox(height: 9),
_GlassChip(text: '${archive.spanLabel} 함께했어요'),
],
),
),
],
),
);
}
}
/// 사진 위의 반투명 알약 (시안 backdrop-filter blur 6)
class _GlassChip extends StatelessWidget {
const _GlassChip({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.pill),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 6, sigmaY: 6),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .18),
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(color: Colors.white.withValues(alpha: .32)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const MemoryMark(size: 14, color: Colors.white),
const SizedBox(width: 6),
Text(
text,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: Colors.white,
height: _kNormal,
),
),
],
),
),
),
);
}
}
/// 추억 기록 — 함께한 기간 · 보호자 메모 (읽기 전용)
class _MemoryRecord extends StatelessWidget {
const _MemoryRecord({required this.archive});
final PetArchive archive;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final value = textTheme.bodyMedium!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w700,
color: AppColors.ink,
height: _kNormal,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SectionTitle(title: '추억 기록'),
const SizedBox(height: AppSpacing.xs),
_InfoRow(
first: true,
icon: RupIconName.calendar,
label: '함께한 기간',
child: Text.rich(
TextSpan(children: [
TextSpan(text: '${formatYmd(archive.from)} '),
TextSpan(
text: '~',
style: value.copyWith(fontWeight: FontWeight.w600, color: AppColors.muted),
),
TextSpan(text: ' ${formatYmd(archive.to)}'),
]),
style: value,
),
),
if (archive.memo case final memo?)
_InfoRow(
icon: RupIconName.text,
label: '보호자 메모',
child: Text(
memo,
style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w500, color: AppColors.ink2, height: 1.6),
),
),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.icon, required this.label, required this.child, this.first = false});
final RupIconName icon;
final String label;
final Widget child;
final bool first;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 13),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
// 시안 color-mix(tint, #fff 85%)
color: Color.lerp(AppColors.memory, Colors.white, .85),
borderRadius: BorderRadius.circular(11),
),
alignment: Alignment.center,
child: RupIcon(icon, size: 18, stroke: 2, color: AppColors.memory),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: AppColors.muted,
letterSpacing: .115,
height: _kNormal,
),
),
const SizedBox(height: 3),
child,
],
),
),
),
],
),
);
}
}
/// 둘러보기 한 줄 — 보라 타일 아이콘 + 제목 + 설명
class _BrowseRow extends StatelessWidget {
const _BrowseRow({
required this.icon,
required this.title,
required this.sub,
required this.onTap,
this.first = false,
});
final Widget icon;
final String title;
final String sub;
final VoidCallback onTap;
final bool first;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 15),
decoration: BoxDecoration(
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(color: AppColors.memory50, borderRadius: BorderRadius.circular(11)),
alignment: Alignment.center,
child: icon,
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: textTheme.titleSmall!.copyWith(fontSize: 15.5, height: _kNormal)),
const SizedBox(height: 1),
Text(sub, style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal)),
],
),
),
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
],
),
),
),
);
}
}
@@ -0,0 +1,302 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/archive/presentation/providers/archive_actions.dart';
import 'package:tma_app/features/archive/presentation/widgets/archive_widgets.dart';
import 'package:tma_app/features/pet/domain/pet.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/shared/widgets/date_picker_sheet.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_form_fields.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
const double _kNormal = AppTypography.normalHeight;
const double _kHeroHeight = 168;
const double _kMemoMinHeight = 84;
/// 바닥 버튼 아래 여백 (시안 padding-bottom 30 — 홈 인디케이터 자리)
const double _kBarBottom = 30;
/// 추억 보관함으로 옮기기 — 함께한 기간과 메모를 남기고 보관한다
class PetArchiveMovePage extends ConsumerStatefulWidget {
const PetArchiveMovePage({super.key, required this.petId});
final String petId;
@override
ConsumerState<PetArchiveMovePage> createState() => _PetArchiveMovePageState();
}
class _PetArchiveMovePageState extends ConsumerState<PetArchiveMovePage> {
final _memo = TextEditingController();
DateTime? _from;
late DateTime _to = dateOnly(DateTime.now());
@override
void dispose() {
_memo.dispose();
super.dispose();
}
void _alert(String text) => ref.read(toastProvider.notifier).show(text, icon: RupIconName.alert);
Future<void> _pickFrom(Pet pet, DateTime from) async {
final v = await showBirthDatePicker(context, initial: from, title: '${withAndParticle(pet.name)} 함께한 날');
if (v == null || !mounted) return;
if (v.isAfter(_to)) {
_alert('함께한 첫날은 마지막 날보다 늦을 수 없어요');
return;
}
setState(() => _from = v);
}
Future<void> _pickTo(Pet pet, DateTime from) async {
final v = await showBirthDatePicker(context, initial: _to, title: '${withObjectParticle(pet.name)} 떠나보낸 날');
if (v == null || !mounted) return;
if (v.isBefore(from)) {
_alert('마지막 날은 함께한 첫날보다 빠를 수 없어요');
return;
}
if (v.isAfter(dateOnly(DateTime.now()))) {
_alert('오늘 이후의 날짜는 고를 수 없어요');
return;
}
setState(() => _to = v);
}
Future<void> _submit(Pet pet, DateTime from) async {
final memo = _memo.text.trim();
await ref.read(archiveActionsProvider).archive(
pet.id,
PetArchive(from: from, to: _to, archivedAt: DateTime.now(), memo: memo.isEmpty ? null : memo),
);
if (mounted) context.go(AppRoutes.myPage);
}
@override
Widget build(BuildContext context) {
final pet = ref.watch(petByIdProvider(widget.petId));
// 이미 보관했거나 지워진 아이 — 옮긴 직후 한 프레임 등
if (pet == null || pet.isArchived) return const Scaffold(body: SizedBox.shrink());
final from = _from ?? pet.birthDate;
final textTheme = Theme.of(context).textTheme;
final label = textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.ink2,
height: _kNormal,
);
return Scaffold(
body: Column(
children: [
Expanded(
child: SafeArea(
bottom: false,
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(AppSpacing.xl, AppSpacing.xs, AppSpacing.xl, AppSpacing.xl),
children: [
SubPageHeader(
title: '${withObjectParticle(pet.name)} 추억 보관함으로',
subtitle: '함께한 시간을 소중히 보관해 둘게요',
onBack: () => context.pop(),
),
Container(
height: _kHeroHeight,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.card),
boxShadow: AppShadows.sm,
),
clipBehavior: Clip.antiAlias,
child: ArchivedPhoto(pet: pet),
),
// 시안: 사진 아래 18 + 제목 위 24 (세로 flex 라 겹치지 않는다)
const SizedBox(height: 42),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text('${withAndParticle(pet.name)} 함께한 기간을 알려주세요', style: label),
),
const SizedBox(height: 11),
Row(
children: [
Expanded(
child: _DateButton(
label: '함께한 첫날',
value: from,
onTap: () => unawaited(_pickFrom(pet, from)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 9),
child: Text(
'~',
style: textTheme.bodyMedium!.copyWith(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: _kNormal,
),
),
),
Expanded(
child: _DateButton(label: '마지막 날', value: _to, onTap: () => unawaited(_pickTo(pet, from))),
),
],
),
const SizedBox(height: 9),
Padding(
padding: const EdgeInsets.only(left: 2),
child: Text(
'${withAndParticle(pet.name)} ${togetherSpan(from, _to)} 함께했어요',
style: textTheme.bodySmall!.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.memory,
height: _kNormal,
),
),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text.rich(
TextSpan(children: [
TextSpan(text: '${withAndParticle(pet.name)} 함께한 시간을 남겨주세요 '),
TextSpan(
text: '(선택)',
style: label.copyWith(fontSize: 12, fontWeight: FontWeight.w600, color: AppColors.muted),
),
]),
style: label,
),
),
const SizedBox(height: 9),
RupTextField(
controller: _memo,
multiline: true,
minHeight: _kMemoMinHeight,
hintText: '고마웠던 순간, 기억하고 싶은 이야기를 자유롭게 적어주세요',
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const RupIcon(RupIconName.check, size: 15, stroke: 2.2, color: AppColors.accent),
const SizedBox(width: 6),
Flexible(
child: Text(
'보관 후에도 추억 보관함에서 언제든 다시 볼 수 있어요',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: 1.5),
),
),
],
),
],
),
),
),
_BottomBar(label: '추억 보관함으로 옮기기', onTap: () => unawaited(_submit(pet, from))),
],
),
);
}
}
/// 날짜 칸 — 작은 제목 + 달력 아이콘 + 날짜
class _DateButton extends StatelessWidget {
const _DateButton({required this.label, required this.value, required this.onTap});
final String label;
final DateTime value;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Pressable(
onTap: onTap,
semanticLabel: '$label ${formatYmd(value)}',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.line, width: 1.5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: textTheme.bodySmall!.copyWith(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: _kNormal,
),
),
const SizedBox(height: 2),
Row(
children: [
const RupIcon(RupIconName.calendar, size: 15, stroke: 2, color: AppColors.memory),
const SizedBox(width: 6),
Flexible(
child: Text(
formatYmd(value),
maxLines: 1,
style: textTheme.bodyMedium!.copyWith(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.ink,
height: _kNormal,
),
),
),
],
),
],
),
),
);
}
}
/// 화면 바닥에 붙은 주황 버튼 — 모서리 없이 가로 끝까지
class _BottomBar extends StatelessWidget {
const _BottomBar({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final inset = MediaQuery.paddingOf(context).bottom;
return Semantics(
button: true,
child: GestureDetector(
onTap: onTap,
child: Container(
width: double.infinity,
color: AppColors.primary,
padding: EdgeInsets.only(top: AppSpacing.lg, bottom: inset > _kBarBottom ? inset : _kBarBottom),
alignment: Alignment.center,
child: Text(label, style: AppTypography.button(16, color: Colors.white)),
),
),
);
}
}
@@ -0,0 +1,28 @@
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/rup_toast.dart';
/// 추억 보관함 — 옮기기 / 다시 불러오기
class ArchiveActions {
ArchiveActions(this._ref);
final Ref _ref;
/// 아이를 보관함으로 옮긴다. 일정은 지우고(여러 아이 일정이면 그 아이만 뗀다),
/// 다이어리·펫 명함·정보는 그대로 두어 보관함에서 다시 볼 수 있게 한다.
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('추억보관함에 보관했어요.', tone: ToastTone.memory);
}
/// 보관함에서 다시 불러온다 — 지운 일정은 돌아오지 않는다
Future<void> restore(String petId) async {
await _ref.read(petListProvider.notifier).restorePet(petId);
_ref.read(toastProvider.notifier).show('다시 불러왔어요');
}
}
final archiveActionsProvider = Provider<ArchiveActions>(ArchiveActions.new);
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_assets.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
import 'package:tma_app/shared/widgets/grayscale.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
/// 보관된 아이 사진의 빛바램 (시안 grayscale(.3))
const double kArchiveFade = .3;
/// 사진이 없을 때 칸 바탕 (시안 rgba(0,0,0,.04))
const Color _kEmptyPhotoTint = Color(0x0A000000);
/// 추억 보관함 표시 — 보라 구름
class MemoryMark extends StatelessWidget {
const MemoryMark({super.key, required this.size, this.color = AppColors.memory});
final double size;
final Color color;
@override
Widget build(BuildContext context) => RupGlyphIcon(RupGlyph.memory, size: size, color: color);
}
/// 보관된 아이의 하위 화면 머리 — 부제에 "콩이 · 추억 보관함"
class ArchiveSubHeader extends StatelessWidget {
const ArchiveSubHeader({super.key, required this.title, required this.petName, this.onBack, this.bottomGap = 18});
final String title;
final String petName;
/// 없으면 한 단계 뒤로
final VoidCallback? onBack;
final double bottomGap;
@override
Widget build(BuildContext context) {
return SubPageHeader(
title: title,
subtitle: '$petName · 추억 보관함',
subtitleLeading: const MemoryMark(size: 12),
onBack: onBack ?? () => context.pop(),
bottomGap: bottomGap,
);
}
}
/// 보관된 아이 사진을 가득 채워 빛바래게 — 사진이 없으면 기본 그림
class ArchivedPhoto extends StatelessWidget {
const ArchivedPhoto({super.key, required this.pet});
final Pet pet;
@override
Widget build(BuildContext context) {
final photo = pet.photoPath;
return Grayscale(
amount: kArchiveFade,
child: photo == null
? ColoredBox(
// 시안 image-slot: 화면 바탕 위에 rgba(0,0,0,.04)
color: Color.alphaBlend(_kEmptyPhotoTint, AppColors.bg),
child: SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover),
)
: PetPhotoImage(path: photo),
);
}
}
/// 보관함에서 찾는 아이가 없을 때 — 다시 불러왔거나 잘못된 주소
class ArchiveMissingPage extends StatelessWidget {
const ArchiveMissingPage({super.key});
@override
Widget build(BuildContext context) {
return TabSubPage(
children: [
ScreenPadding(child: SubPageHeader(title: '추억 보관함', onBack: () => context.go(AppRoutes.archive))),
const EmptyMessage('보관된 아이가 없어요', padding: 60),
],
);
}
}
@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -22,7 +24,9 @@ const Size _kArtSize = Size(327, 179);
const Alignment _kArtAlignment = Alignment(.33, .68);
/// 시안: 버튼 묶음은 화면 아래에서 76 (홈 인디케이터 34 포함)
const double _kButtonsBottomGap = 42;
/// 시안: 인사말은 화면 위에서 96, 버튼 묶음은 화면 아래에서 76 (.rup-login__head / __btns)
const double _kHeadTop = 96;
const double _kButtonsBottom = 76;
const double _kButtonGap = 15;
/// 로그인 — 소셜 계정으로 가입/로그인한다.
@@ -55,6 +59,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final insets = MediaQuery.paddingOf(context);
// 화면 가장자리 기준 위치를 따르되, 상태바·홈 인디케이터가 크면 그만큼 비켜 준다
final headGap = math.max(_kHeadTop - insets.top - AppControlSize.navBarHeight, AppSpacing.xxl);
final buttonsBottom = math.max(_kButtonsBottom, insets.bottom + AppSpacing.xl);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark,
child: Scaffold(
@@ -66,12 +74,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
children: [
_LoginNavBar(onBack: () => context.go(AppRoutes.intro)),
Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xxl,
AppSpacing.xl,
0,
),
padding: EdgeInsets.fromLTRB(AppSpacing.xl, headGap, AppSpacing.xl, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -93,28 +96,19 @@ class _LoginPageState extends ConsumerState<LoginPage> {
const Expanded(
child: Align(alignment: _kArtAlignment, child: _LoginArt()),
),
SafeArea(
top: false,
minimum: const EdgeInsets.only(bottom: AppSpacing.xl),
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.lg,
AppSpacing.xl,
_kButtonsBottomGap,
),
child: Column(
children: [
for (final provider in SocialProvider.values) ...[
if (provider != SocialProvider.values.first)
const SizedBox(height: _kButtonGap),
SocialLoginButton(
provider: provider,
onPressed: _pending == null ? () => _signIn(provider) : null,
),
],
Padding(
padding: EdgeInsets.fromLTRB(AppSpacing.xl, AppSpacing.lg, AppSpacing.xl, buttonsBottom),
child: Column(
children: [
for (final provider in SocialProvider.values) ...[
if (provider != SocialProvider.values.first)
const SizedBox(height: _kButtonGap),
SocialLoginButton(
provider: provider,
onPressed: _pending == null ? () => _signIn(provider) : null,
),
],
),
],
),
),
],
@@ -86,7 +86,8 @@ class SocialLoginButton extends StatelessWidget {
final style = _styleOf(provider);
return Pressable(
onTap: onPressed,
pressedScale: .98,
pressedScale: 1,
pressedBrightness: .95,
semanticLabel: style.label,
child: Container(
height: AppControlSize.buttonHeight,
@@ -51,6 +51,10 @@ class Diary {
/// 누구의 다이어리인지 — 반려동물 id 또는 나(self)
final List<String> targetIds;
/// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다
bool isArchivedWith(Set<String> archivedPetIds) =>
targetIds.isNotEmpty && targetIds.every(archivedPetIds.contains);
final Color ribbon;
final Color tint;
final String? coverPath;
@@ -11,10 +11,24 @@ class DiaryBook {
final List<DiaryPost> posts;
final List<DiaryComment> comments;
bool get atMax => diaries.length >= kMaxDiaries;
/// 책장에 꽂힌 다이어리 — 추억 보관함의 아이만 담은 다이어리는 빠진다
List<Diary> shelf([Set<String> archivedPetIds = const {}]) =>
diaries.where((d) => !d.isArchivedWith(archivedPetIds)).toList();
/// 책장이 가득 찼는지 — 보관함으로 간 다이어리는 세지 않는다
bool atMax([Set<String> archivedPetIds = const {}]) => shelf(archivedPetIds).length >= kMaxDiaries;
Diary? diary(String id) => diaries.where((d) => d.id == id).firstOrNull;
/// 한 아이(또는 나)가 담긴 다이어리 — 추억 보관함에서 함께한 다이어리를 볼 때
List<Diary> diariesOf(String targetId) => diaries.where((d) => d.targetIds.contains(targetId)).toList();
/// 한 아이가 담긴 다이어리들의 기록 수
int postCountOf(String targetId) {
final ids = {for (final d in diariesOf(targetId)) d.id};
return posts.where((p) => ids.contains(p.diaryId)).length;
}
/// 새 기록이 위로 — 날짜, 같은 날이면 쓴 순서
static int _newestFirst(DiaryPost a, DiaryPost b) {
final byDate = b.date.compareTo(a.date);
@@ -32,8 +46,11 @@ class DiaryBook {
return posts.any((p) => p.diaryId == diaryId && p.authorId == authorId && p.date == d);
}
/// 모든 다이어리의 최근 기록 — 홈
List<DiaryPost> latest(int limit) => (List.of(posts)..sort(_newestFirst)).take(limit).toList();
/// 최근 기록 — 홈. [diaryIds] 를 주면 그 다이어리들의 기록만
List<DiaryPost> latest(int limit, {Set<String>? diaryIds}) {
final shown = diaryIds == null ? posts : posts.where((p) => diaryIds.contains(p.diaryId));
return (List.of(shown)..sort(_newestFirst)).take(limit).toList();
}
List<DiaryComment> commentsOf(String postId) =>
comments.where((c) => c.postId == postId).toList()..sort((a, b) => a.createdAt.compareTo(b.createdAt));
@@ -68,7 +68,8 @@ class _DiaryComposePageState extends ConsumerState<DiaryComposePage> {
photoPath: _photo,
);
toast.show('기록을 남겼어요');
if (mounted) context.pop();
// 남겼음을 알려 다이어리가 피드 보기로 돌아가게 한다
if (mounted) context.pop(true);
} finally {
if (mounted) setState(() => _saving = false);
}
@@ -49,12 +49,21 @@ enum FeedMode {
FeedMode get next => FeedMode.values[(index + 1) % FeedMode.values.length];
}
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (추억 보관함에서 열 때)
const double _kTabBottomGap = 30;
/// 다이어리 머리가 위아래로 2씩 좁다 (시안 embedded margin 2px 0 16px — 기록 탭·보관함 모두)
const double _kEmbeddedTrim = 2;
/// 다이어리 안 — 기록 피드(모두·사진·글) / 달력 보기, 검색, 오늘 쓰기
class DiaryPage extends ConsumerStatefulWidget {
const DiaryPage({super.key, required this.diaryId});
const DiaryPage({super.key, required this.diaryId, this.archived = false});
final String diaryId;
/// 추억 보관함에서 다시 보는 다이어리 — 쓰기·설정 없이 읽기만
final bool archived;
@override
ConsumerState<DiaryPage> createState() => _DiaryPageState();
}
@@ -83,7 +92,13 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
ref.read(toastProvider.notifier).show('오늘 기록은 이미 남겼어요. 내일 다시 작성할 수 있어요');
return;
}
unawaited(context.push(AppRoutes.diaryWrite(widget.diaryId)));
unawaited(_openCompose());
}
/// 기록하기 — 남기고 돌아오면 달력이 아니라 피드로 보여 준다
Future<void> _openCompose() async {
final saved = await context.push<bool>(AppRoutes.diaryWrite(widget.diaryId));
if (saved == true && mounted) setState(() => _calendar = false);
}
@override
@@ -99,6 +114,7 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
final filtered = q.isEmpty ? posts : posts.where((p) => p.text.toLowerCase().contains(q)).toList();
final wroteToday = book.wroteOn(diary.id, today);
final safeBottom = MediaQuery.paddingOf(context).bottom;
final archived = widget.archived;
return Scaffold(
body: Stack(
@@ -107,18 +123,27 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
bottom: false,
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(AppSpacing.xl, AppSpacing.xs, AppSpacing.xl, safeBottom + _kFabGap + _kFabSize),
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs - _kEmbeddedTrim,
AppSpacing.xl,
safeBottom + (archived ? _kTabBottomGap : _kFabGap + _kFabSize),
),
children: [
SubPageHeader(
title: diary.name,
subtitle: '기록 ${posts.length}개',
subtitleLeading: archived ? const RupGlyphIcon(RupGlyph.memory, size: 12, color: AppColors.memory) : null,
displayFont: true,
bottomGap: 18 - _kEmbeddedTrim,
onBack: () => context.pop(),
trailing: _HeaderIconButton(
label: '다이어리 설정',
onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))),
child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2),
),
trailing: archived
? null
: _HeaderIconButton(
label: '다이어리 설정',
onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))),
child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2),
),
),
Row(
children: [
@@ -143,33 +168,35 @@ class _DiaryPageState extends ConsumerState<DiaryPage> {
if (_calendar)
..._calendarBody(diary, filtered, today)
else
..._feedBody(filtered, searching: q.isNotEmpty, wroteToday: wroteToday),
..._feedBody(filtered, searching: q.isNotEmpty, wroteToday: wroteToday || archived),
],
),
),
Positioned(
right: AppSpacing.xl,
bottom: safeBottom + _kFabGap,
child: _WriteFab(done: wroteToday, onTap: () => _write(wroteToday)),
),
if (!archived)
Positioned(
right: AppSpacing.xl,
bottom: safeBottom + _kFabGap,
child: _WriteFab(done: wroteToday, onTap: () => _write(wroteToday)),
),
],
),
);
}
/// [wroteToday] 이면 "첫 기록 남기기" 를 숨긴다 (보관함의 다이어리도)
List<Widget> _feedBody(List<DiaryPost> posts, {required bool searching, required bool wroteToday}) {
Widget menu(DiaryPost p) => _PostView(post: p, mode: _mode);
final shown = _mode == FeedMode.image ? posts.where((p) => p.hasPhoto).toList() : posts;
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)
@@ -303,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);
@@ -317,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: [
@@ -326,6 +375,7 @@ class _SearchField extends StatelessWidget {
Expanded(
child: TextField(
controller: controller,
focusNode: _focus,
style: style,
cursorColor: AppColors.primary,
textInputAction: TextInputAction.search,
@@ -39,13 +39,10 @@ class DiarySettingsPage extends ConsumerWidget {
final String diaryId;
Future<void> _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,
@@ -7,6 +7,7 @@ import 'package:tma_app/features/diary/data/diary_repository.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/diary_book.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
/// 다이어리 · 기록 · 댓글
class DiaryNotifier extends AsyncNotifier<DiaryBook> {
@@ -32,7 +33,7 @@ class DiaryNotifier extends AsyncNotifier<DiaryBook> {
required Color ribbon,
String? coverPath,
}) async {
if (_book.atMax) return null;
if (_book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {})) return null;
final diary = Diary(
id: _newId('d'),
name: name.trim().isEmpty ? kDefaultDiaryName : name.trim(),
@@ -82,3 +83,12 @@ final diaryProvider = AsyncNotifierProvider<DiaryNotifier, DiaryBook>(DiaryNotif
/// 불러오기 전에도 쓸 수 있는 다이어리 (빈 목록으로 시작)
final diaryBookProvider = Provider<DiaryBook>((ref) => ref.watch(diaryProvider).value ?? DiaryBook.empty);
/// 책장의 다이어리 — 추억 보관함의 아이만 담은 다이어리는 빠진다
final shelfDiariesProvider = Provider<List<Diary>>((ref) => watchShelf(ref));
/// 책장의 다이어리를 알림기에서 바로 계산한다 — 다른 파생 프로바이더를 거치지 않게 ([petListProvider] 주석 참고)
List<Diary> watchShelf(Ref ref) {
final book = ref.watch(diaryProvider).value ?? DiaryBook.empty;
return book.shelf(ref.watch(petListProvider).value?.archivedIds ?? const {});
}
@@ -36,14 +36,26 @@ class MoodIcon extends StatelessWidget {
}
}
/// 사진이 없는 표지 칸의 바탕 막 (시안 image-slot 의 rgba(0,0,0,.04))
const Color _kEmptyCoverShade = Color(0x0A000000);
/// 다이어리 표지 — 사진(없으면 고유색), 아이들, 책갈피 리본, 이름·기록 수
class DiaryCoverTile extends StatelessWidget {
const DiaryCoverTile({super.key, required this.diary, required this.postCount, required this.onTap});
const DiaryCoverTile({
super.key,
required this.diary,
required this.postCount,
required this.onTap,
this.showTargets = true,
});
final Diary diary;
final int postCount;
final VoidCallback onTap;
/// 왼쪽 위 아이들 — 추억 보관함의 다이어리 목록은 싣지 않는다
final bool showTargets;
@override
Widget build(BuildContext context) {
final cover = diary.coverPath;
@@ -68,7 +80,7 @@ class DiaryCoverTile extends StatelessWidget {
fit: StackFit.expand,
children: [
ColoredBox(color: diary.tint),
if (cover != null) PetPhotoImage(path: cover),
if (cover != null) PetPhotoImage(path: cover) else const ColoredBox(color: _kEmptyCoverShade),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -80,7 +92,7 @@ class DiaryCoverTile extends StatelessWidget {
),
),
Positioned(top: -2, right: 15, child: DiaryRibbon(color: diary.ribbon)),
Positioned(left: 12, top: 11, child: _CoverTargets(ids: diary.targetIds)),
if (showTargets) Positioned(left: 12, top: 11, child: _CoverTargets(ids: diary.targetIds)),
Positioned(
left: 14,
right: 12,
@@ -121,8 +121,9 @@ class _PolaroidPostState extends State<PolaroidPost> {
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<void> 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: ''),
),
);
@@ -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<void> 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);
},
),
],
),
@@ -219,7 +223,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final names = ref.watch(targetNamesProvider);
const gap = SizedBox(height: AppSpacing.lg);
@@ -261,6 +265,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> {
children: [
RupSegmented<bool>(
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<PostAction?> showPostMenuSheet(BuildContext context) {
/// 지우기·나가기 등 위험한 동작의 색 (시안 #E05A4A)
const Color kDangerTone = Color(0xFFE05A4A);
/// 기록 수정 글칸 높이 (시안 minHeight 120 — 작성 화면과 같다)
const double _kEditTextMinHeight = 120;
/// 기록 글 수정 — 저장하면 새 글, 닫으면 null
Future<String?> showEditPostSheet(BuildContext context, String initial) {
return showRupSheet<String>(
@@ -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: '수정 완료',
// 비우면 원래 글을 그대로 둔다 (시안)
@@ -16,6 +16,7 @@ import 'package:tma_app/features/diary/presentation/widgets/diary_cover.dart';
import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
import 'package:tma_app/features/pet/presentation/widgets/target_avatar.dart';
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
@@ -37,6 +38,8 @@ class DiaryShelf extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final book = ref.watch(diaryBookProvider);
final diaries = ref.watch(shelfDiariesProvider);
final atMax = diaries.length >= kMaxDiaries;
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -47,12 +50,12 @@ class DiaryShelf extends ConsumerWidget {
children: [
const Expanded(child: SectionTitle(title: '나의 다이어리')),
Text(
'${book.diaries.length}/$kMaxDiaries',
'${diaries.length}/$kMaxDiaries',
style: textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
height: AppTypography.normalHeight,
color: book.atMax ? AppColors.primary600 : AppColors.muted,
color: atMax ? AppColors.primary600 : AppColors.muted,
),
),
],
@@ -61,13 +64,13 @@ class DiaryShelf extends ConsumerWidget {
const SizedBox(height: AppSpacing.md),
DiaryGrid(
children: [
for (final d in book.diaries)
for (final d in diaries)
DiaryCoverTile(
diary: d,
postCount: book.countOf(d.id),
onTap: () => unawaited(context.push(AppRoutes.diary(d.id))),
),
if (!book.atMax) AddDiaryTile(onTap: () => unawaited(showAddDiarySheet(context, ref))),
if (!atMax) AddDiaryTile(onTap: () => unawaited(showAddDiarySheet(context, ref))),
],
),
],
@@ -83,8 +86,10 @@ class HomeDiaryGrid extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final book = ref.watch(diaryBookProvider);
final names = ref.watch(targetNamesProvider);
final latest = book.latest(_kHomeDiaryCount);
if (latest.isEmpty) return const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요'));
// 추억 보관함으로 간 다이어리의 기록은 홈에 올리지 않는다
final shelf = {for (final d in ref.watch(shelfDiariesProvider)) d.id};
final latest = book.latest(_kHomeDiaryCount, diaryIds: shelf);
if (latest.isEmpty) return const RupCard(child: EmptyMessage('아직 작성한 다이어리가 없어요', padding: 28));
final today = dateOnly(DateTime.now());
return Row(
@@ -103,7 +108,7 @@ class HomeDiaryGrid extends ConsumerWidget {
}
}
class _HomeDiaryTile extends StatelessWidget {
class _HomeDiaryTile extends ConsumerWidget {
const _HomeDiaryTile({required this.post, required this.book, required this.names, required this.today});
final DiaryPost post;
@@ -112,7 +117,7 @@ class _HomeDiaryTile extends StatelessWidget {
final DateTime today;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final diary = book.diary(post.diaryId);
// 누구의 기록인지 — 다이어리의 첫 아이, 없으면 다이어리 이름
final who = diary == null ? '' : (diary.targetIds.map((id) => names[id]).nonNulls.firstOrNull ?? diary.name);
@@ -125,7 +130,10 @@ class _HomeDiaryTile extends StatelessWidget {
label: '$who ${relativeDayLabel(post.date, today)} 기록',
excludeSemantics: true,
child: GestureDetector(
onTap: () => unawaited(context.push(AppRoutes.diary(post.diaryId))),
onTap: () {
ref.read(recordsTabProvider.notifier).select(RecordsTab.diary);
context.go(AppRoutes.records);
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -8,13 +8,21 @@ import 'package:tma_app/features/auth/presentation/providers/user_provider.dart'
import 'package:tma_app/features/diary/presentation/widgets/diary_shelf.dart';
import 'package:tma_app/features/home/presentation/widgets/home_pets_section.dart';
import 'package:tma_app/features/home/presentation/widgets/home_schedule_section.dart';
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
import 'package:tma_app/features/records/presentation/providers/records_tab_provider.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/section_title.dart';
/// 탭바 뒤로 콘텐츠가 가려지지 않게 두는 여유 (시안 .rup-screen 아래 124 에서 탭바 몫을 뺀 값)
const double _kBottomGap = 30;
/// 헤더 아이콘 버튼 크기 (시안 .rup-iconbtn)
const double _kIconButton = 42;
/// 새 알림 점 (시안 .rup-iconbtn__dot — 테두리 포함 8)
const double _kUnreadDot = 8;
/// 홈 — 인사말, 우리 아이, 오늘 일정·예약 일정, 다이어리
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@@ -47,6 +55,7 @@ class HomePage extends ConsumerWidget {
title: '우리 아이',
sub: true,
trailing: '아이 상세보기',
smallTrailing: true,
onTrailingTap: () => context.go(AppRoutes.myPets),
),
),
@@ -76,13 +85,14 @@ class HomePage extends ConsumerWidget {
}
}
class _HomeHeader extends StatelessWidget {
class _HomeHeader extends ConsumerWidget {
const _HomeHeader({required this.nickname});
final String nickname;
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final unread = ref.watch(hasUnreadNoticeProvider);
return Row(
children: [
Expanded(
@@ -93,16 +103,32 @@ class _HomeHeader extends StatelessWidget {
style: Theme.of(context).textTheme.headlineSmall,
),
),
// 알림 화면은 흐름 순서상 뒤에 만든다 — 지금은 아이콘만 둔다
const SizedBox.square(
dimension: 42,
child: Center(
child: RupIcon(
RupIconName.bell,
size: 23,
stroke: 2.2,
color: AppColors.ink2,
semanticLabel: '알림',
Pressable(
pressedScale: 0.9,
semanticLabel: unread ? '알림, 새 알림 있음' : '알림',
onTap: () => context.push(AppRoutes.notifications),
child: SizedBox.square(
dimension: _kIconButton,
child: Stack(
children: [
const Center(
child: RupIcon(RupIconName.bell, size: 23, stroke: 2.2, color: AppColors.ink2),
),
if (unread)
Positioned(
top: 9,
right: 10,
child: Container(
width: _kUnreadDot,
height: _kUnreadDot,
decoration: BoxDecoration(
color: AppColors.primary,
shape: BoxShape.circle,
border: Border.all(color: AppColors.surface, width: 2),
),
),
),
],
),
),
),
@@ -34,7 +34,7 @@ class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value;
final pets = ref.watch(petListProvider).value?.active;
// 불러오는 동안은 자리만 잡는다 — "없어요" 가 스치지 않게
if (pets == null) return const SizedBox(height: _kLoadingHeight);
if (pets.isEmpty) {
@@ -56,6 +56,8 @@ class _HomePetsSectionState extends ConsumerState<HomePetsSection> {
pets: pets,
selectedId: selected?.id,
onSelect: (id) => ref.read(selectedPetIdProvider.notifier).select(id),
// 꾹 눌러 끌어서 우리 아이 순서를 바꾼다
onReorder: (ids) => unawaited(ref.read(petListProvider.notifier).reorder(ids)),
),
if (selected != null) ...[
const SizedBox(height: 20),
@@ -99,9 +99,9 @@ class HomeScheduleSection extends ConsumerWidget {
children: [
_TodayProgress(done: doneCount, total: tasks.length),
if (tasks.isEmpty)
const EmptyMessage('오늘은 등록된 일정이 없어요')
const EmptyMessage('오늘은 등록된 일정이 없어요', padding: 28)
else if (!review && pending.isEmpty)
const EmptyMessage('남은 오늘 일정이 없어요'),
const EmptyMessage('남은 오늘 일정이 없어요', padding: 28),
for (final (i, t) in visible.indexed)
_TaskRow(
event: t,
@@ -122,7 +122,7 @@ class HomeScheduleSection extends ConsumerWidget {
const SizedBox(height: 10),
RupCard(
child: upcoming.isEmpty
? const EmptyMessage('예약된 일정이 없어요')
? const EmptyMessage('예약된 일정이 없어요', padding: 28)
: Column(
children: [
for (final (i, e) in upcoming.indexed)
@@ -140,6 +140,9 @@ class HomeScheduleSection extends ConsumerWidget {
}
}
/// 아바타 묶음 아래 글자 여백 (시안 inline-flex 기준선)
const double _kStackDescent = 3.5;
/// 바로 다음 할 일 — 연한 주황 테두리가 천천히 돈다 (.rup-ring-card)
class _NextTaskCard extends StatelessWidget {
const _NextTaskCard({required this.event, required this.onTap});
@@ -161,7 +164,11 @@ class _NextTaskCard extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Row(
children: [
PetStack(targetIds: d.targetIds, size: 30),
// 시안의 아바타 묶음은 inline-flex 라 글자 아래 여백(3.5)만큼 자리가 더 생긴다
Padding(
padding: const EdgeInsets.only(bottom: _kStackDescent),
child: PetStack(targetIds: d.targetIds, size: 30),
),
const SizedBox(width: 10),
Expanded(
child: Text(
@@ -367,16 +374,19 @@ class _TodayProgress extends StatelessWidget {
Text('오늘 할 일', style: label),
const SizedBox(width: 11),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: TweenAnimationBuilder<double>(
tween: Tween(end: total == 0 ? 0 : done / total),
duration: const Duration(milliseconds: 300),
builder: (context, v, _) => LinearProgressIndicator(
value: v,
minHeight: 6,
backgroundColor: AppColors.line2,
color: AppColors.primary,
// 시안 .rup-prog__bar — 채운 막대도 끝이 둥글다
child: TweenAnimationBuilder<double>(
tween: Tween(end: total == 0 ? 0 : done / total),
duration: const Duration(milliseconds: 300),
builder: (context, v, _) => Container(
height: 6,
decoration: BoxDecoration(color: AppColors.line2, borderRadius: BorderRadius.circular(6)),
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: v,
child: Container(
decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(6)),
),
),
),
),
@@ -444,8 +454,6 @@ class _TaskRow extends ConsumerWidget {
children: [
Text(
d.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall!.copyWith(
fontWeight: FontWeight.w600,
height: _kNormal,
@@ -456,8 +464,6 @@ class _TaskRow extends ConsumerWidget {
),
Text(
time,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal),
),
],
@@ -570,8 +576,6 @@ class _UpcomingRow extends StatelessWidget {
children: [
Text(
d.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600, height: _kNormal),
),
EventMeta(details: d, avatarSize: 14, gap: 4),
@@ -111,6 +111,8 @@ class MyPage extends ConsumerWidget {
],
),
),
const SettingsGroupLabel('추억 보관함'),
RupCard(child: _ArchiveRow(onTap: () => context.go(AppRoutes.archive))),
const SizedBox(height: 56),
const Center(child: RupLogo(height: 22)),
const SizedBox(height: 2),
@@ -161,7 +163,7 @@ class _ProfileCard extends ConsumerWidget {
final nickname = ref.watch(userNicknameProvider);
final photo = ref.watch(profileProvider).photoPath;
final provider = ref.watch(signedInProviderProvider);
final petCount = (ref.watch(petListProvider).value ?? const <Pet>[]).length;
final petCount = ((ref.watch(petListProvider).value ?? const <Pet>[]).active).length;
final cardCount = ref.watch(myCardsProvider).length;
final textTheme = Theme.of(context).textTheme;
@@ -235,6 +237,60 @@ class _ProfileCard extends ConsumerWidget {
}
}
/// 추억 보관함 — 보관된 아이 바로가기
class _ArchiveRow extends ConsumerWidget {
const _ArchiveRow({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = (ref.watch(petListProvider).value ?? const <Pet>[]).archived.length;
final textTheme = Theme.of(context).textTheme;
return Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(color: AppColors.memory50, borderRadius: BorderRadius.circular(10)),
alignment: Alignment.center,
child: const RupGlyphIcon(RupGlyph.memory, size: 18, color: AppColors.memory),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('보관된 아이', style: textTheme.titleSmall!.copyWith(fontWeight: FontWeight.w600, height: _kNormal)),
const SizedBox(height: 1),
Text(
'함께한 다이어리와 펫 명함을 보관해요',
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: _kNormal),
),
],
),
),
if (count > 0) ...[
Text('$count마리', style: textTheme.bodySmall!.copyWith(fontSize: 13, color: AppColors.muted, height: _kNormal)),
const SizedBox(width: 13),
],
const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted),
],
),
),
),
);
}
}
class _StatTile extends StatelessWidget {
const _StatTile({required this.art, required this.title, required this.value, required this.onTap});
@@ -29,7 +29,7 @@ class MyPetsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final today = DateTime.now();
return TabSubPage(
@@ -4,6 +4,7 @@ import 'package:tma_app/features/diary/presentation/providers/diary_providers.da
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
import 'package:tma_app/features/pet/data/pet_photo_store.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
@@ -38,6 +39,7 @@ class AccountActions {
..invalidate(carePresetsProvider)
..invalidate(recordsTabProvider)
..invalidate(appSettingsProvider)
..invalidate(noticeSeenProvider)
..invalidate(entryProvider);
try {
await photos.clearAll();
@@ -6,6 +6,7 @@ import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:tma_app/core/constants/app_assets.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/auth/presentation/providers/user_provider.dart';
import 'package:tma_app/features/mypage/data/profile_repository.dart';
@@ -125,6 +126,8 @@ class _ProfileEditSheetState extends ConsumerState<_ProfileEditSheet> {
onSubmitted: (_) => unawaited(_submit()),
),
),
// 시안 .rup-field 아래 16 (바닥 버튼 위 18 과 겹치지 않는다)
const SizedBox(height: AppSpacing.lg),
RupSheetBleedButton(label: '수정 완료', onPressed: () => unawaited(_submit())),
],
),
@@ -0,0 +1,68 @@
import 'package:tma_app/core/utils/date_format.dart';
/// 알림 종류 — 다이어리 설정의 알림 스위치와 짝을 이룬다
enum NoticeType {
/// 가족이 새 글을 올렸다 (공유 다이어리 · 서버)
newPost,
/// 내 기록에 댓글이 달렸다 (공유 다이어리 · 서버)
newComment,
/// 오늘 기록을 아직 남기지 않았다 (기기에서 만든다)
reminder,
}
/// 알림 한 건
class AppNotice {
const AppNotice({
required this.id,
required this.type,
required this.diaryId,
required this.diaryName,
required this.at,
required this.text,
this.who,
});
final String id;
final NoticeType type;
final String diaryId;
final String diaryName;
final DateTime at;
/// 알림 문구 — 새 글·댓글이면 그 내용, 기록 알림이면 안내 문구
final String text;
/// 새 글·댓글을 남긴 사람 이름
final String? who;
}
/// 알림을 묶는 날 — 오늘 · 어제 · 최근 7일
enum NoticeDay {
today('오늘'),
yesterday('어제'),
week('최근 7일');
const NoticeDay(this.label);
final String label;
/// [at] 이 [now] 기준 어느 묶음인지 — 일주일이 넘었으면 null(보여 주지 않는다)
static NoticeDay? of(DateTime at, DateTime now) {
final days = DateTime.utc(now.year, now.month, now.day).difference(DateTime.utc(at.year, at.month, at.day)).inDays;
if (days <= 0) return NoticeDay.today;
if (days == 1) return NoticeDay.yesterday;
if (days < 7) return NoticeDay.week;
return null;
}
}
/// "방금 전" · "12분 전" · "3시간 전" · "어제" · "4일 전"
String relativeTimeLabel(DateTime at, DateTime now) {
final diff = now.difference(at);
if (diff.inMinutes < 1) return '방금 전';
if (dateOnly(at) == dateOnly(now)) {
return diff.inHours < 1 ? '${diff.inMinutes}분 전' : '${diff.inHours}시간 전';
}
return relativeDayLabel(at, now);
}
@@ -0,0 +1,199 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/clock.dart';
import 'package:tma_app/features/notifications/domain/app_notice.dart';
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/person_avatar.dart';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
const double _kAvatar = 38;
/// 알림 — 모든 다이어리의 소식을 날짜별로 모아 본다
class NotificationsPage extends ConsumerStatefulWidget {
const NotificationsPage({super.key});
@override
ConsumerState<NotificationsPage> createState() => _NotificationsPageState();
}
class _NotificationsPageState extends ConsumerState<NotificationsPage> {
/// 들어오기 전에 마지막으로 본 시각 — 이번에 보는 동안은 새 알림 표시를 유지한다
late final DateTime? _seenBefore = ref.read(noticeSeenProvider);
@override
void initState() {
super.initState();
// 들어온 순간 모두 읽은 것으로 — 홈 종의 점이 사라진다
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) unawaited(ref.read(noticeSeenProvider.notifier).markSeen(ref.read(clockProvider)()));
});
}
@override
Widget build(BuildContext context) {
final notices = ref.watch(noticesProvider);
final now = ref.watch(clockProvider)();
final groups = [
for (final day in NoticeDay.values)
(day: day, items: notices.where((n) => NoticeDay.of(n.at, now) == day).toList()),
].where((g) => g.items.isNotEmpty).toList();
final seen = _seenBefore;
return Scaffold(
body: SafeArea(
bottom: false,
child: ListView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
children: [
SubPageHeader(
title: '알림',
subtitle: '우리 가족 다이어리 소식',
displayFont: true,
onBack: () => context.pop(),
),
if (groups.isEmpty)
const RupCard(child: EmptyMessage('새로운 알림이 없어요', padding: 38))
else
for (final g in groups) ...[
Padding(
padding: const EdgeInsets.fromLTRB(AppSpacing.xs, 0, AppSpacing.xs, 9),
child: Text(
g.day.label,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13.5,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
),
RupCard(
child: Column(
children: [
for (final (i, n) in g.items.indexed)
_NoticeRow(
notice: n,
now: now,
first: i == 0,
unread: seen == null || n.at.isAfter(seen),
onTap: () => unawaited(context.push(AppRoutes.diary(n.diaryId))),
),
],
),
),
const SizedBox(height: 18),
],
],
),
),
);
}
}
class _NoticeRow extends StatelessWidget {
const _NoticeRow({
required this.notice,
required this.now,
required this.first,
required this.unread,
required this.onTap,
});
final AppNotice notice;
final DateTime now;
final bool first;
final bool unread;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final n = notice;
final who = TextSpan(text: n.who ?? '', style: const TextStyle(fontWeight: FontWeight.w700));
final label = switch (n.type) {
NoticeType.newPost => TextSpan(children: [who, const TextSpan(text: '님이 새 글을 올렸어요')]),
NoticeType.newComment => TextSpan(children: [who, const TextSpan(text: '님이 회원님의 기록에 댓글을 남겼어요')]),
NoticeType.reminder => TextSpan(text: n.text),
};
return Semantics(
container: true,
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
decoration: BoxDecoration(
color: unread ? AppColors.primary50 : Colors.transparent,
border: first ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (n.who != null)
const PersonAvatar(size: _kAvatar)
else
Container(
width: _kAvatar,
height: _kAvatar,
decoration: BoxDecoration(color: AppColors.accent.withValues(alpha: .16), shape: BoxShape.circle),
alignment: Alignment.center,
child: const RupIcon(RupIconName.paw, size: 20, stroke: 2, color: AppColors.accent),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(label, style: textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.4, color: AppColors.ink)),
if (n.type != NoticeType.reminder && n.text.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
'“${n.text}”',
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, height: 1.4, color: AppColors.ink2),
),
],
const SizedBox(height: 3),
Text(
'${n.diaryName} · ${relativeTimeLabel(n.at, now)}',
style: textTheme.bodySmall!.copyWith(
fontSize: 11.5,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
],
),
),
if (unread) ...[
const SizedBox(width: AppSpacing.md),
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(top: 6),
decoration: const BoxDecoration(color: AppColors.primary, shape: BoxShape.circle),
),
],
],
),
),
),
);
}
}
@@ -0,0 +1,76 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/core/utils/clock.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/diary_book.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
import 'package:tma_app/features/notifications/domain/app_notice.dart';
const String _kSeenKey = 'notice.seenAt.v1';
/// 기록 알림 — 이 시각까지 오늘 기록이 없으면 알린다 (저녁 8시)
const int kDiaryReminderHour = 20;
/// 기록 알림 문구
const String kDiaryReminderText = '오늘 기록을 아직 남기지 않았어요. 잊기 전에 남겨 볼까요?';
/// 알림 화면을 마지막으로 본 시각 — 이후에 생긴 알림이 "새 알림"
class NoticeSeenNotifier extends Notifier<DateTime?> {
@override
DateTime? build() => DateTime.tryParse(ref.watch(sharedPreferencesProvider).getString(_kSeenKey) ?? '');
Future<void> markSeen(DateTime at) async {
state = at;
await ref.read(sharedPreferencesProvider).setString(_kSeenKey, at.toIso8601String());
}
}
final noticeSeenProvider = NotifierProvider<NoticeSeenNotifier, DateTime?>(NoticeSeenNotifier.new);
/// 모든 다이어리의 알림 — 최근 것이 위로.
/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다.
final noticesProvider = Provider<List<AppNotice>>((ref) => _watchNotices(ref));
/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 ([petListProvider] 주석 참고)
List<AppNotice> _watchNotices(Ref ref) {
final now = ref.watch(clockProvider)();
// 저녁 8시·자정에 다시 계산한다 — 앱을 켜 둔 채 시각이 지나도 기록 알림이 맞게 나타나고 사라진다
final next = now.hour < kDiaryReminderHour
? DateTime(now.year, now.month, now.day, kDiaryReminderHour)
: DateTime(now.year, now.month, now.day + 1);
final timer = Timer(next.difference(now), ref.invalidateSelf);
ref.onDispose(timer.cancel);
// 마이페이지 "일정·다이어리 알림" 을 끄면 모두 끈다
final settings = ref.watch(appSettingsProvider);
if (settings[AppSetting.scheduleNotification] == false) return const [];
final book = ref.watch(diaryProvider).value ?? DiaryBook.empty;
final notices = <AppNotice>[];
if (now.hour >= kDiaryReminderHour) {
final at = DateTime(now.year, now.month, now.day, kDiaryReminderHour);
// 추억 보관함으로 간 다이어리는 알리지 않는다
for (final d in watchShelf(ref)) {
if (!d.notifies(DiaryNotice.reminder) || book.wroteOn(d.id, now)) continue;
notices.add(AppNotice(
id: 'reminder-${d.id}-${toIsoDate(now)}',
type: NoticeType.reminder,
diaryId: d.id,
diaryName: d.name,
at: at,
text: kDiaryReminderText,
));
}
}
return notices..sort((a, b) => b.at.compareTo(a.at));
}
/// 아직 보지 않은 알림이 있는지 — 홈 종에 점을 찍는다
final hasUnreadNoticeProvider = Provider<bool>((ref) {
final seen = ref.watch(noticeSeenProvider);
return _watchNotices(ref).any((n) => seen == null || n.at.isAfter(seen));
});
@@ -7,7 +7,6 @@ import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart';
import 'package:tma_app/features/onboarding/presentation/widgets/onboarding_scene.dart';
import 'package:tma_app/shared/widgets/design_art_box.dart';
import 'package:tma_app/shared/widgets/rup_button.dart';
/// 온보딩 CTA 높이 — 시안 onb-4 의 버튼(372×42, rx 21)
@@ -59,11 +58,8 @@ class _OnboardingPageState extends ConsumerState<OnboardingPage> {
child: Stack(
fit: StackFit.expand,
children: [
DesignCover(
designSize: kOnboardingCanvas,
// 장이 바뀌면 등장 애니메이션을 처음부터 다시 재생한다
child: OnboardingScene(key: ValueKey(slide), slide: slide),
),
// 장이 바뀌면 등장 애니메이션을 처음부터 다시 재생한다
_OnboardingArt(child: OnboardingScene(key: ValueKey(slide), slide: slide)),
Positioned(
left: 0,
right: 0,
@@ -119,3 +115,39 @@ class _Hint extends StatelessWidget {
);
}
}
/// 시안 .rup-onb__art — 402 폭 기기에서 414×897 그림을 (-6, -11) 에 놓는다.
/// 폭에 맞춰 같은 비율로 키우고, 키가 큰 화면에서 아래가 비면 그만큼 더 키워 가운데 둔다
class _OnboardingArt extends StatelessWidget {
const _OnboardingArt({required this.child});
final Widget child;
static const double _deviceWidth = 402;
static const Rect _art = Rect.fromLTWH(-6, -11, 414, 897);
@override
Widget build(BuildContext context) {
return ClipRect(
child: LayoutBuilder(
builder: (context, box) {
final unit = box.maxWidth / _deviceWidth;
var rect = Rect.fromLTWH(_art.left * unit, _art.top * unit, _art.width * unit, _art.height * unit);
if (rect.bottom < box.maxHeight) {
final grow = (box.maxHeight - rect.top) / rect.height;
final width = rect.width * grow;
rect = Rect.fromLTWH((box.maxWidth - width) / 2, rect.top, width, rect.height * grow);
}
return Stack(
children: [
Positioned.fromRect(
rect: rect,
child: FittedBox(fit: BoxFit.fill, child: SizedBox.fromSize(size: kOnboardingCanvas, child: child)),
),
],
);
},
),
);
}
}
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
/// 반려동물 저장소 계약 — 백엔드 API(/api/pets)가 생기면 Dio 구현으로 바꾼다.
abstract interface class PetRepository {
@@ -15,6 +16,15 @@ abstract interface class PetRepository {
Future<Pet> update(String id, PetInput input);
Future<void> delete(String id);
/// 추억 보관함으로 옮긴다 — 아이와 정보는 그대로 두고 보관 기록을 붙인다
Future<Pet> archive(String id, PetArchive archive);
/// 보관함에서 다시 불러온다
Future<Pet> restore(String id);
/// 순서 바꾸기 — [ids] 순서로 두고, 빠진 아이(추억 보관함 등)는 뒤에 그대로 둔다
Future<List<Pet>> reorder(List<String> ids);
}
const String _kPetsKey = 'pets.v1';
@@ -55,9 +65,29 @@ class LocalPetRepository implements PetRepository {
@override
Future<Pet> update(String id, PetInput input) async {
final pets = _read();
// 고유색은 앱이 정한 값이라 수정해도 유지한다
final tint = pets.firstWhere((p) => p.id == id).tint;
final pet = Pet.fromInput(id, input, tint: tint);
// 고유색·보관 기록은 앱이 정한 값이라 수정해도 유지한다
final old = pets.firstWhere((p) => p.id == id);
final pet = Pet.fromInput(id, input, tint: old.tint, archive: old.archive);
await _write([for (final p in pets) p.id == id ? pet : p]);
return pet;
}
@override
Future<Pet> archive(String id, PetArchive archive) => _setArchive(id, archive);
@override
Future<Pet> restore(String id) => _setArchive(id, null);
@override
Future<List<Pet>> reorder(List<String> ids) async {
final pets = reorderPets(_read(), ids);
await _write(pets);
return pets;
}
Future<Pet> _setArchive(String id, PetArchive? archive) async {
final pets = _read();
final pet = pets.firstWhere((p) => p.id == id).withArchive(archive);
await _write([for (final p in pets) p.id == id ? pet : p]);
return pet;
}
@@ -68,6 +98,15 @@ class LocalPetRepository implements PetRepository {
}
}
/// [ids] 순서로 줄 세운 목록 — 빠진 아이는 원래 순서대로 뒤에 붙인다 (시안 reorderPets)
List<Pet> reorderPets(List<Pet> pets, List<String> ids) {
final byId = {for (final p in pets) p.id: p};
return [
for (final id in ids) ?byId[id],
for (final p in pets) if (!ids.contains(p.id)) p,
];
}
final petRepositoryProvider = Provider<PetRepository>((ref) {
return LocalPetRepository(ref.watch(sharedPreferencesProvider));
});
+18 -2
View File
@@ -2,6 +2,7 @@ import 'dart:ui' show Color;
import 'package:tma_app/core/utils/color_utils.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
/// 아이별 고유색 — 등록 순서대로 돌아가며 배정한다 (시안 buildPet 의 tints)
const List<Color> kPetTints = [
@@ -105,12 +106,14 @@ class Pet extends PetInput {
super.allergies,
super.conditions,
super.watchConditions,
this.archive,
});
/// [tint] 는 앱이 정한다 — 새로 만들 때 배정하고, 수정할 때는 기존 색을 넘긴다
factory Pet.fromInput(String id, PetInput input, {Color tint = const Color(0xFFFF9B6A)}) => Pet(
/// [tint]·[archive] 는 앱이 정한다 — 새로 만들 때 배정하고, 수정할 때는 기존 값을 넘긴다
factory Pet.fromInput(String id, PetInput input, {Color tint = const Color(0xFFFF9B6A), PetArchive? archive}) => Pet(
id: id,
tint: tint,
archive: archive,
name: input.name,
species: input.species,
breed: input.breed,
@@ -134,6 +137,14 @@ class Pet extends PetInput {
/// 목록 왼쪽 띠·칩 등에 쓰는 아이의 고유색
final Color tint;
/// 추억 보관함으로 옮겼으면 그 기록 — 함께 지내는 아이는 null
final PetArchive? archive;
bool get isArchived => archive != null;
/// 보관 정보만 바꾼 아이 — 보관하기(값) / 다시 불러오기(null)
Pet withArchive(PetArchive? value) => Pet.fromInput(id, this, tint: tint, archive: value);
/// 사진만 바꾼 입력값 — 명함 편집기의 카메라 버튼
PetInput withPhoto(String path) => PetInput(
name: name,
@@ -181,6 +192,7 @@ class Pet extends PetInput {
'allergies': allergies,
'conditions': conditions,
'watchConditions': watchConditions,
if (archive case final a?) 'archive': a.toJson(),
};
factory Pet.fromJson(Map<String, Object?> json) => Pet(
@@ -202,6 +214,10 @@ class Pet extends PetInput {
allergies: _stringList(json['allergies']),
conditions: _stringList(json['conditions']),
watchConditions: _stringList(json['watchConditions']),
archive: switch (json['archive']) {
final Map<String, Object?> a => PetArchive.fromJson(a),
_ => null,
},
);
}
@@ -0,0 +1,46 @@
import 'package:tma_app/core/utils/date_format.dart';
/// 추억 보관함으로 옮긴 아이의 기록 — 함께한 기간과 보호자가 남긴 이야기
class PetArchive {
const PetArchive({required this.from, required this.to, required this.archivedAt, this.memo});
/// 함께한 첫날
final DateTime from;
/// 마지막 날
final DateTime to;
/// 보관한 때 — 보관함 목록 순서에 쓴다
final DateTime archivedAt;
/// 함께한 시간에 대한 메모 (선택)
final String? memo;
/// "8년 3개월"
String get spanLabel => togetherSpan(from, to);
Map<String, Object?> toJson() => {
'from': toIsoDate(from),
'to': toIsoDate(to),
'archivedAt': archivedAt.toIso8601String(),
'memo': memo,
};
factory PetArchive.fromJson(Map<String, Object?> json) => PetArchive(
from: parseIsoDate(json['from']! as String),
to: parseIsoDate(json['to']! as String),
archivedAt: DateTime.parse(json['archivedAt']! as String),
memo: json['memo'] as String?,
);
}
/// 함께한 기간 — "8년 3개월" · "2년" · "5개월". 한 달이 안 되면 "한 달"
String togetherSpan(DateTime from, DateTime to) {
var months = (to.year - from.year) * 12 + (to.month - from.month);
if (to.day < from.day) months -= 1;
if (months < 0) months = 0;
final years = months ~/ 12;
final rest = months % 12;
final parts = [if (years > 0) '$years년', if (rest > 0) '$rest개월'];
return parts.isEmpty ? '한 달' : parts.join(' ');
}
@@ -9,16 +9,17 @@ import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_age.dart';
import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_info_sections.dart';
import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
import 'package:tma_app/features/pet_card/presentation/providers/pet_card_providers.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart' show kUnregistered;
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';
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/rup_dialog.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/sub_page_header.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
@@ -26,12 +27,10 @@ import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kPhotoSize = 76;
const double _kPhotoRadius = 20;
const Color _kPhotoBorder = Color(0xFFC8C8C8);
const double _kNormal = AppTypography.normalHeight;
/// 건강 정보 태그 색 (시안 TagField tone)
const Color _kAllergyTone = Color(0xFFE0534F);
const Color _kConditionTone = Color(0xFFC77A1F);
const Color _kWatchTone = Color(0xFF8E5BD0);
/// 사진이 없을 때 칸 바탕 (시안 image-slot rgba(0,0,0,.04))
const Color _kEmptyPhotoFill = Color(0x0A000000);
const double _kNormal = AppTypography.normalHeight;
/// 반려동물 정보 — 기본·건강 정보를 읽기 전용으로 보여 주고, 연필을 누르면 수정
class PetInfoPage extends ConsumerWidget {
@@ -55,7 +54,6 @@ class PetInfoPage extends ConsumerWidget {
}
final cardCount = ref.watch(myCardsProvider).where((c) => c.petId == pet.id).length;
final gender = '${pet.gender.label} · ${pet.neutered ? '중성화 완료' : '미중성화'}';
return TabSubPage(
children: [
@@ -63,7 +61,7 @@ class PetInfoPage extends ConsumerWidget {
child: SubPageHeader(
title: '반려동물 정보',
subtitle: '${pet.name}의 상세 정보',
onBack: () => context.pop(),
onBack: () => context.go(AppRoutes.myPage),
trailing: Pressable(
onTap: () => unawaited(context.push(AppRoutes.petEdit(pet.id))),
pressedScale: .9,
@@ -79,40 +77,71 @@ class PetInfoPage extends ConsumerWidget {
child: _IdentityCard(
pet: pet,
cardCount: cardCount,
onCardTap: () => unawaited(
context.push(cardCount == 0 ? AppRoutes.petCardCreate(pet.id) : AppRoutes.petCardManage),
),
onCardTap: () => cardCount == 0
? unawaited(context.push(AppRoutes.petCardManage))
: context.go(AppRoutes.petCards),
),
),
const DividerBand(),
_Section(
title: '기본 정보',
children: [
_Field(label: '나이', value: petAgeLabel(pet.birthDate, DateTime.now())),
_Field(label: '생년월일', value: formatYmd(pet.birthDate)),
_Field(label: '성별', value: gender),
_Field(label: '몸무게', value: pet.weightLabel),
_Field(label: '혈액형', value: pet.bloodType ?? kUnregistered),
_Field(label: '마이크로칩', value: pet.microchip ?? kUnregistered),
],
),
const DividerBand(),
_Section(
title: '건강 정보',
children: [
_TagField(label: '알레르기', items: pet.allergies, tone: _kAllergyTone),
_TagField(label: '보유 질환', items: pet.conditions, tone: _kConditionTone),
_TagField(label: '염려 질환', items: pet.watchConditions, tone: _kWatchTone),
_Field(label: '담당 병원', value: pet.vet ?? kUnregistered),
],
),
if (pet.memo case final memo?) ...[
const DividerBand(),
_MemoSection(memo: memo),
],
...petInfoSections(pet),
const SizedBox(height: 48),
_ArchiveLink(onTap: () => unawaited(_confirmArchive(context, pet))),
const SizedBox(height: AppSpacing.sm),
],
);
}
/// 보관 안내 — 보관하기를 누르면 기간·메모를 남기는 화면으로
static Future<void> _confirmArchive(BuildContext context, Pet pet) async {
final ok = await showRupConfirm(
context,
title: '${withObjectParticle(pet.name)} 추억 보관함으로',
message: '아이와 아이에 대한 정보를 추억 보관함에 보관할까요?\n'
'등록된 일정은 모두 삭제되며, 다이어리와 기록은 그대로 보관돼요.\n'
'일정 외의 정보는 언제든지 다시 복구할 수 있어요.',
confirmLabel: '보관하기',
layout: RupConfirmLayout.row,
glyph: RupGlyph.memory,
iconColor: AppColors.memory,
iconBackground: AppColors.memory50,
confirmVariant: RupButtonVariant.memory,
);
if (ok && context.mounted) unawaited(context.push(AppRoutes.petArchive(pet.id)));
}
}
/// "추억 보관함으로" 글자 버튼
class _ArchiveLink extends StatelessWidget {
const _ArchiveLink({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Center(
child: Semantics(
button: true,
child: GestureDetector(
onTap: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const RupGlyphIcon(RupGlyph.memory, size: 17, color: AppColors.memory),
const SizedBox(width: 6),
Text(
'추억 보관함으로',
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.memory,
height: _kNormal,
),
),
],
),
),
),
);
}
}
/// 사진 · 이름 · 종 · 명함 바로가기
@@ -135,6 +164,7 @@ class _IdentityCard extends StatelessWidget {
width: _kPhotoSize,
height: _kPhotoSize,
decoration: BoxDecoration(
color: photo == null ? _kEmptyPhotoFill : null,
borderRadius: BorderRadius.circular(_kPhotoRadius),
border: Border.all(color: _kPhotoBorder, width: 1.5),
),
@@ -152,7 +182,13 @@ class _IdentityCard extends StatelessWidget {
pet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontFamily: AppFonts.display, fontSize: 22, height: 1, color: AppColors.ink),
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w800,
fontSize: 22,
height: 1,
color: AppColors.ink,
),
),
const SizedBox(height: 5),
Text(
@@ -200,164 +236,3 @@ class _IdentityCard extends StatelessWidget {
}
}
/// 섹션 제목 + 항목 카드 (항목 사이에 가는 줄)
class _Section extends StatelessWidget {
const _Section({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: title),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.fromLTRB(18, 2, 18, 18),
child: Column(
children: [
for (final (i, child) in children.indexed)
// Container 는 테두리 두께만큼 자리를 잡는다 (시안 border-top 1px 도 줄 높이에 더해진다)
Container(
decoration: BoxDecoration(
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: child,
),
],
),
),
],
),
);
}
}
class _Field extends StatelessWidget {
const _Field({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(label, style: _labelStyle(textTheme)),
const SizedBox(width: 14),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
height: _kNormal,
),
),
),
],
),
);
}
}
TextStyle _labelStyle(TextTheme textTheme) =>
textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w600, color: AppColors.muted, height: _kNormal);
/// 태그 목록 항목 — 비어 있으면 "없음"
class _TagField extends StatelessWidget {
const _TagField({required this.label, required this.items, required this.tone});
final String label;
final List<String> items;
final Color tone;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 3), child: Text(label, style: _labelStyle(textTheme))),
const SizedBox(width: 14),
Expanded(
child: items.isEmpty
? Text(
'없음',
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted, height: _kNormal),
)
: Wrap(
alignment: WrapAlignment.end,
spacing: 5,
runSpacing: AppSpacing.xs,
children: [
for (final item in items)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: AppSpacing.xs),
decoration: BoxDecoration(
color: tone.withValues(alpha: .1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
item,
style: textTheme.bodySmall!.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: tone,
height: _kNormal,
),
),
),
],
),
),
],
),
);
}
}
class _MemoSection extends StatelessWidget {
const _MemoSection({required this.memo});
final String memo;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '보호자 메모'),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.all(18),
child: Text(
memo,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.6, color: AppColors.ink2),
),
),
],
),
);
}
}
@@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/router/app_routes.dart';
import 'package:tma_app/core/theme/app_typography.dart';
import 'package:tma_app/core/utils/date_format.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
@@ -244,6 +245,7 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
if (mounted) context.go(AppRoutes.myPets);
} else {
await notifier.updatePet(editing.id, _buildInput());
ref.read(selectedPetIdProvider.notifier).select(editing.id);
toast.show('반려동물 정보를 저장했어요');
// 수정은 반려동물 정보에서 열었으므로 그 화면으로 돌아간다
if (mounted) context.pop();
@@ -300,8 +302,8 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
onBack: () => context.pop(),
),
const SizedBox(height: 18),
// 시안: 사진 아래 4 는 "기본 정보" 위 30 과 겹쳐 사라진다
PetPhotoSlot(photoPath: _photoPath, onPick: _pickPhoto),
const SizedBox(height: AppSpacing.xs),
const PetFormSectionLabel(text: '기본 정보', required: true),
RupField(
@@ -468,13 +470,22 @@ class _PetRegisterFormState extends ConsumerState<_PetRegisterForm> {
if (editing != null)
Padding(
padding: const EdgeInsets.only(top: 22),
child: TextButton(
onPressed: _delete,
child: Text(
'삭제하기',
style: textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.muted,
child: Semantics(
button: true,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _delete,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
child: Text(
'삭제하기',
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
),
),
),
@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
/// 등록된 반려동물 목록
class PetListNotifier extends AsyncNotifier<List<Pet>> {
@@ -29,10 +30,43 @@ class PetListNotifier extends AsyncNotifier<List<Pet>> {
state = AsyncData([...?state.value?.where((p) => p.id != id)]);
}
}
Future<void> archivePet(String id, PetArchive archive) async => _replace(await _repo.archive(id, archive));
/// 우리 아이 순서 바꾸기 — 화면에는 바로 반영하고 저장한다
Future<void> reorder(List<String> ids) async {
state = AsyncData(reorderPets(state.value ?? const <Pet>[], ids));
await _repo.reorder(ids);
}
Future<void> restorePet(String id) async => _replace(await _repo.restore(id));
void _replace(Pet pet) {
if (!ref.mounted) return;
state = AsyncData([for (final p in state.value ?? const <Pet>[]) p.id == pet.id ? pet : p]);
}
}
/// 등록한 모든 아이 — 추억 보관함의 아이까지. 화면에서는 [PetListX.active] 로 걸러 쓴다.
///
/// ⚠️ 파생 프로바이더는 알림기(AsyncNotifier 등)만 watch 하고, 다른 파생 프로바이더를 watch 하지 않는다.
/// 가려졌던 화면이 다시 보일 때 Riverpod 3.3 은 빌드 도중에 멈춰 둔 구독을 풀면서 파생값을 다시 계산하는데,
/// 파생값이 다른 파생 프로바이더에 알리면 markNeedsBuild 오류가 난다 (Riverpod 3.4 에서 고쳐졌지만 Dart 3.12 필요).
/// 걸러 쓰는 값은 목록 확장([PetListX])이나 순수 함수로 나눠 각 프로바이더가 직접 계산한다.
final petListProvider = AsyncNotifierProvider<PetListNotifier, List<Pet>>(PetListNotifier.new);
/// 함께 지내는 아이 / 추억 보관함의 아이로 가르기
extension PetListX on List<Pet> {
/// 함께 지내는 아이 — 추억 보관함으로 옮긴 아이는 빠진다
List<Pet> get active => where((p) => !p.isArchived).toList();
/// 추억 보관함의 아이 — 등록 순서대로 (시안)
List<Pet> get archived => where((p) => p.isArchived).toList();
/// 보관된 아이 id — 다이어리를 가를 때
Set<String> get archivedIds => {for (final p in this) if (p.isArchived) p.id};
}
/// 홈에서 고른 아이 id
class SelectedPetIdNotifier extends Notifier<String?> {
@override
@@ -45,14 +79,14 @@ final selectedPetIdProvider = NotifierProvider<SelectedPetIdNotifier, String?>(
SelectedPetIdNotifier.new,
);
/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌으면 첫 번째 아이
/// 홈에서 보고 있는 아이 — 고른 아이가 없거나 지워졌거나 보관됐으면 첫 번째 아이
final selectedPetProvider = Provider<Pet?>((ref) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final id = ref.watch(selectedPetIdProvider);
return pets.where((p) => p.id == id).firstOrNull ?? pets.firstOrNull;
});
/// 반려동물 id 로 찾기 — 수정 화면 등
/// 반려동물 id 로 찾기 — 수정 화면 등 (보관된 아이도 찾는다)
final petByIdProvider = Provider.family<Pet?, String>((ref, id) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
return pets.where((p) => p.id == id).firstOrNull;
@@ -8,6 +8,9 @@ import 'package:tma_app/features/pet/presentation/widgets/pet_photo_image.dart';
/// 사진이 없을 때 원 테두리 — 시안 .rup-avatar--slot
const Color _kEmptyRing = Color(0xFFC8C8C8);
/// 사진이 없을 때 원 바탕 — 시안 image-slot 의 rgba(0,0,0,.04)
const Color _kEmptyFill = Color(0x0A000000);
/// [PetAvatar.ring] — 흰 틈 2.5 + 아이 색 고리 1.5 (시안 box-shadow 0 0 0 2.5px surface, 0 0 0 4px tint)
const double _kRingGap = 2.5;
const double _kRingOuter = 4;
@@ -33,6 +36,7 @@ class PetAvatar extends StatelessWidget {
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: photo == null ? _kEmptyFill : null,
border: photo == null ? Border.all(color: _kEmptyRing, width: 1.5) : null,
// 뒤에 오는 그림자가 위에 그려진다 — 색 고리 위에 흰 틈을 얹는다
boxShadow: ring
@@ -0,0 +1,214 @@
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/core/utils/date_format.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_age.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart' show kUnregistered;
import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
import 'package:tma_app/shared/widgets/tab_sub_page.dart';
const double _kNormal = AppTypography.normalHeight;
/// 건강 정보 태그 색 (시안 TagField tone)
const Color _kAllergyTone = Color(0xFFE0534F);
const Color _kConditionTone = Color(0xFFC77A1F);
const Color _kWatchTone = Color(0xFF8E5BD0);
/// 반려동물 정보의 기본·건강·메모 섹션 — 반려동물 정보와 추억 보관함의 "원래 정보"가 함께 쓴다.
/// 섹션마다 위에 구분 띠를 둔다.
List<Widget> petInfoSections(Pet pet) {
final gender = '${pet.gender.label} · ${pet.neutered ? '중성화 완료' : '미중성화'}';
return [
const DividerBand(),
_Section(
title: '기본 정보',
children: [
_Field(label: '나이', value: petAgeLabel(pet.birthDate, DateTime.now())),
_Field(label: '생년월일', value: formatYmd(pet.birthDate)),
_Field(label: '성별', value: gender),
_Field(label: '몸무게', value: pet.weightLabel),
_Field(label: '혈액형', value: pet.bloodType ?? kUnregistered),
_Field(label: '마이크로칩', value: pet.microchip ?? kUnregistered),
],
),
const DividerBand(),
_Section(
title: '건강 정보',
children: [
_TagField(label: '알레르기', items: pet.allergies, tone: _kAllergyTone),
_TagField(label: '보유 질환', items: pet.conditions, tone: _kConditionTone),
_TagField(label: '염려 질환', items: pet.watchConditions, tone: _kWatchTone),
_Field(label: '담당 병원', value: pet.vet ?? kUnregistered),
],
),
if (pet.memo case final memo?) ...[
const DividerBand(),
_MemoSection(memo: memo),
],
];
}
/// 섹션 제목 + 항목 카드 (항목 사이에 가는 줄)
class _Section extends StatelessWidget {
const _Section({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: title),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.fromLTRB(18, 2, 18, 18),
child: Column(
children: [
for (final (i, child) in children.indexed)
// Container 는 테두리 두께만큼 자리를 잡는다 (시안 border-top 1px 도 줄 높이에 더해진다)
Container(
decoration: BoxDecoration(
border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)),
),
child: child,
),
],
),
),
],
),
);
}
}
class _Field extends StatelessWidget {
const _Field({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(label, style: _labelStyle(textTheme)),
const SizedBox(width: 14),
Expanded(
child: Text(
value,
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.w600,
color: AppColors.ink,
height: _kNormal,
),
),
),
],
),
);
}
}
TextStyle _labelStyle(TextTheme textTheme) =>
textTheme.bodyMedium!.copyWith(fontSize: 13.5, fontWeight: FontWeight.w600, color: AppColors.muted, height: _kNormal);
/// 태그 목록 항목 — 비어 있으면 "없음"
class _TagField extends StatelessWidget {
const _TagField({required this.label, required this.items, required this.tone});
final String label;
final List<String> items;
final Color tone;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.only(top: 3), child: Text(label, style: _labelStyle(textTheme))),
const SizedBox(width: 14),
Expanded(
child: items.isEmpty
? Text(
'없음',
textAlign: TextAlign.right,
style: textTheme.bodyMedium!.copyWith(fontSize: 14, color: AppColors.muted, height: _kNormal),
)
: Wrap(
alignment: WrapAlignment.end,
spacing: 5,
runSpacing: AppSpacing.xs,
children: [
for (final item in items)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: AppSpacing.xs),
decoration: BoxDecoration(
color: tone.withValues(alpha: .1),
borderRadius: BorderRadius.circular(10),
),
child: Text(
item,
style: textTheme.bodySmall!.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: tone,
height: _kNormal,
),
),
),
],
),
),
],
),
);
}
}
class _MemoSection extends StatelessWidget {
const _MemoSection({required this.memo});
final String memo;
@override
Widget build(BuildContext context) {
return ScreenPadding(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: SectionTitle(title: '보호자 메모'),
),
const SizedBox(height: AppSpacing.md),
RupCard(
padding: const EdgeInsets.all(18),
child: Text(
memo,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: 14, height: 1.6, color: AppColors.ink2),
),
),
],
),
);
}
}
@@ -219,12 +219,15 @@ class _BleedList extends StatelessWidget {
/// 선택지 한 줄 — 고른 항목은 연주황 바탕 + 체크
class _ChoiceRow extends StatelessWidget {
const _ChoiceRow({required this.label, required this.selected, required this.onTap});
const _ChoiceRow({required this.label, required this.selected, required this.onTap, this.vertical = 13});
final String label;
final bool selected;
final VoidCallback onTap;
/// 세로 여백 — 종 선택 13, 몸무게 단위 14
final double vertical;
@override
Widget build(BuildContext context) {
return Semantics(
@@ -236,7 +239,7 @@ class _ChoiceRow extends StatelessWidget {
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
padding: EdgeInsets.symmetric(horizontal: 14, vertical: vertical),
decoration: BoxDecoration(
color: selected ? AppColors.primary50 : Colors.transparent,
borderRadius: BorderRadius.circular(12),
@@ -280,6 +283,7 @@ Future<WeightUnit?> showWeightUnitPicker(BuildContext context, {required WeightU
_ChoiceRow(
label: unit.label,
selected: unit == current,
vertical: 14,
onTap: () => Navigator.of(context).pop(unit),
),
],
@@ -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/features/pet/domain/pet.dart';
@@ -9,65 +10,187 @@ const double _kRingSize = 58;
const double _kAvatarSize = 46;
const double _kRingBorder = 2.5;
/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리
class PetPicker extends StatelessWidget {
const PetPicker({super.key, required this.pets, required this.selectedId, required this.onSelect});
/// 꾹 눌러 순서 바꾸기 — 시안: 320ms 누르면 들어 올리고, 1.12 배로 손가락을 따라온다
const Duration _kHoldDelay = Duration(milliseconds: 320);
const double _kLiftScale = 1.12;
/// 들어 올린 아이 그림자 (시안 drop-shadow(0 8px 14px rgba(0,0,0,.22)))
const List<BoxShadow> _kLiftShadow = [BoxShadow(color: Color(0x38000000), offset: Offset(0, 8), blurRadius: 14)];
const List<Shadow> _kLiftTextShadow = [Shadow(color: Color(0x38000000), offset: Offset(0, 8), blurRadius: 14)];
/// 가로로 넘기는 아이 목록 (시안 .rup-petpick) — 고른 아이는 주황 테두리.
/// [onReorder] 가 있으면 꾹 눌러 끌어서 순서를 바꾼다 (홈 우리 아이).
class PetPicker extends StatefulWidget {
const PetPicker({
super.key,
required this.pets,
required this.selectedId,
required this.onSelect,
this.onReorder,
});
final List<Pet> pets;
final String? selectedId;
final ValueChanged<String> onSelect;
/// 끌어서 바꾼 순서 (아이 id 목록)
final ValueChanged<List<String>>? onReorder;
@override
State<PetPicker> createState() => _PetPickerState();
}
class _PetPickerState extends State<PetPicker> {
/// 끄는 동안의 임시 순서 — 평소에는 null
List<String>? _order;
String? _dragId;
List<Pet> get _shown {
final order = _order;
if (order == null) return widget.pets;
final byId = {for (final p in widget.pets) p.id: p};
return [for (final id in order) ?byId[id]];
}
void _start(String id) {
HapticFeedback.selectionClick();
setState(() {
_order = [for (final p in widget.pets) p.id];
_dragId = id;
});
}
/// 끌고 있는 아이가 다른 아이 위로 가면 그 자리로 옮긴다
void _moveOver(String overId) {
final order = _order;
final dragId = _dragId;
if (order == null || dragId == null || overId == dragId) return;
final to = order.indexOf(overId);
setState(() {
order
..remove(dragId)
..insert(to, dragId);
});
}
void _end() {
final order = _order;
final before = [for (final p in widget.pets) p.id];
setState(() {
_order = null;
_dragId = null;
});
if (order != null && order.join() != before.join()) widget.onReorder?.call(order);
}
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final reorderable = widget.onReorder != null && widget.pets.length > 1;
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
// 시안: 목록 칸(화면 여백 안쪽)에서 잘린다
clipBehavior: Clip.hardEdge,
padding: const EdgeInsets.fromLTRB(2, AppSpacing.xs, 2, AppSpacing.sm),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (i, pet) in pets.indexed) ...[
for (final (i, pet) in _shown.indexed) ...[
if (i > 0) const SizedBox(width: 14),
Semantics(
button: true,
selected: pet.id == selectedId,
label: pet.name,
excludeSemantics: true,
child: GestureDetector(
onTap: () => onSelect(pet.id),
child: Column(
children: [
AnimatedContainer(
duration: AppDurations.fast,
width: _kRingSize,
height: _kRingSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: pet.id == selectedId ? AppColors.primary : Colors.transparent,
width: _kRingBorder,
),
),
alignment: Alignment.center,
child: PetAvatar(pet: pet, size: _kAvatarSize),
),
const SizedBox(height: 7),
Text(
pet.name,
style: textTheme.bodySmall!.copyWith(
fontSize: 12.5,
fontWeight: pet.id == selectedId ? FontWeight.w700 : FontWeight.w600,
color: pet.id == selectedId ? AppColors.primary600 : AppColors.muted,
),
),
],
),
),
),
if (reorderable) _draggable(pet) else _chip(pet),
],
],
),
);
}
Widget _chip(Pet pet, {bool lifted = false}) {
return _PetChip(
pet: pet,
selected: pet.id == widget.selectedId,
lifted: lifted,
onTap: () => widget.onSelect(pet.id),
);
}
Widget _draggable(Pet pet) {
return DragTarget<String>(
onWillAcceptWithDetails: (details) {
_moveOver(pet.id);
return true;
},
builder: (context, candidates, rejected) => LongPressDraggable<String>(
data: pet.id,
delay: _kHoldDelay,
hapticFeedbackOnStart: false,
// 들어 올린 아이는 손가락 한가운데에 붙는다
dragAnchorStrategy: pointerDragAnchorStrategy,
feedback: FractionalTranslation(
translation: const Offset(-.5, -.5),
child: Material(
type: MaterialType.transparency,
child: Transform.scale(scale: _kLiftScale, child: _chip(pet, lifted: true)),
),
),
// 빈 자리는 남겨 둔다 — 다른 아이가 그 자리로 비켜 준다
childWhenDragging: Opacity(opacity: 0, child: _chip(pet)),
onDragStarted: () => _start(pet.id),
onDragEnd: (_) => _end(),
child: _chip(pet),
),
);
}
}
class _PetChip extends StatelessWidget {
const _PetChip({required this.pet, required this.selected, required this.onTap, this.lifted = false});
final Pet pet;
final bool selected;
final bool lifted;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Semantics(
button: true,
selected: selected,
label: pet.name,
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: AppDurations.fast,
width: _kRingSize,
height: _kRingSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: selected ? AppColors.primary : Colors.transparent, width: _kRingBorder),
// 시안 drop-shadow 는 그려진 모양을 따른다 — 고리는 고른 아이만 보인다
boxShadow: lifted && selected ? _kLiftShadow : null,
),
alignment: Alignment.center,
child: DecoratedBox(
decoration: BoxDecoration(shape: BoxShape.circle, boxShadow: lifted ? _kLiftShadow : null),
child: PetAvatar(pet: pet, size: _kAvatarSize),
),
),
const SizedBox(height: 7),
Text(
pet.name,
style: textTheme.bodySmall!.copyWith(
fontSize: 12.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
color: selected ? AppColors.primary600 : AppColors.muted,
shadows: lifted ? _kLiftTextShadow : null,
),
),
],
),
),
);
}
}
@@ -52,9 +52,12 @@ class CardStyle {
this.scrimTone = ScrimTone.light,
});
/// 새 명함의 기본값 — 첫 번째 헤더색(코랄), 흰 배경
/// 기본값 — 첫 번째 헤더색(코랄), 흰 배경
factory CardStyle.initial() => CardStyle(tint: kCardTints.first.a, tint2: kCardTints.first.b);
/// 새 명함 — 그 아이의 고유색 한 가지로 시작한다 (시안: tint = tint2 = pet.tint → 직접 선택 칸이 켜진다)
factory CardStyle.forPet(Color petTint) => CardStyle(tint: petTint, tint2: petTint);
/// 카드 상단 그라데이션 두 색
final Color tint;
final Color tint2;
@@ -68,6 +68,12 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
toast.show('대표 명함으로 설정했어요', icon: RupIconName.star);
}
/// 꾸미기 — 저장하고 돌아오면 상세히로 보여 준다 (시안 onSave → setMode('detail'))
Future<void> _edit(PetCard card) async {
final saved = await context.push<bool>(AppRoutes.petCardEdit(card.id));
if (saved == true && mounted) setState(() => _mode = CardViewMode.detail);
}
Future<void> _delete(PetCard card) async {
final ok = await showRupConfirm(
context,
@@ -111,6 +117,8 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
title: pet.name,
subtitle: '펫 명함',
displayFont: true,
// 시안 상세 머리 margin '4px 0 16px'
bottomGap: 16,
onBack: () => context.pop(),
trailing: SegToggle<CardViewMode>(
first: (CardViewMode.simple, CardViewMode.simple.label),
@@ -146,7 +154,7 @@ class _PetCardDetailPageState extends ConsumerState<PetCardDetailPage> {
child: _TextAction(
label: '카드 수정',
color: AppColors.ink2,
onTap: () => unawaited(context.push(AppRoutes.petCardEdit(card.id))),
onTap: () => unawaited(_edit(card)),
),
),
const SizedBox(width: 1, height: 13, child: ColoredBox(color: AppColors.line)),
@@ -236,7 +244,11 @@ class _TextAction extends StatelessWidget {
child: Text(
label,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w700, color: color),
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w700,
color: color,
height: AppTypography.normalHeight,
),
),
),
),
@@ -74,7 +74,12 @@ class _CardEditor extends ConsumerStatefulWidget {
}
class _CardEditorState extends ConsumerState<_CardEditor> {
late CardStyle _style = widget.card?.style ?? CardStyle.initial();
late CardStyle _style = widget.card?.style ?? _newStyle();
CardStyle _newStyle() {
final tint = ref.read(petByIdProvider(widget.petId))?.tint;
return tint == null ? CardStyle.initial() : CardStyle.forPet(tint);
}
bool _saving = false;
bool get _isEdit => widget.card != null;
@@ -138,7 +143,8 @@ class _CardEditorState extends ConsumerState<_CardEditor> {
if (_isEdit) {
await notifier.updateStyle(widget.card!.id, _style);
toast.show('펫 명함을 수정했어요');
if (mounted) context.pop();
// 저장했음을 알려 상세 화면이 "상세히" 로 돌아가게 한다
if (mounted) context.pop(true);
} else {
final card = await notifier.create(petId, _style);
toast.show('펫 명함을 만들었어요');
@@ -46,7 +46,7 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final cards = ref.watch(myCardsProvider);
final shown = _filterPetId == null ? cards : cards.where((c) => c.petId == _filterPetId).toList();
final filterPet = pets.where((p) => p.id == _filterPetId).firstOrNull;
@@ -58,7 +58,7 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
child: ListView(
padding: EdgeInsets.fromLTRB(
AppSpacing.xl,
AppSpacing.sm,
AppSpacing.xs,
AppSpacing.xl,
MediaQuery.paddingOf(context).bottom + 40,
),
@@ -73,8 +73,10 @@ class _PetCardManagePageState extends ConsumerState<PetCardManagePage> {
onPressed: () => unawaited(_create(pets, cards.length)),
),
),
SizedBox(
height: _kChipHeight,
// 시안 .rup-petbar { padding: 2px 0 6px }
Container(
height: _kChipHeight + 8,
padding: const EdgeInsets.only(top: 2, bottom: 6),
child: ListView(
scrollDirection: Axis.horizontal,
children: [
@@ -43,7 +43,7 @@ class _PetCardsPageState extends ConsumerState<PetCardsPage> {
@override
Widget build(BuildContext context) {
final pets = ref.watch(petListProvider).value ?? const <Pet>[];
final pets = (ref.watch(petListProvider).value ?? const <Pet>[]).active;
final pet = pets.where((p) => p.id == _selectedPetId).firstOrNull ?? pets.firstOrNull;
Widget padded(Widget child) =>
@@ -67,7 +67,7 @@ class _PetCardsPageState extends ConsumerState<PetCardsPage> {
),
const SizedBox(height: AppSpacing.sm),
if (pet == null)
const EmptyMessage('등록된 반려동물이 없어요', padding: 40)
const EmptyMessage('등록된 반려동물이 없어요')
else ...[
padded(PetPicker(pets: pets, selectedId: pet.id, onSelect: (id) => setState(() => _selectedPetId = id))),
const SizedBox(height: 14),
@@ -195,6 +195,8 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 시안 .rup-section-title — 구분 띠 아래 22 와 제목 위 26 이 겹쳐 26 (좌우 4 는 줄 안에서)
const SizedBox(height: 4),
Row(
children: [
Expanded(
@@ -234,7 +236,7 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
const SizedBox(width: 5),
Pressable(
pressedScale: .96,
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.bell),
onTap: () => ref.read(toastProvider.notifier).show('명함 받기는 곧 열릴 예정이에요', icon: RupIconName.qrcode),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: AppSpacing.sm),
decoration: BoxDecoration(
@@ -252,9 +254,11 @@ class _ReceivedSectionState extends ConsumerState<_ReceivedSection> {
),
),
),
const SizedBox(width: AppSpacing.xs),
],
),
const SizedBox(height: 2),
// 시안: 제목 줄 아래 12 + 목록 위 2
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.fromLTRB(28, 64, 28, 48),
// 시안: 받은 명함이 한 장도 없으면 검색어와 상관없이 이 문구
@@ -77,20 +77,29 @@ class PetCardNotifier extends AsyncNotifier<PetCardState> {
final petCardsProvider = AsyncNotifierProvider<PetCardNotifier, PetCardState>(PetCardNotifier.new);
/// 지금 등록된 아이들의 명함만 (지워진 아이의 명함은 숨긴다)
final myCardsProvider = Provider<List<PetCard>>((ref) {
/// 함께 지내는 아이들의 명함만 (지워진 아이·추억 보관함의 아이 명함은 숨긴다)
final myCardsProvider = Provider<List<PetCard>>((ref) => _watchMyCards(ref));
/// 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 (petListProvider 주석 참고)
List<PetCard> _watchMyCards(Ref ref) {
final cards = ref.watch(petCardsProvider).value?.cards ?? const <PetCard>[];
final petIds = {for (final p in ref.watch(petListProvider).value ?? const <Pet>[]) p.id};
final petIds = {for (final p in (ref.watch(petListProvider).value ?? const <Pet>[]).active) p.id};
return cards.where((c) => petIds.contains(c.petId)).toList();
});
}
final cardByIdProvider = Provider.family<PetCard?, String>((ref, cardId) {
return ref.watch(myCardsProvider).where((c) => c.id == cardId).firstOrNull;
return _watchMyCards(ref).where((c) => c.id == cardId).firstOrNull;
});
/// 한 아이의 명함 — 추억 보관함에서 보관된 아이의 명함을 다시 볼 때
final cardsOfPetProvider = Provider.family<List<PetCard>, String>((ref, petId) {
final cards = ref.watch(petCardsProvider).value?.cards ?? const <PetCard>[];
return cards.where((c) => c.petId == petId).toList();
});
/// 아이의 대표 명함 — 지정한 것이 없으면 그 아이의 첫 명함
final representativeCardProvider = Provider.family<PetCard?, String>((ref, petId) {
final cards = ref.watch(myCardsProvider).where((c) => c.petId == petId).toList();
final cards = _watchMyCards(ref).where((c) => c.petId == petId).toList();
final repId = ref.watch(petCardsProvider).value?.representatives[petId];
return cards.where((c) => c.id == repId).firstOrNull ?? cards.firstOrNull;
});
@@ -153,8 +153,9 @@ class BackgroundColorRow extends StatelessWidget {
_Swatch(
label: '기본',
onTap: () => onChanged(style.withBackgroundColor(null)),
decoration: _swatchDecoration(selected: isDefault, color: Colors.white),
child: _DefaultLabel(selected: isDefault),
// 시안: '기본' 칸은 골라도 그림자가 없다
decoration: _swatchDecoration(selected: isDefault, color: Colors.white).copyWith(boxShadow: []),
child: _DefaultLabel(selected: isDefault, fontSize: 11),
),
for (final b in kCardBackgrounds)
_Swatch(
@@ -176,16 +177,19 @@ class BackgroundColorRow extends StatelessWidget {
}
class _DefaultLabel extends StatelessWidget {
const _DefaultLabel({required this.selected});
const _DefaultLabel({required this.selected, required this.fontSize});
final bool selected;
/// 시안: 색 줄 11, 이미지 줄 11.5
final double fontSize;
@override
Widget build(BuildContext context) {
return Text(
'기본',
style: Theme.of(context).textTheme.bodySmall!.copyWith(
fontSize: 11.5,
fontSize: fontSize,
fontWeight: FontWeight.w700,
color: selected ? AppColors.primary : AppColors.muted,
),
@@ -217,8 +221,13 @@ class BackgroundImageGrid extends StatelessWidget {
_ImageTile(
label: '기본',
selected: isDefault,
shadow: false,
onTap: () => onChanged(style.withBackgroundImage(null)),
child: Container(color: Colors.white, alignment: Alignment.center, child: _DefaultLabel(selected: isDefault)),
child: Container(
color: Colors.white,
alignment: Alignment.center,
child: _DefaultLabel(selected: isDefault, fontSize: 11.5),
),
),
for (final (i, path) in AppAssets.cardBackgrounds.indexed)
_ImageTile(
@@ -275,6 +284,7 @@ class _ImageTile extends StatelessWidget {
required this.onTap,
required this.child,
this.onRemove,
this.shadow = true,
});
final String label;
@@ -283,6 +293,9 @@ class _ImageTile extends StatelessWidget {
final Widget child;
final VoidCallback? onRemove;
/// 골랐을 때 주황 그림자 — '기본' 칸은 없다
final bool shadow;
@override
Widget build(BuildContext context) {
return Stack(
@@ -299,7 +312,7 @@ class _ImageTile extends StatelessWidget {
foregroundDecoration: _swatchDecoration(selected: selected).copyWith(boxShadow: []),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(_kSwatchRadius),
boxShadow: selected
boxShadow: selected && shadow
? [BoxShadow(color: AppColors.primary.withValues(alpha: .3), offset: const Offset(0, 2), blurRadius: 8)]
: null,
),
@@ -10,6 +10,7 @@ import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/card_background_image.dart';
import 'package:tma_app/shared/widgets/icons/rup_icon.dart';
import 'package:tma_app/shared/widgets/pressable.dart';
/// 성별 표시 색 — 시안 GENDER
const Map<PetGender, Color> kGenderColors = {
@@ -237,42 +238,46 @@ class _Body extends StatelessWidget {
children: [
Row(
children: [
Flexible(
child: Text(
pet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// 시안: 충북대체 일반 굵기 + 0.7px 외곽선 → 굵게 보인다
fontFamily: AppFonts.displayRegular,
fontWeight: FontWeight.w700,
fontSize: 22,
height: 1,
color: ink.strong,
),
// 이름·성별 묶음이 남은 폭을 모두 차지해 공유 버튼을 오른쪽 끝으로 민다 (시안 flex:1 빈칸)
Expanded(
child: Row(
children: [
Flexible(
child: Text(
pet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
// 시안: 충북대체 일반 굵기 + 0.7px 외곽선 → 굵게 보인다
fontFamily: AppFonts.displayRegular,
fontWeight: FontWeight.w700,
fontSize: 22,
height: 1,
color: ink.strong,
),
),
),
const SizedBox(width: 7),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: gender.withValues(alpha: .12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
pet.gender.label,
style: small.copyWith(fontSize: 11, fontWeight: FontWeight.w700, color: gender),
),
),
],
),
),
const SizedBox(width: 7),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: gender.withValues(alpha: .12),
borderRadius: BorderRadius.circular(999),
),
child: Text(
pet.gender.label,
style: small.copyWith(fontSize: 11, fontWeight: FontWeight.w700, color: gender),
),
),
const Spacer(),
if (onShare != null)
Semantics(
button: true,
label: '${pet.name} 명함 공유',
child: GestureDetector(
onTap: onShare,
child: SvgPicture.string(_kShareGlyph, width: 32, height: 32),
),
Pressable(
onTap: onShare,
pressedScale: .92,
semanticLabel: '${pet.name} 명함 공유',
child: SvgPicture.string(_kShareGlyph, width: 32, height: 32),
),
],
),
@@ -12,15 +12,31 @@ import 'package:tma_app/shared/widgets/pressable.dart';
const double _kMinHeight = 193;
const double _kRadius = 18;
/// 누르는 동안 바탕 (시안 .rup-namecard-empty:active)
const Color _kPressedBg = Color(0xFFFCF7F2);
/// 아직 펫 명함이 없는 아이 — 이름 + 안내 + "명함 만들기"
class NameCardEmpty extends StatelessWidget {
class NameCardEmpty extends StatefulWidget {
const NameCardEmpty({super.key, required this.petName, required this.onCreate});
final String petName;
final VoidCallback onCreate;
@override
State<NameCardEmpty> createState() => _NameCardEmptyState();
}
class _NameCardEmptyState extends State<NameCardEmpty> {
bool _pressed = false;
void _press(bool v) {
if (_pressed != v) setState(() => _pressed = v);
}
@override
Widget build(BuildContext context) {
final petName = widget.petName;
final onCreate = widget.onCreate;
final textTheme = Theme.of(context).textTheme;
return Semantics(
button: true,
@@ -29,10 +45,13 @@ class NameCardEmpty extends StatelessWidget {
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onCreate,
onTapDown: (_) => _press(true),
onTapUp: (_) => _press(false),
onTapCancel: () => _press(false),
child: DashedBorderBox(
color: AppColors.emptyCardBorder,
radius: _kRadius,
backgroundColor: AppColors.emptyCardBg,
backgroundColor: _pressed ? _kPressedBg : AppColors.emptyCardBg,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: _kMinHeight, minWidth: double.infinity),
child: Padding(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.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/pet/domain/pet.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
@@ -83,6 +84,7 @@ class PetIdCard extends StatelessWidget {
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w800,
height: AppTypography.normalHeight,
),
),
],
@@ -30,7 +30,7 @@ Future<String?> showPickPetSheet(BuildContext context, {required List<Pet> pets}
Text(
'어떤 반려동물의 카드를 만들까요?\n등록된 정보로 카드가 자동 채워져요.',
textAlign: TextAlign.center,
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: 1.5),
style: textTheme.bodySmall!.copyWith(fontSize: 12.5, color: AppColors.muted, height: AppTypography.normalHeight),
),
const SizedBox(height: 18),
for (final (i, p) in pets.indexed) ...[
@@ -4,13 +4,15 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:share_plus/share_plus.dart';
import 'package:tma_app/core/constants/app_colors.dart';
import 'package:tma_app/core/constants/app_sizes.dart';
import 'package:tma_app/core/storage/album_saver.dart';
import 'package:tma_app/core/theme/app_shadows.dart';
import 'package:tma_app/features/pet_card/domain/pet_card_facts.dart';
import 'package:tma_app/shared/widgets/blurred_modal.dart';
import 'package:tma_app/shared/widgets/capture_boundary.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';
import 'package:tma_app/shared/widgets/rup_sheet.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
@@ -42,10 +44,24 @@ Future<void> showPetCardShareSheet(
);
}
Future<void> _sharePng(Uint8List bytes, String fileName) async {
await SharePlus.instance.share(
ShareParams(files: [XFile.fromData(bytes, mimeType: 'image/png', name: fileName)]),
);
/// 앨범에 저장하지 못했을 때 (권한 거절 등)
const String _kSaveFailed = '저장하지 못했어요. 사진 접근 권한을 확인해 주세요';
/// 앨범에 저장하고 결과를 알린다 — 시안: 시트를 닫고 "…저장했어요".
/// 시트를 닫은 뒤에 불리므로 저장기·토스트는 닫기 전에 꺼내 넘긴다
Future<void> _saveToAlbum(
AlbumSaver saver,
ToastNotifier toast,
Uint8List? bytes,
String name,
String done,
) async {
final saved = bytes != null && await saver.savePng(bytes, name);
if (saved) {
toast.show(done, icon: RupIconName.download);
} else {
toast.show(_kSaveFailed, icon: RupIconName.alert);
}
}
class _ShareBody extends ConsumerWidget {
@@ -61,16 +77,18 @@ class _ShareBody extends ConsumerWidget {
final textTheme = Theme.of(context).textTheme;
final toast = ref.read(toastProvider.notifier);
final saver = ref.read(albumSaverProvider);
Future<void> saveImage() async {
final bytes = await capture();
if (context.mounted) Navigator.of(context).pop();
if (bytes != null) await _sharePng(bytes, 'rup_${petName}_명함.png');
await _saveToAlbum(saver, toast, bytes, 'rup_${petName}_명함', '이미지로 저장했어요');
}
Future<void> copyCode() async {
await Clipboard.setData(ClipboardData(text: code));
if (context.mounted) Navigator.of(context).pop();
toast.show('명함 코드를 복사했어요', icon: RupIconName.link);
toast.show('코드 $code 을 복사했어요.', icon: RupIconName.link);
}
return Column(
@@ -138,7 +156,7 @@ class _ShareBody extends ConsumerWidget {
label: 'QR 코드\n공유',
desc: '스캔 한 번',
tint: const Color(0xFF9B6BE8),
onTap: () => unawaited(_showQr(context, petName: petName, code: code)),
onTap: () => unawaited(_showQr(context, saver, toast, petName: petName, code: code)),
),
),
],
@@ -166,48 +184,45 @@ class _ShareTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Semantics(
button: true,
label: label.replaceAll('\n', ' '),
excludeSemantics: true,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(5, 20, 5, 16),
decoration: BoxDecoration(
color: Color.alphaBlend(tint.withValues(alpha: .08), AppColors.surface),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tint.withValues(alpha: .22)),
),
child: Column(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: tint,
borderRadius: BorderRadius.circular(17),
boxShadow: [BoxShadow(color: tint.withValues(alpha: .3), offset: const Offset(0, 3), blurRadius: 8)],
),
alignment: Alignment.center,
child: icon == RupIconName.qrcode
? const RupGlyphIcon(RupGlyph.qr, size: 26, color: Colors.white)
: RupIcon(icon, size: 26, stroke: 2, color: Colors.white),
// 시안 .rup-share-tile:active — scale(.95)
return Pressable(
onTap: onTap,
semanticLabel: label.replaceAll('\n', ' '),
child: Container(
padding: const EdgeInsets.fromLTRB(5, 20, 5, 16),
decoration: BoxDecoration(
color: Color.alphaBlend(tint.withValues(alpha: .08), AppColors.surface),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tint.withValues(alpha: .22)),
),
child: Column(
children: [
Container(
width: 54,
height: 54,
decoration: BoxDecoration(
color: tint,
borderRadius: BorderRadius.circular(17),
boxShadow: [BoxShadow(color: tint.withValues(alpha: .3), offset: const Offset(0, 3), blurRadius: 8)],
),
const SizedBox(height: 17),
Text(
label,
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: 1.22),
),
const SizedBox(height: 8),
Text(
desc,
maxLines: 1,
style: textTheme.bodySmall!.copyWith(fontSize: 10.5, color: AppColors.muted, height: 1.3),
),
],
),
alignment: Alignment.center,
child: icon == RupIconName.qrcode
? const RupGlyphIcon(RupGlyph.qr, size: 26, color: Colors.white)
: RupIcon(icon, size: 26, stroke: 2, color: Colors.white),
),
const SizedBox(height: 17),
Text(
label,
textAlign: TextAlign.center,
style: textTheme.bodyMedium!.copyWith(fontWeight: FontWeight.w800, height: 1.22),
),
const SizedBox(height: 8),
Text(
desc,
maxLines: 1,
style: textTheme.bodySmall!.copyWith(fontSize: 10.5, color: AppColors.muted, height: 1.3),
),
],
),
),
);
@@ -215,12 +230,17 @@ class _ShareTile extends StatelessWidget {
}
/// 펫 명함 QR — 가운데 명함 표시가 있는 QR + 이미지 저장
Future<void> _showQr(BuildContext context, {required String petName, required String code}) {
Future<void> _showQr(
BuildContext context,
AlbumSaver saver,
ToastNotifier toast, {
required String petName,
required String code,
}) {
final capture = CaptureController();
return showDialog<void>(
context: context,
useRootNavigator: true,
barrierColor: AppColors.scrim,
final sheet = Navigator.of(context);
return showBlurredDialog<void>(
context,
builder: (context) {
final textTheme = Theme.of(context).textTheme;
return Dialog(
@@ -234,6 +254,7 @@ Future<void> _showQr(BuildContext context, {required String petName, required St
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(AppRadius.card),
border: Border.all(color: AppColors.line2),
boxShadow: AppShadows.sm,
),
child: Stack(
@@ -299,8 +320,10 @@ Future<void> _showQr(BuildContext context, {required String petName, required St
leading: const RupIcon(RupIconName.download, size: 18, stroke: 2.1, color: Colors.white),
onPressed: () async {
final bytes = await capture.capturePng();
// 시안: QR 창과 공유 시트를 함께 닫는다
if (context.mounted) Navigator.of(context).pop();
if (bytes != null) await _sharePng(bytes, 'rup_${petName}_QR.png');
if (sheet.mounted) sheet.pop();
await _saveToAlbum(saver, toast, bytes, 'rup_${petName}_QR', 'QR 코드를 저장했어요');
},
),
],
@@ -15,6 +15,9 @@ enum RepeatUnit {
final String label;
}
/// 반복을 켰을 때 — 매일. 직접 설정을 열면 2주마다부터 (시안 repeatUnit 'week' · repeatN 2)
const RepeatSpec kInitialRepeat = RepeatSpec(RepeatPattern.daily, n: 2);
/// 반복 주기 — "2주마다"
class RepeatSpec {
const RepeatSpec(this.mode, {this.unit = RepeatUnit.week, this.n = 1});
@@ -60,7 +60,7 @@ class EventEditorPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 아이 목록·일정을 다 불러온 뒤에 연다 — 불러오기 전에 열려 기본값으로 채워지지 않게
final pets = ref.watch(petListProvider).value;
final pets = ref.watch(petListProvider).value?.active;
final schedule = ref.watch(scheduleProvider).value;
if (pets == null || schedule == null) return const Scaffold(body: SizedBox.shrink());
@@ -158,10 +158,12 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
// ── 일반 일정 목록 ────────────────────────────────────
void _pickCare(CarePreset p) {
// 이름 칸의 변경 알림이 다시 그려 준다
_kind = EventKind.general;
_category = p.category;
_title.text = p.title;
// 이름이 같으면 이름 칸이 알리지 않으므로 직접 다시 그린다 (예약 → 같은 일반 일정으로 돌아올 때)
setState(() {
_kind = EventKind.general;
_category = p.category;
_title.text = p.title;
});
}
Future<void> _removeCare(CarePreset p) async {
@@ -176,8 +178,10 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
if (v.isEmpty) return;
await ref.read(carePresetsProvider.notifier).add(CarePreset(v, _category));
if (!mounted) return;
_kind = EventKind.general;
_title.text = v;
setState(() {
_kind = EventKind.general;
_title.text = v;
});
}
// ── 시간 ─────────────────────────────────────────────
@@ -251,7 +255,8 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
// ── 저장 ─────────────────────────────────────────────
EventDetails _details({HourMinute? startOverride, bool dropEnd = false}) {
final start = _allDay ? null : (startOverride ?? _start);
// 하루 여러 번(회차 시각)은 '하루 종일' 과 상관없이 그 시각으로 만든다 (시안 doseTimes)
final start = startOverride ?? (_allDay ? null : _start);
final place = _place.text.trim();
return EventDetails(
title: _title.text.trim(),
@@ -284,7 +289,8 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
if (_saving) return;
final toast = ref.read(toastProvider.notifier);
if (_title.text.trim().isEmpty) {
toast.show('일정 이름을 입력해주세요', icon: RupIconName.alert);
// 시안 showToast 기본 아이콘(check)
toast.show('일정 이름을 입력해주세요');
return;
}
final notifier = ref.read(scheduleProvider.notifier);
@@ -324,13 +330,10 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
Future<void> _delete() async {
final editing = widget.editing;
if (editing == null) return;
final ok = await showRupConfirm(
final ok = await showRupRemoveConfirm(
context,
title: '일정을 제거할까요?',
message: editing.isRepeat ? '반복으로 등록된 일정 전체가 함께 제거돼요.' : '제거한 일정은 되돌릴 수 없어요.',
confirmLabel: '제거하기',
destructive: true,
layout: RupConfirmLayout.row,
);
if (!ok || !mounted) return;
// 먼저 화면을 닫아 지워진 일정을 그리지 않게 한다
@@ -435,11 +438,11 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
subtitle: _repeat == null ? '반복되는 일정에 활용해보세요' : '${_repeat!.label} 반복',
on: _repeat != null,
onChanged: (v) => setState(() {
_repeat = v ? const RepeatSpec(RepeatPattern.daily) : null;
_repeat = v ? kInitialRepeat : null;
_customCycle = false;
}),
child: RepeatOptions(
spec: _repeat ?? const RepeatSpec(RepeatPattern.daily),
spec: _repeat ?? kInitialRepeat,
custom: _customCycle,
onSpec: (s) => setState(() => _repeat = s),
onCustom: (v) => setState(() => _customCycle = v),
@@ -480,6 +483,7 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
large: true,
thin: true,
dimmed: !hasTitle,
dimmedOpacity: .45,
onPressed: () => unawaited(_submit()),
),
if (_editing) ...[
@@ -598,7 +602,15 @@ class _EventEditorState extends ConsumerState<_EventEditor> {
children: [
const RupIcon(RupIconName.calendar, size: 15, stroke: 2, color: AppColors.primary),
const SizedBox(width: 6),
Text(formatYmd(value), style: _uiStyle(14.5, FontWeight.w700, AppColors.ink)),
Flexible(
child: Text(
formatYmd(value),
maxLines: 1,
overflow: TextOverflow.fade,
softWrap: false,
style: _uiStyle(14.5, FontWeight.w700, AppColors.ink),
),
),
],
),
],
@@ -294,7 +294,8 @@ class RepeatOptions extends StatelessWidget {
on: on,
onTap: () {
onCustom(false);
onSpec(s);
// 매일·평일·주말은 방식만 바꾸고, 직접 설정한 단위·횟수는 남겨 둔다 (시안 setRepeatMode)
onSpec(s.mode == RepeatPattern.every ? s : spec.copyWith(mode: s.mode));
},
);
}
@@ -592,7 +593,9 @@ class _CustomSwatch extends StatelessWidget {
final Color? color;
final VoidCallback onTap;
// CSS conic-gradient 는 12시에서 시작한다 (Flutter 기본은 3시)
static const _rainbow = SweepGradient(
transform: GradientRotation(-pi / 2),
colors: [
Color(0xFFFF6B6B), Color(0xFFFFC94D), Color(0xFF3FB58C),
Color(0xFF4DA6E0), Color(0xFFB07CF0), Color(0xFFFF6B6B),
@@ -59,8 +59,6 @@ class EventMeta extends ConsumerWidget {
Expanded(
child: Text(
place == null || place.isEmpty ? label : '$label · $place',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: AppColors.muted,
height: AppTypography.normalHeight,
@@ -175,8 +173,6 @@ class EventTile extends StatelessWidget {
children: [
Text(
d.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: FontWeight.w600,
height: AppTypography.normalHeight,
@@ -44,6 +44,7 @@ class ScheduleCalendar extends StatefulWidget {
required this.selected,
required this.today,
required this.onSelect,
this.onYearViewChanged,
});
final ScheduleBook book;
@@ -51,6 +52,9 @@ class ScheduleCalendar extends StatefulWidget {
final DateTime today;
final ValueChanged<DateTime> onSelect;
/// 12개월 보기로 바뀔 때 — 일정 목록은 그동안 숨긴다
final ValueChanged<bool>? onYearViewChanged;
@override
State<ScheduleCalendar> createState() => _ScheduleCalendarState();
}
@@ -90,11 +94,14 @@ class _ScheduleCalendarState extends State<ScheduleCalendar> {
}
}
void _setYearView(bool value) {
setState(() => _yearView = value);
widget.onYearViewChanged?.call(value);
}
void _pickMonth(int month) {
setState(() {
_month = month;
_yearView = false;
});
setState(() => _month = month);
_setYearView(false);
widget.onSelect(DateTime(_year, month));
}
@@ -108,7 +115,7 @@ class _ScheduleCalendarState extends State<ScheduleCalendar> {
_Head(
title: _yearView ? '$_year년' : '$_year년 $_month월',
yearView: _yearView,
onTitle: () => setState(() => _yearView = !_yearView),
onTitle: () => _setYearView(!_yearView),
onPrev: () => _step(-1),
onNext: () => _step(1),
),
@@ -433,8 +440,9 @@ class _DayCell extends StatelessWidget {
),
),
const SizedBox(height: 3),
SizedBox(
width: _kDotsWidth,
// 시안 .rup-cal-dots { max-width: 18 } — 점 묶음은 내용 폭만큼이라 칸 가운데에 놓인다
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _kDotsWidth),
child: Wrap(
spacing: 2.5,
runSpacing: 2.5,
@@ -17,12 +17,19 @@ import 'package:tma_app/shared/widgets/rup_card.dart';
import 'package:tma_app/shared/widgets/rup_toast.dart';
import 'package:tma_app/shared/widgets/section_title.dart';
/// 일정 관리 — 함께 관리 · 달력 · 고른 날의 일정
class ScheduleView extends ConsumerWidget {
/// 일정 관리 — 함께 관리 · 달력 · 고른 날의 일정 (12개월 보기에서는 일정 목록을 숨긴다)
class ScheduleView extends ConsumerStatefulWidget {
const ScheduleView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ScheduleView> createState() => _ScheduleViewState();
}
class _ScheduleViewState extends ConsumerState<ScheduleView> {
bool _yearView = false;
@override
Widget build(BuildContext context) {
final book = ref.watch(scheduleBookProvider);
final selected = ref.watch(scheduleDateProvider);
final today = dateOnly(DateTime.now());
@@ -32,7 +39,6 @@ class ScheduleView extends ConsumerWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: AppSpacing.xs),
const _ShareCard(),
const SizedBox(height: AppSpacing.md),
ScheduleCalendar(
@@ -40,55 +46,58 @@ class ScheduleView extends ConsumerWidget {
selected: selected,
today: today,
onSelect: ref.read(scheduleDateProvider.notifier).select,
onYearViewChanged: (v) => setState(() => _yearView = v),
),
const SizedBox(height: 26),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Row(
children: [
Expanded(
child: Text.rich(
TextSpan(children: [
TextSpan(text: selected == today ? '오늘 일정' : '${formatMonthDay(selected)} 일정'),
const WidgetSpan(child: SizedBox(width: 9)),
TextSpan(
text: '${weekdayKo(selected)}요일 · ${events.length}건',
style: textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: AppTypography.normalHeight,
if (!_yearView) ...[
const SizedBox(height: 26),
Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs),
child: Row(
children: [
Expanded(
child: Text.rich(
TextSpan(children: [
TextSpan(text: selected == today ? '오늘 일정' : '${formatMonthDay(selected)} 일정'),
const WidgetSpan(child: SizedBox(width: 9)),
TextSpan(
text: '${weekdayKo(selected)}요일 · ${events.length}건',
style: textTheme.bodySmall!.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.muted,
height: AppTypography.normalHeight,
),
),
]),
maxLines: 1,
style: textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
height: AppTypography.displayNormalHeight,
),
]),
maxLines: 1,
style: textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
height: AppTypography.displayNormalHeight,
),
),
),
RupSoftPillButton(
label: '일정 추가',
uiFont: true,
onPressed: () => unawaited(context.push(AppRoutes.eventCreate(selected))),
RupSoftPillButton(
label: '일정 추가',
uiFont: true,
onPressed: () => unawaited(context.push(AppRoutes.eventCreate(selected))),
),
],
),
),
const SizedBox(height: AppSpacing.md),
if (events.isEmpty)
const RupCard(child: EmptyMessage('이 날은 일정이 없어요.'))
else
for (final (i, e) in events.indexed) ...[
if (i > 0) const SizedBox(height: 9),
EventTile(
event: e,
done: book.isDone(e.id),
onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))),
onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)),
),
],
),
),
const SizedBox(height: AppSpacing.md),
if (events.isEmpty)
const RupCard(child: EmptyMessage('이 날은 일정이 없어요.', padding: 40))
else
for (final (i, e) in events.indexed) ...[
if (i > 0) const SizedBox(height: 9),
EventTile(
event: e,
done: book.isDone(e.id),
onTap: () => unawaited(context.push(AppRoutes.eventEdit(e.id))),
onToggleDone: () => unawaited(ref.read(scheduleProvider.notifier).toggleDone(e.id)),
),
],
],
],
);
}
@@ -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),
),
);
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/widgets.dart';
// CSS filter: grayscale() 가 쓰는 밝기 가중치
const double _kR = 0.2126;
const double _kG = 0.7152;
const double _kB = 0.0722;
/// CSS `filter: grayscale(amount)` 와 같은 색 행렬 — 0 이면 그대로, 1 이면 흑백
ColorFilter grayscaleFilter(double amount) {
final k = 1 - amount.clamp(0.0, 1.0);
return ColorFilter.matrix([
_kR + (1 - _kR) * k, _kG - _kG * k, _kB - _kB * k, 0, 0, //
_kR - _kR * k, _kG + (1 - _kG) * k, _kB - _kB * k, 0, 0, //
_kR - _kR * k, _kG - _kG * k, _kB + (1 - _kB) * k, 0, 0, //
0, 0, 0, 1, 0,
]);
}
/// 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});
final double amount;
final Widget child;
@override
Widget build(BuildContext context) => ColorFiltered(colorFilter: grayscaleFilter(amount), child: 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,
]);
+12 -1
View File
@@ -24,6 +24,9 @@ enum RupButtonVariant {
/// 주황 화면 위의 흰 버튼 (인트로·온보딩 CTA — .rup-btn 이 아니라 본문 서체)
light,
/// 추억 보관함 보라 (보관하기 확인)
memory,
}
const double _kFontSize = 15;
@@ -100,6 +103,7 @@ class RupButton extends StatelessWidget {
this.thin = false,
this.expand = true,
this.dimmed = false,
this.dimmedOpacity = .5,
this.height,
this.leading,
});
@@ -120,6 +124,9 @@ class RupButton extends StatelessWidget {
/// 누를 수는 있지만 아직 조건이 안 된 상태를 흐리게 보여 준다 (등록 버튼 등)
final bool dimmed;
/// 흐리게 보일 때 불투명도 — 시안이 화면마다 다르다 (등록 .5 / 일정 저장 .45)
final double dimmedOpacity;
/// 높이를 시안 값으로 고정해야 하는 자리(인트로 CTA 46 등)에서만 지정
final double? height;
final Widget? leading;
@@ -141,6 +148,7 @@ class RupButton extends StatelessWidget {
RupButtonVariant.danger => (AppColors.danger, Colors.white, null),
RupButtonVariant.alert => (AppColors.catHospital, Colors.white, AppColors.catHospital),
RupButtonVariant.light => (AppColors.offWhite, AppColors.ink, null),
RupButtonVariant.memory => (AppColors.memory, Colors.white, AppColors.memory),
};
final size = large ? _kFontSizeLarge : _kFontSize;
final textStyle = variant == RupButtonVariant.light
@@ -164,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,
+127 -9
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;
@@ -26,13 +29,14 @@ Future<bool> showRupConfirm(
bool destructive = false,
RupConfirmLayout layout = RupConfirmLayout.stacked,
RupIconName? icon,
RupGlyph? glyph,
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;
@@ -54,8 +58,9 @@ Future<bool> showRupConfirm(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (row) const SizedBox(height: AppSpacing.xs),
if (icon != null)
// 시안 row 확인창은 아이콘·제목 위에 4 를 둔다 (댓글 삭제처럼 없는 곳은 tightTop)
if (row && !tightTop) const SizedBox(height: AppSpacing.xs),
if (icon != null || glyph != null)
Center(
child: Container(
width: 54,
@@ -66,7 +71,9 @@ Future<bool> showRupConfirm(
borderRadius: BorderRadius.circular(AppSpacing.lg),
),
alignment: Alignment.center,
child: RupIcon(icon, size: 26, stroke: 2, color: iconColor),
child: glyph != null
? RupGlyphIcon(glyph, size: 26, color: iconColor)
: RupIcon(icon!, size: 26, stroke: 2, color: iconColor),
),
),
Text(
@@ -99,7 +106,7 @@ Future<bool> showRupConfirm(
Expanded(
child: RupButton(
label: confirmLabel,
variant: destructive ? RupButtonVariant.alert : RupButtonVariant.primary,
variant: confirmVariant ?? (destructive ? RupButtonVariant.alert : RupButtonVariant.primary),
onPressed: () => Navigator.of(context).pop(true),
),
),
@@ -109,7 +116,7 @@ Future<bool> showRupConfirm(
RupButton(
label: confirmLabel,
large: true,
variant: destructive ? RupButtonVariant.danger : RupButtonVariant.primary,
variant: confirmVariant ?? (destructive ? RupButtonVariant.danger : RupButtonVariant.primary),
onPressed: () => Navigator.of(context).pop(true),
),
const SizedBox(height: AppSpacing.sm),
@@ -131,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,
),
+38 -11
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,
@@ -102,13 +126,16 @@ class EmptyMessage extends StatelessWidget {
/// 화면 폭 전체를 가로지르는 구분 띠 (시안 .rup-divider-band)
class DividerBand extends StatelessWidget {
const DividerBand({super.key});
const DividerBand({super.key, this.top = 34});
/// 띠 위 여백 — 시안이 화면마다 덮어쓰는 곳이 있다 (추억 보관함 18)
final double top;
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(top: 34, bottom: 22),
child: SizedBox(height: 8, width: double.infinity, child: ColoredBox(color: AppColors.sand)),
return Padding(
padding: EdgeInsets.only(top: top, bottom: 22),
child: const SizedBox(height: 8, width: double.infinity, child: ColoredBox(color: AppColors.sand)),
);
}
}
@@ -14,6 +14,9 @@ class SubPageHeader extends StatelessWidget {
this.subtitle,
this.trailing,
this.displayFont = false,
this.titleLeading,
this.subtitleLeading,
this.bottomGap = 18,
});
final String title;
@@ -24,6 +27,13 @@ class SubPageHeader extends StatelessWidget {
/// 제목을 제목 서체로 (시안에서 화면마다 다르다)
final bool displayFont;
/// 제목·부제 앞의 작은 아이콘 (추억 보관함의 구름 등)
final Widget? titleLeading;
final Widget? subtitleLeading;
/// 머리 아래 여백 (시안 대부분 18)
final double bottomGap;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
@@ -37,7 +47,7 @@ class SubPageHeader extends StatelessWidget {
)
: textTheme.titleSmall!.copyWith(fontSize: 18, fontWeight: FontWeight.w800, height: 1.25);
return Padding(
padding: const EdgeInsets.only(top: AppSpacing.xs, bottom: 18),
padding: EdgeInsets.only(top: AppSpacing.xs, bottom: bottomGap),
child: Row(
children: [
Pressable(
@@ -54,12 +64,20 @@ class SubPageHeader extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: titleStyle),
_withLeading(
titleLeading,
gap: 7,
child: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis, style: titleStyle),
),
if (subtitle != null) ...[
const SizedBox(height: 1),
Text(
subtitle!,
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight),
_withLeading(
subtitleLeading,
gap: 5,
child: Text(
subtitle!,
style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: AppTypography.normalHeight),
),
),
],
],
@@ -70,4 +88,9 @@ class SubPageHeader extends StatelessWidget {
),
);
}
static Widget _withLeading(Widget? leading, {required double gap, required Widget child}) {
if (leading == null) return child;
return Row(children: [leading, SizedBox(width: gap), Flexible(child: child)]);
}
}
@@ -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();
@@ -7,12 +7,14 @@ import Foundation
import file_selector_macos
import flutter_secure_storage_darwin
import gal
import share_plus
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}
+12 -4
View File
@@ -242,10 +242,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2"
sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
version: "3.3.2"
flutter_secure_storage:
dependency: "direct main"
description:
@@ -320,6 +320,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.0"
gal:
dependency: "direct main"
description:
name: gal
sha256: f71e79840fe023a21f2f949771375444b6efcd34b9e625d5f4f5504971380a77
url: "https://pub.dev"
source: hosted
version: "2.3.3"
glob:
dependency: transitive
description:
@@ -684,10 +692,10 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83"
sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
version: "3.3.2"
share_plus:
dependency: "direct main"
description:
+1
View File
@@ -45,6 +45,7 @@ dependencies:
path_provider: ^2.1.6
share_plus: ^13.3.0
qr_flutter: ^4.1.0
gal: ^2.3.3
dev_dependencies:
flutter_test:
+141
View File
@@ -0,0 +1,141 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/utils/korean.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/diary_book.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/pet/data/repositories/pet_repository.dart';
import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'support/app_harness.dart';
final PetArchive _archive = PetArchive(
from: DateTime(2021, 9, 14),
to: DateTime(2026, 9, 28),
archivedAt: DateTime(2026, 9, 28, 21, 5),
memo: '고마웠어',
);
Diary _diary(String id, List<String> targets) =>
Diary(id: id, name: id, targetIds: targets, ribbon: kRibbonColors.first, tint: kDiaryTints.first);
DiaryPost _post(String id, String diaryId, DateTime date) =>
DiaryPost(id: id, diaryId: diaryId, date: date, createdAt: date, text: id, mood: Mood.happy);
void main() {
group('togetherSpan', () {
test('해와 달로 읽는다', () {
expect(togetherSpan(DateTime(2018, 6, 1), DateTime(2026, 9, 1)), '8년 3개월');
expect(togetherSpan(DateTime(2024, 3, 10), DateTime(2026, 3, 10)), '2년');
expect(togetherSpan(DateTime(2026, 4, 1), DateTime(2026, 9, 15)), '5개월');
});
test('마지막 날이 첫날의 날짜보다 이르면 그 달은 세지 않는다', () {
expect(togetherSpan(DateTime(2021, 9, 14), DateTime(2026, 9, 13)), '4년 11개월');
});
test('한 달이 안 되면 "한 달"', () {
expect(togetherSpan(DateTime(2026, 9, 1), DateTime(2026, 9, 20)), '한 달');
expect(togetherSpan(DateTime(2026, 9, 20), DateTime(2026, 9, 1)), '한 달');
});
});
test('공동격 조사는 받침에 따라 와/과', () {
expect(withAndParticle('콩이'), '콩이와');
expect(withAndParticle('사랑'), '사랑과');
expect(withAndParticle('Coco'), 'Coco와');
});
group('보관 기록 저장', () {
test('보관 기록은 JSON 으로 오가도 같다', () {
final back = PetArchive.fromJson(_archive.toJson());
expect(back.from, _archive.from);
expect(back.to, _archive.to);
expect(back.archivedAt, _archive.archivedAt);
expect(back.memo, '고마웠어');
expect(back.spanLabel, '5년');
});
test('보관한 아이는 보관 기록까지 저장되고, 함께 지내는 아이는 보관 항목이 없다', () {
final pet = samplePet();
expect(pet.toJson().containsKey('archive'), isFalse);
expect(Pet.fromJson(pet.toJson()).isArchived, isFalse);
final archived = Pet.fromJson(pet.withArchive(_archive).toJson());
expect(archived.isArchived, isTrue);
expect(archived.archive!.memo, '고마웠어');
expect(archived.name, pet.name);
expect(archived.tint, pet.tint);
});
});
group('LocalPetRepository — 보관·복구', () {
Future<LocalPetRepository> repo() async {
SharedPreferences.setMockInitialValues({});
final r = LocalPetRepository(await SharedPreferences.getInstance());
await r.create(samplePet());
return r;
}
test('보관하면 보관 기록이 붙고, 다시 불러오면 떨어진다', () async {
final r = await repo();
final id = (await r.fetchPets()).single.id;
await r.archive(id, _archive);
expect((await r.fetchPets()).single.isArchived, isTrue);
await r.restore(id);
expect((await r.fetchPets()).single.isArchived, isFalse);
});
test('정보를 수정해도 보관 기록은 그대로다', () async {
final r = await repo();
final id = (await r.fetchPets()).single.id;
await r.archive(id, _archive);
final updated = await r.update(id, samplePet(name: '콩순이'));
expect(updated.name, '콩순이');
expect(updated.isArchived, isTrue);
});
});
group('보관된 아이의 다이어리', () {
final book = DiaryBook(
diaries: [
_diary('only', ['pet-1']),
_diary('mixed', ['pet-1', 'pet-2']),
_diary('other', ['pet-2']),
],
posts: [
_post('a', 'only', DateTime(2026, 9, 20)),
_post('b', 'mixed', DateTime(2026, 9, 21)),
_post('c', 'other', DateTime(2026, 9, 22)),
],
);
test('보관된 아이만 담은 다이어리는 책장에서 빠진다', () {
expect(book.shelf({'pet-1'}).map((d) => d.id), ['mixed', 'other']);
expect(book.shelf().map((d) => d.id), ['only', 'mixed', 'other']);
});
test('책장이 가득 찼는지는 보관함으로 간 다이어리를 빼고 센다', () {
final full = DiaryBook(diaries: [
for (var i = 0; i < kMaxDiaries - 1; i++) _diary('d$i', ['pet-2']),
_diary('gone', ['pet-1']),
]);
expect(full.atMax(), isTrue);
expect(full.atMax({'pet-1'}), isFalse);
});
test('아이가 담긴 다이어리와 그 기록 수', () {
expect(book.diariesOf('pet-1').map((d) => d.id), ['only', 'mixed']);
expect(book.postCountOf('pet-1'), 2);
});
test('최근 기록은 고른 다이어리에서만 뽑을 수 있다', () {
expect(book.latest(3).map((p) => p.id), ['c', 'b', 'a']);
expect(book.latest(3, diaryIds: {'mixed', 'other'}).map((p) => p.id), ['c', 'b']);
});
});
}
+192
View File
@@ -0,0 +1,192 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/utils/hour_minute.dart';
import 'package:tma_app/features/archive/presentation/pages/archive_list_page.dart';
import 'package:tma_app/features/archive/presentation/pages/pet_archive_move_page.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
import 'package:tma_app/features/pet/domain/pet_archive.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart';
import 'package:tma_app/features/pet_card/presentation/widgets/pet_id_card.dart';
import 'package:tma_app/features/schedule/domain/event_category.dart';
import 'package:tma_app/features/schedule/domain/schedule_event.dart';
import 'support/app_harness.dart';
final DateTime _today = DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day);
final Diary _diary = Diary(
id: 'd1',
name: '콩이 성장일기',
targetIds: const ['pet-1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
);
final DiaryPost _post = DiaryPost(
id: 'p1',
diaryId: 'd1',
date: DateTime(2026, 9, 1),
createdAt: DateTime(2026, 9, 1, 9),
text: '공원 산책',
mood: Mood.happy,
);
final ScheduleEvent _walk = ScheduleEvent(
id: 'e1',
date: _today,
details: const EventDetails(
title: '저녁 산책',
kind: EventKind.general,
category: EventCategory.walk,
targetIds: ['pet-1'],
start: HourMinute(19, 0),
),
);
final PetArchive _archive = PetArchive(
from: DateTime(2021, 9, 14),
to: DateTime(2026, 9, 14),
archivedAt: DateTime(2026, 9, 14, 21),
memo: '고마웠어 콩이야',
);
/// 콩이 + 다이어리·기록·명함·일정. [archived] 면 콩이는 이미 추억 보관함에 있다
Map<String, Object> _prefs({bool archived = false}) {
final pet = archived ? samplePet().withArchive(_archive) : samplePet();
return {
'pets.v1': jsonEncode([pet.toJson()]),
'diary.diaries.v1': jsonEncode([_diary.toJson()]),
'diary.posts.v1': jsonEncode([_post.toJson()]),
'petCards.v1': jsonEncode([PetCard(id: 'c1', petId: 'pet-1', style: CardStyle.initial()).toJson()]),
'schedule.events.v1': jsonEncode([_walk.toJson()]),
};
}
/// 목록 아래쪽 줄은 스크롤해야 만들어진다 — 보일 때까지 내려서 누른다
Future<void> _tapVisible(WidgetTester tester, Finder finder) async {
await tester.scrollUntilVisible(finder, 200, scrollable: find.byType(Scrollable).first);
await tester.pump();
await tester.tap(finder);
await settle(tester);
}
Future<void> _openMyPage(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel('마이페이지'));
await settle(tester);
}
Future<void> _openArchive(WidgetTester tester) async {
await _openMyPage(tester);
await _tapVisible(tester, find.text('보관된 아이'));
expect(find.byType(ArchiveListPage), findsOneWidget);
}
void main() {
testWidgets('반려동물 정보에서 추억 보관함으로 옮기면 일정은 지워지고 홈·책장에서 빠진다', (tester) async {
final prefs = await launchSignedIn(tester, extra: _prefs());
await _openMyPage(tester);
await tester.tap(find.bySemanticsLabel('반려동물 1마리'));
await settle(tester);
await tester.tap(find.text('콩이'));
await settle(tester);
await _tapVisible(tester, find.text('추억 보관함으로'));
expect(find.text('콩이를 추억 보관함으로'), findsOneWidget);
await tester.tap(find.text('보관하기'));
await settle(tester);
expect(find.byType(PetArchiveMovePage), findsOneWidget);
expect(find.text('콩이와 함께한 기간을 알려주세요'), findsOneWidget);
expect(find.text('2021.09.14'), findsOneWidget);
await tester.enterText(find.byType(TextField), '고마웠어');
await tester.tap(find.text('추억 보관함으로 옮기기'));
await settle(tester);
// 마이페이지로 돌아와 보관함에 한 마리
expect(find.text('추억보관함에 보관했어요.'), findsOneWidget);
expect(find.bySemanticsLabel('반려동물 0마리'), findsOneWidget);
await tester.scrollUntilVisible(find.text('보관된 아이'), 200, scrollable: find.byType(Scrollable).first);
expect(find.text('1마리'), findsOneWidget);
final saved = prefs.getString('pets.v1')!;
expect(saved, contains('"archive"'));
expect(saved, contains('고마웠어'));
// 콩이만의 일정은 지운다
expect(prefs.getString('schedule.events.v1'), isNot(contains('저녁 산책')));
// 홈에는 아이 등록 안내, 다이어리 책장에서도 빠진다
await tester.tap(find.bySemanticsLabel('홈'));
await settle(tester);
expect(find.text('우리 아이를 등록해 주세요'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('일정·기록'));
await settle(tester);
await tester.tap(find.text('다이어리'));
await settle(tester);
expect(find.text('콩이 성장일기'), findsNothing);
expect(find.text('0/6'), findsOneWidget);
});
testWidgets('보관된 아이를 누르면 함께한 기간과 메모, 원래 정보·명함·다이어리를 다시 본다', (tester) async {
await launchSignedIn(tester, extra: _prefs(archived: true));
await _openArchive(tester);
expect(find.text('1마리'), findsOneWidget);
expect(find.text('강아지 · 말티즈 · 기록 1개'), findsOneWidget);
expect(find.text('2021.09.14 ~ 2026.09.14'), findsOneWidget);
await tester.tap(find.text('콩이'));
await settle(tester);
expect(find.text('5년 함께했어요'), findsOneWidget);
expect(find.text('고마웠어 콩이야'), findsOneWidget);
expect(find.text('함께한 펫 명함 1개'), findsOneWidget);
await _tapVisible(tester, find.text('기본·건강 정보를 다시 봐요.'));
expect(find.text('콩이의 정보'), findsOneWidget);
expect(find.text('기본 정보'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
await _tapVisible(tester, find.text('함께한 펫 명함 1개'));
expect(find.byType(NameCard), findsOneWidget);
await tester.tap(find.byType(NameCard));
await settle(tester);
expect(find.byType(PetIdCard), findsOneWidget);
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
await _tapVisible(tester, find.text('함께한 날들을 다시 봐요.'));
expect(find.text('다이어리 · 1권'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('콩이 성장일기, 기록 1개'));
await settle(tester);
// 읽기만 — 쓰기·설정 버튼이 없다
expect(find.byType(DiaryPage), findsOneWidget);
expect(find.text('공원 산책'), findsOneWidget);
expect(find.bySemanticsLabel('다이어리 쓰기'), findsNothing);
expect(find.bySemanticsLabel('다이어리 설정'), findsNothing);
});
testWidgets('다시 불러오면 보관함에서 빠지고 나의 반려동물로 돌아온다', (tester) async {
final prefs = await launchSignedIn(tester, extra: _prefs(archived: true));
await _openArchive(tester);
await tester.tap(find.text('콩이'));
await settle(tester);
await _tapVisible(tester, find.text('콩이 다시 불러오기'));
expect(find.text('다시 불러왔어요'), findsOneWidget);
expect(find.byType(ArchiveListPage), findsOneWidget);
expect(find.text('보관된 아이가 없어요'), findsOneWidget);
expect(prefs.getString('pets.v1'), isNot(contains('"archive"')));
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
expect(find.bySemanticsLabel('반려동물 1마리'), findsOneWidget);
});
}
+1 -1
View File
@@ -94,7 +94,7 @@ void main() {
test('다이어리는 6권까지', () {
final book = DiaryBook(diaries: [for (var i = 0; i < kMaxDiaries; i++) _diary('d$i')]);
expect(book.atMax, isTrue);
expect(book.atMax(), isTrue);
});
});
+32 -1
View File
@@ -66,5 +66,36 @@ void main() {
expect(find.text('명함 만들기'), findsNothing);
expect(find.bySemanticsLabel('콩이 명함 공유'), findsOneWidget);
});
}
testWidgets('우리 아이를 꾹 눌러 끌면 순서가 바뀌고 저장된다', (tester) async {
final pets = [samplePet(id: 'pet-1', name: '콩이'), samplePet(id: 'pet-2', name: '보리')];
final prefs = await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([for (final p in pets) p.toJson()])});
final from = tester.getCenter(find.text('콩이'));
final to = tester.getCenter(find.text('보리'));
// 320ms 이상 누르면 들어 올리고, 끌어서 보리 자리에 놓는다
final gesture = await tester.startGesture(from);
await tester.pump(const Duration(milliseconds: 400));
await gesture.moveTo(Offset.lerp(from, to, .5)!);
await tester.pump();
await gesture.moveTo(to);
await tester.pump();
await gesture.up();
await settle(tester);
expect(tester.getCenter(find.text('보리')).dx, lessThan(tester.getCenter(find.text('콩이')).dx));
final saved = (jsonDecode(prefs.getString('pets.v1')!) as List<Object?>).cast<Map<String, Object?>>();
expect(saved.map((p) => p['name']), ['보리', '콩이']);
});
testWidgets('짧게 누르면 순서는 그대로 두고 그 아이를 고른다', (tester) async {
final pets = [samplePet(id: 'pet-1', name: '콩이'), samplePet(id: 'pet-2', name: '보리')];
final prefs = await launchSignedIn(tester, extra: {'pets.v1': jsonEncode([for (final p in pets) p.toJson()])});
await tester.tap(find.text('보리'));
await settle(tester);
expect(find.text('보리의 펫 명함이 아직 없어요', findRichText: true), findsOneWidget);
final saved = (jsonDecode(prefs.getString('pets.v1')!) as List<Object?>).cast<Map<String, Object?>>();
expect(saved.map((p) => p['name']), ['콩이', '보리']);
});
}
+165
View File
@@ -0,0 +1,165 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/core/storage/preferences_provider.dart';
import 'package:tma_app/core/utils/clock.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/domain/mood.dart';
import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart';
import 'package:tma_app/features/mypage/data/app_settings_repository.dart';
import 'package:tma_app/features/notifications/domain/app_notice.dart';
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
final DateTime _evening = DateTime(2026, 9, 28, 20, 30);
Diary _diary(String id, {Set<DiaryNotice> muted = const {}}) => Diary(
id: id,
name: '$id 일기',
targetIds: const ['pet-1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
mutedNotices: muted,
);
DiaryPost _post(String diaryId, DateTime date) => DiaryPost(
id: 'p-$diaryId',
diaryId: diaryId,
date: DateTime(date.year, date.month, date.day),
createdAt: date,
text: '산책',
mood: Mood.happy,
);
/// 저장소에 다이어리·기록을 넣고, 시계를 [now] 로 고정한 컨테이너
Future<ProviderContainer> _container({
DateTime? now,
List<Diary> diaries = const [],
List<DiaryPost> posts = const [],
Map<String, Object> extra = const {},
}) async {
SharedPreferences.setMockInitialValues({
'diary.diaries.v1': jsonEncode([for (final d in diaries) d.toJson()]),
'diary.posts.v1': jsonEncode([for (final p in posts) p.toJson()]),
...extra,
});
final prefs = await SharedPreferences.getInstance();
final at = now ?? _evening;
final container = ProviderContainer(
overrides: [
sharedPreferencesProvider.overrideWithValue(prefs),
clockProvider.overrideWithValue(() => at),
],
);
addTearDown(container.dispose);
await container.read(diaryProvider.future);
return container;
}
void main() {
group('NoticeDay.of', () {
final now = DateTime(2026, 9, 28, 9);
test('같은 날은 오늘, 하루 전은 어제, 일주일 안은 최근 7일', () {
expect(NoticeDay.of(DateTime(2026, 9, 28, 0, 5), now), NoticeDay.today);
expect(NoticeDay.of(DateTime(2026, 9, 27, 23, 50), now), NoticeDay.yesterday);
expect(NoticeDay.of(DateTime(2026, 9, 22, 12), now), NoticeDay.week);
});
test('일주일이 넘은 알림은 묶음이 없다', () {
expect(NoticeDay.of(DateTime(2026, 9, 21, 12), now), isNull);
});
test('달이 바뀌어도 날짜 차이로 묶는다', () {
expect(NoticeDay.of(DateTime(2026, 9, 30), DateTime(2026, 10, 1)), NoticeDay.yesterday);
});
});
group('relativeTimeLabel', () {
final now = DateTime(2026, 9, 28, 20, 30);
test('오늘은 방금 전 · N분 전 · N시간 전', () {
expect(relativeTimeLabel(now.subtract(const Duration(seconds: 20)), now), '방금 전');
expect(relativeTimeLabel(DateTime(2026, 9, 28, 20), now), '30분 전');
expect(relativeTimeLabel(DateTime(2026, 9, 28, 17, 10), now), '3시간 전');
});
test('지난 날은 어제 · N일 전', () {
expect(relativeTimeLabel(DateTime(2026, 9, 27, 23), now), '어제');
expect(relativeTimeLabel(DateTime(2026, 9, 25, 9), now), '3일 전');
});
});
group('noticesProvider — 기록 알림', () {
test('저녁 8시가 지나도록 오늘 기록이 없는 다이어리마다 알린다', () async {
final c = await _container(diaries: [_diary('d1'), _diary('d2')]);
final notices = c.read(noticesProvider);
expect(notices.map((n) => n.diaryId), unorderedEquals(['d1', 'd2']));
expect(notices.first.type, NoticeType.reminder);
expect(notices.first.text, kDiaryReminderText);
expect(notices.first.at, DateTime(2026, 9, 28, kDiaryReminderHour));
});
test('저녁 8시 전에는 알리지 않는다', () async {
final c = await _container(now: DateTime(2026, 9, 28, 19, 59), diaries: [_diary('d1')]);
expect(c.read(noticesProvider), isEmpty);
});
test('오늘 기록을 남긴 다이어리는 알리지 않는다', () async {
final c = await _container(
diaries: [_diary('d1'), _diary('d2')],
posts: [_post('d1', DateTime(2026, 9, 28, 9))],
);
expect(c.read(noticesProvider).map((n) => n.diaryId), ['d2']);
});
test('어제 기록은 오늘 기록으로 치지 않는다', () async {
final c = await _container(diaries: [_diary('d1')], posts: [_post('d1', DateTime(2026, 9, 27, 21))]);
expect(c.read(noticesProvider), hasLength(1));
});
test('다이어리 설정에서 기록 알림을 끄면 알리지 않는다', () async {
final c = await _container(diaries: [_diary('d1', muted: {DiaryNotice.reminder})]);
expect(c.read(noticesProvider), isEmpty);
});
test('마이페이지 일정·다이어리 알림을 끄면 모두 끈다', () async {
final c = await _container(
diaries: [_diary('d1')],
extra: {AppSetting.scheduleNotification.key: false},
);
expect(c.read(noticesProvider), isEmpty);
});
});
group('새 알림 표시', () {
test('한 번도 보지 않았으면 새 알림이 있다', () async {
final c = await _container(diaries: [_diary('d1')]);
expect(c.read(hasUnreadNoticeProvider), isTrue);
});
test('알림 화면을 본 뒤에는 새 알림이 없고, 본 시각이 저장된다', () async {
final c = await _container(diaries: [_diary('d1')]);
await c.read(noticeSeenProvider.notifier).markSeen(_evening);
expect(c.read(hasUnreadNoticeProvider), isFalse);
final prefs = c.read(sharedPreferencesProvider);
expect(DateTime.parse(prefs.getString('notice.seenAt.v1')!), _evening);
});
test('다음 날의 기록 알림은 다시 새 알림이다', () async {
final c = await _container(
now: DateTime(2026, 9, 29, 20, 10),
diaries: [_diary('d1')],
extra: {'notice.seenAt.v1': _evening.toIso8601String()},
);
expect(c.read(hasUnreadNoticeProvider), isTrue);
});
test('알림이 없으면 새 알림도 없다', () async {
final c = await _container(now: DateTime(2026, 9, 28, 10), diaries: [_diary('d1')]);
expect(c.read(hasUnreadNoticeProvider), isFalse);
});
});
}
+85
View File
@@ -0,0 +1,85 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/utils/clock.dart';
import 'package:tma_app/features/diary/domain/diary.dart';
import 'package:tma_app/features/diary/presentation/pages/diary_page.dart';
import 'package:tma_app/features/notifications/presentation/pages/notifications_page.dart';
import 'package:tma_app/features/notifications/presentation/providers/notice_providers.dart';
import 'support/app_harness.dart';
final Diary _diary = Diary(
id: 'd1',
name: '콩이 성장일기',
targetIds: const ['pet-1'],
ribbon: kRibbonColors.first,
tint: kDiaryTints.first,
);
final Map<String, Object> _prefs = {
'pets.v1': jsonEncode([samplePet().toJson()]),
'diary.diaries.v1': jsonEncode([_diary.toJson()]),
};
/// 오늘 [hour]:30 으로 고정한 시계 — 앱의 다른 화면은 실제 날짜를 쓰므로 날짜는 오늘로 맞춘다
DateTime _today(int hour) {
final now = DateTime.now();
return DateTime(now.year, now.month, now.day, hour, 30);
}
Future<void> _launchAt(WidgetTester tester, int hour, {Map<String, Object> extra = const {}}) async {
final at = _today(hour);
await launchSignedIn(
tester,
extra: {..._prefs, ...extra},
overrides: [clockProvider.overrideWithValue(() => at)],
);
}
void main() {
testWidgets('저녁에 기록이 없으면 종에 새 알림 표시가 뜨고, 알림 화면에서 기록 알림을 본다', (tester) async {
await _launchAt(tester, 20);
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음'));
await settle(tester);
expect(find.byType(NotificationsPage), findsOneWidget);
expect(find.text('우리 가족 다이어리 소식'), findsOneWidget);
expect(find.text('오늘'), findsOneWidget);
expect(find.text(kDiaryReminderText, findRichText: true), findsOneWidget);
expect(find.text('콩이 성장일기 · 30분 전'), findsOneWidget);
// 보고 나오면 종의 새 알림 표시가 사라진다
await tester.tap(find.bySemanticsLabel('뒤로'));
await settle(tester);
expect(find.bySemanticsLabel('알림'), findsOneWidget);
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
});
testWidgets('기록 알림을 누르면 그 다이어리로 간다', (tester) async {
await _launchAt(tester, 21);
await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음'));
await settle(tester);
await tester.tap(find.text(kDiaryReminderText, findRichText: true));
await settle(tester);
expect(find.byType(DiaryPage), findsOneWidget);
expect(find.text('콩이 성장일기'), findsWidgets);
});
testWidgets('알림이 없으면 빈 안내를 보여 준다', (tester) async {
await _launchAt(tester, 10);
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
await tester.tap(find.bySemanticsLabel('알림'));
await settle(tester);
expect(find.text('새로운 알림이 없어요'), findsOneWidget);
});
testWidgets('다이어리 설정에서 기록 알림을 끄면 저녁에도 알리지 않는다', (tester) async {
final muted = _diary.copyWith(mutedNotices: {DiaryNotice.reminder});
await _launchAt(tester, 20, extra: {'diary.diaries.v1': jsonEncode([muted.toJson()])});
expect(find.bySemanticsLabel('알림, 새 알림 있음'), findsNothing);
});
}
+76
View File
@@ -0,0 +1,76 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:tma_app/core/storage/album_saver.dart';
import 'package:tma_app/features/pet_card/domain/card_style.dart';
import 'package:tma_app/features/pet_card/domain/pet_card.dart';
import 'support/app_harness.dart';
/// 앨범 대신 받은 이름만 모아 두는 저장기
class _FakeAlbum implements AlbumSaver {
_FakeAlbum({this.ok = true});
final bool ok;
final saved = <String>[];
@override
Future<bool> savePng(Uint8List bytes, String name) async {
saved.add(name);
return ok;
}
}
Map<String, Object> _prefs() => {
'pets.v1': jsonEncode([samplePet().toJson()]),
'petCards.v1': jsonEncode([PetCard(id: 'c1', petId: 'pet-1', style: CardStyle.initial()).toJson()]),
};
Future<_FakeAlbum> _openShare(WidgetTester tester, {bool ok = true}) async {
final album = _FakeAlbum(ok: ok);
await launchSignedIn(tester, extra: _prefs(), overrides: [albumSaverProvider.overrideWithValue(album)]);
await tester.tap(find.bySemanticsLabel('콩이 명함 공유'));
await settle(tester);
expect(find.text('콩이 펫 명함 공유'), findsOneWidget);
return album;
}
/// 명함 이미지를 떠 내는 일은 실제 비동기라 runAsync 안에서 누른다
Future<void> _tapAndWait(WidgetTester tester, Finder finder) async {
await tester.runAsync(() async {
await tester.tap(finder);
await Future<void>.delayed(const Duration(milliseconds: 300));
});
await settle(tester);
}
void main() {
testWidgets('이미지로 저장하면 앨범에 저장하고 시트를 닫은 뒤 알린다', (tester) async {
final album = await _openShare(tester);
await _tapAndWait(tester, find.bySemanticsLabel('이미지로 저장'));
expect(album.saved, ['rup_콩이_명함']);
expect(find.text('콩이 펫 명함 공유'), findsNothing);
expect(find.text('이미지로 저장했어요'), findsOneWidget);
});
testWidgets('QR 이미지 저장은 QR 창과 공유 시트를 함께 닫는다', (tester) async {
final album = await _openShare(tester);
await tester.tap(find.bySemanticsLabel('QR 코드 공유'));
await settle(tester);
expect(find.text('펫 명함 QR'), findsOneWidget);
await _tapAndWait(tester, find.text('QR 이미지 저장'));
expect(album.saved, ['rup_콩이_QR']);
expect(find.text('펫 명함 QR'), findsNothing);
expect(find.text('콩이 펫 명함 공유'), findsNothing);
expect(find.text('QR 코드를 저장했어요'), findsOneWidget);
});
testWidgets('사진 접근을 거절해 저장하지 못하면 알려 준다', (tester) async {
await _openShare(tester, ok: false);
await _tapAndWait(tester, find.bySemanticsLabel('이미지로 저장'));
expect(find.text('저장하지 못했어요. 사진 접근 권한을 확인해 주세요'), findsOneWidget);
});
}
+18
View File
@@ -7,6 +7,8 @@ import 'package:tma_app/features/pet/domain/pet.dart';
import 'package:tma_app/features/pet/domain/pet_age.dart';
import 'package:tma_app/features/pet/domain/pet_species.dart';
import 'support/app_harness.dart';
PetInput _input({String name = '콩이', double weight = 3.2}) => PetInput(
name: name,
species: '강아지',
@@ -138,4 +140,20 @@ void main() {
expect(await repo.fetchPets(), isEmpty);
});
});
group('reorderPets — 우리 아이 순서 바꾸기', () {
final pets = [samplePet(id: 'a', name: '콩이'), samplePet(id: 'b', name: '보리'), samplePet(id: 'c', name: '초코')];
test('준 순서대로 줄 세운다', () {
expect(reorderPets(pets, ['c', 'a', 'b']).map((p) => p.id), ['c', 'a', 'b']);
});
test('목록에 없는 아이(추억 보관함 등)는 원래 순서대로 뒤에 둔다', () {
expect(reorderPets(pets, ['b', 'a']).map((p) => p.id), ['b', 'a', 'c']);
});
test('없는 id 는 건너뛴다', () {
expect(reorderPets(pets, ['x', 'b']).map((p) => p.id), ['b', 'a', 'c']);
});
});
}
+73
View File
@@ -138,4 +138,77 @@ void main() {
expect(find.text('예방접종'), findsOneWidget);
expect(find.text('예약된 일정이 없어요'), findsNothing);
});
testWidgets('하루 종일이 켜진 채 기간을 하루 2회로 정하면 회차 시각(08:00·20:00)으로 저장된다', (tester) async {
final prefs = await launchSignedIn(tester, extra: _withPet());
await _openRecords(tester);
await tester.tap(find.text('일정 추가'));
await settle(tester);
await tester.enterText(find.byType(TextField).first, '영양제');
await _tapVisible(tester, find.bySemanticsLabel('기간 설정'));
await _tapVisible(tester, find.text('2회'));
await _tapVisible(tester, find.text('일정 저장'));
final saved = jsonDecode(prefs.getString('schedule.events.v1')!) as List<Object?>;
final starts = {for (final e in saved.cast<Map<String, Object?>>()) e['start']};
expect(starts, {'08:00', '20:00'});
});
testWidgets('예약 분류를 골랐다가 같은 일반 일정 칩을 다시 누르면 분류 선택이 풀린다', (tester) async {
await launchSignedIn(tester, extra: _withPet());
await _openRecords(tester);
await tester.tap(find.text('일정 추가'));
await settle(tester);
await _tapVisible(tester, find.text('목욕'));
await _tapVisible(tester, find.bySemanticsLabel('병원·검진'));
expect(tester.getSemantics(find.bySemanticsLabel('병원·검진')), isSemantics(isSelected: true));
// 이름 칸에도 '목욕' 이 들어 있으므로 위쪽의 칩을 누른다
await _tapVisible(tester, find.text('목욕').first);
expect(tester.getSemantics(find.bySemanticsLabel('병원·검진')), isSemantics(isSelected: false));
});
testWidgets('주기 직접 설정은 2주마다로 시작하고, 매일·평일을 골랐다 돌아와도 단위·횟수가 남는다', (tester) async {
await launchSignedIn(tester, extra: _withPet());
await _openRecords(tester);
await tester.tap(find.text('일정 추가'));
await settle(tester);
await _tapVisible(tester, find.bySemanticsLabel('반복 일정'));
await _tapVisible(tester, find.text('주기 직접 설정'));
expect(find.text('2주 마다 반복'), findsWidgets);
await _tapVisible(tester, find.text('평일만'));
expect(find.text('평일마다 반복'), findsOneWidget);
await _tapVisible(tester, find.text('주기 직접 설정'));
expect(find.text('2주 마다 반복'), findsWidgets);
});
testWidgets('시간 휠의 가운데 숫자를 누르면 직접 입력할 수 있다', (tester) async {
await launchSignedIn(tester, extra: _withPet());
await _openRecords(tester);
await tester.tap(find.text('일정 추가'));
await settle(tester);
await _tapVisible(tester, find.text('시작'));
expect(find.text('오후 2:00 선택'), findsOneWidget);
// 가운데(선택된) 시 — 02
await tester.tap(find.text('02').first);
await tester.pump();
await tester.enterText(find.byType(TextField).last, '7');
await tester.testTextInput.receiveAction(TextInputAction.done);
await settle(tester);
expect(find.text('오후 7:00 선택'), findsOneWidget);
// 범위를 넘으면 가장 가까운 값으로 (분 99 → 59)
await tester.tap(find.text('00').first);
await tester.pump();
await tester.enterText(find.byType(TextField).last, '99');
await tester.testTextInput.receiveAction(TextInputAction.done);
await settle(tester);
expect(find.text('오후 7:59 선택'), findsOneWidget);
});
}
+8 -3
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:tma_app/app.dart';
@@ -28,8 +29,12 @@ Pet samplePet({String id = 'pet-1', String name = '콩이'}) => Pet.fromInput(
);
/// 시안 크기 화면에서 로그인한 사용자로 앱을 띄우고 스플래시를 지나 홈까지 간다.
/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다.
Future<SharedPreferences> launchSignedIn(WidgetTester tester, {Map<String, Object> extra = const {}}) async {
/// 저장소 값을 확인할 수 있게 [SharedPreferences] 를 돌려준다. [overrides] 로 시계 등을 바꿔 끼운다.
Future<SharedPreferences> launchSignedIn(
WidgetTester tester, {
Map<String, Object> extra = const {},
List<Override> overrides = const [],
}) async {
tester.view.physicalSize = const Size(402, 874);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
@@ -38,7 +43,7 @@ Future<SharedPreferences> launchSignedIn(WidgetTester tester, {Map<String, Objec
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
overrides: [sharedPreferencesProvider.overrideWithValue(prefs), ...overrides],
child: const RupApp(),
),
);
@@ -8,6 +8,7 @@
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <gal/gal_plugin_c_api.h>
#include <share_plus/share_plus_windows_plugin_c_api.h>
#include <url_launcher_windows/url_launcher_windows.h>
@@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
GalPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("GalPluginCApi"));
SharePlusWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
UrlLauncherWindowsRegisterWithRegistrar(
@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
flutter_secure_storage_windows
gal
share_plus
url_launcher_windows
)