generated from ttipo/project-base
feat(flutter): 공유 다이어리 — 초대·참여·멤버와 함께 쓰기
다이어리를 가족·지인과 함께 쓰는 화면을 연다. - 다이어리 설정: 개인↔공유 전환(멤버가 있으면 확인), 멤버 목록·내보내기, 초대 QR·코드 복사, 다이어리 나가기 - 참여: 코드 입력 시트 · 초대 QR 스캐너 · 참여 확인 (명함 스캐너를 공용 QR 스캐너로 바꿔 함께 쓴다) - 만들 때 공유 다이어리를 고를 수 있다 - 다른 멤버의 기록·댓글은 그 사람 이름·사진으로, 수정·삭제는 내 것만 - 참여한 다이어리 표지는 서버가 준 주인의 아이들로 그린다 - 다른 멤버의 새 글·내 기록의 댓글을 알림으로 보여 주고, 공유 다이어리에 들어오면 다시 읽는다 - 알림 설정은 멤버마다 따로 저장한다 - 기기 저장 모드에서는 공유 다이어리를 쓸 수 없다고 안내한다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user