feat(flutter): 공유 다이어리 — 초대·참여·멤버와 함께 쓰기

다이어리를 가족·지인과 함께 쓰는 화면을 연다.
- 다이어리 설정: 개인↔공유 전환(멤버가 있으면 확인), 멤버 목록·내보내기, 초대 QR·코드 복사, 다이어리 나가기
- 참여: 코드 입력 시트 · 초대 QR 스캐너 · 참여 확인 (명함 스캐너를 공용 QR 스캐너로 바꿔 함께 쓴다)
- 만들 때 공유 다이어리를 고를 수 있다
- 다른 멤버의 기록·댓글은 그 사람 이름·사진으로, 수정·삭제는 내 것만
- 참여한 다이어리 표지는 서버가 준 주인의 아이들로 그린다
- 다른 멤버의 새 글·내 기록의 댓글을 알림으로 보여 주고, 공유 다이어리에 들어오면 다시 읽는다
- 알림 설정은 멤버마다 따로 저장한다
- 기기 저장 모드에서는 공유 다이어리를 쓸 수 없다고 안내한다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 20:50:13 +09:00
parent 688e72bf04
commit f50ab85a75
27 changed files with 2787 additions and 678 deletions
+6
View File
@@ -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<GoRouter>((ref) {
),
],
),
GoRoute(
path: AppRoutes.diaryJoinScan,
parentNavigatorKey: rootKey,
pageBuilder: (context, state) => _fadePage(state, const DiaryJoinScannerPage()),
),
// 펫 명함 — 경로가 겹치므로 고정 경로(manage·new·scan…)를 :cardId 보다 먼저 둔다
GoRoute(
path: AppRoutes.petCardManage,
+3
View File
@@ -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';
@@ -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<String, Object?> 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<String, Object?> json, String? me) {
final id = _personId(json['authorId'], me);
if (id == kMeAuthorId) return null;
final author = json['author'] as Map<String, Object?>?;
return DiaryPerson(
id: id,
nickname: author?['nickname'] as String?,
photoPath: serverFileRefOrNull(author?['photoFileId'] as String?),
);
}
Diary diaryFromApi(Map<String, Object?> 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<String>().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<String>() ?? const <String>[])
?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<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
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<Map<String, Object?>>() ?? const <Map<String, Object?>>[])
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<String, Object?> json) => DiaryInvitation(
code: code,
diaryId: json['diaryId']! as String,
name: json['name']! as String,
targetIds: (json['targetIds'] as List?)?.whereType<String>().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<String>() ?? const <String>[])
?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<String, Object?> json) => diaryFromApi(json, myUserId: _myUserId());
DiaryPost _post(Map<String, Object?> 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<String, Object?> 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<String, Object?> 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<String, Object?> 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<DiaryBook> 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<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted) => _dio.put<void>(
'$_kDiariesPath/$diaryId/muted-notices',
data: {'mutedNotices': [for (final n in muted) n.name]},
);
@override
Future<Diary> setShared(String diaryId, bool shared) async {
final response = await _dio.put<Map<String, dynamic>>('$_kDiariesPath/$diaryId/sharing', data: {'shared': shared});
return _diary(response.data!);
}
@override
Future<DiaryInvitation> previewInvitation(String code) async {
final response = await _dio.get<Map<String, dynamic>>('$_kInvitationsPath/${Uri.encodeComponent(code)}');
return diaryInvitationFromApi(code, response.data!);
}
@override
Future<DiaryBook> join(String code) async {
await _dio.post<Map<String, dynamic>>(_kMembershipsPath, data: {'code': code});
// 참여한 다이어리의 지난 기록·댓글까지 함께 보여야 하므로 모두 다시 읽는다
return fetch();
}
@override
Future<void> leave(String diaryId) => _dio.delete<void>('$_kDiariesPath/$diaryId/membership');
@override
Future<void> removeMember(String diaryId, String memberId) =>
_dio.delete<void>('$_kDiariesPath/$diaryId/members/$memberId');
@override
Future<void> deleteDiary(String diaryId) => _dio.delete<void>('$_kDiariesPath/$diaryId');
@@ -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<DiaryBook> fetch();
/// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001)
/// 공유 다이어리(초대·참여·멤버)를 쓸 수 있는지 — 다른 회원과 함께 쓰므로 서버가 있어야 한다
bool get canShare;
/// 새 다이어리 — 책장이 가득 찼으면 null (서버 DIARY_001). [shared] 면 처음부터 공유 다이어리
Future<Diary?> createDiary({
required String name,
required List<String> targetIds,
required Color ribbon,
required Color tint,
String? coverPath,
bool shared = false,
});
/// 이름·대상·리본·표지·알림을 바꾼다 (바탕색은 만들 때 정한 값을 유지한다)
/// 이름·대상·리본·표지를 바꾼다 (주인만 · 바탕색은 만들 때 정한 값을 유지한다)
Future<Diary> updateDiary(Diary diary);
/// 내 알림 설정 — 멤버마다 따로 둔다
Future<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted);
/// 개인 ↔ 공유 전환 (주인만). 개인으로 바꾸면 다른 멤버는 모두 나간다
Future<Diary> setShared(String diaryId, bool shared);
/// 초대 코드로 참여할 다이어리를 미리 본다
Future<DiaryInvitation> previewInvitation(String code);
/// 초대 코드로 참여한다 — 참여한 다이어리의 기록·댓글까지 모두 다시 읽어 돌려준다
Future<DiaryBook> join(String code);
/// 참여한 다이어리에서 나간다 (주인이 아닐 때)
Future<void> leave(String diaryId);
/// 멤버를 내보낸다 (주인만)
Future<void> removeMember(String diaryId, String memberId);
/// 다이어리와 그 안의 기록·댓글을 모두 지운다
Future<void> deleteDiary(String diaryId);
@@ -59,6 +82,8 @@ abstract interface class DiaryRepository {
Future<void> 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<DiaryBook> fetch() async => _read();
@override
bool get canShare => false;
/// 가득 찼는지는 알림기가 아이의 보관 여부까지 보고 먼저 확인한다
@override
Future<Diary?> 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<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted) async {
final book = _read();
final diary = book.diary(diaryId);
if (diary != null) await _write(book.updateDiary(diary.copyWith(mutedNotices: muted)));
}
// 기기 저장 모드에는 함께 쓸 다른 회원이 없다 — 화면은 [canShare] 로 막는다
@override
Future<Diary> setShared(String diaryId, bool shared) => throw StateError(_kLocalNoSharing);
@override
Future<DiaryInvitation> previewInvitation(String code) => throw StateError(_kLocalNoSharing);
@override
Future<DiaryBook> join(String code) => throw StateError(_kLocalNoSharing);
@override
Future<void> leave(String diaryId) => throw StateError(_kLocalNoSharing);
@override
Future<void> removeMember(String diaryId, String memberId) => throw StateError(_kLocalNoSharing);
@override
Future<void> deleteDiary(String diaryId) async => _write(_read().deleteDiary(diaryId));
+103 -5
View File
@@ -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<Color> 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<String> targetIds;
/// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다
/// 대상이 모두 추억 보관함의 아이인지 — 그러면 책장에서 빠지고 보관함에서 본다.
/// 참여한 다이어리는 주인의 아이들이라 내 보관함과 상관없이 책장에 둔다
bool isArchivedWith(Set<String> 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<DiaryMember> members;
/// 참여한 다이어리의 아이들 (내 다이어리는 비어 있다 — 내 반려동물 목록으로 그린다)
final List<DiaryTarget> 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<String, String> myTargetNames) => isOwner
? targetIds.map((id) => myTargetNames[id]).nonNulls.firstOrNull
: targets.map((t) => t.name).nonNulls.firstOrNull;
Diary copyWith({
String? name,
List<String>? targetIds,
Color? ribbon,
String? coverPath,
Set<DiaryNotice>? mutedNotices,
List<DiaryMember>? 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<String, Object?> 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<String, Object?> 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<String, Object?> toJson() => {
'id': id,
@@ -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;
}
@@ -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<DiaryPage> {
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<DiaryPage> {
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});
@@ -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<void> _delete(BuildContext context, WidgetRef ref, Diary diary) async {
@override
ConsumerState<DiarySettingsPage> createState() => _DiarySettingsPageState();
}
class _DiarySettingsPageState extends ConsumerState<DiarySettingsPage> {
/// "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<void> _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<void> _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<void> _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<void> _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<bool>(
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<bool>(
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,
),
),
),
),
),
],
],
],
),
),
),
);
@@ -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<DiaryBook> {
if (ref.mounted) state = AsyncData(next);
}
/// 공유 다이어리를 쓸 수 있는지 (서버 모드)
bool get canShare => _repo.canShare;
/// 책장이 가득 찼는지 — 보관함으로 간 다이어리는 세지 않는다
bool get atMax => _book.atMax(ref.read(petListProvider).value?.archivedIds ?? const {});
/// 새 다이어리 — 가득 찼으면 null
Future<Diary?> createDiary({
required String name,
required List<String> 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<DiaryBook> {
_set(_book.deleteDiary(id));
}
/// 내 알림 하나를 켜거나 끈다
Future<void> 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<void> refresh() async {
// 기기 저장 모드는 나만 쓰므로 다시 읽을 것이 없다
if (!_repo.canShare) return;
_set(await _repo.fetch());
}
/// 개인 ↔ 공유 전환 (주인만)
Future<void> setShared(String id, {required bool shared}) async =>
_set(_book.updateDiary(await _repo.setShared(id, shared)));
Future<DiaryInvitation> previewInvitation(String code) => _repo.previewInvitation(code);
/// 초대 코드로 참여 — 참여한 다이어리의 기록까지 다시 읽는다
Future<void> join(String code) async => _set(await _repo.join(code));
/// 참여한 다이어리에서 나간다 — 내 책장에서 그 다이어리와 기록이 사라진다
Future<void> leave(String id) async {
await _repo.leave(id);
_set(_book.deleteDiary(id));
}
/// 멤버를 내보낸다 (주인만) — 서버가 초대 코드를 새로 바꾸므로 다시 읽는다
Future<void> removeMember(String diaryId, String memberId) async {
await _repo.removeMember(diaryId, memberId);
_set(await _repo.fetch());
}
Future<void> addPost({required String diaryId, required String text, Mood? mood, String? photoPath}) async {
final post = await _repo.addPost(
diaryId: diaryId,
@@ -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<String> 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});
@@ -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<DiaryInvitation>(
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<void> 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<ScanOutcome<DiaryInvitation>>(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<void> _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<void> _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());
}
@@ -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),
),
),
),
),
],
),
),
@@ -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<DiaryMember> onRemove;
/// 초대 줄을 누르면 아래 "멤버 초대하기" 로 내려간다
final VoidCallback onInvite;
@override
Widget build(BuildContext context, WidgetRef ref) {
final rows = <Widget>[
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<void> 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<void> _zoom(BuildContext context, WidgetRef ref) {
return showBlurredDialog<void>(
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),
),
),
],
),
),
],
);
}
}
@@ -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<void> 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<String> _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<bool>(
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),
),
],
@@ -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;
@@ -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일
@@ -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<NotificationsPage> {
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,
@@ -31,8 +31,11 @@ class NoticeSeenNotifier extends Notifier<DateTime?> {
final noticeSeenProvider = NotifierProvider<NoticeSeenNotifier, DateTime?>(NoticeSeenNotifier.new);
/// 알림으로 보여 줄 기간 — 알림 화면은 최근 7일까지 묶어 보여 준다 ([NoticeDay])
const Duration _kNoticeWindow = Duration(days: 7);
/// 모든 다이어리의 알림 — 최근 것이 위로.
/// 지금은 기기에서 만드는 기록 알림만 있다. 공유 다이어리의 새 글·댓글은 서버가 생기면 여기에 더한다.
/// 기록 알림은 기기에서 만들고, 공유 다이어리의 새 글·댓글은 서버에서 읽은 기록·댓글 중 남이 쓴 것으로 만든다.
final noticesProvider = Provider<List<AppNotice>>((ref) => _watchNotices(ref));
/// 알림 목록을 알림기에서 바로 계산한다 — 파생 프로바이더끼리 잇지 않는다 ([petListProvider] 주석 참고)
@@ -50,7 +53,7 @@ List<AppNotice> _watchNotices(Ref ref) {
if (settings[AppSetting.scheduleNotification] == false) return const [];
final book = ref.watch(diaryProvider).value ?? DiaryBook.empty;
final notices = <AppNotice>[];
final notices = <AppNotice>[...diaryActivityNotices(book, watchShelf(ref), now)];
if (now.hour >= kDiaryReminderHour) {
final at = DateTime(now.year, now.month, now.day, kDiaryReminderHour);
// 추억 보관함으로 간 다이어리는 알리지 않는다
@@ -69,6 +72,49 @@ List<AppNotice> _watchNotices(Ref ref) {
return notices..sort((a, b) => b.at.compareTo(a.at));
}
/// 공유 다이어리에서 다른 멤버가 남긴 새 글 · 내 기록에 달린 댓글 (최근 7일, 다이어리별 알림 설정을 따른다)
List<AppNotice> diaryActivityNotices(DiaryBook book, List<Diary> shelf, DateTime now) {
final since = now.subtract(_kNoticeWindow);
final shared = {for (final d in shelf.where((d) => d.shared)) d.id: d};
final notices = <AppNotice>[];
final myPosts = <String, DiaryPost>{};
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<bool>((ref) {
final seen = ref.watch(noticeSeenProvider);
@@ -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<String> onCode);
/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라)
final scannerCameraProvider = Provider<ScannerCamera?>((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<CardScannerPage> createState() => _CardScannerPageState();
}
class _CardScannerPageState extends ConsumerState<CardScannerPage> {
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<void> _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<void>.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<SystemUiOverlayStyle>(
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<Color?>(
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<MobileScannerState>(
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<SharedCard>(
copy: _kCopy,
parse: parseCardCode,
// 펫 명함 QR 이 아니면 서버가 못 찾았을 때와 같은 안내
invalidMessage: ErrorLexicon.getMessage('CARD_002'),
load: (ref, code) => ref.read(receivedCardsProvider.notifier).preview(code),
);
}
}
@@ -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<void> startReceivingCard(BuildContext context, WidgetRef ref) async {
ref.read(toastProvider.notifier).show('기기 저장 모드에서는 명함을 받을 수 없어요', icon: RupIconName.qrcode);
return;
}
final outcome = await context.push<ScanOutcome>(AppRoutes.petCardScan);
final outcome = await context.push<ScanOutcome<SharedCard>>(AppRoutes.petCardScan);
if (!context.mounted) return;
final card = switch (outcome) {
ScanFound(:final card) => card,
ScanFound(:final value) => value,
ScanTypeCode() => await showCardCodeSheet(context),
null => null,
};
@@ -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<T> {
const ScanOutcome();
}
/// QR 로 찾았다
final class ScanFound<T> extends ScanOutcome<T> {
const ScanFound(this.value);
final T value;
}
/// "코드 입력하기"를 눌렀다 — 부른 화면이 입력 시트를 연다
final class ScanTypeCode<T> extends ScanOutcome<T> {
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<String> onCode);
/// 스캐너 카메라 — 테스트에서 실제 카메라 대신 끼운다 (null 이면 기기 카메라)
final scannerCameraProvider = Provider<ScannerCamera?>((ref) => null);
/// QR 스캐너 — 비춘 값이 찾는 코드면 [load] 로 불러와 [ScanFound] 로 닫는다.
/// 코드가 아니면 [invalidMessage] 를 알리고 계속 비춘다. 불러오기에 실패하면(인터셉터가 알림) 다시 비출 수 있다.
class QrScannerPage<T> 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<T> Function(WidgetRef ref, String code) load;
@override
ConsumerState<QrScannerPage<T>> createState() => _QrScannerPageState<T>();
}
class _QrScannerPageState<T> extends ConsumerState<QrScannerPage<T>> {
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<void> _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<void>.delayed(rest);
if (mounted) context.pop(ScanFound<T>(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<SystemUiOverlayStyle>(
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<T>()),
),
],
),
),
// 조준 프레임 바깥을 어둡게 덮는 막이 위 막대를 가리지 않게 맨 위에 둔다
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<Color?>(
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<MobileScannerState>(
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)),
);
}
}
+6 -1
View File
@@ -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)
+12 -3
View File
@@ -34,6 +34,8 @@ Future<bool> showRupConfirm(
Color? iconBackground,
RupButtonVariant? confirmVariant,
bool tightTop = false,
Widget? leading,
InlineSpan? titleSpan,
}) async {
final result = await showBlurredDialog<bool>(
context,
@@ -60,7 +62,13 @@ Future<bool> 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<bool> 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)
+152 -2
View File
@@ -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<String, Object?> 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');
+1 -1
View File
@@ -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';
+397
View File
@@ -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 = <String>[];
@override
bool get canShare => true;
@override
Future<DiaryBook> fetch() async => book;
@override
Future<Diary?> createDiary({
required String name,
required List<String> 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<Diary> updateDiary(Diary diary) async => diary;
@override
Future<void> setMutedNotices(String diaryId, Set<DiaryNotice> muted) async => calls.add('muted $muted');
@override
Future<Diary> 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<DiaryInvitation> previewInvitation(String code) async {
calls.add('preview $code');
final found = invitation;
if (found == null) throw ApiException.fromCode('DIARY_003');
return found;
}
@override
Future<DiaryBook> join(String code) async {
calls.add('join $code');
book = book.addDiary(_joined());
return book;
}
@override
Future<void> leave(String diaryId) async => calls.add('leave $diaryId');
@override
Future<void> 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<void> deleteDiary(String diaryId) async {}
@override
Future<DiaryPost> 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<DiaryPost> updatePostText(DiaryPost post, String text) async => post.copyWith(text: text);
@override
Future<void> deletePost(String postId) async {}
@override
Future<DiaryComment> addComment(String postId, String text) async =>
DiaryComment(id: 'c-new', postId: postId, text: text, createdAt: DateTime.now());
@override
Future<DiaryComment> updateComment(DiaryComment comment, String text) async => comment.copyWith(text: text);
@override
Future<void> deleteComment(String commentId) async {}
@override
Future<void> 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<void> _openShelf(WidgetTester tester) async {
await tester.tap(find.bySemanticsLabel('일정·기록'));
await settle(tester);
await tester.tap(find.text('다이어리'));
await settle(tester);
}
Future<void> _openDiary(WidgetTester tester, String name) async {
await _openShelf(tester);
await tester.tap(find.text(name));
await settle(tester);
expect(find.byType(DiaryPage), findsOneWidget);
}
Future<void> _openSettings(WidgetTester tester, String name) async {
await _openDiary(tester, name);
await tester.tap(find.bySemanticsLabel('다이어리 설정'));
await settle(tester);
expect(find.byType(DiarySettingsPage), findsOneWidget);
}
Future<void> _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);
});
}
+100 -1
View File
@@ -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<List<int>>(
'$kFilesPath/${serverFileIdOf(seen.photoPath)}',
options: Options(responseType: ResponseType.bytes),
);
expect(postPhoto.data, _png);
final cover = await memberDio.get<List<int>>(
'$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<DioException>().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;