diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 97293c6..0dc418e 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -14,6 +14,7 @@ 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'; import 'package:tma_app/features/diary/presentation/pages/diary_settings_page.dart'; +import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart'; import 'package:tma_app/features/entry/presentation/providers/entry_provider.dart'; import 'package:tma_app/features/home/presentation/pages/home_page.dart'; import 'package:tma_app/features/intro/presentation/pages/intro_page.dart'; @@ -130,6 +131,11 @@ final appRouterProvider = Provider((ref) { ), ], ), + GoRoute( + path: AppRoutes.diaryJoinScan, + parentNavigatorKey: rootKey, + pageBuilder: (context, state) => _fadePage(state, const DiaryJoinScannerPage()), + ), // 펫 명함 — 경로가 겹치므로 고정 경로(manage·new·scan…)를 :cardId 보다 먼저 둔다 GoRoute( path: AppRoutes.petCardManage, diff --git a/flutter/lib/core/router/app_routes.dart b/flutter/lib/core/router/app_routes.dart index a855f0f..301c88e 100644 --- a/flutter/lib/core/router/app_routes.dart +++ b/flutter/lib/core/router/app_routes.dart @@ -53,6 +53,9 @@ abstract final class AppRoutes { static String diaryWrite(String id) => '/diaries/$id/write'; static String diarySettings(String id) => '/diaries/$id/settings'; + /// 공유 다이어리 참여 스캐너 — /diaries/:diaryId 와 겹치지 않게 다른 경로에 둔다 + static const String diaryJoinScan = '/diary-invitations/scan'; + static const String petCardManage = '/pet-cards/manage'; static const String petCardCreatePattern = '/pet-cards/new/:petId'; static const String petCardDetailPattern = '/pet-cards/:cardId'; diff --git a/flutter/lib/features/diary/data/api_diary_repository.dart b/flutter/lib/features/diary/data/api_diary_repository.dart index 9c5af27..08aa1b9 100644 --- a/flutter/lib/features/diary/data/api_diary_repository.dart +++ b/flutter/lib/features/diary/data/api_diary_repository.dart @@ -12,31 +12,89 @@ import 'package:tma_app/core/utils/date_format.dart'; 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/diary_invitation.dart'; import 'package:tma_app/features/diary/domain/mood.dart'; +import 'package:tma_app/features/pet/domain/pet_target.dart'; -/// 다이어리 경로 (backend DiariesController · DiaryPostsController · DiaryCommentsController) +/// 다이어리 경로 (backend DiariesController · DiaryPostsController · DiaryCommentsController · +/// DiaryInvitationsController · DiaryMembershipsController) const String _kDiariesPath = '/diaries'; const String _kPostsPath = '/diary-posts'; const String _kCommentsPath = '/diary-comments'; +const String _kInvitationsPath = '/diary-invitations'; +const String _kMembershipsPath = '/diary-memberships'; /// 책장이 가득 찼을 때 서버가 주는 코드 — 앱은 null 로 받아 화면이 직접 안내한다 const String kDiaryFullCode = 'DIARY_001'; -Diary diaryFromApi(Map json) => Diary( - id: json['id']! as String, +/// 서버의 회원 id → 앱의 사람 id (나이면 [kMeAuthorId]). +/// 내 id 를 모르면(이전 버전에서 로그인) 모두 나로 본다 — 그때는 공유 다이어리가 없었다 +String _personId(Object? id, String? me) => me == null || id == me ? kMeAuthorId : id! as String; + +/// 남이면 그 사람, 나면 null — 내 글·댓글은 내 프로필로 그린다 +DiaryPerson? _author(Map json, String? me) { + final id = _personId(json['authorId'], me); + if (id == kMeAuthorId) return null; + final author = json['author'] as Map?; + return DiaryPerson( + id: id, + nickname: author?['nickname'] as String?, + photoPath: serverFileRefOrNull(author?['photoFileId'] as String?), + ); +} + +Diary diaryFromApi(Map json, {String? myUserId}) { + final isOwner = json['isOwner'] as bool? ?? true; + return Diary( + id: json['id']! as String, + name: json['name']! as String, + targetIds: (json['targetIds'] as List?)?.whereType().toList() ?? const [], + ribbon: colorFromHex(json['ribbon'] as String?) ?? kRibbonColors.first, + tint: colorFromHex(json['tint'] as String?) ?? kDiaryTints.first, + coverPath: serverFileRefOrNull(json['coverFileId'] as String?), + mutedNotices: { + for (final n in (json['mutedNotices'] as List?)?.whereType() ?? const []) + ?DiaryNotice.values.where((v) => v.name == n).firstOrNull, + }, + shared: json['shared'] as bool? ?? false, + isOwner: isOwner, + inviteCode: json['inviteCode'] as String?, + members: [ + for (final m in (json['members'] as List?)?.whereType>() ?? const >[]) + DiaryMember( + id: _personId(m['userId'], myUserId), + nickname: m['nickname'] as String?, + photoPath: serverFileRefOrNull(m['photoFileId'] as String?), + isOwner: m['isOwner'] as bool? ?? false, + ), + ], + // 내 다이어리는 내 반려동물 목록으로 그리므로 다른 사람의 다이어리일 때만 담는다 + targets: isOwner + ? const [] + : [ + for (final t in (json['targets'] as List?)?.whereType>() ?? const >[]) + DiaryTarget( + id: t['id']! as String, + name: t['name'] as String?, + photoPath: serverFileRefOrNull(t['photoFileId'] as String?), + isSelf: t['id'] == kSelfTargetId, + ), + ], + ); +} + +DiaryInvitation diaryInvitationFromApi(String code, Map json) => DiaryInvitation( + code: code, + diaryId: json['diaryId']! as String, name: json['name']! as String, - targetIds: (json['targetIds'] as List?)?.whereType().toList() ?? const [], - ribbon: colorFromHex(json['ribbon'] as String?) ?? kRibbonColors.first, - tint: colorFromHex(json['tint'] as String?) ?? kDiaryTints.first, - coverPath: serverFileRefOrNull(json['coverFileId'] as String?), - mutedNotices: { - for (final n in (json['mutedNotices'] as List?)?.whereType() ?? const []) - ?DiaryNotice.values.where((v) => v.name == n).firstOrNull, - }, + ownerNickname: json['ownerNickname'] as String?, + memberCount: (json['memberCount'] as num?)?.toInt() ?? 1, + maxMembers: (json['maxMembers'] as num?)?.toInt() ?? kMaxDiaryMembers, + isMember: json['isMember'] as bool? ?? false, ); /// 서버 저장소 — 사진은 저장할 때 올린다. -/// 기록·댓글의 작성자가 로그인한 나이면 앱의 "나"(kMeAuthorId)로 바꿔 담는다 (하루 한 번 쓰기를 가린다). +/// 작성자·멤버가 로그인한 나이면 앱의 "나"(kMeAuthorId)로 바꿔 담는다 (하루 한 번 쓰기·내 글 가리기). class ApiDiaryRepository implements DiaryRepository { ApiDiaryRepository({ required Dio dio, @@ -53,36 +111,43 @@ class ApiDiaryRepository implements DiaryRepository { final FileUploader _uploader; final String? Function() _myUserId; - /// 지금은 다이어리 주인만 쓰므로, 내 id 를 모르면(이전 버전에서 로그인) 모두 나로 본다 - String _author(Object? id) { + @override + bool get canShare => true; + + Diary _diary(Map json) => diaryFromApi(json, myUserId: _myUserId()); + + DiaryPost _post(Map json) { final me = _myUserId(); - return me == null || id == me ? kMeAuthorId : id! as String; + return DiaryPost( + id: json['id']! as String, + diaryId: json['diaryId']! as String, + date: parseIsoDate(json['date']! as String), + createdAt: DateTime.parse(json['createdAt']! as String).toLocal(), + text: json['text'] as String? ?? '', + mood: Mood.byName(json['mood'] as String?), + photoPath: serverFileRefOrNull(json['photoFileId'] as String?), + authorId: _personId(json['authorId'], me), + author: _author(json, me), + ); } - DiaryPost _post(Map json) => DiaryPost( - id: json['id']! as String, - diaryId: json['diaryId']! as String, - date: parseIsoDate(json['date']! as String), - createdAt: DateTime.parse(json['createdAt']! as String).toLocal(), - text: json['text'] as String? ?? '', - mood: Mood.byName(json['mood'] as String?), - photoPath: serverFileRefOrNull(json['photoFileId'] as String?), - authorId: _author(json['authorId']), - ); - - DiaryComment _comment(Map json) => DiaryComment( - id: json['id']! as String, - postId: json['postId']! as String, - text: json['text'] as String? ?? '', - createdAt: DateTime.parse(json['createdAt']! as String).toLocal(), - authorId: _author(json['authorId']), - ); + DiaryComment _comment(Map json) { + final me = _myUserId(); + return DiaryComment( + id: json['id']! as String, + postId: json['postId']! as String, + text: json['text'] as String? ?? '', + createdAt: DateTime.parse(json['createdAt']! as String).toLocal(), + authorId: _personId(json['authorId'], me), + author: _author(json, me), + ); + } @override Future fetch() async { if (!_session.hasSession) return DiaryBook.empty; return DiaryBook( - diaries: await fetchAllPages(_dio, _kDiariesPath, diaryFromApi), + diaries: await fetchAllPages(_dio, _kDiariesPath, _diary), posts: await fetchAllPages(_dio, _kPostsPath, _post), comments: await fetchAllPages(_dio, _kCommentsPath, _comment), ); @@ -95,6 +160,7 @@ class ApiDiaryRepository implements DiaryRepository { required Color ribbon, required Color tint, String? coverPath, + bool shared = false, }) async { final coverFileId = await _uploader.ensureUploaded(coverPath); try { @@ -106,13 +172,14 @@ class ApiDiaryRepository implements DiaryRepository { 'ribbon': hexOf(ribbon), 'tint': hexOf(tint), 'coverFileId': coverFileId, + 'shared': shared, }, // 가득 찬 경우는 화면이 "최대 6개" 로 직접 안내한다 options: Options(extra: { kSilentCodesExtra: [kDiaryFullCode], }), ); - return diaryFromApi(response.data!); + return _diary(response.data!); } on DioException catch (e) { final error = e.error; if (error is ApiException && error.code == kDiaryFullCode) return null; @@ -129,12 +196,43 @@ class ApiDiaryRepository implements DiaryRepository { 'targetIds': diary.targetIds, 'ribbon': hexOf(diary.ribbon), 'coverFileId': await _uploader.ensureUploaded(diary.coverPath), - 'mutedNotices': [for (final n in diary.mutedNotices) n.name], }, ); - return diaryFromApi(response.data!); + return _diary(response.data!); } + @override + Future setMutedNotices(String diaryId, Set muted) => _dio.put( + '$_kDiariesPath/$diaryId/muted-notices', + data: {'mutedNotices': [for (final n in muted) n.name]}, + ); + + @override + Future setShared(String diaryId, bool shared) async { + final response = await _dio.put>('$_kDiariesPath/$diaryId/sharing', data: {'shared': shared}); + return _diary(response.data!); + } + + @override + Future previewInvitation(String code) async { + final response = await _dio.get>('$_kInvitationsPath/${Uri.encodeComponent(code)}'); + return diaryInvitationFromApi(code, response.data!); + } + + @override + Future join(String code) async { + await _dio.post>(_kMembershipsPath, data: {'code': code}); + // 참여한 다이어리의 지난 기록·댓글까지 함께 보여야 하므로 모두 다시 읽는다 + return fetch(); + } + + @override + Future leave(String diaryId) => _dio.delete('$_kDiariesPath/$diaryId/membership'); + + @override + Future removeMember(String diaryId, String memberId) => + _dio.delete('$_kDiariesPath/$diaryId/members/$memberId'); + @override Future deleteDiary(String diaryId) => _dio.delete('$_kDiariesPath/$diaryId'); diff --git a/flutter/lib/features/diary/data/diary_repository.dart b/flutter/lib/features/diary/data/diary_repository.dart index aee3ad9..01a97d3 100644 --- a/flutter/lib/features/diary/data/diary_repository.dart +++ b/flutter/lib/features/diary/data/diary_repository.dart @@ -12,6 +12,7 @@ import 'package:tma_app/features/auth/data/repositories/api_auth_repository.dart import 'package:tma_app/features/diary/data/api_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/diary_invitation.dart'; import 'package:tma_app/features/diary/domain/mood.dart'; /// 다이어리 저장소 계약 — 서버(ApiDiaryRepository) / 기기(LocalDiaryRepository). @@ -19,18 +20,40 @@ import 'package:tma_app/features/diary/domain/mood.dart'; abstract interface class DiaryRepository { Future fetch(); - /// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001) + /// 공유 다이어리(초대·참여·멤버)를 쓸 수 있는지 — 다른 회원과 함께 쓰므로 서버가 있어야 한다 + bool get canShare; + + /// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001). [shared] 면 처음부터 공유 다이어리 Future createDiary({ required String name, required List targetIds, required Color ribbon, required Color tint, String? coverPath, + bool shared = false, }); - /// 이름·대상·리본·표지·알림을 바꾼다 (바탕색은 만들 때 정한 값을 유지한다) + /// 이름·대상·리본·표지를 바꾼다 (주인만 · 바탕색은 만들 때 정한 값을 유지한다) Future updateDiary(Diary diary); + /// 내 알림 설정 — 멤버마다 따로 둔다 + Future setMutedNotices(String diaryId, Set muted); + + /// 개인 ↔ 공유 전환 (주인만). 개인으로 바꾸면 다른 멤버는 모두 나간다 + Future setShared(String diaryId, bool shared); + + /// 초대 코드로 참여할 다이어리를 미리 본다 + Future previewInvitation(String code); + + /// 초대 코드로 참여한다 — 참여한 다이어리의 기록·댓글까지 모두 다시 읽어 돌려준다 + Future join(String code); + + /// 참여한 다이어리에서 나간다 (주인이 아닐 때) + Future leave(String diaryId); + + /// 멤버를 내보낸다 (주인만) + Future removeMember(String diaryId, String memberId); + /// 다이어리와 그 안의 기록·댓글을 모두 지운다 Future deleteDiary(String diaryId); @@ -59,6 +82,8 @@ abstract interface class DiaryRepository { Future dropTarget(String targetId); } +const String _kLocalNoSharing = '공유 다이어리는 서버 모드에서만 쓸 수 있다'; + const String _kDiariesKey = 'diary.diaries.v1'; const String _kPostsKey = 'diary.posts.v1'; const String _kCommentsKey = 'diary.comments.v1'; @@ -91,6 +116,9 @@ class LocalDiaryRepository implements DiaryRepository { @override Future fetch() async => _read(); + @override + bool get canShare => false; + /// 가득 찼는지는 알림기가 아이의 보관 여부까지 보고 먼저 확인한다 @override Future createDiary({ @@ -99,6 +127,7 @@ class LocalDiaryRepository implements DiaryRepository { required Color ribbon, required Color tint, String? coverPath, + bool shared = false, }) async { final diary = Diary( id: _newId('d'), @@ -118,6 +147,29 @@ class LocalDiaryRepository implements DiaryRepository { return diary; } + @override + Future setMutedNotices(String diaryId, Set muted) async { + final book = _read(); + final diary = book.diary(diaryId); + if (diary != null) await _write(book.updateDiary(diary.copyWith(mutedNotices: muted))); + } + + // 기기 저장 모드에는 함께 쓸 다른 회원이 없다 — 화면은 [canShare] 로 막는다 + @override + Future setShared(String diaryId, bool shared) => throw StateError(_kLocalNoSharing); + + @override + Future previewInvitation(String code) => throw StateError(_kLocalNoSharing); + + @override + Future join(String code) => throw StateError(_kLocalNoSharing); + + @override + Future leave(String diaryId) => throw StateError(_kLocalNoSharing); + + @override + Future removeMember(String diaryId, String memberId) => throw StateError(_kLocalNoSharing); + @override Future deleteDiary(String diaryId) async => _write(_read().deleteDiary(diaryId)); diff --git a/flutter/lib/features/diary/domain/diary.dart b/flutter/lib/features/diary/domain/diary.dart index 0ff4723..ade8d85 100644 --- a/flutter/lib/features/diary/domain/diary.dart +++ b/flutter/lib/features/diary/domain/diary.dart @@ -12,9 +12,48 @@ const int kCommentMaxLength = 50; /// 다이어리 이름을 비워 두면 쓰는 이름 const String kDefaultDiaryName = '새 다이어리'; -/// 기록한 사람 — 지금은 나 하나 (공유 다이어리가 생기면 회원 id) +/// 기록한 사람이 나일 때의 id — 서버의 내 회원 id 도 이 값으로 바꿔 담는다 (하루 한 번 쓰기·내 글 가리기) const String kMeAuthorId = 'me'; +/// 공유 다이어리 한 권에 함께할 수 있는 사람 수 — 주인 포함 (시안 MAX_MEMBERS) +const int kMaxDiaryMembers = 4; + +/// 닉네임을 모르는 사람 +const String kUnknownPersonName = '집사'; + +/// 다이어리에 글·댓글을 남긴 사람 · 멤버 +class DiaryPerson { + const DiaryPerson({required this.id, this.nickname, this.photoPath}); + + /// 회원 id — 나이면 [kMeAuthorId] + final String id; + final String? nickname; + final String? photoPath; + + bool get isMe => id == kMeAuthorId; + + String get name => nickname ?? kUnknownPersonName; +} + +/// 공유 다이어리의 멤버 +class DiaryMember extends DiaryPerson { + const DiaryMember({required super.id, super.nickname, super.photoPath, this.isOwner = false}); + + final bool isOwner; +} + +/// 다른 사람의 다이어리에 담긴 아이 — 내 반려동물 목록에 없으므로 서버가 이름·사진을 함께 준다 +class DiaryTarget { + const DiaryTarget({required this.id, this.name, this.photoPath, this.isSelf = false}); + + final String id; + final String? name; + final String? photoPath; + + /// 다이어리 주인 본인 (대상 "나") + final bool isSelf; +} + /// 책갈피 리본 색 선택지 (시안 RIBBON_SWATCHES) const List kRibbonColors = [ Color(0xFFFFFFFF), Color(0xFF2B2622), Color(0xFFFF7500), @@ -44,6 +83,11 @@ class Diary { required this.tint, this.coverPath, this.mutedNotices = const {}, + this.shared = false, + this.isOwner = true, + this.inviteCode, + this.members = const [], + this.targets = const [], }); final String id; @@ -52,9 +96,10 @@ class Diary { /// 누구의 다이어리인지 — 반려동물 id 또는 나(self) final List targetIds; - /// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다 + /// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다. + /// 참여한 다이어리는 주인의 아이들이라 내 보관함과 상관없이 책장에 둔다 bool isArchivedWith(Set archivedPetIds) => - targetIds.isNotEmpty && targetIds.every(archivedPetIds.contains); + isOwner && targetIds.isNotEmpty && targetIds.every(archivedPetIds.contains); final Color ribbon; final Color tint; final String? coverPath; @@ -64,12 +109,41 @@ class Diary { bool notifies(DiaryNotice n) => !mutedNotices.contains(n); + /// 함께 쓰는 다이어리인지 (개인 다이어리는 false) + final bool shared; + + /// 내가 만든 다이어리인지 — 아니면 초대받아 참여한 다이어리 + final bool isOwner; + + /// 멤버 초대 코드 — 주인에게만 온다 + final String? inviteCode; + + /// 함께 쓰는 사람들 (주인이 먼저). 개인 다이어리·기기 저장 모드는 비어 있다 + final List members; + + /// 참여한 다이어리의 아이들 (내 다이어리는 비어 있다 — 내 반려동물 목록으로 그린다) + final List targets; + + /// 멤버 수 — 나만 쓰면 1 + int get memberCount => members.isEmpty ? 1 : members.length; + + bool get membersFull => memberCount >= kMaxDiaryMembers; + + /// 다이어리를 만든 사람 + DiaryMember? get owner => members.where((m) => m.isOwner).firstOrNull; + + /// 첫 아이 이름 — 내 다이어리는 [myTargetNames](내 반려동물·나)에서, 참여한 다이어리는 서버가 준 이름으로 + String? firstTargetName(Map myTargetNames) => isOwner + ? targetIds.map((id) => myTargetNames[id]).nonNulls.firstOrNull + : targets.map((t) => t.name).nonNulls.firstOrNull; + Diary copyWith({ String? name, List? targetIds, Color? ribbon, String? coverPath, Set? mutedNotices, + List? members, }) => Diary( id: id, @@ -79,6 +153,11 @@ class Diary { tint: tint, coverPath: coverPath ?? this.coverPath, mutedNotices: mutedNotices ?? this.mutedNotices, + shared: shared, + isOwner: isOwner, + inviteCode: inviteCode, + members: members ?? this.members, + targets: targets, ); Map toJson() => { @@ -116,6 +195,7 @@ class DiaryPost { this.mood, this.photoPath, this.authorId = kMeAuthorId, + this.author, }); final String id; @@ -131,6 +211,11 @@ class DiaryPost { final String? photoPath; final String authorId; + /// 남이 쓴 기록이면 그 사람 (내 기록은 null — 내 프로필로 그린다) + final DiaryPerson? author; + + bool get isMine => authorId == kMeAuthorId; + bool get hasPhoto => photoPath != null; DiaryPost copyWith({String? text}) => DiaryPost( @@ -142,6 +227,7 @@ class DiaryPost { mood: mood, photoPath: photoPath, authorId: authorId, + author: author, ); Map toJson() => { @@ -175,6 +261,7 @@ class DiaryComment { required this.text, required this.createdAt, this.authorId = kMeAuthorId, + this.author, }); final String id; @@ -183,8 +270,19 @@ class DiaryComment { final DateTime createdAt; final String authorId; - DiaryComment copyWith({String? text}) => - DiaryComment(id: id, postId: postId, text: text ?? this.text, createdAt: createdAt, authorId: authorId); + /// 남이 쓴 댓글이면 그 사람 (내 댓글은 null) + final DiaryPerson? author; + + bool get isMine => authorId == kMeAuthorId; + + DiaryComment copyWith({String? text}) => DiaryComment( + id: id, + postId: postId, + text: text ?? this.text, + createdAt: createdAt, + authorId: authorId, + author: author, + ); Map toJson() => { 'id': id, diff --git a/flutter/lib/features/diary/domain/diary_invitation.dart b/flutter/lib/features/diary/domain/diary_invitation.dart new file mode 100644 index 0000000..fc97daa --- /dev/null +++ b/flutter/lib/features/diary/domain/diary_invitation.dart @@ -0,0 +1,43 @@ +/// 공유 다이어리 초대 — 참여하기 전에 확인하는 다이어리 정보 +class DiaryInvitation { + const DiaryInvitation({ + required this.code, + required this.diaryId, + required this.name, + required this.ownerNickname, + required this.memberCount, + required this.maxMembers, + required this.isMember, + }); + + final String code; + final String diaryId; + final String name; + final String? ownerNickname; + final int memberCount; + final int maxMembers; + + /// 이미 함께 쓰고 있는 다이어리인지 (내가 만든 다이어리 포함) + final bool isMember; + + bool get isFull => memberCount >= maxMembers; +} + +/// 초대 코드 앞머리 (서버가 `RUP-` + 6자로 만든다) +const String kInviteCodePrefix = 'RUP-'; + +/// 초대 QR 에 담는 주소 — 앱의 스캐너가 읽는다 +String diaryInviteLink(String code) => 'rup://diary-invite/$code'; + +final RegExp _kInviteBody = RegExp(r'^[A-Z0-9]{6}$'); + +/// QR 또는 직접 입력한 값에서 초대 코드를 꺼낸다 — 주소(rup://diary-invite/RUP-8F3K2Q)· +/// 코드(RUP-8F3K2Q, 소문자도)·앞머리 없이 쓴 6자(8F3K2Q) 모두 받는다. 초대 코드가 아니면 null +String? parseInviteCode(String raw) { + var text = raw.trim(); + const link = 'rup://diary-invite/'; + if (text.startsWith(link)) text = text.substring(link.length); + text = text.split('?').first.trim().toUpperCase().replaceAll(' ', ''); + if (text.startsWith(kInviteCodePrefix)) text = text.substring(kInviteCodePrefix.length); + return _kInviteBody.hasMatch(text) ? '$kInviteCodePrefix$text' : null; +} diff --git a/flutter/lib/features/diary/presentation/pages/diary_page.dart b/flutter/lib/features/diary/presentation/pages/diary_page.dart index a103552..14a2002 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_page.dart @@ -3,6 +3,7 @@ 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/api/api_guard.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'; @@ -55,6 +56,9 @@ const double _kTabBottomGap = 30; /// 다이어리 머리가 위아래로 2씩 좁다 (시안 embedded margin 2px 0 16px — 기록 탭·보관함 모두) const double _kEmbeddedTrim = 2; +/// 멤버 수 칩 바탕 (시안 #F6F4EF) +const Color _kChipBg = Color(0xFFF6F4EF); + /// 다이어리 안 — 기록 피드(모두·사진·글) / 달력 보기, 검색, 오늘 쓰기 class DiaryPage extends ConsumerStatefulWidget { const DiaryPage({super.key, required this.diaryId, this.archived = false}); @@ -79,6 +83,10 @@ class _DiaryPageState extends ConsumerState { void initState() { super.initState(); _query.addListener(() => setState(() {})); + // 함께 쓰는 다이어리면 들어올 때 다른 멤버의 새 기록을 다시 읽는다 + if (ref.read(diaryBookProvider).diary(widget.diaryId)?.shared ?? false) { + unawaited(succeeded(ref.read(diaryProvider.notifier).refresh())); + } } @override @@ -139,10 +147,16 @@ class _DiaryPageState extends ConsumerState { onBack: () => context.pop(), trailing: archived ? null - : _HeaderIconButton( - label: '다이어리 설정', - onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))), - child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2), + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (diary.shared) ...[_MembersChip(count: diary.memberCount), const SizedBox(width: AppSpacing.sm)], + _HeaderIconButton( + label: '다이어리 설정', + onTap: () => unawaited(context.push(AppRoutes.diarySettings(diary.id))), + child: const RupGlyphIcon(RupGlyph.gear, size: 21, color: AppColors.ink2), + ), + ], ), ), Row( @@ -311,6 +325,42 @@ class _NoPosts extends StatelessWidget { } } +/// 공유 다이어리 멤버 수 (시안 .rup-memseg__chip.is-solo) +class _MembersChip extends StatelessWidget { + const _MembersChip({required this.count}); + + final int count; + + @override + Widget build(BuildContext context) { + return Semantics( + label: '멤버 $count명 (최대 $kMaxDiaryMembers명)', + excludeSemantics: true, + child: Container( + height: 34, + padding: const EdgeInsets.symmetric(horizontal: 13), + decoration: BoxDecoration(color: _kChipBg, borderRadius: BorderRadius.circular(AppRadius.pill)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const RupIcon(RupIconName.users, size: 14, stroke: 2, color: AppColors.ink2), + const SizedBox(width: 5), + Text( + '$count/$kMaxDiaryMembers', + style: Theme.of(context).textTheme.bodySmall!.copyWith( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: AppColors.ink2, + height: AppTypography.normalHeight, + ), + ), + ], + ), + ), + ); + } +} + class _HeaderIconButton extends StatelessWidget { const _HeaderIconButton({required this.label, required this.onTap, required this.child}); diff --git a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart index e896e7a..8bc2dfa 100644 --- a/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart +++ b/flutter/lib/features/diary/presentation/pages/diary_settings_page.dart @@ -10,11 +10,14 @@ import 'package:tma_app/core/router/app_routes.dart'; import 'package:tma_app/core/theme/app_typography.dart'; import 'package:tma_app/features/diary/domain/diary.dart'; import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart'; +import 'package:tma_app/features/diary/presentation/widgets/diary_join.dart'; +import 'package:tma_app/features/diary/presentation/widgets/diary_sharing_sections.dart'; import 'package:tma_app/features/diary/presentation/widgets/diary_sheets.dart'; import 'package:tma_app/features/records/presentation/providers/records_tab_provider.dart'; import 'package:tma_app/shared/widgets/heart_toggle.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_card.dart'; import 'package:tma_app/shared/widgets/rup_dialog.dart'; import 'package:tma_app/shared/widgets/rup_toast.dart'; @@ -24,6 +27,9 @@ import 'package:tma_app/shared/widgets/sub_page_header.dart'; const double _kNormal = AppTypography.normalHeight; +/// 나가기·전환 확인창의 경고 색 (시안 #E05A4A) +const Color _kWarn = Color(0xFFE05A4A); + /// 알림 줄 — 아이콘·색·제목·설명 (시안 NOTIF_ROWS) typedef _NoticeRow = ({DiaryNotice notice, RupIconName icon, Color tint, String title, String desc}); @@ -33,155 +39,261 @@ const List<_NoticeRow> _kNoticeRows = [ (notice: DiaryNotice.reminder, icon: RupIconName.paw, tint: AppColors.accent, title: '우리 아이 기록 알림', desc: '오늘 일기를 쓰지 않았을 때 잊지 않도록 알려드려요.'), ]; -/// 다이어리 설정 — 정보 수정 · 개인/공유 · 알림 · 제거 -class DiarySettingsPage extends ConsumerWidget { +/// 다이어리 설정 — 정보 수정 · 개인/공유 · 멤버 · 알림 · 초대 · 나가기/제거 +class DiarySettingsPage extends ConsumerStatefulWidget { const DiarySettingsPage({super.key, required this.diaryId}); final String diaryId; - Future _delete(BuildContext context, WidgetRef ref, Diary diary) async { + @override + ConsumerState createState() => _DiarySettingsPageState(); +} + +class _DiarySettingsPageState extends ConsumerState { + /// "N명 더 초대할 수 있어요" 를 누르면 내려갈 곳 + final _inviteKey = GlobalKey(); + + DiaryNotifier get _diaries => ref.read(diaryProvider.notifier); + ToastNotifier get _toast => ref.read(toastProvider.notifier); + + /// 책장으로 돌아간다 — 지우거나 나간 다이어리를 그리지 않게 먼저 화면을 바꾼다 + void _backToShelf() { + ref.read(recordsTabProvider.notifier).select(RecordsTab.diary); + context.go(AppRoutes.records); + } + + Future _delete(Diary diary) async { final ok = await showRupRemoveConfirm( context, title: '다이어리를 제거할까요?', message: '다이어리를 삭제하면 다시 복구할 수 없으며,\n이때까지 적은 기록이 전부 삭제돼요.', ); - if (!ok || !context.mounted) return; - // 먼저 책장으로 돌아가 지워진 다이어리를 그리지 않게 한다 - ref.read(recordsTabProvider.notifier).select(RecordsTab.diary); - context.go(AppRoutes.records); - if (!await succeeded(ref.read(diaryProvider.notifier).deleteDiary(diary.id))) return; - ref.read(toastProvider.notifier).show('다이어리를 제거했어요'); + if (!ok || !mounted) return; + final diaries = _diaries; + final toast = _toast; + _backToShelf(); + if (!await succeeded(diaries.deleteDiary(diary.id))) return; + toast.show('다이어리를 제거했어요'); + } + + /// 개인 ↔ 공유 — 개인으로 바꾸면 함께 쓰던 멤버가 모두 나가므로 먼저 묻는다 + Future _setShared(Diary diary, bool shared) async { + if (shared == diary.shared) return; + if (!_diaries.canShare) { + _toast.show(kSharedDiaryLocalOnly, icon: RupIconName.users); + return; + } + if (!shared && diary.memberCount > 1) { + final ok = await showRupConfirm( + context, + title: '개인 다이어리로 전환할까요?', + message: '함께 기록하던 멤버 ${diary.memberCount - 1}명이 모두 다이어리에서 나가게 돼요.\n이 작업은 되돌릴 수 없어요.', + confirmLabel: '전환하기', + layout: RupConfirmLayout.row, + icon: RupIconName.alert, + iconColor: _kWarn, + ); + if (!ok || !mounted) return; + } + if (!await succeeded(_diaries.setShared(diary.id, shared: shared))) return; + _toast.show(shared ? '공유 다이어리로 전환했어요' : '개인 다이어리로 전환했어요', + icon: shared ? RupIconName.users : RupIconName.check); + } + + Future _remove(Diary diary, DiaryMember member) async { + final name = memberName(ref, member); + final ok = await showRupConfirm( + context, + title: '$name님을 내보낼까요?', + message: '내보낸 멤버는 더 이상 이 다이어리를 보거나 기록할 수 없어요.', + confirmLabel: '내보내기', + layout: RupConfirmLayout.row, + leading: MemberAvatar(person: member, size: 52), + ); + if (!ok || !mounted) return; + if (!await succeeded(_diaries.removeMember(diary.id, member.id))) return; + _toast.show('$name님을 내보냈어요'); + } + + Future _leave(Diary diary) async { + final ok = await showRupConfirm( + context, + title: '다이어리에서 나갈까요?', + message: '공유 다이어리는 한번 나가면\n주인이 다시 초대해주지 않으면 다시 들어올 수 없어요.', + confirmLabel: '나가기', + layout: RupConfirmLayout.row, + icon: RupIconName.alert, + iconColor: _kWarn, + ); + if (!ok || !mounted) return; + final diaries = _diaries; + final toast = _toast; + _backToShelf(); + if (!await succeeded(diaries.leave(diary.id))) return; + toast.show('다이어리에서 나왔어요'); + } + + void _jumpToInvite() { + final target = _inviteKey.currentContext; + if (target != null) { + unawaited(Scrollable.ensureVisible(target, duration: const Duration(milliseconds: 350), curve: Curves.easeOut)); + } } @override - Widget build(BuildContext context, WidgetRef ref) { - final diary = ref.watch(diaryBookProvider).diary(diaryId); + Widget build(BuildContext context) { + final diary = ref.watch(diaryBookProvider).diary(widget.diaryId); if (diary == null) return const Scaffold(body: SizedBox.shrink()); final textTheme = Theme.of(context).textTheme; + final code = diary.inviteCode; + final canInvite = diary.isOwner && diary.shared && !diary.membersFull && code != null; return Scaffold( body: SafeArea( bottom: false, - child: ListView( + child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( AppSpacing.xl, AppSpacing.xs, AppSpacing.xl, MediaQuery.paddingOf(context).bottom + 40, ), - children: [ - SubPageHeader( - title: '다이어리 설정', - subtitle: diary.name, - displayFont: true, - onBack: () => context.pop(), - trailing: Pressable( - onTap: () => unawaited(showDiaryFormSheet(context, editing: diary)), - pressedScale: .9, - semanticLabel: '다이어리 수정하기', - child: const SizedBox( - width: 30, - height: 34, - child: Center(child: RupIcon(RupIconName.pencilCute, size: 25, stroke: 1.9, color: AppColors.ink2)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SubPageHeader( + title: '다이어리 설정', + subtitle: diary.name, + displayFont: true, + onBack: () => context.pop(), + // 다이어리 정보는 주인만 고친다 + trailing: diary.isOwner + ? Pressable( + onTap: () => unawaited(showDiaryFormSheet(context, editing: diary)), + pressedScale: .9, + semanticLabel: '다이어리 수정하기', + child: const SizedBox( + width: 30, + height: 34, + child: Center(child: RupIcon(RupIconName.pencilCute, size: 25, stroke: 1.9, color: AppColors.ink2)), + ), + ) + : null, + ), + const SizedBox(height: 2), + if (diary.isOwner) + SegToggle( + large: true, + first: (false, '개인 다이어리'), + second: (true, '공유 다이어리'), + value: diary.shared, + iconOf: (shared, on) => RupIcon(shared ? RupIconName.users : RupIconName.user, size: 18, stroke: on ? 2.4 : 1.9), + onChanged: (shared) => unawaited(_setShared(diary, shared)), + ) + else + JoinedDiaryCard(diary: diary), + if (diary.shared) ...[ + SizedBox(height: diary.isOwner ? 24 : 22), + SharingSectionTitle(title: '멤버', count: '${diary.memberCount}/$kMaxDiaryMembers'), + const SizedBox(height: AppSpacing.md), + DiaryMembersCard( + diary: diary, + onRemove: (m) => unawaited(_remove(diary, m)), + onInvite: _jumpToInvite, + ), + ], + const SizedBox(height: 26), + const Padding( + padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: SectionTitle(title: '알림 설정'), + ), + const SizedBox(height: AppSpacing.md), + RupCard( + child: Column( + children: [ + for (final (i, row) in _kNoticeRows.indexed) + Container( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14), + decoration: BoxDecoration( + border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: row.tint.withValues(alpha: .14), + borderRadius: BorderRadius.circular(11), + ), + alignment: Alignment.center, + child: RupIcon(row.icon, size: 19, stroke: 2, color: row.tint), + ), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + row.title, + style: textTheme.titleSmall!.copyWith(fontSize: 14.5, height: _kNormal), + ), + const SizedBox(height: 2), + Text( + row.desc, + style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: 1.45), + ), + ], + ), + ), + const SizedBox(width: 13), + HeartToggle( + value: diary.notifies(row.notice), + label: row.title, + onChanged: (on) => unawaited(succeeded(_diaries.setNotice(diary, row.notice, on: on))), + ), + ], + ), + ), + ], ), ), - ), - const SizedBox(height: 2), - SegToggle( - large: true, - first: (false, '개인 다이어리'), - second: (true, '공유 다이어리'), - value: false, - iconOf: (shared, on) => RupIcon(shared ? RupIconName.users : RupIconName.user, size: 18, stroke: on ? 2.4 : 1.9), - onChanged: (shared) { - if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users); - }, - ), - const SizedBox(height: 26), - const Padding( - padding: EdgeInsets.symmetric(horizontal: AppSpacing.xs), - child: SectionTitle(title: '알림 설정'), - ), - const SizedBox(height: AppSpacing.md), - RupCard( - child: Column( - children: [ - for (final (i, row) in _kNoticeRows.indexed) - Container( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg, vertical: 14), - decoration: BoxDecoration( - border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2)), + const SizedBox(height: 22), + if (canInvite) DiaryInviteSection(key: _inviteKey, diary: diary, code: code), + if (!diary.isOwner) + RupButton( + label: '다이어리 나가기', + variant: RupButtonVariant.ghost, + foreground: _kWarn, + leading: const RupIcon(RupIconName.logout, size: 17, stroke: 2, color: _kWarn), + onPressed: () => unawaited(_leave(diary)), + ) + else ...[ + const SizedBox(height: 22), + Semantics( + container: true, + button: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => unawaited(_delete(diary)), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), + child: Text( + '다이어리 제거', + textAlign: TextAlign.center, + style: textTheme.bodyMedium!.copyWith( + fontSize: 13.5, + fontWeight: FontWeight.w700, + color: AppColors.muted, + height: _kNormal, + ), ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: row.tint.withValues(alpha: .14), - borderRadius: BorderRadius.circular(11), - ), - alignment: Alignment.center, - child: RupIcon(row.icon, size: 19, stroke: 2, color: row.tint), - ), - const SizedBox(width: 13), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - row.title, - style: textTheme.titleSmall!.copyWith(fontSize: 14.5, height: _kNormal), - ), - const SizedBox(height: 2), - Text( - row.desc, - style: textTheme.bodySmall!.copyWith(color: AppColors.muted, height: 1.45), - ), - ], - ), - ), - const SizedBox(width: 13), - HeartToggle( - value: diary.notifies(row.notice), - label: row.title, - onChanged: (on) => unawaited(succeeded(ref.read(diaryProvider.notifier).updateDiary( - diary.copyWith( - mutedNotices: on - ? ({...diary.mutedNotices}..remove(row.notice)) - : {...diary.mutedNotices, row.notice}, - ), - ))), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 44), - Semantics( - container: true, - button: true, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => unawaited(_delete(context, ref, diary)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), - child: Text( - '다이어리 제거', - textAlign: TextAlign.center, - style: textTheme.bodyMedium!.copyWith( - fontSize: 13.5, - fontWeight: FontWeight.w700, - color: AppColors.muted, - height: _kNormal, ), ), ), - ), - ), - ], + ], + ], + ), ), ), ); diff --git a/flutter/lib/features/diary/presentation/providers/diary_providers.dart b/flutter/lib/features/diary/presentation/providers/diary_providers.dart index fa03ea5..9758ead 100644 --- a/flutter/lib/features/diary/presentation/providers/diary_providers.dart +++ b/flutter/lib/features/diary/presentation/providers/diary_providers.dart @@ -7,6 +7,7 @@ import 'package:tma_app/core/utils/date_format.dart'; 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/diary_invitation.dart'; import 'package:tma_app/features/diary/domain/mood.dart'; import 'package:tma_app/features/pet/presentation/providers/pet_providers.dart'; @@ -28,20 +29,28 @@ class DiaryNotifier extends AsyncNotifier { if (ref.mounted) state = AsyncData(next); } + /// 공유 다이어리를 쓸 수 있는지 (서버 모드) + bool get canShare => _repo.canShare; + + /// 책장이 가득 찼는지 — 보관함으로 간 다이어리는 세지 않는다 + bool get atMax => _book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {}); + /// 새 다이어리 — 가득 찼으면 null Future createDiary({ required String name, required List targetIds, required Color ribbon, String? coverPath, + bool shared = false, }) async { - if (_book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {})) return null; + if (atMax) return null; final diary = await _repo.createDiary( name: name.trim().isEmpty ? kDefaultDiaryName : name.trim(), targetIds: targetIds, ribbon: ribbon, tint: kDiaryTints[Random().nextInt(kDiaryTints.length)], coverPath: coverPath, + shared: shared, ); if (diary != null) _set(_book.addDiary(diary)); return diary; @@ -54,6 +63,43 @@ class DiaryNotifier extends AsyncNotifier { _set(_book.deleteDiary(id)); } + /// 내 알림 하나를 켜거나 끈다 + Future setNotice(Diary diary, DiaryNotice notice, {required bool on}) async { + final muted = on ? ({...diary.mutedNotices}..remove(notice)) : {...diary.mutedNotices, notice}; + await _repo.setMutedNotices(diary.id, muted); + final latest = _book.diary(diary.id); + if (latest != null) _set(_book.updateDiary(latest.copyWith(mutedNotices: muted))); + } + + // ── 공유 다이어리 ──────────────────────────────────────── + /// 다른 멤버가 새로 남긴 기록·댓글을 다시 읽는다 — 불러오는 중 화면 없이 바꿔 끼운다 + Future refresh() async { + // 기기 저장 모드는 나만 쓰므로 다시 읽을 것이 없다 + if (!_repo.canShare) return; + _set(await _repo.fetch()); + } + + /// 개인 ↔ 공유 전환 (주인만) + Future setShared(String id, {required bool shared}) async => + _set(_book.updateDiary(await _repo.setShared(id, shared))); + + Future previewInvitation(String code) => _repo.previewInvitation(code); + + /// 초대 코드로 참여 — 참여한 다이어리의 기록까지 다시 읽는다 + Future join(String code) async => _set(await _repo.join(code)); + + /// 참여한 다이어리에서 나간다 — 내 책장에서 그 다이어리와 기록이 사라진다 + Future leave(String id) async { + await _repo.leave(id); + _set(_book.deleteDiary(id)); + } + + /// 멤버를 내보낸다 (주인만) — 서버가 초대 코드를 새로 바꾸므로 다시 읽는다 + Future removeMember(String diaryId, String memberId) async { + await _repo.removeMember(diaryId, memberId); + _set(await _repo.fetch()); + } + Future addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) async { final post = await _repo.addPost( diaryId: diaryId, diff --git a/flutter/lib/features/diary/presentation/widgets/diary_cover.dart b/flutter/lib/features/diary/presentation/widgets/diary_cover.dart index 4af5e3f..161942a 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_cover.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_cover.dart @@ -2,6 +2,7 @@ import 'dart:ui' show ImageFilter; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.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_shadows.dart'; @@ -12,6 +13,8 @@ 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/shared/widgets/dashed_border_box.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/photo_image.dart'; /// 표지 가로:세로 1:1.16 (시안 aspectRatio 1 / 1.16) const double kDiaryCoverAspect = 1 / 1.16; @@ -91,8 +94,8 @@ class DiaryCoverTile extends StatelessWidget { ), ), ), - Positioned(top: -2, right: 15, child: DiaryRibbon(color: diary.ribbon)), - if (showTargets) Positioned(left: 12, top: 11, child: _CoverTargets(ids: diary.targetIds)), + Positioned(top: -2, right: 15, child: DiaryRibbon(color: diary.ribbon, shared: diary.shared)), + if (showTargets) Positioned(left: 12, top: 11, child: _CoverTargets(diary: diary)), Positioned( left: 14, right: 12, @@ -137,23 +140,27 @@ class DiaryCoverTile extends StatelessWidget { /// 표지 왼쪽 위 아이들 — 조금씩 겹쳐 놓는다 class _CoverTargets extends StatelessWidget { - const _CoverTargets({required this.ids}); + const _CoverTargets({required this.diary}); - final List ids; + final Diary diary; static const double _size = 28; static const double _overlap = 8; @override Widget build(BuildContext context) { - if (ids.isEmpty) return const SizedBox.shrink(); + // 참여한 다이어리의 아이들은 내 반려동물 목록에 없다 — 서버가 준 이름·사진으로 그린다 + final avatars = diary.isOwner + ? [for (final id in diary.targetIds) TargetAvatar(targetId: id, size: _size)] + : [for (final t in diary.targets) _SharedTargetAvatar(target: t, size: _size)]; + if (avatars.isEmpty) return const SizedBox.shrink(); return SizedBox( height: _size, - width: _size + (ids.length - 1) * (_size - _overlap), + width: _size + (avatars.length - 1) * (_size - _overlap), child: Stack( clipBehavior: Clip.none, children: [ - for (final (i, id) in ids.indexed) + for (final (i, avatar) in avatars.indexed) Positioned( left: i * (_size - _overlap), child: DecoratedBox( @@ -162,7 +169,7 @@ class _CoverTargets extends StatelessWidget { color: Colors.white, boxShadow: [BoxShadow(color: Colors.white.withValues(alpha: .9), spreadRadius: 2)], ), - child: TargetAvatar(targetId: id, size: _size), + child: avatar, ), ), ], @@ -171,6 +178,35 @@ class _CoverTargets extends StatelessWidget { } } +/// 참여한 다이어리의 아이 — 주인 본인("나")이면 사람 사진, 아이면 아이 사진 (없으면 기본 얼굴) +class _SharedTargetAvatar extends StatelessWidget { + const _SharedTargetAvatar({required this.target, required this.size}); + + final DiaryTarget target; + final double size; + + @override + Widget build(BuildContext context) { + if (target.isSelf) return PersonAvatar(size: size, photoPath: target.photoPath, background: AppColors.primary50); + final photo = target.photoPath; + final placeholder = SvgPicture.asset(AppAssets.petDefault, fit: BoxFit.cover); + return Semantics( + label: target.name == null ? null : '${target.name} 사진', + image: true, + child: Container( + width: size, + height: size, + decoration: const BoxDecoration(shape: BoxShape.circle, color: _kSharedTargetFill), + clipBehavior: Clip.antiAlias, + child: photo == null ? placeholder : PhotoImage(path: photo, fallback: placeholder), + ), + ); + } +} + +/// 사진 없는 아이 바탕 — PetAvatar 와 같은 옅은 회색 +const Color _kSharedTargetFill = Color(0x0A000000); + /// 책갈피 리본 — 반투명 유리 느낌(뒤를 흐리게) + 고른 색 class DiaryRibbon extends StatelessWidget { const DiaryRibbon({super.key, required this.color, this.shared = false}); diff --git a/flutter/lib/features/diary/presentation/widgets/diary_join.dart b/flutter/lib/features/diary/presentation/widgets/diary_join.dart new file mode 100644 index 0000000..69bacd5 --- /dev/null +++ b/flutter/lib/features/diary/presentation/widgets/diary_join.dart @@ -0,0 +1,299 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:tma_app/core/api/api_guard.dart'; +import 'package:tma_app/core/api/error_lexicon.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/features/diary/domain/diary.dart'; +import 'package:tma_app/features/diary/domain/diary_invitation.dart'; +import 'package:tma_app/features/diary/presentation/providers/diary_providers.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/qr_scanner_page.dart'; +import 'package:tma_app/shared/widgets/rup_button.dart'; +import 'package:tma_app/shared/widgets/rup_dialog.dart'; +import 'package:tma_app/shared/widgets/rup_form_fields.dart'; +import 'package:tma_app/shared/widgets/rup_sheet.dart'; +import 'package:tma_app/shared/widgets/rup_toast.dart'; + +/// 공유 다이어리(초대·참여·멤버)는 다른 회원과 함께 쓰므로 서버가 있어야 한다 +const String kSharedDiaryLocalOnly = '기기 저장 모드에서는 공유 다이어리를 쓸 수 없어요'; + +/// 초대 코드 입력칸 최대 길이 (시안 maxLength 12) +const int _kCodeMaxLength = 12; + +/// 공유 다이어리 참여 스캐너 문구 +const QrScanCopy _kScanCopy = QrScanCopy( + title: '공유 다이어리 참여', + hint: '초대받은 다이어리의 QR 코드를\n사각형 안에 비춰주세요.', + foundHint: '초대 QR을 찾았어요!', + manualLabel: '초대 코드 입력하기', + unavailableHint: '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 초대 코드를 입력해 주세요.', +); + +/// 공유 다이어리 참여 스캐너 — 초대 QR 을 비추면 참여할 다이어리를 불러온다 +class DiaryJoinScannerPage extends StatelessWidget { + const DiaryJoinScannerPage({super.key}); + + @override + Widget build(BuildContext context) { + return QrScannerPage( + copy: _kScanCopy, + parse: parseInviteCode, + // 초대 QR 이 아니면 서버가 못 찾았을 때와 같은 안내 + invalidMessage: ErrorLexicon.getMessage('DIARY_003'), + load: (ref, code) => ref.read(diaryProvider.notifier).previewInvitation(code), + ); + } +} + +/// 참여하기 시트에서 고른 것 — QR 스캔으로 가거나, 코드로 찾은 초대 +sealed class _JoinChoice { + const _JoinChoice(); +} + +final class _ChoseScan extends _JoinChoice { + const _ChoseScan(); +} + +final class _Found extends _JoinChoice { + const _Found(this.invitation); + + final DiaryInvitation invitation; +} + +/// 공유 다이어리 참여 — 참여하기 시트(QR 스캔 / 코드 입력) → 확인 → 참여 +Future startJoiningDiary(BuildContext context, WidgetRef ref) async { + final diaries = ref.read(diaryProvider.notifier); + final toast = ref.read(toastProvider.notifier); + if (!diaries.canShare) { + toast.show(kSharedDiaryLocalOnly, icon: RupIconName.users); + return; + } + if (diaries.atMax) { + toast.show('다이어리는 최대 $kMaxDiaries개까지예요', icon: RupIconName.alert); + return; + } + + DiaryInvitation? invitation; + // 스캐너에서 "초대 코드 입력하기"를 누르면 시트로 돌아온다 + while (invitation == null) { + if (!context.mounted) return; + final choice = await showRupSheet<_JoinChoice>( + context, + title: '공유 다이어리 참여', + builder: (_) => const _JoinBody(), + ); + if (!context.mounted) return; + switch (choice) { + case null: + return; + case _Found(invitation: final found): + return _confirmJoin(context, ref, found); + case _ChoseScan(): + final outcome = await context.push>(AppRoutes.diaryJoinScan); + switch (outcome) { + case ScanFound(:final value): + invitation = value; + case ScanTypeCode(): + continue; + case null: + return; + } + } + } + if (context.mounted) await _confirmJoin(context, ref, invitation); +} + +/// 참여 전 확인 — 이미 함께 쓰거나 자리가 없으면 서버와 같은 안내로 멈춘다 +Future _confirmJoin(BuildContext context, WidgetRef ref, DiaryInvitation invitation) async { + final toast = ref.read(toastProvider.notifier); + if (invitation.isMember) { + toast.show(ErrorLexicon.getMessage('DIARY_005'), icon: RupIconName.alert); + return; + } + if (invitation.isFull) { + toast.show(ErrorLexicon.getMessage('DIARY_004'), icon: RupIconName.alert); + return; + } + final ok = await showRupConfirm( + context, + title: '${invitation.name}에\n참여하시겠어요?', + titleSpan: TextSpan(children: [ + TextSpan(text: invitation.name, style: const TextStyle(color: AppColors.primary600)), + const TextSpan(text: '에\n참여하시겠어요?'), + ]), + message: '공유 다이어리에 참여해서\n아이에 대한 기록을 함께 작성해봐요!', + confirmLabel: '참여하기', + layout: RupConfirmLayout.row, + glyph: RupGlyph.diaryShared, + iconColor: AppColors.accent, + iconBackground: AppColors.accent100, + ); + if (!ok) return; + if (!await succeeded(ref.read(diaryProvider.notifier).join(invitation.code))) return; + toast.show('공유 다이어리에 참여했어요'); +} + +/// 참여하기 시트 — QR 스캔 / 또는 / 초대 코드 입력 + 참여하기 +class _JoinBody extends ConsumerStatefulWidget { + const _JoinBody(); + + @override + ConsumerState<_JoinBody> createState() => _JoinBodyState(); +} + +class _JoinBodyState extends ConsumerState<_JoinBody> { + final _code = TextEditingController(); + bool _busy = false; + + @override + void initState() { + super.initState(); + // 입력이 생기면 버튼을 켠다 + _code.addListener(() => setState(() {})); + } + + @override + void dispose() { + _code.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_busy) return; + final toast = ref.read(toastProvider.notifier); + if (_code.text.trim().isEmpty) { + toast.show('초대 코드를 입력해주세요', icon: RupIconName.alert); + return; + } + final code = parseInviteCode(_code.text); + if (code == null) { + // 초대 코드 모양이 아니다 — 서버가 못 찾았을 때와 같은 안내 + toast.show(ErrorLexicon.getMessage('DIARY_003'), icon: RupIconName.alert); + return; + } + setState(() => _busy = true); + try { + final invitation = await ref.read(diaryProvider.notifier).previewInvitation(code); + if (mounted) Navigator.of(context).pop(_Found(invitation)); + } catch (e) { + if (!isReportedApiFailure(e)) rethrow; + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + TextStyle ui(double size, FontWeight weight, Color color) => + textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: AppTypography.normalHeight); + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(top: AppSpacing.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 1) QR 스캔 — 가장 빠른 방법 + Pressable( + pressedScale: 1, + pressedBrightness: .97, + onTap: () => Navigator.of(context).pop(const _ChoseScan()), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.primary50, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: AppColors.primary100, width: 1.5), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppSpacing.md), + boxShadow: AppShadows.sm, + ), + alignment: Alignment.center, + child: const RupIcon(RupIconName.qrcode, color: AppColors.primary), + ), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('QR 코드 스캔', style: ui(15, FontWeight.w700, AppColors.ink)), + const SizedBox(height: 2), + Text('초대 QR을 비추면 바로 확인돼요', style: ui(12, FontWeight.w400, AppColors.muted)), + ], + ), + ), + const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted), + ], + ), + ), + ), + // 구분선 + "또는" (시안 .rup-orline) + Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Row( + children: [ + const Expanded(child: Divider(height: 1, thickness: 1, color: AppColors.line2)), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text('또는', style: ui(12, FontWeight.w700, AppColors.muted)), + ), + const Expanded(child: Divider(height: 1, thickness: 1, color: AppColors.line2)), + ], + ), + ), + // 2) 초대 코드 직접 입력 + RupField( + label: '초대 코드 입력', + child: RupTextField( + controller: _code, + hintText: 'RUP-8F3K2Q', + maxLength: _kCodeMaxLength, + textInputAction: TextInputAction.done, + textAlign: TextAlign.center, + textStyle: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 19, + letterSpacing: 19 * .06, + height: AppTypography.displayNormalHeight, + ), + inputFormatters: [_UpperCaseFormatter()], + onSubmitted: (_) => unawaited(_submit()), + ), + ), + const SizedBox(height: AppSpacing.md), + RupButton( + label: '참여하기', + large: true, + dimmed: _code.text.trim().isEmpty || _busy, + dimmedOpacity: .45, + onPressed: () => unawaited(_submit()), + ), + ], + ), + ), + ); + } +} + +/// 초대 코드는 대문자로 받는다 (시안 toUpperCase) +class _UpperCaseFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) => + newValue.copyWith(text: newValue.text.toUpperCase()); +} diff --git a/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart index ec74d4d..f492d5b 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_post_card.dart @@ -37,17 +37,21 @@ TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color, TextStyle _display(double size, {FontWeight weight = FontWeight.w700, Color color = AppColors.ink}) => TextStyle(fontFamily: AppFonts.display, fontSize: size, fontWeight: weight, color: color, height: AppTypography.displayNormalHeight); -/// 글쓴이 아바타 — 지금은 나 하나 +/// 글쓴이 아바타 — 남이면 그 사람 사진, 나면(null) 내 프로필 사진 class _AuthorAvatar extends ConsumerWidget { - const _AuthorAvatar({required this.size}); + const _AuthorAvatar({required this.size, this.author}); final double size; + final DiaryPerson? author; @override Widget build(BuildContext context, WidgetRef ref) => - PersonAvatar(size: size, photoPath: ref.watch(profileProvider).photoPath); + PersonAvatar(size: size, photoPath: author == null ? ref.watch(profileProvider).photoPath : author!.photoPath); } +/// 글쓴이 이름 — 남이면 그 사람 닉네임, 나면 내 닉네임 +String _authorName(WidgetRef ref, DiaryPerson? author) => author?.name ?? ref.watch(userNicknameProvider); + /// 글쓴이 · 날짜 · 기분 · ⋮ class _PostMeta extends ConsumerWidget { const _PostMeta({required this.post, required this.onMenu, this.avatar = 28, this.nameSize = 13}); @@ -62,20 +66,21 @@ class _PostMeta extends ConsumerWidget { final mood = post.mood; return Row( children: [ - _AuthorAvatar(size: avatar), + _AuthorAvatar(size: avatar, author: post.author), SizedBox(width: avatar > 30 ? 10 : 9), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(ref.watch(userNicknameProvider), style: _display(nameSize)), + Text(_authorName(ref, post.author), style: _display(nameSize)), const SizedBox(height: 1), Text(relativeDayLabel(post.date, DateTime.now()), style: _ui(context, 11, FontWeight.w400, AppColors.muted)), ], ), ), - if (mood != null) ...[MoodIcon(mood: mood, size: 22), const SizedBox(width: 9)], - _MoreButton(onTap: onMenu), + if (mood != null) ...[MoodIcon(mood: mood, size: 22), if (post.isMine) const SizedBox(width: 9)], + // 수정·삭제는 쓴 사람만 + if (post.isMine) _MoreButton(onTap: onMenu), ], ); } @@ -323,7 +328,6 @@ class _CommentsState extends ConsumerState<_Comments> { @override Widget build(BuildContext context) { final comments = ref.watch(diaryBookProvider.select((b) => b.commentsOf(widget.postId))); - final name = ref.watch(userNicknameProvider); final muted = _ui(context, 12, FontWeight.w600, AppColors.muted); return Container( @@ -343,7 +347,7 @@ class _CommentsState extends ConsumerState<_Comments> { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const _AuthorAvatar(size: 30), + _AuthorAvatar(size: 30, author: c.author), const SizedBox(width: 9), Expanded( child: Column( @@ -351,8 +355,8 @@ class _CommentsState extends ConsumerState<_Comments> { children: [ Row( children: [ - Expanded(child: Text(name, style: _display(12.5))), - if (_editingId != c.id) ...[ + Expanded(child: Text(_authorName(ref, c.author), style: _display(12.5))), + if (c.isMine && _editingId != c.id) ...[ _SmallAction(label: '수정', style: muted, onTap: () => setState(() { _editingId = c.id; _edit.text = c.text; @@ -558,29 +562,30 @@ class PhotoPostTile extends StatelessWidget { fit: StackFit.expand, children: [ ColoredBox(color: AppColors.primary50, child: PetPhotoImage(path: post.photoPath!)), - Positioned( - top: 7, - right: 7, - child: Semantics( - button: true, - label: '기록 관리', - excludeSemantics: true, - child: GestureDetector( - onTap: onMenu, - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: .9), - shape: BoxShape.circle, - boxShadow: const [BoxShadow(color: Color(0x2E000000), blurRadius: 4, offset: Offset(0, 1))], + if (post.isMine) + Positioned( + top: 7, + right: 7, + child: Semantics( + button: true, + label: '기록 관리', + excludeSemantics: true, + child: GestureDetector( + onTap: onMenu, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: .9), + shape: BoxShape.circle, + boxShadow: const [BoxShadow(color: Color(0x2E000000), blurRadius: 4, offset: Offset(0, 1))], + ), + alignment: Alignment.center, + child: const RupIcon(RupIconName.moreV, size: 17, color: AppColors.ink2), ), - alignment: Alignment.center, - child: const RupIcon(RupIconName.moreV, size: 17, color: AppColors.ink2), ), ), ), - ), ], ), ), diff --git a/flutter/lib/features/diary/presentation/widgets/diary_sharing_sections.dart b/flutter/lib/features/diary/presentation/widgets/diary_sharing_sections.dart new file mode 100644 index 0000000..d59e104 --- /dev/null +++ b/flutter/lib/features/diary/presentation/widgets/diary_sharing_sections.dart @@ -0,0 +1,467 @@ +import 'dart:async'; + +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: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/features/auth/presentation/providers/user_provider.dart'; +import 'package:tma_app/features/diary/domain/diary.dart'; +import 'package:tma_app/features/diary/domain/diary_invitation.dart'; +import 'package:tma_app/features/mypage/data/profile_repository.dart'; +import 'package:tma_app/shared/widgets/blurred_modal.dart'; +import 'package:tma_app/shared/widgets/dashed_border_box.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/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_toast.dart'; + +const double _kNormal = AppTypography.normalHeight; +const double _kMemberAvatar = 40; +const double _kInviteQr = 150; +const double _kZoomQr = 230; + +TextStyle _ui(BuildContext context, double size, FontWeight weight, Color color) => + Theme.of(context).textTheme.bodyMedium!.copyWith(fontSize: size, fontWeight: weight, color: color, height: _kNormal); + +/// 섹션 제목 (시안 .rup-section-title h2) — 옆에 작은 보조 글 +class SharingSectionTitle extends StatelessWidget { + const SharingSectionTitle({super.key, required this.title, this.count}); + + final String title; + final String? count; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + title, + style: textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, height: AppTypography.displayNormalHeight), + ), + if (count != null) ...[ + const SizedBox(width: 9), + Text(count!, style: _ui(context, 13, FontWeight.w700, AppColors.muted)), + ], + ], + ), + ); + } +} + +/// 멤버 사진 — 나면 내 프로필 사진 +class MemberAvatar extends ConsumerWidget { + const MemberAvatar({super.key, required this.person, required this.size}); + + final DiaryPerson person; + final double size; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final photo = person.isMe ? ref.watch(profileProvider).photoPath : person.photoPath; + return PersonAvatar(size: size, photoPath: photo); + } +} + +/// 멤버 이름 — 나면 내 닉네임 +String memberName(WidgetRef ref, DiaryPerson person) => person.isMe ? ref.watch(userNicknameProvider) : person.name; + +/// 멤버 목록 — 주인 표시, 주인이 보면 "내보내기", 자리가 남으면 초대 줄 +class DiaryMembersCard extends ConsumerWidget { + const DiaryMembersCard({super.key, required this.diary, required this.onRemove, required this.onInvite}); + + final Diary diary; + final ValueChanged onRemove; + + /// 초대 줄을 누르면 아래 "멤버 초대하기" 로 내려간다 + final VoidCallback onInvite; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final rows = [ + for (final m in diary.members) + _MemberRow( + avatar: MemberAvatar(person: m, size: _kMemberAvatar), + name: '${memberName(ref, m)}${m.isMe ? ' (나)' : ''}', + trailing: m.isOwner + ? const _OwnerBadge() + : diary.isOwner + ? _KickButton(name: memberName(ref, m), onTap: () => onRemove(m)) + : null, + ), + if (diary.isOwner && !diary.membersFull) + Pressable( + pressedScale: 1, + pressedOpacity: .7, + onTap: onInvite, + child: _MemberRow( + avatar: const DashedBorderBox( + color: AppColors.line, + strokeWidth: 2, + radius: _kMemberAvatar / 2, + child: SizedBox.square( + dimension: _kMemberAvatar - 4, + child: Center(child: RupIcon(RupIconName.plus, size: 20, stroke: 2.2, color: AppColors.muted)), + ), + ), + name: '${kMaxDiaryMembers - diary.memberCount}명 더 초대할 수 있어요', + muted: true, + trailing: const RupIcon(RupIconName.chevR, size: 17, color: AppColors.muted), + ), + ), + ]; + return RupCard( + child: Column( + children: [ + for (final (i, row) in rows.indexed) + DecoratedBox( + decoration: BoxDecoration(border: i == 0 ? null : const Border(top: BorderSide(color: AppColors.line2))), + child: row, + ), + ], + ), + ); + } +} + +class _MemberRow extends StatelessWidget { + const _MemberRow({required this.avatar, required this.name, this.trailing, this.muted = false}); + + final Widget avatar; + final String name; + final Widget? trailing; + + /// 초대 줄 — 흐린 작은 글 + final bool muted; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13), + child: Row( + children: [ + avatar, + const SizedBox(width: AppSpacing.md), + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: muted + ? _ui(context, 13.5, FontWeight.w600, AppColors.muted) + : _ui(context, 15, FontWeight.w700, AppColors.ink), + ), + ), + if (trailing != null) ...[const SizedBox(width: AppSpacing.sm), trailing!], + ], + ), + ); + } +} + +/// "내보내기" — 작은 흰 버튼 (시안 .rup-btn--ghost, 7/13 · 12.5) +class _KickButton extends StatelessWidget { + const _KickButton({required this.name, required this.onTap}); + + final String name; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Pressable( + onTap: onTap, + semanticLabel: '$name님 내보내기', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(AppRadius.pill), + border: Border.all(color: AppColors.line, width: 1.5), + ), + child: Text('내보내기', style: AppTypography.button(12.5, color: AppColors.ink)), + ), + ); + } +} + +/// "주인" 표시 (시안: primary-50 바탕, primary-100 테두리) +class _OwnerBadge extends StatelessWidget { + const _OwnerBadge(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + decoration: BoxDecoration( + color: AppColors.primary50, + borderRadius: BorderRadius.circular(AppSpacing.sm), + border: Border.all(color: AppColors.primary100), + ), + child: Text('주인', style: _ui(context, 11.5, FontWeight.w700, AppColors.primary600)), + ); + } +} + +/// 참여한 다이어리 — 누가 만든 다이어리인지 (주인이 아닐 때 개인/공유 선택 대신) +class JoinedDiaryCard extends ConsumerWidget { + const JoinedDiaryCard({super.key, required this.diary}); + + final Diary diary; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final owner = diary.owner; + return RupCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration(color: AppColors.accent100, borderRadius: BorderRadius.circular(AppSpacing.md)), + alignment: Alignment.center, + child: const RupIcon(RupIconName.users, size: 21, stroke: 2, color: AppColors.accent), + ), + const SizedBox(width: 11), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('공유 다이어리', style: _ui(context, 14.5, FontWeight.w700, AppColors.ink)), + const SizedBox(height: 1), + Text( + '${owner == null ? kUnknownPersonName : memberName(ref, owner)}님이 만든 다이어리예요', + style: _ui(context, 12, FontWeight.w400, AppColors.muted), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// 초대 코드 복사 — 클립보드에 넣고 알린다 +Future copyInviteCode(WidgetRef ref, String code) async { + final toast = ref.read(toastProvider.notifier); + await Clipboard.setData(ClipboardData(text: code)); + toast.show('초대 코드를 복사했어요', icon: RupIconName.copy); +} + +/// 초대 QR — 가운데 다이어리 표시 +class _InviteQr extends StatelessWidget { + const _InviteQr({required this.code, required this.size, required this.badge, required this.padding}); + + final String code; + final double size; + final double badge; + final double padding; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(padding), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(padding + 6), + border: Border.all(color: AppColors.line), + boxShadow: AppShadows.sm, + ), + child: Stack( + alignment: Alignment.center, + children: [ + QrImageView( + data: diaryInviteLink(code), + size: size, + padding: EdgeInsets.zero, + errorCorrectionLevel: QrErrorCorrectLevel.H, + semanticsLabel: '다이어리 초대 QR 코드', + ), + Container( + width: badge, + height: badge, + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(badge * .32), + boxShadow: [BoxShadow(color: Colors.white, spreadRadius: badge / 10)], + ), + alignment: Alignment.center, + child: RupGlyphIcon(RupGlyph.diary, size: badge / 2, color: Colors.white), + ), + ], + ), + ); + } +} + +/// 멤버 초대하기 — QR(누르면 커진다) + 초대 코드 복사 + 안내 +class DiaryInviteSection extends ConsumerWidget { + const DiaryInviteSection({super.key, required this.diary, required this.code}); + + final Diary diary; + final String code; + + Future _zoom(BuildContext context, WidgetRef ref) { + return showBlurredDialog( + context, + builder: (dialogContext) => Dialog( + backgroundColor: Colors.transparent, + elevation: 0, + insetPadding: const EdgeInsets.all(AppSpacing.xl), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 330), + child: RupCard( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('초대 QR', style: _ui(dialogContext, 17, FontWeight.w800, AppColors.ink)), + const SizedBox(height: AppSpacing.xs), + Text.rich( + TextSpan(children: [ + const TextSpan(text: '상대방이 rup 카메라로 스캔하면\n'), + TextSpan(text: diary.name, style: const TextStyle(fontWeight: FontWeight.w700, color: AppColors.ink)), + const TextSpan(text: '에 함께 기록해요.'), + ]), + textAlign: TextAlign.center, + style: _ui(dialogContext, 13, FontWeight.w400, AppColors.ink2).copyWith(height: 1.55), + ), + const SizedBox(height: 18), + _InviteQr(code: code, size: _kZoomQr, badge: 56, padding: 18), + const SizedBox(height: 18), + Text( + code, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 24, + letterSpacing: 24 * .08, + height: AppTypography.displayNormalHeight, + color: AppColors.ink, + ), + ), + const SizedBox(height: 16), + RupButton( + label: '초대 코드 복사', + large: true, + variant: RupButtonVariant.soft, + leading: const RupIcon(RupIconName.copy, size: 17, stroke: 2.1, color: AppColors.primary600), + onPressed: () => unawaited(copyInviteCode(ref, code)), + ), + ], + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SharingSectionTitle(title: '멤버 초대하기'), + Padding( + padding: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.xs, AppSpacing.xs, AppSpacing.lg), + child: Text( + 'QR코드 혹은 초대코드로 함께 기록할 사람을 불러보세요.', + style: _ui(context, 13, FontWeight.w400, AppColors.muted).copyWith(height: 1.55), + ), + ), + Center( + child: Semantics( + button: true, + label: '초대 QR 크게 보기', + child: GestureDetector( + onTap: () => unawaited(_zoom(context, ref)), + child: _InviteQr(code: code, size: _kInviteQr, badge: 40, padding: 14), + ), + ), + ), + const SizedBox(height: 9), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const RupIcon(RupIconName.search, size: 13, stroke: 2, color: AppColors.muted), + const SizedBox(width: AppSpacing.xs), + Text('탭하면 QR이 커져요', style: _ui(context, 11.5, FontWeight.w400, AppColors.muted)), + ], + ), + const SizedBox(height: 14), + Semantics( + button: true, + label: '초대 코드 복사', + child: GestureDetector( + onTap: () => unawaited(copyInviteCode(ref, code)), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13), + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.line, width: 1.5), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('초대 코드', style: _ui(context, 12, FontWeight.w700, AppColors.muted)), + const SizedBox(height: 2), + Text( + code, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 21, + letterSpacing: 21 * .06, + height: AppTypography.displayNormalHeight, + color: AppColors.ink, + ), + ), + ], + ), + ), + const RupIcon(RupIconName.copy, size: 19, stroke: 2, color: AppColors.primary), + ], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(AppSpacing.xs, AppSpacing.lg, AppSpacing.xs, 22), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 1), + child: RupIcon(RupIconName.alert, size: 15, stroke: 2.2, color: AppColors.primary), + ), + const SizedBox(width: AppSpacing.sm), + Expanded( + child: Text.rich( + const TextSpan(children: [ + TextSpan(text: '받은 사람은 '), + TextSpan(text: '다이어리 추가 → 공유 다이어리 참여하기', style: TextStyle(fontWeight: FontWeight.w700)), + TextSpan(text: '에서 코드를 입력하면 함께 기록할 수 있어요.'), + ]), + style: _ui(context, 12.5, FontWeight.w400, AppColors.ink2).copyWith(height: 1.55), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart index ffe6381..3cccc65 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_sheets.dart @@ -10,6 +10,7 @@ import 'package:tma_app/core/theme/app_typography.dart'; import 'package:tma_app/features/diary/domain/diary.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/diary/presentation/widgets/diary_join.dart'; import 'package:tma_app/features/pet/data/pet_photo_store.dart'; import 'package:tma_app/features/pet/domain/pet.dart'; import 'package:tma_app/features/pet/domain/pet_target.dart'; @@ -25,9 +26,6 @@ import 'package:tma_app/shared/widgets/rup_toast.dart'; const double _kNormal = AppTypography.normalHeight; -/// 공유 다이어리(초대·참여·멤버)는 서버가 필요해 아직 열지 않는다 -const String kSharedDiarySoon = '공유 다이어리는 곧 열릴 예정이에요'; - /// 표지 편집 칸 높이 (수정 시트) — 만들 때는 정사각형 const double _kEditCoverHeight = 150; const double _kCoverRadius = 10; @@ -135,7 +133,7 @@ Future showAddDiarySheet(BuildContext context, WidgetRef ref) { description: '초대 코드로 함께 쓰는 다이어리에 참여해요', onTap: () { Navigator.of(sheetContext).pop(); - ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users); + unawaited(startJoiningDiary(context, ref)); }, ), ], @@ -167,6 +165,7 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> { late final List _targets = [...?widget.editing?.targetIds]; late Color _ribbon = widget.editing?.ribbon ?? kRibbonColors.first; late String? _cover = widget.editing?.coverPath; + bool _shared = false; bool _saving = false; bool get _editing => widget.editing != null; @@ -203,8 +202,14 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> { targetIds: List.of(_targets), ribbon: _ribbon, coverPath: _cover, + shared: _shared, ); - toast.show(created == null ? '다이어리는 최대 $kMaxDiaries개까지예요' : '새 다이어리를 만들었어요', + toast.show( + created == null + ? '다이어리는 최대 $kMaxDiaries개까지예요' + : _shared + ? '공유 다이어리를 만들었어요' + : '새 다이어리를 만들었어요', icon: created == null ? RupIconName.alert : RupIconName.check); } else { await notifier.updateDiary(editing.copyWith( @@ -270,14 +275,18 @@ class _DiaryFormState extends ConsumerState<_DiaryForm> { RupSegmented( options: const [(false, '개인 다이어리'), (true, '공유 다이어리')], icons: const [RupIconName.user, RupIconName.users], - value: false, + value: _shared, onChanged: (shared) { - if (shared) ref.read(toastProvider.notifier).show(kSharedDiarySoon, icon: RupIconName.users); + if (shared && !ref.read(diaryProvider.notifier).canShare) { + ref.read(toastProvider.notifier).show(kSharedDiaryLocalOnly, icon: RupIconName.users); + return; + } + setState(() => _shared = shared); }, ), const SizedBox(height: 7), Text( - '나만 기록하는 다이어리예요. 나중에 공유로 바꿀 수 있어요.', + _shared ? '만든 뒤 가족·지인을 초대해 함께 기록할 수 있어요.' : '나만 기록하는 다이어리예요. 나중에 공유로 바꿀 수 있어요.', style: _ui(context, 11.5, FontWeight.w400, AppColors.muted).copyWith(height: 1.45), ), ], diff --git a/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart index 8f243db..38a00f2 100644 --- a/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart +++ b/flutter/lib/features/diary/presentation/widgets/diary_shelf.dart @@ -120,7 +120,7 @@ class _HomeDiaryTile extends ConsumerWidget { 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); + final who = diary == null ? '' : (diary.firstTargetName(names) ?? diary.name); final photo = post.photoPath; final textTheme = Theme.of(context).textTheme; diff --git a/flutter/lib/features/notifications/domain/app_notice.dart b/flutter/lib/features/notifications/domain/app_notice.dart index 84816b3..e7c2e51 100644 --- a/flutter/lib/features/notifications/domain/app_notice.dart +++ b/flutter/lib/features/notifications/domain/app_notice.dart @@ -22,6 +22,7 @@ class AppNotice { required this.at, required this.text, this.who, + this.whoPhotoPath, }); final String id; @@ -35,6 +36,9 @@ class AppNotice { /// 새 글·댓글을 남긴 사람 이름 final String? who; + + /// 그 사람의 프로필 사진 + final String? whoPhotoPath; } /// 알림을 묶는 날 — 오늘 · 어제 · 최근 7일 diff --git a/flutter/lib/features/notifications/presentation/pages/notifications_page.dart b/flutter/lib/features/notifications/presentation/pages/notifications_page.dart index c8f7920..441e280 100644 --- a/flutter/lib/features/notifications/presentation/pages/notifications_page.dart +++ b/flutter/lib/features/notifications/presentation/pages/notifications_page.dart @@ -3,11 +3,13 @@ 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/api/api_guard.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/diary/presentation/providers/diary_providers.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'; @@ -37,6 +39,10 @@ class _NotificationsPageState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) unawaited(ref.read(noticeSeenProvider.notifier).markSeen(ref.read(clockProvider)())); }); + // 공유 다이어리가 있으면 다른 멤버의 새 글·댓글을 다시 읽는다 + if (ref.read(diaryBookProvider).diaries.any((d) => d.shared)) { + unawaited(succeeded(ref.read(diaryProvider.notifier).refresh())); + } } @override @@ -147,7 +153,7 @@ class _NoticeRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (n.who != null) - const PersonAvatar(size: _kAvatar) + PersonAvatar(size: _kAvatar, photoPath: n.whoPhotoPath) else Container( width: _kAvatar, diff --git a/flutter/lib/features/notifications/presentation/providers/notice_providers.dart b/flutter/lib/features/notifications/presentation/providers/notice_providers.dart index f4923a2..5c634d5 100644 --- a/flutter/lib/features/notifications/presentation/providers/notice_providers.dart +++ b/flutter/lib/features/notifications/presentation/providers/notice_providers.dart @@ -31,8 +31,11 @@ class NoticeSeenNotifier extends Notifier { final noticeSeenProvider = NotifierProvider(NoticeSeenNotifier.new); +/// 알림으로 보여 줄 기간 — 알림 화면은 최근 7일까지 묶어 보여 준다 ([NoticeDay]) +const Duration _kNoticeWindow = Duration(days: 7); + /// 모든 다이어리의 알림 — 최근 것이 위로. -/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다. +/// 기록 알림은 기기에서 만들고, 공유 다이어리의 새 글·댓글은 서버에서 읽은 기록·댓글 중 남이 쓴 것으로 만든다. final noticesProvider = Provider>((ref) => _watchNotices(ref)); /// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 ([petListProvider] 주석 참고) @@ -50,7 +53,7 @@ List _watchNotices(Ref ref) { if (settings[AppSetting.scheduleNotification] == false) return const []; final book = ref.watch(diaryProvider).value ?? DiaryBook.empty; - final notices = []; + final notices = [...diaryActivityNotices(book, watchShelf(ref), now)]; if (now.hour >= kDiaryReminderHour) { final at = DateTime(now.year, now.month, now.day, kDiaryReminderHour); // 추억 보관함으로 간 다이어리는 알리지 않는다 @@ -69,6 +72,49 @@ List _watchNotices(Ref ref) { return notices..sort((a, b) => b.at.compareTo(a.at)); } +/// 공유 다이어리에서 다른 멤버가 남긴 새 글 · 내 기록에 달린 댓글 (최근 7일, 다이어리별 알림 설정을 따른다) +List diaryActivityNotices(DiaryBook book, List shelf, DateTime now) { + final since = now.subtract(_kNoticeWindow); + final shared = {for (final d in shelf.where((d) => d.shared)) d.id: d}; + final notices = []; + final myPosts = {}; + for (final p in book.posts) { + final diary = shared[p.diaryId]; + if (diary == null) continue; + if (p.isMine) { + myPosts[p.id] = p; + continue; + } + if (!diary.notifies(DiaryNotice.newPost) || p.createdAt.isBefore(since)) continue; + notices.add(AppNotice( + id: 'post-${p.id}', + type: NoticeType.newPost, + diaryId: diary.id, + diaryName: diary.name, + at: p.createdAt, + text: p.text, + who: p.author?.name, + whoPhotoPath: p.author?.photoPath, + )); + } + for (final c in book.comments) { + final post = myPosts[c.postId]; + final diary = post == null ? null : shared[post.diaryId]; + if (diary == null || c.isMine || !diary.notifies(DiaryNotice.newComment) || c.createdAt.isBefore(since)) continue; + notices.add(AppNotice( + id: 'comment-${c.id}', + type: NoticeType.newComment, + diaryId: diary.id, + diaryName: diary.name, + at: c.createdAt, + text: c.text, + who: c.author?.name, + whoPhotoPath: c.author?.photoPath, + )); + } + return notices; +} + /// 아직 보지 않은 알림이 있는지 — 홈 종에 점을 찍는다 final hasUnreadNoticeProvider = Provider((ref) { final seen = ref.watch(noticeSeenProvider); diff --git a/flutter/lib/features/pet_card/presentation/pages/card_scanner_page.dart b/flutter/lib/features/pet_card/presentation/pages/card_scanner_page.dart index 5c968ae..b84fbdf 100644 --- a/flutter/lib/features/pet_card/presentation/pages/card_scanner_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/card_scanner_page.dart @@ -1,463 +1,30 @@ -import 'dart:async'; -import 'dart:math' as math; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; -import 'package:tma_app/core/api/api_guard.dart'; import 'package:tma_app/core/api/error_lexicon.dart'; -import 'package:tma_app/core/constants/app_colors.dart'; -import 'package:tma_app/core/theme/app_typography.dart'; import 'package:tma_app/features/pet_card/domain/received_card.dart'; import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.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_toast.dart'; +import 'package:tma_app/shared/widgets/qr_scanner_page.dart'; -// 시안 .rup-scanner 치수·색 -const Color _kScannerBg = Color(0xFF14110E); -const Color _kDim = Color(0x99120F0C); // rgba(18,15,12,.6) -const Color _kFoundGreen = Color(0xFF4ED08A); -const double _kFrame = 244; -const double _kFrameRadius = 22; -const double _kCorner = 36; -const double _kCornerStroke = 4; -const double _kCloseSize = 38; -const double _kCheckSize = 70; - -/// 스캔 줄이 위아래로 한 번 오가는 시간 (시안 2.3s) -const Duration _kSweep = Duration(milliseconds: 1150); - -/// 명함을 찾은 뒤 체크 표시를 보여 주는 최소 시간 (시안 820ms) -const Duration _kFoundHold = Duration(milliseconds: 820); - -/// 같은 QR 을 실패한 뒤 다시 읽기까지 기다리는 시간 — 같은 알림이 계속 뜨지 않게 -const Duration _kRetryGap = Duration(seconds: 3); - -/// 스캐너를 닫은 결과 -sealed class ScanOutcome { - const ScanOutcome(); -} - -/// QR 로 명함을 찾았다 -final class ScanFound extends ScanOutcome { - const ScanFound(this.card); - - final SharedCard card; -} - -/// "명함 코드 입력하기"를 눌렀다 -final class ScanTypeCode extends ScanOutcome { - const ScanTypeCode(); -} - -/// QR 을 읽어 알려 주는 카메라 화면 -typedef ScannerCamera = Widget Function(ValueChanged onCode); - -/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라) -final scannerCameraProvider = Provider((ref) => null); +/// 명함 받기 스캐너 문구 +const QrScanCopy _kCopy = QrScanCopy( + title: '명함 받기', + hint: '상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.', + foundHint: '명함을 찾았어요! 정보 불러오는 중…', + manualLabel: '명함 코드 입력하기', + unavailableHint: '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 명함 코드를 입력해 주세요.', +); /// 명함 받기 스캐너 — 상대 명함의 QR 을 비추면 명함을 불러온다 -class CardScannerPage extends ConsumerStatefulWidget { +class CardScannerPage extends StatelessWidget { const CardScannerPage({super.key}); - @override - ConsumerState createState() => _CardScannerPageState(); -} - -class _CardScannerPageState extends ConsumerState { - late final ScannerCamera? _fakeCamera = ref.read(scannerCameraProvider); - MobileScannerController? _controller; - bool _found = false; - bool _busy = false; - String? _lastRaw; - DateTime _lastAt = DateTime.fromMillisecondsSinceEpoch(0); - - @override - void initState() { - super.initState(); - if (_fakeCamera == null) _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]); - } - - @override - void dispose() { - unawaited(_controller?.dispose()); - // 다른 화면은 상태 표시줄 모양을 따로 정하지 않는다 — 어두운 글자(앱 기본)로 되돌린다 - SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); - super.dispose(); - } - - void _onDetect(BarcodeCapture capture) { - final raw = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull; - if (raw != null) unawaited(_onCode(raw)); - } - - Future _onCode(String raw) async { - if (_busy) return; - final now = DateTime.now(); - if (raw == _lastRaw && now.difference(_lastAt) < _kRetryGap) return; - _lastRaw = raw; - _lastAt = now; - - final code = parseCardCode(raw); - if (code == null) { - // 펫 명함 QR 이 아니다 — 서버가 못 찾았을 때와 같은 안내 - ref.read(toastProvider.notifier).show(ErrorLexicon.getMessage('CARD_002'), icon: RupIconName.alert); - return; - } - _busy = true; - setState(() => _found = true); - final shown = Stopwatch()..start(); - try { - final card = await ref.read(receivedCardsProvider.notifier).preview(code); - final rest = _kFoundHold - shown.elapsed; - if (rest > Duration.zero) await Future.delayed(rest); - if (mounted) context.pop(ScanFound(card)); - } catch (e) { - if (!isReportedApiFailure(e)) rethrow; - // 실패 알림은 인터셉터가 띄웠다 — 다시 비출 수 있게 되돌린다 - _lastAt = DateTime.now(); - if (mounted) { - setState(() { - _found = false; - _busy = false; - }); - } - } - } - - Widget _camera() { - final camera = _fakeCamera; - if (camera != null) return camera(_onCode); - return MobileScanner( - controller: _controller, - onDetect: _onDetect, - // 카메라를 켜는 동안·쓸 수 없을 때는 시안의 어두운 바탕 (안내는 아래 문구가 맡는다) - placeholderBuilder: (context) => const _Feed(), - errorBuilder: (context, error) => const _Feed(), - ); - } - @override Widget build(BuildContext context) { - final top = MediaQuery.paddingOf(context).top; - final topBarHeight = top + 8 + _kCloseSize; - return AnnotatedRegion( - value: SystemUiOverlayStyle.light, - child: Scaffold( - backgroundColor: _kScannerBg, - body: Stack( - children: [ - Positioned.fill(child: _camera()), - Positioned.fill( - child: Column( - children: [ - SizedBox(height: topBarHeight), - Expanded( - child: Center( - child: _Frame(found: _found), - ), - ), - _Bottom( - found: _found, - camera: _controller, - onManual: () => context.pop(const ScanTypeCode()), - ), - ], - ), - ), - // 조준 프레임 바깥을 어둡게 덮는 막이 위 막대를 가리지 않게 맨 위에 둔다 - Positioned( - top: top + 8, - left: 18, - right: 18, - child: _TopBar(onClose: () => context.pop()), - ), - ], - ), - ), - ); - } -} - -/// 카메라 화면이 아직 없을 때의 바탕 (시안 .rup-scanner__feed) -class _Feed extends StatelessWidget { - const _Feed(); - - @override - Widget build(BuildContext context) { - return const DecoratedBox( - decoration: BoxDecoration( - gradient: RadialGradient( - center: Alignment(0, -.2), - radius: 1.1, - colors: [Color(0xFF3A332B), Color(0xFF221D17), _kScannerBg], - stops: [0, .56, 1], - ), - ), - ); - } -} - -class _TopBar extends StatelessWidget { - const _TopBar({required this.onClose}); - - final VoidCallback onClose; - - @override - Widget build(BuildContext context) { - return Row( - children: [ - Semantics( - button: true, - label: '닫기', - child: Pressable( - pressedScale: .9, - onTap: onClose, - child: Container( - width: _kCloseSize, - height: _kCloseSize, - decoration: const BoxDecoration(color: Color(0x24FFFFFF), shape: BoxShape.circle), - alignment: Alignment.center, - child: const RupIcon(RupIconName.x, size: 20, stroke: 2.4, color: Colors.white), - ), - ), - ), - const Expanded( - child: Text( - '명함 받기', - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: AppFonts.display, - fontSize: 16, - fontWeight: FontWeight.w800, - height: AppTypography.displayNormalHeight, - color: Colors.white, - ), - ), - ), - const SizedBox(width: _kCloseSize), - ], - ); - } -} - -/// 조준 프레임 — 바깥은 어둡게, 모서리 표시 + 스캔 줄, 찾으면 초록 체크 -class _Frame extends StatefulWidget { - const _Frame({required this.found}); - - final bool found; - - @override - State<_Frame> createState() => _FrameState(); -} - -class _FrameState extends State<_Frame> with SingleTickerProviderStateMixin { - late final AnimationController _sweep = AnimationController(vsync: this, duration: _kSweep) - ..repeat(reverse: true); - - @override - void dispose() { - _sweep.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final found = widget.found; - return SizedBox.square( - dimension: _kFrame, - child: CustomPaint( - painter: const _DimOutside(), - child: Stack( - clipBehavior: Clip.none, - children: [ - Positioned.fill( - child: TweenAnimationBuilder( - tween: ColorTween(end: found ? _kFoundGreen : AppColors.primary), - duration: const Duration(milliseconds: 300), - builder: (context, color, _) => CustomPaint(painter: _Corners(color ?? AppColors.primary)), - ), - ), - if (!found) - AnimatedBuilder( - animation: _sweep, - builder: (context, child) { - final t = Curves.easeInOut.transform(_sweep.value); - return Positioned(left: 10, right: 10, top: 12 + (_kFrame - 28) * t, child: child!); - }, - child: Container( - height: 2, - decoration: const BoxDecoration( - gradient: LinearGradient(colors: [Color(0x00FF7500), AppColors.primary, Color(0x00FF7500)]), - boxShadow: [BoxShadow(color: Color(0xB3FF7500), blurRadius: 14, spreadRadius: 2)], - ), - ), - ), - Center( - child: AnimatedOpacity( - duration: const Duration(milliseconds: 250), - opacity: found ? 1 : 0, - child: AnimatedScale( - duration: const Duration(milliseconds: 400), - curve: const Cubic(.34, 1.5, .5, 1), - scale: found ? 1 : .5, - child: Container( - width: _kCheckSize, - height: _kCheckSize, - decoration: const BoxDecoration( - color: _kFoundGreen, - shape: BoxShape.circle, - boxShadow: [BoxShadow(color: Color(0x804ED08A), offset: Offset(0, 10), blurRadius: 30)], - ), - alignment: Alignment.center, - child: const RupIcon(RupIconName.check, size: 36, stroke: 3, color: Colors.white), - ), - ), - ), - ), - ], - ), - ), - ); - } -} - -/// 프레임 바깥 화면 전체를 어둡게 (시안 box-shadow 0 0 0 2000px) -class _DimOutside extends CustomPainter { - const _DimOutside(); - - @override - void paint(Canvas canvas, Size size) { - const reach = 4000.0; - final outside = Path() - ..fillType = PathFillType.evenOdd - ..addRect(Rect.fromLTRB(-reach, -reach, size.width + reach, size.height + reach)) - ..addRRect(RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kFrameRadius))); - canvas.drawPath(outside, Paint()..color = _kDim); - } - - @override - bool shouldRepaint(_DimOutside oldDelegate) => false; -} - -/// 네 모서리 표시 — 프레임 테두리를 따라 굵기 4, 길이 36 -class _Corners extends CustomPainter { - const _Corners(this.color); - - final Color color; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = _kCornerStroke - ..strokeCap = StrokeCap.butt; - const r = 16.0; // 시안 바깥 반경 18 − 굵기 절반 - const reach = _kCorner - _kCornerStroke / 2; - final w = size.width; - final h = size.height; - final rect = Rect.fromCircle(center: Offset.zero, radius: r); - final corners = <(Offset, double)>[ - (Offset.zero, math.pi), // 왼쪽 위 - (Offset(w, 0), -math.pi / 2), // 오른쪽 위 - (Offset(w, h), 0), // 오른쪽 아래 - (Offset(0, h), math.pi / 2), // 왼쪽 아래 - ]; - for (final (corner, start) in corners) { - // 모서리를 원점으로 옮겨 같은 모양(가로·세로 직선 + 사분원)을 돌려 그린다 - canvas - ..save() - ..translate(corner.dx, corner.dy) - ..rotate(start - math.pi); - final path = Path() - ..moveTo(0, reach) - ..lineTo(0, r) - ..arcTo(rect.shift(const Offset(r, r)), math.pi, math.pi / 2, false) - ..lineTo(reach, 0); - canvas - ..drawPath(path, paint) - ..restore(); - } - } - - @override - bool shouldRepaint(_Corners oldDelegate) => oldDelegate.color != color; -} - -/// 안내 문구 + "명함 코드 입력하기" -class _Bottom extends StatelessWidget { - const _Bottom({required this.found, required this.camera, required this.onManual}); - - final bool found; - - /// 카메라 상태 — 쓸 수 없으면 안내 문구를 바꾼다 (테스트 카메라면 null) - final MobileScannerController? camera; - final VoidCallback onManual; - - @override - Widget build(BuildContext context) { - final camera = this.camera; - final hint = camera == null - ? _hint(found: found, unavailable: false) - : ValueListenableBuilder( - valueListenable: camera, - builder: (context, state, _) => _hint(found: found, unavailable: state.error != null), - ); - return Padding( - padding: EdgeInsets.fromLTRB(24, 0, 24, math.max(MediaQuery.paddingOf(context).bottom + 96, 150)), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - hint, - // 찾은 뒤에는 버튼을 숨기되 자리는 지켜 프레임이 움직이지 않게 한다 - Visibility.maintain( - visible: !found, - child: Padding( - padding: const EdgeInsets.only(top: 22), - child: Pressable( - pressedScale: .96, - onTap: onManual, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), - decoration: BoxDecoration( - color: const Color(0x33FF7500), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0x9EFF963C), width: 1.5), - boxShadow: const [BoxShadow(color: Color(0x38FF7500), offset: Offset(0, 4), blurRadius: 18)], - ), - child: const Text( - '명함 코드 입력하기', - style: TextStyle( - fontFamily: AppFonts.display, - fontSize: 15.5, - fontWeight: FontWeight.w800, - height: AppTypography.displayNormalHeight, - color: Colors.white, - ), - ), - ), - ), - ), - ), - ], - ), - ); - } - - Widget _hint({required bool found, required bool unavailable}) { - final text = found - ? '명함을 찾았어요! 정보 불러오는 중…' - : unavailable - ? '카메라를 쓸 수 없어요.\n설정에서 카메라 권한을 허용하거나 명함 코드를 입력해 주세요.' - : '상대의 펫 명함 QR 코드를\n사각형 안에 비춰주세요.'; - return Text( - text, - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13.5, height: 1.55, color: Color(0xD1FFFFFF)), + return QrScannerPage( + copy: _kCopy, + parse: parseCardCode, + // 펫 명함 QR 이 아니면 서버가 못 찾았을 때와 같은 안내 + invalidMessage: ErrorLexicon.getMessage('CARD_002'), + load: (ref, code) => ref.read(receivedCardsProvider.notifier).preview(code), ); } } diff --git a/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart b/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart index f6d639f..cd90f44 100644 --- a/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart +++ b/flutter/lib/features/pet_card/presentation/pages/receive_card_page.dart @@ -10,12 +10,12 @@ 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/features/pet_card/domain/received_card.dart'; -import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart'; import 'package:tma_app/features/pet_card/presentation/providers/received_card_providers.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/card_code_sheet.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/name_card.dart'; import 'package:tma_app/features/pet_card/presentation/widgets/received_card_info.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/qr_scanner_page.dart'; import 'package:tma_app/shared/widgets/rup_button.dart'; import 'package:tma_app/shared/widgets/rup_toast.dart'; import 'package:tma_app/shared/widgets/section_title.dart'; @@ -26,10 +26,10 @@ Future startReceivingCard(BuildContext context, WidgetRef ref) async { ref.read(toastProvider.notifier).show('기기 저장 모드에서는 명함을 받을 수 없어요', icon: RupIconName.qrcode); return; } - final outcome = await context.push(AppRoutes.petCardScan); + final outcome = await context.push>(AppRoutes.petCardScan); if (!context.mounted) return; final card = switch (outcome) { - ScanFound(:final card) => card, + ScanFound(:final value) => value, ScanTypeCode() => await showCardCodeSheet(context), null => null, }; diff --git a/flutter/lib/shared/widgets/qr_scanner_page.dart b/flutter/lib/shared/widgets/qr_scanner_page.dart new file mode 100644 index 0000000..edb708c --- /dev/null +++ b/flutter/lib/shared/widgets/qr_scanner_page.dart @@ -0,0 +1,502 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:tma_app/core/api/api_guard.dart'; +import 'package:tma_app/core/constants/app_colors.dart'; +import 'package:tma_app/core/theme/app_typography.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_toast.dart'; + +// 시안 .rup-scanner 치수·색 +const Color _kScannerBg = Color(0xFF14110E); +const Color _kDim = Color(0x99120F0C); // rgba(18,15,12,.6) +const Color _kFoundGreen = Color(0xFF4ED08A); +const double _kFrame = 244; +const double _kFrameRadius = 22; +const double _kCorner = 36; +const double _kCornerStroke = 4; +const double _kCloseSize = 38; +const double _kCheckSize = 70; + +/// 스캔 줄이 위아래로 한 번 오가는 시간 (시안 2.3s) +const Duration _kSweep = Duration(milliseconds: 1150); + +/// 찾은 뒤 체크 표시를 보여 주는 최소 시간 (시안 820ms) +const Duration _kFoundHold = Duration(milliseconds: 820); + +/// 같은 QR 을 실패한 뒤 다시 읽기까지 기다리는 시간 — 같은 알림이 계속 뜨지 않게 +const Duration _kRetryGap = Duration(seconds: 3); + +/// 스캐너를 닫은 결과 +sealed class ScanOutcome { + const ScanOutcome(); +} + +/// QR 로 찾았다 +final class ScanFound extends ScanOutcome { + const ScanFound(this.value); + + final T value; +} + +/// "코드 입력하기"를 눌렀다 — 부른 화면이 입력 시트를 연다 +final class ScanTypeCode extends ScanOutcome { + const ScanTypeCode(); +} + +/// 스캐너 문구 — 명함 받기 · 공유 다이어리 참여 +class QrScanCopy { + const QrScanCopy({ + required this.title, + required this.hint, + required this.foundHint, + required this.manualLabel, + required this.unavailableHint, + }); + + final String title; + + /// 비출 때 안내 (줄바꿈 포함) + final String hint; + + /// 찾았을 때 안내 + final String foundHint; + + /// 아래 버튼 — 코드를 직접 입력하러 간다 + final String manualLabel; + + /// 카메라를 쓸 수 없을 때 안내 + final String unavailableHint; +} + +/// QR 을 읽어 알려 주는 카메라 화면 +typedef ScannerCamera = Widget Function(ValueChanged onCode); + +/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라) +final scannerCameraProvider = Provider((ref) => null); + +/// QR 스캐너 — 비춘 값이 찾는 코드면 [load] 로 불러와 [ScanFound] 로 닫는다. +/// 코드가 아니면 [invalidMessage] 를 알리고 계속 비춘다. 불러오기에 실패하면(인터셉터가 알림) 다시 비출 수 있다. +class QrScannerPage extends ConsumerStatefulWidget { + const QrScannerPage({ + super.key, + required this.copy, + required this.parse, + required this.invalidMessage, + required this.load, + }); + + final QrScanCopy copy; + + /// 읽은 값 → 코드 (찾는 QR 이 아니면 null) + final String? Function(String raw) parse; + final String invalidMessage; + final Future Function(WidgetRef ref, String code) load; + + @override + ConsumerState> createState() => _QrScannerPageState(); +} + +class _QrScannerPageState extends ConsumerState> { + late final ScannerCamera? _fakeCamera = ref.read(scannerCameraProvider); + MobileScannerController? _controller; + bool _found = false; + bool _busy = false; + String? _lastRaw; + DateTime _lastAt = DateTime.fromMillisecondsSinceEpoch(0); + + @override + void initState() { + super.initState(); + if (_fakeCamera == null) _controller = MobileScannerController(formats: const [BarcodeFormat.qrCode]); + } + + @override + void dispose() { + unawaited(_controller?.dispose()); + // 다른 화면은 상태 표시줄 모양을 따로 정하지 않는다 — 어두운 글자(앱 기본)로 되돌린다 + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + final raw = capture.barcodes.map((b) => b.rawValue).nonNulls.firstOrNull; + if (raw != null) unawaited(_onCode(raw)); + } + + Future _onCode(String raw) async { + if (_busy) return; + final now = DateTime.now(); + if (raw == _lastRaw && now.difference(_lastAt) < _kRetryGap) return; + _lastRaw = raw; + _lastAt = now; + + final code = widget.parse(raw); + if (code == null) { + // 찾는 QR 이 아니다 + ref.read(toastProvider.notifier).show(widget.invalidMessage, icon: RupIconName.alert); + return; + } + _busy = true; + setState(() => _found = true); + final shown = Stopwatch()..start(); + try { + final value = await widget.load(ref, code); + final rest = _kFoundHold - shown.elapsed; + if (rest > Duration.zero) await Future.delayed(rest); + if (mounted) context.pop(ScanFound(value)); + } catch (e) { + if (!isReportedApiFailure(e)) rethrow; + // 실패 알림은 인터셉터가 띄웠다 — 다시 비출 수 있게 되돌린다 + _lastAt = DateTime.now(); + if (mounted) { + setState(() { + _found = false; + _busy = false; + }); + } + } + } + + Widget _camera() { + final camera = _fakeCamera; + if (camera != null) return camera(_onCode); + return MobileScanner( + controller: _controller, + onDetect: _onDetect, + // 카메라를 켜는 동안·쓸 수 없을 때는 시안의 어두운 바탕 (안내는 아래 문구가 맡는다) + placeholderBuilder: (context) => const _Feed(), + errorBuilder: (context, error) => const _Feed(), + ); + } + + @override + Widget build(BuildContext context) { + final top = MediaQuery.paddingOf(context).top; + final topBarHeight = top + 8 + _kCloseSize; + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: _kScannerBg, + body: Stack( + children: [ + Positioned.fill(child: _camera()), + Positioned.fill( + child: Column( + children: [ + SizedBox(height: topBarHeight), + Expanded( + child: Center( + child: _Frame(found: _found), + ), + ), + _Bottom( + copy: widget.copy, + found: _found, + camera: _controller, + onManual: () => context.pop(ScanTypeCode()), + ), + ], + ), + ), + // 조준 프레임 바깥을 어둡게 덮는 막이 위 막대를 가리지 않게 맨 위에 둔다 + Positioned( + top: top + 8, + left: 18, + right: 18, + child: _TopBar(title: widget.copy.title, onClose: () => context.pop()), + ), + ], + ), + ), + ); + } +} + +/// 카메라 화면이 아직 없을 때의 바탕 (시안 .rup-scanner__feed) +class _Feed extends StatelessWidget { + const _Feed(); + + @override + Widget build(BuildContext context) { + return const DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: Alignment(0, -.2), + radius: 1.1, + colors: [Color(0xFF3A332B), Color(0xFF221D17), _kScannerBg], + stops: [0, .56, 1], + ), + ), + ); + } +} + +class _TopBar extends StatelessWidget { + const _TopBar({required this.title, required this.onClose}); + + final String title; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Semantics( + button: true, + label: '닫기', + child: Pressable( + pressedScale: .9, + onTap: onClose, + child: Container( + width: _kCloseSize, + height: _kCloseSize, + decoration: const BoxDecoration(color: Color(0x24FFFFFF), shape: BoxShape.circle), + alignment: Alignment.center, + child: const RupIcon(RupIconName.x, size: 20, stroke: 2.4, color: Colors.white), + ), + ), + ), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 16, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: Colors.white, + ), + ), + ), + const SizedBox(width: _kCloseSize), + ], + ); + } +} + +/// 조준 프레임 — 바깥은 어둡게, 모서리 표시 + 스캔 줄, 찾으면 초록 체크 +class _Frame extends StatefulWidget { + const _Frame({required this.found}); + + final bool found; + + @override + State<_Frame> createState() => _FrameState(); +} + +class _FrameState extends State<_Frame> with SingleTickerProviderStateMixin { + late final AnimationController _sweep = AnimationController(vsync: this, duration: _kSweep) + ..repeat(reverse: true); + + @override + void dispose() { + _sweep.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final found = widget.found; + return SizedBox.square( + dimension: _kFrame, + child: CustomPaint( + painter: const _DimOutside(), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: TweenAnimationBuilder( + tween: ColorTween(end: found ? _kFoundGreen : AppColors.primary), + duration: const Duration(milliseconds: 300), + builder: (context, color, _) => CustomPaint(painter: _Corners(color ?? AppColors.primary)), + ), + ), + if (!found) + AnimatedBuilder( + animation: _sweep, + builder: (context, child) { + final t = Curves.easeInOut.transform(_sweep.value); + return Positioned(left: 10, right: 10, top: 12 + (_kFrame - 28) * t, child: child!); + }, + child: Container( + height: 2, + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0x00FF7500), AppColors.primary, Color(0x00FF7500)]), + boxShadow: [BoxShadow(color: Color(0xB3FF7500), blurRadius: 14, spreadRadius: 2)], + ), + ), + ), + Center( + child: AnimatedOpacity( + duration: const Duration(milliseconds: 250), + opacity: found ? 1 : 0, + child: AnimatedScale( + duration: const Duration(milliseconds: 400), + curve: const Cubic(.34, 1.5, .5, 1), + scale: found ? 1 : .5, + child: Container( + width: _kCheckSize, + height: _kCheckSize, + decoration: const BoxDecoration( + color: _kFoundGreen, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Color(0x804ED08A), offset: Offset(0, 10), blurRadius: 30)], + ), + alignment: Alignment.center, + child: const RupIcon(RupIconName.check, size: 36, stroke: 3, color: Colors.white), + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 프레임 바깥 화면 전체를 어둡게 (시안 box-shadow 0 0 0 2000px) +class _DimOutside extends CustomPainter { + const _DimOutside(); + + @override + void paint(Canvas canvas, Size size) { + const reach = 4000.0; + final outside = Path() + ..fillType = PathFillType.evenOdd + ..addRect(Rect.fromLTRB(-reach, -reach, size.width + reach, size.height + reach)) + ..addRRect(RRect.fromRectAndRadius(Offset.zero & size, const Radius.circular(_kFrameRadius))); + canvas.drawPath(outside, Paint()..color = _kDim); + } + + @override + bool shouldRepaint(_DimOutside oldDelegate) => false; +} + +/// 네 모서리 표시 — 프레임 테두리를 따라 굵기 4, 길이 36 +class _Corners extends CustomPainter { + const _Corners(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = _kCornerStroke + ..strokeCap = StrokeCap.butt; + const r = 16.0; // 시안 바깥 반경 18 − 굵기 절반 + const reach = _kCorner - _kCornerStroke / 2; + final w = size.width; + final h = size.height; + final rect = Rect.fromCircle(center: Offset.zero, radius: r); + final corners = <(Offset, double)>[ + (Offset.zero, math.pi), // 왼쪽 위 + (Offset(w, 0), -math.pi / 2), // 오른쪽 위 + (Offset(w, h), 0), // 오른쪽 아래 + (Offset(0, h), math.pi / 2), // 왼쪽 아래 + ]; + for (final (corner, start) in corners) { + // 모서리를 원점으로 옮겨 같은 모양(가로·세로 직선 + 사분원)을 돌려 그린다 + canvas + ..save() + ..translate(corner.dx, corner.dy) + ..rotate(start - math.pi); + final path = Path() + ..moveTo(0, reach) + ..lineTo(0, r) + ..arcTo(rect.shift(const Offset(r, r)), math.pi, math.pi / 2, false) + ..lineTo(reach, 0); + canvas + ..drawPath(path, paint) + ..restore(); + } + } + + @override + bool shouldRepaint(_Corners oldDelegate) => oldDelegate.color != color; +} + +/// 안내 문구 + "코드 입력하기" +class _Bottom extends StatelessWidget { + const _Bottom({required this.copy, required this.found, required this.camera, required this.onManual}); + + final QrScanCopy copy; + final bool found; + + /// 카메라 상태 — 쓸 수 없으면 안내 문구를 바꾼다 (테스트 카메라면 null) + final MobileScannerController? camera; + final VoidCallback onManual; + + @override + Widget build(BuildContext context) { + final camera = this.camera; + final hint = camera == null + ? _hint(found: found, unavailable: false) + : ValueListenableBuilder( + valueListenable: camera, + builder: (context, state, _) => _hint(found: found, unavailable: state.error != null), + ); + return Padding( + padding: EdgeInsets.fromLTRB(24, 0, 24, math.max(MediaQuery.paddingOf(context).bottom + 96, 150)), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + hint, + // 찾은 뒤에는 버튼을 숨기되 자리는 지켜 프레임이 움직이지 않게 한다 + Visibility.maintain( + visible: !found, + child: Padding( + padding: const EdgeInsets.only(top: 22), + child: Pressable( + pressedScale: .96, + onTap: onManual, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15), + decoration: BoxDecoration( + color: const Color(0x33FF7500), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0x9EFF963C), width: 1.5), + boxShadow: const [BoxShadow(color: Color(0x38FF7500), offset: Offset(0, 4), blurRadius: 18)], + ), + child: Text( + copy.manualLabel, + style: const TextStyle( + fontFamily: AppFonts.display, + fontSize: 15.5, + fontWeight: FontWeight.w800, + height: AppTypography.displayNormalHeight, + color: Colors.white, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _hint({required bool found, required bool unavailable}) { + final text = found + ? copy.foundHint + : unavailable + ? copy.unavailableHint + : copy.hint; + return Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13.5, height: 1.55, color: Color(0xD1FFFFFF)), + ); + } +} diff --git a/flutter/lib/shared/widgets/rup_button.dart b/flutter/lib/shared/widgets/rup_button.dart index b49470c..e794fca 100644 --- a/flutter/lib/shared/widgets/rup_button.dart +++ b/flutter/lib/shared/widgets/rup_button.dart @@ -106,6 +106,7 @@ class RupButton extends StatelessWidget { this.dimmedOpacity = .5, this.height, this.leading, + this.foreground, }); final String label; @@ -131,9 +132,12 @@ class RupButton extends StatelessWidget { final double? height; final Widget? leading; + /// 글자색을 바꿀 때 — 흰 버튼의 붉은 "다이어리 나가기" 등 + final Color? foreground; + @override Widget build(BuildContext context) { - final (bg, fg, border) = switch (variant) { + final (bg, variantFg, border) = switch (variant) { RupButtonVariant.primary => ( AppColors.primary, Colors.white, @@ -150,6 +154,7 @@ class RupButton extends StatelessWidget { RupButtonVariant.light => (AppColors.offWhite, AppColors.ink, null), RupButtonVariant.memory => (AppColors.memory, Colors.white, AppColors.memory), }; + final fg = foreground ?? variantFg; final size = large ? _kFontSizeLarge : _kFontSize; final textStyle = variant == RupButtonVariant.light ? Theme.of(context).textTheme.labelLarge!.copyWith(color: fg, fontSize: size) diff --git a/flutter/lib/shared/widgets/rup_dialog.dart b/flutter/lib/shared/widgets/rup_dialog.dart index 2a64a00..cea0bac 100644 --- a/flutter/lib/shared/widgets/rup_dialog.dart +++ b/flutter/lib/shared/widgets/rup_dialog.dart @@ -34,6 +34,8 @@ Future showRupConfirm( Color? iconBackground, RupButtonVariant? confirmVariant, bool tightTop = false, + Widget? leading, + InlineSpan? titleSpan, }) async { final result = await showBlurredDialog( context, @@ -60,7 +62,13 @@ Future showRupConfirm( children: [ // 시안 row 확인창은 아이콘·제목 위에 4 를 둔다 (댓글 삭제처럼 없는 곳은 tightTop) if (row && !tightTop) const SizedBox(height: AppSpacing.xs), - if (icon != null || glyph != null) + // 아이콘 상자 대신 그릴 것 — 멤버를 내보낼 때의 사람 사진 등 + if (leading != null) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.md), + child: Center(child: leading), + ) + else if (icon != null || glyph != null) Center( child: Container( width: 54, @@ -76,8 +84,9 @@ Future showRupConfirm( : RupIcon(icon!, size: 26, stroke: 2, color: iconColor), ), ), - Text( - title, + Text.rich( + // 제목 일부에 색을 줄 때 (참여할 다이어리 이름 등) + titleSpan ?? TextSpan(text: title), textAlign: TextAlign.center, style: row ? textTheme.titleSmall!.copyWith(fontSize: 16.5, fontWeight: FontWeight.w800) diff --git a/flutter/test/api_diary_repository_test.dart b/flutter/test/api_diary_repository_test.dart index 40b6ea1..04be3a5 100644 --- a/flutter/test/api_diary_repository_test.dart +++ b/flutter/test/api_diary_repository_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tma_app/core/api/file_uploader.dart'; import 'package:tma_app/features/diary/data/api_diary_repository.dart'; import 'package:tma_app/features/diary/domain/diary.dart'; +import 'package:tma_app/features/diary/domain/diary_invitation.dart'; import 'package:tma_app/features/diary/domain/mood.dart'; import 'support/fake_api.dart'; @@ -113,10 +114,10 @@ void main() { expect(api.reported.single.message, isNot('서버 문구')); }); - test('다이어리 수정은 바탕색을 보내지 않는다 (만들 때 정한 값을 유지한다)', () async { + test('다이어리 수정은 바탕색·알림을 보내지 않는다 (바탕색은 만들 때 정하고, 알림은 멤버마다 따로 둔다)', () async { api.on('PATCH', '/diaries/:id', (r) { expect(r.json!.containsKey('tint'), isFalse); - expect(r.json!['mutedNotices'], ['newComment']); + expect(r.json!.containsKey('mutedNotices'), isFalse); return ok(_diaryJson()); }); final diary = Diary( @@ -133,6 +134,155 @@ void main() { expect(api.sent('PATCH', '/diaries/d1'), hasLength(1)); }); + test('내 알림 설정은 멤버 알림 경로로 보낸다', () async { + api.on('PUT', '/diaries/:id/muted-notices', (r) { + expect(r.json, { + 'mutedNotices': ['newPost'], + }); + return noContent; + }); + + await repo.setMutedNotices('d1', const {DiaryNotice.newPost}); + + expect(api.sent('PUT', '/diaries/d1/muted-notices'), hasLength(1)); + }); + + group('공유 다이어리', () { + Map sharedJson({bool isOwner = true}) => { + ..._diaryJson(), + 'shared': true, + 'isOwner': isOwner, + 'ownerId': isOwner ? 'me-uuid' : 'friend-uuid', + 'inviteCode': isOwner ? 'RUP-8F3K2Q' : null, + 'members': [ + {'userId': isOwner ? 'me-uuid' : 'friend-uuid', 'nickname': '콩이아빠', 'photoFileId': 'face-1', 'isOwner': true}, + {'userId': isOwner ? 'friend-uuid' : 'me-uuid', 'nickname': '콩이엄마', 'photoFileId': null, 'isOwner': false}, + ], + 'targets': [ + {'id': 'p1', 'name': '콩이', 'photoFileId': 'pet-1', 'tint': '#FF8A5B', 'archived': false}, + {'id': 'self', 'name': '콩이아빠', 'photoFileId': 'face-1', 'tint': null, 'archived': false}, + ], + }; + + test('참여한 다이어리는 멤버의 나를 알아보고, 주인의 아이들을 서버가 준 이름·사진으로 담는다', () async { + api.on('GET', '/diaries', (r) => ok(page([sharedJson(isOwner: false)]))); + api.on('GET', '/diary-posts', (r) => ok(page([ + { + ..._postJson(authorId: 'friend-uuid'), + 'author': {'userId': 'friend-uuid', 'nickname': '콩이아빠', 'photoFileId': 'face-1'}, + }, + ]))); + api.on('GET', '/diary-comments', (r) => ok(page(const []))); + + final book = await repo.fetch(); + + final diary = book.diaries.single; + expect(diary.shared, isTrue); + expect(diary.isOwner, isFalse); + expect(diary.inviteCode, isNull); + expect(diary.owner!.name, '콩이아빠'); + expect(diary.members.last.isMe, isTrue); + expect(diary.targets.map((t) => t.name), ['콩이', '콩이아빠']); + expect(diary.targets.first.photoPath, 'server-file:pet-1'); + expect(diary.targets.last.isSelf, isTrue); + // 주인의 아이들이라 내 추억 보관함과 상관없이 책장에 둔다 + expect(diary.isArchivedWith({'p1'}), isFalse); + final post = book.posts.single; + expect(post.isMine, isFalse); + expect(post.author!.name, '콩이아빠'); + expect(post.author!.photoPath, 'server-file:face-1'); + }); + + test('내 다이어리는 아이 요약을 담지 않는다 — 내 반려동물 목록으로 그린다', () { + final diary = diaryFromApi(sharedJson(), myUserId: 'me-uuid'); + + expect(diary.isOwner, isTrue); + expect(diary.inviteCode, 'RUP-8F3K2Q'); + expect(diary.targets, isEmpty); + expect(diary.memberCount, 2); + expect(diary.members.first.isMe, isTrue); + }); + + test('만들 때 공유 여부를 보낸다', () async { + api.on('POST', '/diaries', (r) { + expect(r.json!['shared'], isTrue); + return ok(sharedJson(), status: 201); + }); + + final created = await repo.createDiary( + name: '콩이의 하루', + targetIds: const ['p1'], + ribbon: kRibbonColors.first, + tint: kDiaryTints.first, + shared: true, + ); + + expect(created!.shared, isTrue); + }); + + test('개인 ↔ 공유 전환', () async { + api.on('PUT', '/diaries/:id/sharing', (r) { + expect(r.json, {'shared': false}); + return ok(_diaryJson()); + }); + + final diary = await repo.setShared('d1', false); + + expect(diary.shared, isFalse); + }); + + test('초대 코드로 미리 보고, 참여하면 참여한 다이어리의 기록까지 다시 읽는다', () async { + api.on('GET', '/diary-invitations/:code', (r) => ok({ + 'diaryId': 'd1', + 'name': '콩이의 하루', + 'ownerNickname': '콩이아빠', + 'memberCount': 2, + 'maxMembers': 4, + 'isMember': false, + })); + api.on('POST', '/diary-memberships', (r) { + expect(r.json, {'code': 'RUP-8F3K2Q'}); + return ok(sharedJson(isOwner: false), status: 201); + }); + api.on('GET', '/diaries', (r) => ok(page([sharedJson(isOwner: false)]))); + api.on('GET', '/diary-posts', (r) => ok(page(const []))); + api.on('GET', '/diary-comments', (r) => ok(page(const []))); + + final invitation = await repo.previewInvitation('RUP-8F3K2Q'); + final book = await repo.join(invitation.code); + + expect(invitation.name, '콩이의 하루'); + expect(invitation.isFull, isFalse); + expect(api.sent('GET', '/diary-invitations/RUP-8F3K2Q'), hasLength(1)); + expect(book.diaries.single.isOwner, isFalse); + }); + + test('나가기 · 멤버 내보내기', () async { + api.on('DELETE', '/diaries/:id/membership', (r) => noContent); + api.on('DELETE', '/diaries/:id/members/:userId', (r) => noContent); + + await repo.leave('d1'); + await repo.removeMember('d1', 'friend-uuid'); + + expect(api.sent('DELETE', '/diaries/d1/membership'), hasLength(1)); + expect(api.sent('DELETE', '/diaries/d1/members/friend-uuid'), hasLength(1)); + }); + }); + + group('parseInviteCode', () { + test('QR 주소·코드·소문자·앞머리 없는 6자를 모두 초대 코드로 읽는다', () { + expect(parseInviteCode('rup://diary-invite/RUP-8F3K2Q'), 'RUP-8F3K2Q'); + expect(parseInviteCode(' rup-8f3k2q '), 'RUP-8F3K2Q'); + expect(parseInviteCode('8F3K2Q'), 'RUP-8F3K2Q'); + }); + + test('초대 코드가 아니면 null', () { + expect(parseInviteCode('RUP-8F3K'), isNull); + expect(parseInviteCode('rup://pet-card/0428-117'), isNull); + expect(parseInviteCode('RUP-../../'), isNull); + }); + }); + test('아이를 지울 때 다이어리 정리는 서버가 이미 했다', () async { await repo.dropTarget('p1'); diff --git a/flutter/test/receive_card_flow_test.dart b/flutter/test/receive_card_flow_test.dart index 82de244..1b5c02e 100644 --- a/flutter/test/receive_card_flow_test.dart +++ b/flutter/test/receive_card_flow_test.dart @@ -5,8 +5,8 @@ import 'package:tma_app/features/pet_card/data/received_card_repository.dart'; import 'package:tma_app/features/pet_card/domain/card_style.dart'; import 'package:tma_app/features/pet_card/domain/card_view_mode.dart'; import 'package:tma_app/features/pet_card/domain/received_card.dart'; -import 'package:tma_app/features/pet_card/presentation/pages/card_scanner_page.dart'; import 'package:tma_app/shared/widgets/icons/rup_icon.dart'; +import 'package:tma_app/shared/widgets/qr_scanner_page.dart'; import 'support/app_harness.dart'; diff --git a/flutter/test/shared_diary_flow_test.dart b/flutter/test/shared_diary_flow_test.dart new file mode 100644 index 0000000..47ba6e9 --- /dev/null +++ b/flutter/test/shared_diary_flow_test.dart @@ -0,0 +1,397 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/core/api/api_exception.dart'; +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/diary_invitation.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/diary/presentation/pages/diary_settings_page.dart'; + +import 'support/app_harness.dart'; + +const String _kCode = 'RUP-8F3K2Q'; + +const DiaryMember _me = DiaryMember(id: kMeAuthorId, nickname: '나집사'); +const DiaryMember _dad = DiaryMember(id: 'dad', nickname: '콩이아빠', isOwner: true); +const DiaryMember _mom = DiaryMember(id: 'mom', nickname: '콩이엄마'); + +/// 내가 만든 공유 다이어리 — 나(주인) + 콩이엄마 +Diary _ownedShared() => Diary( + id: 'd1', + name: '콩이 성장일기', + targetIds: const ['pet-1'], + ribbon: kRibbonColors.first, + tint: kDiaryTints.first, + shared: true, + inviteCode: _kCode, + members: const [DiaryMember(id: kMeAuthorId, nickname: '나집사', isOwner: true), _mom], + ); + +/// 초대받아 참여한 다이어리 — 콩이아빠(주인) + 나 +Diary _joined({String id = 'd2', String name = '함께 쓰는 다이어리'}) => Diary( + id: id, + name: name, + targetIds: const ['dad-pet'], + ribbon: kRibbonColors.first, + tint: kDiaryTints.first, + shared: true, + isOwner: false, + members: const [_dad, _me], + targets: const [DiaryTarget(id: 'dad-pet', name: '초코')], + ); + +/// 서버 없이 공유 다이어리를 흉내 내는 저장소 +class _FakeSharedRepo implements DiaryRepository { + _FakeSharedRepo(this.book, {this.invitation}); + + DiaryBook book; + final DiaryInvitation? invitation; + final calls = []; + + @override + bool get canShare => true; + + @override + Future fetch() async => book; + + @override + Future createDiary({ + required String name, + required List targetIds, + required Color ribbon, + required Color tint, + String? coverPath, + bool shared = false, + }) async { + calls.add('create shared=$shared'); + final diary = Diary( + id: 'new', + name: name, + targetIds: targetIds, + ribbon: ribbon, + tint: tint, + shared: shared, + inviteCode: shared ? _kCode : null, + members: shared ? const [DiaryMember(id: kMeAuthorId, isOwner: true)] : const [], + ); + book = book.addDiary(diary); + return diary; + } + + @override + Future updateDiary(Diary diary) async => diary; + + @override + Future setMutedNotices(String diaryId, Set muted) async => calls.add('muted $muted'); + + @override + Future setShared(String diaryId, bool shared) async { + calls.add('shared $diaryId $shared'); + final d = book.diary(diaryId)!; + final next = Diary( + id: d.id, + name: d.name, + targetIds: d.targetIds, + ribbon: d.ribbon, + tint: d.tint, + shared: shared, + inviteCode: shared ? _kCode : null, + members: shared ? d.members.where((m) => m.isOwner).toList() : const [], + ); + book = book.updateDiary(next); + return next; + } + + @override + Future previewInvitation(String code) async { + calls.add('preview $code'); + final found = invitation; + if (found == null) throw ApiException.fromCode('DIARY_003'); + return found; + } + + @override + Future join(String code) async { + calls.add('join $code'); + book = book.addDiary(_joined()); + return book; + } + + @override + Future leave(String diaryId) async => calls.add('leave $diaryId'); + + @override + Future removeMember(String diaryId, String memberId) async { + calls.add('remove $diaryId $memberId'); + // 서버처럼 멤버를 빼고 초대 코드를 바꾼다 + final d = book.diary(diaryId)!; + book = book.updateDiary(Diary( + id: d.id, + name: d.name, + targetIds: d.targetIds, + ribbon: d.ribbon, + tint: d.tint, + shared: d.shared, + inviteCode: 'RUP-NEW000', + members: d.members.where((m) => m.id != memberId).toList(), + )); + } + + @override + Future deleteDiary(String diaryId) async {} + + @override + Future addPost({ + required String diaryId, + required DateTime date, + required String text, + Mood? mood, + String? photoPath, + }) async => + DiaryPost(id: 'p-new', diaryId: diaryId, date: date, createdAt: date, text: text); + + @override + Future updatePostText(DiaryPost post, String text) async => post.copyWith(text: text); + + @override + Future deletePost(String postId) async {} + + @override + Future addComment(String postId, String text) async => + DiaryComment(id: 'c-new', postId: postId, text: text, createdAt: DateTime.now()); + + @override + Future updateComment(DiaryComment comment, String text) async => comment.copyWith(text: text); + + @override + Future deleteComment(String commentId) async {} + + @override + Future dropTarget(String targetId) async {} +} + +Future<_FakeSharedRepo> _launch(WidgetTester tester, DiaryBook book, {DiaryInvitation? invitation}) async { + final repo = _FakeSharedRepo(book, invitation: invitation); + await launchSignedIn( + tester, + extra: {'pets.v1': jsonEncode([samplePet().toJson()])}, + overrides: [diaryRepositoryProvider.overrideWithValue(repo)], + ); + return repo; +} + +Future _openShelf(WidgetTester tester) async { + await tester.tap(find.bySemanticsLabel('일정·기록')); + await settle(tester); + await tester.tap(find.text('다이어리')); + await settle(tester); +} + +Future _openDiary(WidgetTester tester, String name) async { + await _openShelf(tester); + await tester.tap(find.text(name)); + await settle(tester); + expect(find.byType(DiaryPage), findsOneWidget); +} + +Future _openSettings(WidgetTester tester, String name) async { + await _openDiary(tester, name); + await tester.tap(find.bySemanticsLabel('다이어리 설정')); + await settle(tester); + expect(find.byType(DiarySettingsPage), findsOneWidget); +} + +Future _tapVisible(WidgetTester tester, Finder finder) async { + await tester.ensureVisible(finder); + await tester.pump(); + await tester.tap(finder); + await settle(tester); +} + +void main() { + testWidgets('주인은 멤버와 초대 코드를 보고, 멤버를 내보낼 수 있다', (tester) async { + final repo = await _launch(tester, DiaryBook(diaries: [_ownedShared()])); + await _openDiary(tester, '콩이 성장일기'); + expect(find.text('2/4'), findsOneWidget); + + await tester.tap(find.bySemanticsLabel('다이어리 설정')); + await settle(tester); + expect(find.text('멤버'), findsOneWidget); + expect(find.text('2/4'), findsOneWidget); + expect(find.text('주인'), findsOneWidget); + expect(find.text('콩이엄마'), findsOneWidget); + expect(find.text('2명 더 초대할 수 있어요'), findsOneWidget); + await tester.ensureVisible(find.text(_kCode)); + expect(find.text(_kCode), findsOneWidget); + + await _tapVisible(tester, find.bySemanticsLabel('콩이엄마님 내보내기')); + expect(find.text('콩이엄마님을 내보낼까요?'), findsOneWidget); + await tester.tap(find.widgetWithText(GestureDetector, '내보내기').last); + await settle(tester); + + expect(repo.calls, contains('remove d1 mom')); + expect(find.text('콩이엄마님을 내보냈어요'), findsOneWidget); + expect(find.text('1/4'), findsOneWidget); + // 내보내면 서버가 초대 코드를 바꾼다 — 다시 읽어 새 코드를 보여 준다 + await tester.ensureVisible(find.text('RUP-NEW000')); + expect(find.text(_kCode), findsNothing); + }); + + testWidgets('멤버가 있는 다이어리를 개인으로 바꾸면 먼저 묻는다', (tester) async { + final repo = await _launch(tester, DiaryBook(diaries: [_ownedShared()])); + await _openSettings(tester, '콩이 성장일기'); + + await tester.tap(find.text('개인 다이어리')); + await settle(tester); + expect(find.text('개인 다이어리로 전환할까요?'), findsOneWidget); + + await tester.tap(find.text('전환하기')); + await settle(tester); + + expect(repo.calls, contains('shared d1 false')); + expect(find.text('개인 다이어리로 전환했어요'), findsOneWidget); + expect(find.text('멤버'), findsNothing); + }); + + testWidgets('참여한 다이어리는 만든 사람을 보여 주고, 나갈 수 있다', (tester) async { + final repo = await _launch(tester, DiaryBook(diaries: [_joined()])); + await _openSettings(tester, '함께 쓰는 다이어리'); + + expect(find.text('콩이아빠님이 만든 다이어리예요'), findsOneWidget); + expect(find.bySemanticsLabel('다이어리 수정하기'), findsNothing); + expect(find.text('내보내기'), findsNothing); + expect(find.text('다이어리 제거'), findsNothing); + + await _tapVisible(tester, find.text('다이어리 나가기')); + expect(find.text('다이어리에서 나갈까요?'), findsOneWidget); + await tester.tap(find.text('나가기')); + await settle(tester); + + expect(repo.calls, contains('leave d2')); + expect(find.text('다이어리에서 나왔어요'), findsOneWidget); + expect(find.byType(DiarySettingsPage), findsNothing); + }); + + testWidgets('초대 코드를 입력하고 확인하면 공유 다이어리에 참여한다', (tester) async { + final repo = await _launch( + tester, + DiaryBook.empty, + invitation: const DiaryInvitation( + code: _kCode, + diaryId: 'd2', + name: '함께 쓰는 다이어리', + ownerNickname: '콩이아빠', + memberCount: 1, + maxMembers: 4, + isMember: false, + ), + ); + await _openShelf(tester); + await tester.tap(find.bySemanticsLabel('새 다이어리')); + await settle(tester); + await tester.tap(find.text('공유 다이어리 참여하기')); + await settle(tester); + expect(find.text('공유 다이어리 참여'), findsOneWidget); + + await tester.enterText(find.byType(TextField), 'rup-8f3k2q'); + await tester.pump(); + await tester.tap(find.text('참여하기')); + await settle(tester); + + expect(repo.calls, contains('preview $_kCode')); + expect(find.text('함께 쓰는 다이어리에\n참여하시겠어요?'), findsOneWidget); + await tester.tap(find.text('참여하기')); + await settle(tester); + + expect(repo.calls, contains('join $_kCode')); + expect(find.text('공유 다이어리에 참여했어요'), findsOneWidget); + expect(find.text('함께 쓰는 다이어리'), findsOneWidget); + }); + + testWidgets('없는 초대 코드는 서버 안내로 멈추고 시트를 열어 둔다', (tester) async { + final repo = await _launch(tester, DiaryBook.empty); + await _openShelf(tester); + await tester.tap(find.bySemanticsLabel('새 다이어리')); + await settle(tester); + await tester.tap(find.text('공유 다이어리 참여하기')); + await settle(tester); + + await tester.enterText(find.byType(TextField), 'RUP-ZZZZZZ'); + await tester.pump(); + await tester.tap(find.text('참여하기')); + await settle(tester); + + expect(repo.calls, ['preview RUP-ZZZZZZ']); + expect(find.text('공유 다이어리 참여'), findsOneWidget); + }); + + testWidgets('공유 다이어리로 만들 수 있다', (tester) async { + final repo = await _launch(tester, DiaryBook.empty); + await _openShelf(tester); + await tester.tap(find.bySemanticsLabel('새 다이어리')); + await settle(tester); + await tester.tap(find.text('새 다이어리 만들기')); + await settle(tester); + + await tester.tap(find.text('공유 다이어리')); + await tester.pump(); + expect(find.text('만든 뒤 가족·지인을 초대해 함께 기록할 수 있어요.'), findsOneWidget); + await tester.enterText(find.byType(TextField).first, '가족 일기'); + await _tapVisible(tester, find.text('다이어리 만들기')); + + expect(repo.calls, contains('create shared=true')); + expect(find.text('공유 다이어리를 만들었어요'), findsOneWidget); + }); + + testWidgets('다른 멤버의 기록은 그 사람 이름으로 보이고, 고치거나 지울 수 없다', (tester) async { + final today = DateTime.now(); + final post = DiaryPost( + id: 'p-dad', + diaryId: 'd2', + date: DateTime(today.year, today.month, today.day), + createdAt: today, + text: '초코랑 산책했어요', + authorId: 'dad', + author: _dad, + ); + final comment = DiaryComment(id: 'c-dad', postId: 'p-dad', text: '귀엽다', createdAt: today, authorId: 'dad', author: _dad); + await _launch(tester, DiaryBook(diaries: [_joined()], posts: [post], comments: [comment])); + await _openDiary(tester, '함께 쓰는 다이어리'); + + expect(find.text('초코랑 산책했어요'), findsOneWidget); + expect(find.text('콩이아빠'), findsOneWidget); + expect(find.bySemanticsLabel('기록 관리'), findsNothing); + + await _tapVisible(tester, find.textContaining('댓글')); + expect(find.text('귀엽다'), findsOneWidget); + expect(find.text('수정'), findsNothing); + expect(find.text('삭제'), findsNothing); + }); + + testWidgets('다른 멤버의 새 글은 알림에 나온다', (tester) async { + final now = DateTime.now(); + final post = DiaryPost( + id: 'p-dad', + diaryId: 'd2', + date: DateTime(now.year, now.month, now.day), + createdAt: now.subtract(const Duration(minutes: 5)), + text: '초코랑 산책했어요', + authorId: 'dad', + author: _dad, + ); + await _launch(tester, DiaryBook(diaries: [_joined()], posts: [post])); + + // 새 글이 있으니 종에 점이 찍혀 있다 + await tester.tap(find.bySemanticsLabel('알림, 새 알림 있음')); + await settle(tester); + + expect(find.text('콩이아빠님이 새 글을 올렸어요'), findsOneWidget); + expect(find.text('“초코랑 산책했어요”'), findsOneWidget); + // 저녁 8시가 지났으면 기록 알림도 함께 나온다 + expect(find.textContaining('함께 쓰는 다이어리 ·'), findsWidgets); + }); +} diff --git a/flutter/test_live/api_contract_live_test.dart b/flutter/test_live/api_contract_live_test.dart index 26f6a8f..94c01ca 100644 --- a/flutter/test_live/api_contract_live_test.dart +++ b/flutter/test_live/api_contract_live_test.dart @@ -26,6 +26,7 @@ import 'package:tma_app/features/auth/data/repositories/device_key_store.dart'; import 'package:tma_app/features/auth/domain/social_provider.dart'; import 'package:tma_app/features/diary/data/api_diary_repository.dart'; import 'package:tma_app/features/diary/domain/diary.dart'; +import 'package:tma_app/features/diary/domain/diary_invitation.dart'; import 'package:tma_app/features/diary/domain/mood.dart'; import 'package:tma_app/features/mypage/data/profile_repository.dart'; import 'package:tma_app/features/pet/data/repositories/api_pet_repository.dart'; @@ -192,7 +193,7 @@ void main() { final comment = await diaries.addComment(post.id, '귀여워'); await diaries.updatePostText(post, '산책 다녀왔어요'); await diaries.updateComment(comment, '정말 귀여워'); - await diaries.updateDiary(diary.copyWith(mutedNotices: {DiaryNotice.reminder})); + await diaries.setMutedNotices(diary.id, {DiaryNotice.reminder}); final diaryBook = await diaries.fetch(); expect(diaryBook.wroteOn(diary.id, day), isTrue); @@ -233,6 +234,104 @@ void main() { expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다'); }); + test('공유 다이어리 — 초대 코드로 함께 쓰고, 멤버의 글·사진이 서로 보이며, 내보내면 코드가 바뀐다', () async { + // 두 번째 회원 — 회원 id 는 prefs 한 자리에 남으므로 각자 붙잡아 둔다 + final ownerId = prefs.getString(kAuthUserIdKey)!; + final memberSession = SessionStore(MemoryTokenVault()); + final memberDio = DioClient(baseUrl: baseUrl, session: memberSession, onError: reported.add).dio; + final memberUploader = FileUploader(memberDio, reported.add); + final memberAuth = ApiAuthRepository( + dio: memberDio, + session: memberSession, + prefs: prefs, + deviceKeys: FixedDeviceKeyStore('live-member-${DateTime.now().microsecondsSinceEpoch}'), + ); + await memberAuth.signIn(SocialProvider.kakao); + final memberId = prefs.getString(kAuthUserIdKey)!; + await prefs.setString(kAuthUserIdKey, ownerId); + addTearDown(() async { + if (memberSession.hasSession) await memberAuth.deleteAccount(); + await prefs.setString(kAuthUserIdKey, ownerId); + }); + + final owner = ApiDiaryRepository(dio: dio, session: session, uploader: uploader, myUserId: () => ownerId); + final member = ApiDiaryRepository( + dio: memberDio, + session: memberSession, + uploader: memberUploader, + myUserId: () => memberId, + ); + final today = DateTime.now(); + final day = DateTime(today.year, today.month, today.day); + + // 주인 — 공유 다이어리를 만들면 초대 코드가 생긴다 + final shared = await owner.createDiary( + name: '함께 쓰는 하루', + targetIds: const ['self'], + ribbon: kRibbonColors[3], + tint: kDiaryTints[1], + coverPath: photoPath, + shared: true, + ); + expect(shared!.shared, isTrue); + expect(shared.isOwner, isTrue); + final code = shared.inviteCode!; + expect(parseInviteCode(code), code); + + // 멤버 — 미리 보고 참여한다 + final invitation = await member.previewInvitation(code); + expect(invitation.name, '함께 쓰는 하루'); + expect(invitation.isMember, isFalse); + var memberBook = await member.join(code); + final joined = memberBook.diaries.single; + expect(joined.isOwner, isFalse); + expect(joined.inviteCode, isNull); + expect(joined.owner!.name, '라이브 집사'); + expect(joined.members.where((m) => m.isMe), hasLength(1)); + expect(joined.targets.single.isSelf, isTrue); + + // 멤버가 사진과 함께 기록하고, 주인이 댓글을 단다 + final memberPost = await member.addPost(diaryId: joined.id, date: day, text: '같이 산책', photoPath: photoPath); + await owner.addComment(memberPost.id, '고마워요'); + + final ownerBook = await owner.fetch(); + final seen = ownerBook.posts.firstWhere((p) => p.id == memberPost.id); + expect(seen.isMine, isFalse); + expect(seen.author!.id, memberId); + expect(ownerBook.diaries.firstWhere((d) => d.id == shared.id).memberCount, 2); + + // 서로의 사진을 받을 수 있다 — 주인은 멤버의 기록 사진, 멤버는 주인의 표지 + final postPhoto = await dio.get>( + '$kFilesPath/${serverFileIdOf(seen.photoPath)}', + options: Options(responseType: ResponseType.bytes), + ); + expect(postPhoto.data, _png); + final cover = await memberDio.get>( + '$kFilesPath/${serverFileIdOf(joined.coverPath)}', + options: Options(responseType: ResponseType.bytes), + ); + expect(cover.data, _png); + + // 멤버에게는 주인의 댓글이 남의 댓글로 온다 + memberBook = await member.fetch(); + expect(memberBook.comments.single.isMine, isFalse); + expect(memberBook.comments.single.author!.name, '라이브 집사'); + + // 내보내면 멤버의 책장에서 사라지고, 예전 코드로는 다시 들어올 수 없다 + await owner.removeMember(shared.id, memberId); + expect((await member.fetch()).diaries, isEmpty); + await expectLater( + member.previewInvitation(code), + throwsA(isA().having((e) => (e.error! as ApiException).code, 'code', 'DIARY_003')), + ); + final rotated = (await owner.fetch()).diaries.firstWhere((d) => d.id == shared.id); + expect(rotated.inviteCode, isNot(code)); + expect(rotated.memberCount, 1); + + reported.removeWhere((e) => e.code == 'DIARY_003'); + expect(reported, isEmpty, reason: '정상 흐름에서는 실패 알림이 없어야 한다'); + }); + test('규칙 위반은 사전 문구의 에러 코드로 돌아온다', () async { final diaries = ApiDiaryRepository(dio: dio, session: session, uploader: uploader, myUserId: () => null); final diary = (await diaries.fetch()).diaries.first;