From 3a3be4c3d310398b44dc1752996244d26812b910 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 17:41:20 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8C=AC=EB=8D=A4=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=C2=B7=20=EC=84=B1=EC=9A=B4=20=EC=B0=BD=EB=A6=BD=EC=9E=90=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=8A=B9=EA=B3=84=20=C2=B7=20=EB=8C=80?= =?UTF-8?q?=ED=91=9C=20=ED=8C=AC=EB=8D=A4=20=EC=9E=90=EB=8F=99=20=EC=A7=80?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/account/account.ts | 53 +++++++++++----- app/firebase/functions/src/core/errors.ts | 3 - .../functions/test/planet_create.test.ts | 63 +++++++++++++++---- app/lib/core/data/backend.dart | 2 - app/lib/features/planet/planet_data.dart | 6 ++ .../features/planet/planet_detail_screen.dart | 27 +++++++- .../planet/planet_repository_firestore.dart | 3 + .../planet/widgets/planet_detail_hero.dart | 2 +- app/lib/l10n/app_en.arb | 4 +- app/lib/l10n/app_es.arb | 4 +- app/lib/l10n/app_ja.arb | 4 +- app/lib/l10n/app_ko.arb | 4 +- app/lib/l10n/app_localizations.dart | 18 +++++- app/lib/l10n/app_localizations_en.dart | 10 ++- app/lib/l10n/app_localizations_es.dart | 10 ++- app/lib/l10n/app_localizations_ja.dart | 8 ++- app/lib/l10n/app_localizations_ko.dart | 8 ++- app/lib/l10n/app_localizations_zh.dart | 8 ++- app/lib/l10n/app_zh.arb | 4 +- app/lib/l10n/remote_app_localizations.dart | 12 +++- app/test/f3_screens_test.dart | 30 ++++++++- 21 files changed, 229 insertions(+), 54 deletions(-) diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index 07d87f3..caf4ef1 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -59,10 +59,8 @@ export const PLANET_COLORS = ['white', 'black', 'blue', 'green', 'gold', 'orange * `nebulaConversionDeadlineEnforced` — 7일을 넘긴 성운의 전환을 막을지. B-10 「미달성 시 유지」와 * 「7일 이내 판정」이 갈려 발주사 확인 중이며, 회신 전에는 막지 않는다(false). * - 대표 팬덤: 원장 C-15 「변경 무료 · 재변경까지 초기 30일 대기 · 운영자 조정 가능」. - * - `nebulaFounderLeaveBlocked` — 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03 - * 권고 A안 · true). false 면 막지 않는다. - * - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지 - * (Q-PN-04). `latest`(권고 B안 · 가장 최근 가입) · `earliest`(C안) · `none`(A안 · 비워 둠). + * - `primaryOnLeave` — 대표 팬덤에서 탈퇴할 때 남은 가입 팬덤 가운데 무엇을 대표로 둘지. + * 원장 C-101 확정값은 `latest`(가장 최근 가입). `earliest` · `none` 은 확정 전 남겨 둔 운영 스위치. * 자동 지정은 C-15 대기에 세지 않는다. */ const FANDOM_DEFAULTS = { @@ -71,7 +69,6 @@ const FANDOM_DEFAULTS = { nebulaDays: 7, nebulaConversionDeadlineEnforced: false, primaryCooldownDays: 30, - nebulaFounderLeaveBlocked: true, primaryOnLeave: 'latest' as 'latest' | 'earliest' | 'none', }; @@ -204,6 +201,7 @@ export const fnNebulaCreate = onCall(async (req) => { * * 이미 멤버면 아무것도 쓰지 않는다(두 번 눌러도 `memberCount` 는 한 번만 오른다). * 대표 팬덤이 없던 계정은 처음 가입한 팬덤이 대표가 된다 — 그 외에는 대표를 바꾸지 않는다. + * 탄생 권한자가 비어 있는 성운(원장 S20)에 가입하면 그 사람이 탄생 권한자가 된다. */ export const fnFandomJoin = onCall(async (req) => { const uid = callerUid(req); @@ -218,6 +216,10 @@ export const fnFandomJoin = onCall(async (req) => { if (!fandomSnap.exists) fail(ERR.permissionDenied, '행성을 찾을 수 없습니다.'); if (memberSnap.exists) return { fandomId, joined: false }; writeJoin(tx, uid, fandomId, fandomSnap.data()); + // 원장 S20(확인 전 유지 기준) — 창립자가 혼자 떠나 탄생 권한자가 빈 성운은 다음 가입자가 이어받는다. + if (fandomSnap.get('stage') === 0 && !fandomSnap.get('founderUid')) { + tx.set(fandomSnap.ref, { founderUid: uid }, { merge: true }); + } if (!primaryOf(userSnap.data())) { tx.set(userSnap.ref, { primaryFandomId: fandomId, fandomId, updatedAt: FieldValue.serverTimestamp(), @@ -231,10 +233,12 @@ export const fnFandomJoin = onCall(async (req) => { * `fn-fandom-leave` — 탈퇴. 멤버가 아니면 아무것도 하지 않는다(두 번 눌러도 한 번만 내린다). * * 과거 기여 · Passport 기록(원장 · growthEvents · popTransactions)은 지우지 않는다(C-15). + * 순번은 `memberSeq` 를 되돌리지 않으므로 다른 사용자에게 다시 주지 않는다(C-100 · B-68). * - * - 성운(Stage0) 창립자는 탄생 전 떠날 수 없다(E-FDM-05 · Q-PN-03) — 탄생 권한자가 사라지면 - * 조건을 채워도 행성이 될 수 없다. - * - 대표 팬덤을 떠나면 `primaryOnLeave` 대로 남은 팬덤 하나를 대표로 둔다(Q-PN-04). 자동 지정이라 + * - 성운(Stage0) 창립자도 탄생 전 떠날 수 있다(원장 C-100). 탄생 권한(`founderUid`)은 남은 멤버 + * 가운데 `joinedAt` 이 가장 이른 사용자에게 넘기고 알림을 남긴다. 남은 멤버가 없으면 비워 두고 + * 다음 가입자가 이어받는다(원장 S20 확인 전 유지 기준). `ownerUid`(행성 문서) 등 최초 생성자 기록은 그대로다. + * - 대표 팬덤을 떠나면 남은 팬덤 중 가장 최근 가입한 팬덤을 대표로 둔다(원장 C-101). 자동 지정이라 * `primaryChangedAt` 을 찍지 않는다 — 사용자의 다음 직접 변경을 30일 대기로 막지 않는다. */ export const fnFandomLeave = onCall(async (req) => { @@ -248,31 +252,52 @@ export const fnFandomLeave = onCall(async (req) => { tx.get(memberRef), tx.get(db.doc(`users/${uid}`)), tx.get(db.doc(`fandoms/${fandomId}`)), ]); if (!memberSnap.exists) return { fandomId, left: false }; - if (policy.nebulaFounderLeaveBlocked === true && - fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid) { - fail(ERR.nebulaFounderLeave, '행성이 탄생한 뒤에 탈퇴할 수 있어요.'); + + // 트랜잭션은 읽기를 쓰기보다 먼저 끝내야 한다 — 승계 · 대표 후보를 여기서 다 읽는다. + // 떠나는 사람을 빼고 하나가 남도록 두 건만 읽는다. + const handsOver = fandomSnap.get('stage') === 0 && fandomSnap.get('founderUid') === uid; + let nextFounder: string | null = null; + if (handsOver) { + const first = await tx.get(fandomSnap.ref.collection('members').orderBy('joinedAt', 'asc').limit(2)); + nextFounder = first.docs.map((d) => d.id).find((id) => id !== uid) ?? null; } const wasPrimary = primaryOf(userSnap.data()) === fandomId; let nextPrimary: string | null = null; if (wasPrimary && policy.primaryOnLeave !== 'none') { - // 떠나는 팬덤을 빼고 하나가 남도록 두 건만 읽는다. const rest = await tx.get(db.collection(`users/${uid}/memberships`) .orderBy('joinedAt', policy.primaryOnLeave === 'earliest' ? 'asc' : 'desc').limit(2)); nextPrimary = rest.docs.map((d) => d.id).find((id) => id !== fandomId) ?? null; } + tx.delete(memberRef); tx.delete(db.doc(`users/${uid}/memberships/${fandomId}`)); tx.set(userSnap.ref, { fandomIds: FieldValue.arrayRemove(fandomId) }, { merge: true }); - tx.update(db.doc(`fandoms/${fandomId}`), { + tx.update(fandomSnap.ref, { memberCount: FieldValue.increment(-1), + ...(handsOver ? { founderUid: nextFounder } : {}), updatedAt: FieldValue.serverTimestamp(), }); + if (nextFounder) { + // 알림함(`users/{uid}/notifications`) 한 건 — 같은 승계를 두 번 적지 않게 ID 를 고정한다. + tx.set(db.doc(`users/${nextFounder}/notifications/founder_${fandomId}_${uid}`), { + type: 'essential', + title: `「${fandomSnap.get('name') ?? ''}」 성운의 행성 탄생 권한을 이어받았어요.`, + fandomId, + read: false, + createdAt: FieldValue.serverTimestamp(), + }); + } if (wasPrimary) { tx.set(userSnap.ref, { primaryFandomId: nextPrimary, fandomId: nextPrimary ?? '', updatedAt: FieldValue.serverTimestamp(), }, { merge: true }); } - return { fandomId, left: true, primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()) }; + return { + fandomId, + left: true, + primaryFandomId: wasPrimary ? nextPrimary : primaryOf(userSnap.data()), + ...(handsOver ? { founderUid: nextFounder } : {}), + }; }); }); diff --git a/app/firebase/functions/src/core/errors.ts b/app/firebase/functions/src/core/errors.ts index a3ff6ce..7fa0272 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -33,8 +33,6 @@ export const ERR = { nebulaDailyLimit: 'E-FDM-02', nebulaNotReady: 'E-FDM-03', primaryCooldown: 'E-FDM-04', - // 성운 창립자는 행성 탄생 전 탈퇴 불가(해석 확인 요청 Q-PN-03 A안). - nebulaFounderLeave: 'E-FDM-05', permissionDenied: 'E-PERM-01', authFailed: 'E-AUTH-03', // 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다. @@ -76,7 +74,6 @@ const STATUS: Record = { 'E-FDM-02': 'resource-exhausted', 'E-FDM-03': 'failed-precondition', 'E-FDM-04': 'failed-precondition', - 'E-FDM-05': 'failed-precondition', 'E-PERM-01': 'permission-denied', 'E-AUTH-03': 'permission-denied', 'E-TRN-01': 'permission-denied', diff --git a/app/firebase/functions/test/planet_create.test.ts b/app/firebase/functions/test/planet_create.test.ts index efdc21f..fd918c0 100644 --- a/app/firebase/functions/test/planet_create.test.ts +++ b/app/firebase/functions/test/planet_create.test.ts @@ -129,25 +129,62 @@ describe('가입 · 탈퇴 · 대표 팬덤', () => { }); }); -describe('창립자 탈퇴 · 대표 팬덤 자동 지정(Q-PN-03 · Q-PN-04 권고안)', () => { +describe('창립자 탈퇴 승계 · 대표 팬덤 자동 지정(원장 C-100 · C-101 · S20)', () => { const setPolicy = (patch: Record) => db().doc('config/policy').set(patch, { merge: true }); + const THIRD = 'test-planet-create-third'; afterEach(async () => { - await db().doc('config/policy').set({ - nebulaFounderLeaveBlocked: FieldValue.delete(), primaryOnLeave: FieldValue.delete(), - }, { merge: true }); + await db().doc('config/policy').set({ primaryOnLeave: FieldValue.delete() }, { merge: true }); + await wipeUser(THIRD); + await db().recursiveDelete(db().collection(`users/${THIRD}/memberships`)); + await db().recursiveDelete(db().collection(`users/${OTHER}/notifications`)); + await db().recursiveDelete(db().collection(`users/${THIRD}/notifications`)); }); - it('성운 창립자는 탄생 전 탈퇴 E-FDM-05 · 탄생 뒤에는 탈퇴 가능 · 설정을 끄면 막지 않는다', async () => { - const id = (await call(fnNebulaCreate, { name: 'Founder Stay' })).fandomId as string; - expect(await codeOf(() => call(fnFandomLeave, { fandomId: id }))).toBe('E-FDM-05'); - expect((await fandom(id)).memberCount).toBe(1); + it('탄생 전 창립자 탈퇴 허용 → 가장 먼저 가입한 남은 멤버가 탄생 권한자 · 알림 · 이전 창립자 탄생 거부 · 순번 비재사용', async () => { + const id = (await call(fnNebulaCreate, { name: 'Founder Go' })).fandomId as string; + await call(fnFandomJoin, { fandomId: id }, OTHER); + await call(fnFandomJoin, { fandomId: id }, THIRD); + // 가입 시각을 OTHER < THIRD 로 못박는다. + await db().doc(`fandoms/${id}/members/${OTHER}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 2 * 86_400_000) }); + await db().doc(`fandoms/${id}/members/${THIRD}`).update({ joinedAt: Timestamp.fromMillis(Date.now() - 86_400_000) }); + const out = await call(fnFandomLeave, { fandomId: id }); + expect(out).toMatchObject({ left: true, founderUid: OTHER }); + expect(await fandom(id)).toMatchObject({ founderUid: OTHER, memberCount: 2, memberSeq: 3 }); + const notes = await db().collection(`users/${OTHER}/notifications`).get(); + expect(notes.docs.map((d) => d.get('title'))).toEqual(['「Founder Go」 성운의 행성 탄생 권한을 이어받았어요.']); + + await db().doc(`fandoms/${id}`).update({ memberCount: 10, growthPop: 1_000 }); + const pick = { fandomId: id, planetType: 'shape1', colorKey: 'blue' }; + expect(await codeOf(() => call(fnPlanetCreate, pick))).toBe('E-PERM-01'); + + // 떠난 창립자가 다시 가입해도 #001 이 아니라 다음 순번(#4)을 받는다. + await call(fnFandomJoin, { fandomId: id }); + expect((await db().doc(`fandoms/${id}/members/${UID}`).get()).get('memberNo')).toBe(4); + expect((await fandom(id)).founderUid).toBe(OTHER); + await call(fnPlanetCreate, pick, OTHER); + expect((await fandom(id)).stage).toBe(1); + }); + + it('창립자 혼자인 성운 — 탈퇴 허용 · 성운 유지 · 탄생 권한은 비웠다가 다음 가입자에게(S20)', async () => { + const id = (await call(fnNebulaCreate, { name: 'Founder Alone' })).fandomId as string; + expect(await call(fnFandomLeave, { fandomId: id })).toMatchObject({ left: true, founderUid: null }); + expect(await fandom(id)).toMatchObject({ stage: 0, memberCount: 0, founderUid: null, name: 'Founder Alone' }); + + await call(fnFandomJoin, { fandomId: id }, OTHER); + expect((await fandom(id)).founderUid).toBe(OTHER); + // 이미 권한자가 있으면 다음 가입자는 이어받지 않는다. + await call(fnFandomJoin, { fandomId: id }, THIRD); + expect((await fandom(id)).founderUid).toBe(OTHER); + }); + + it('탄생 뒤 창립자 탈퇴는 탄생 권한을 건드리지 않는다', async () => { + const id = (await call(fnNebulaCreate, { name: 'Founder Born' })).fandomId as string; + await call(fnFandomJoin, { fandomId: id }, OTHER); await db().doc(`fandoms/${id}`).update({ stage: 1 }); - expect((await call(fnFandomLeave, { fandomId: id })).left).toBe(true); - - const other = (await call(fnNebulaCreate, { name: 'Founder Go' }, OTHER)).fandomId as string; - await setPolicy({ nebulaFounderLeaveBlocked: false }); - expect((await call(fnFandomLeave, { fandomId: other }, OTHER)).left).toBe(true); + expect(await call(fnFandomLeave, { fandomId: id })).not.toHaveProperty('founderUid'); + expect((await fandom(id)).founderUid).toBe(UID); + expect((await db().collection(`users/${OTHER}/notifications`).get()).empty).toBe(true); }); /** A(대표) · B · C 에 가입 — 가입 시각은 B < C 로 못박는다. */ diff --git a/app/lib/core/data/backend.dart b/app/lib/core/data/backend.dart index ad7d3bf..a53c829 100644 --- a/app/lib/core/data/backend.dart +++ b/app/lib/core/data/backend.dart @@ -85,8 +85,6 @@ class Backend { static String _message(String code, FirebaseFunctionsException error) { final details = error.details; return switch (code) { - // 성운 창립자 탄생 전 탈퇴(Q-PN-03). - 'E-FDM-05' => appL10n.fandomFounderLeaveBlocked, // 검증 활동(Quest 완료) 미달 — 서버가 `done` · `need` 를 싣는다(Campaign 개설자격 A안). 'E-CMP-04' when details is Map && diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart index a286d3d..266ac8c 100644 --- a/app/lib/features/planet/planet_data.dart +++ b/app/lib/features/planet/planet_data.dart @@ -329,6 +329,9 @@ abstract interface class PlanetRepository { /// 이 행성/성운에 가입 — `fn-fandom-join`. 이미 멤버면 서버가 아무것도 하지 않는다. Future join(); + /// 탈퇴 — `fn-fandom-leave`. 대표 팬덤 자동 지정(C-101) · 성운 창립자 승계(C-100)는 서버가 한다. + Future leave(); + /// 명세 — *최초 5개, 스크롤 시 5개씩 호출*. Future> morePosts({required int offset, int limit = 5}); @@ -391,6 +394,9 @@ class SamplePlanetRepository implements PlanetRepository { @override Future join() async {} + @override + Future leave() async {} + @override Future history({String? cursor, bool personal = false}) async => const PlanetHistoryPage(items: []); @override diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart index 2e34b54..f130cd9 100644 --- a/app/lib/features/planet/planet_detail_screen.dart +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -6,6 +6,7 @@ import '../../core/data/backend.dart' show BackendException; import '../../core/data/planet_visual_state.dart'; import '../../core/theme/tokens.dart'; +import '../../core/ui/app_dialog.dart'; import '../../core/ui/app_icons.dart'; import '../eclipse/eclipse_data.dart'; import '../eclipse/widgets/eclipse_banner.dart'; @@ -74,6 +75,30 @@ class _PlanetDetailScreenState extends State { } } + /// 「가입중」 알약 → 탈퇴 시트 → 확인 창 — Figma `343:213428`(탈퇴하기 · 취소) · + /// `343:213582`(행성 탈퇴를 할까요?). 대표 팬덤 자동 지정 · 성운 창립자 승계는 서버 몫이다(C-100 · C-101). + void _leave() => showActionSheet(context, [ + ActionSheetItem(context.l10n.planetLeave, () async { + final ok = await showConfirmDialog( + context, + context.l10n.planetLeaveConfirmTitle, + body: context.l10n.planetLeaveConfirmBody, + confirmLabel: context.l10n.commonConfirm, + ); + if (!ok) return; + try { + await widget.repository.leave(); + if (mounted) { + setState(() { _detail = widget.repository.load(nebula: widget.nebula); }); + } + } on BackendException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.message))); + } + } + }), + ]); + @override Widget build(BuildContext context) => FutureBuilder( future: _detail, @@ -127,7 +152,7 @@ class _PlanetDetailScreenState extends State { ownerName: d.ownerName, ownerPhoto: d.ownerPhoto, joined: d.joined, - onJoin: d.joined ? null : _join, + onJoin: d.joined ? _leave : _join, onOpenHistory: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => PlanetHistoryScreen( diff --git a/app/lib/features/planet/planet_repository_firestore.dart b/app/lib/features/planet/planet_repository_firestore.dart index 187b1a6..3822d92 100644 --- a/app/lib/features/planet/planet_repository_firestore.dart +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -103,6 +103,9 @@ class FirestorePlanetRepository implements PlanetRepository { @override Future join() => _backend.call('fnFandomJoin', {'fandomId': fandomId}); + @override + Future leave() => _backend.call('fnFandomLeave', {'fandomId': fandomId}); + @override Future load({required bool nebula}) async { final db = _backend.db; diff --git a/app/lib/features/planet/widgets/planet_detail_hero.dart b/app/lib/features/planet/widgets/planet_detail_hero.dart index 36d9356..eac4eb3 100644 --- a/app/lib/features/planet/widgets/planet_detail_hero.dart +++ b/app/lib/features/planet/widgets/planet_detail_hero.dart @@ -43,7 +43,7 @@ class PlanetDetailHero extends StatelessWidget { final bool joined; final VoidCallback? onHeroParticipate; - /// 「가입하기」 알약을 눌렀을 때. 이미 가입했으면 null 이고 알약은 상태만 보인다. + /// 알약을 눌렀을 때 — 「가입하기」면 가입, 「가입중」이면 탈퇴 시트. null 이면 상태만 보인다. final VoidCallback? onJoin; /// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다. diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index ecdc4f7..ce49e30 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -630,7 +630,9 @@ "nebulaCreateFailed": "Couldn't create the nebula. Please try again later.", "nebulaAlreadyToday": "You've already created a nebula today.", "nebulaCreatedKeepPrimary": "Nebula created. Your main planet stays the same because it can only be changed 30 days after the last change.", - "fandomFounderLeaveBlocked": "You can leave after the planet is born.", + "planetLeave": "Leave", + "planetLeaveConfirmTitle": "Leave this planet?", + "planetLeaveConfirmBody": "Contributed POP and activity\ncannot be undone.", "sortPopular": "Popular", "campaignSortContributing": "Contributing", "campaignStatusReview": "This campaign is under review.", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 9ed0795..9c57af6 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -630,7 +630,9 @@ "nebulaCreateFailed": "No se pudo crear la nebulosa. Inténtalo más tarde.", "nebulaAlreadyToday": "Ya creaste una nebulosa hoy.", "nebulaCreatedKeepPrimary": "Nebulosa creada. Tu planeta principal no cambia: solo puede cambiarse 30 días después del último cambio.", - "fandomFounderLeaveBlocked": "Podrás salir cuando nazca el planeta.", + "planetLeave": "Salir", + "planetLeaveConfirmTitle": "¿Salir de este planeta?", + "planetLeaveConfirmBody": "El POP aportado y la actividad\nno se pueden deshacer.", "sortPopular": "Populares", "campaignSortContributing": "Contribuyendo", "campaignStatusReview": "Esta campaña está en revisión.", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index aef6f77..7bbc93c 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -630,7 +630,9 @@ "nebulaCreateFailed": "星雲を作成できませんでした。しばらくしてから再度お試しください。", "nebulaAlreadyToday": "今日はすでに星雲を作成しました。", "nebulaCreatedKeepPrimary": "星雲を作成しました。メイン惑星は前回の変更から30日経過後に変更できるため、そのままです。", - "fandomFounderLeaveBlocked": "惑星が誕生した後に脱退できます。", + "planetLeave": "脱退する", + "planetLeaveConfirmTitle": "惑星から脱退しますか?", + "planetLeaveConfirmBody": "貢献したPOPと活動は\n元に戻せません。", "sortPopular": "人気順", "campaignSortContributing": "貢献中", "campaignStatusReview": "運営が審査中のキャンペーンです。", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 7251217..d7ec337 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1248,7 +1248,9 @@ "nebulaCreateFailed": "성운을 만들지 못했습니다. 잠시 후 다시 시도해 주세요.", "nebulaAlreadyToday": "이미 오늘 성운을 생성하셨습니다.", "nebulaCreatedKeepPrimary": "성운을 만들었습니다. 기본 행성은 최근 변경 후 30일이 지나야 바꿀 수 있어 그대로 유지됩니다.", - "fandomFounderLeaveBlocked": "행성이 탄생한 뒤에 탈퇴할 수 있어요.", + "planetLeave": "탈퇴하기", + "planetLeaveConfirmTitle": "행성 탈퇴를 할까요?", + "planetLeaveConfirmBody": "기여한 POP와 활동은 모두\n되돌릴 수 없어요.", "sortPopular": "인기순", "campaignSortContributing": "기여중", "campaignStatusReview": "운영 검수 중인 캠페인입니다.", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 5289c1e..c552218 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -3884,11 +3884,23 @@ abstract class AppLocalizations { /// **'성운을 만들었습니다. 기본 행성은 최근 변경 후 30일이 지나야 바꿀 수 있어 그대로 유지됩니다.'** String get nebulaCreatedKeepPrimary; - /// No description provided for @fandomFounderLeaveBlocked. + /// No description provided for @planetLeave. /// /// In ko, this message translates to: - /// **'행성이 탄생한 뒤에 탈퇴할 수 있어요.'** - String get fandomFounderLeaveBlocked; + /// **'탈퇴하기'** + String get planetLeave; + + /// No description provided for @planetLeaveConfirmTitle. + /// + /// In ko, this message translates to: + /// **'행성 탈퇴를 할까요?'** + String get planetLeaveConfirmTitle; + + /// No description provided for @planetLeaveConfirmBody. + /// + /// In ko, this message translates to: + /// **'기여한 POP와 활동은 모두\n되돌릴 수 없어요.'** + String get planetLeaveConfirmBody; /// No description provided for @sortPopular. /// diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 3323598..2dab0df 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -2190,8 +2190,14 @@ class AppLocalizationsEn extends AppLocalizations { 'Nebula created. Your main planet stays the same because it can only be changed 30 days after the last change.'; @override - String get fandomFounderLeaveBlocked => - 'You can leave after the planet is born.'; + String get planetLeave => 'Leave'; + + @override + String get planetLeaveConfirmTitle => 'Leave this planet?'; + + @override + String get planetLeaveConfirmBody => + 'Contributed POP and activity\ncannot be undone.'; @override String get sortPopular => 'Popular'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index ed1d512..b51fb03 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -2200,8 +2200,14 @@ class AppLocalizationsEs extends AppLocalizations { 'Nebulosa creada. Tu planeta principal no cambia: solo puede cambiarse 30 días después del último cambio.'; @override - String get fandomFounderLeaveBlocked => - 'Podrás salir cuando nazca el planeta.'; + String get planetLeave => 'Salir'; + + @override + String get planetLeaveConfirmTitle => '¿Salir de este planeta?'; + + @override + String get planetLeaveConfirmBody => + 'El POP aportado y la actividad\nno se pueden deshacer.'; @override String get sortPopular => 'Populares'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 168f702..f484adf 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -2118,7 +2118,13 @@ class AppLocalizationsJa extends AppLocalizations { '星雲を作成しました。メイン惑星は前回の変更から30日経過後に変更できるため、そのままです。'; @override - String get fandomFounderLeaveBlocked => '惑星が誕生した後に脱退できます。'; + String get planetLeave => '脱退する'; + + @override + String get planetLeaveConfirmTitle => '惑星から脱退しますか?'; + + @override + String get planetLeaveConfirmBody => '貢献したPOPと活動は\n元に戻せません。'; @override String get sortPopular => '人気順'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index f0f5a11..edb2669 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -2119,7 +2119,13 @@ class AppLocalizationsKo extends AppLocalizations { '성운을 만들었습니다. 기본 행성은 최근 변경 후 30일이 지나야 바꿀 수 있어 그대로 유지됩니다.'; @override - String get fandomFounderLeaveBlocked => '행성이 탄생한 뒤에 탈퇴할 수 있어요.'; + String get planetLeave => '탈퇴하기'; + + @override + String get planetLeaveConfirmTitle => '행성 탈퇴를 할까요?'; + + @override + String get planetLeaveConfirmBody => '기여한 POP와 활동은 모두\n되돌릴 수 없어요.'; @override String get sortPopular => '인기순'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 89eaee6..1971a1e 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -2115,7 +2115,13 @@ class AppLocalizationsZh extends AppLocalizations { String get nebulaCreatedKeepPrimary => '星云已创建。默认星球需在上次更改 30 天后才能更换,因此保持不变。'; @override - String get fandomFounderLeaveBlocked => '行星诞生后才能退出。'; + String get planetLeave => '退出'; + + @override + String get planetLeaveConfirmTitle => '要退出这个行星吗?'; + + @override + String get planetLeaveConfirmBody => '已贡献的POP和活动\n都无法撤回。'; @override String get sortPopular => '热门'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index ec504c9..9481e5f 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -630,7 +630,9 @@ "nebulaCreateFailed": "无法创建星云,请稍后再试。", "nebulaAlreadyToday": "你今天已创建过星云。", "nebulaCreatedKeepPrimary": "星云已创建。默认星球需在上次更改 30 天后才能更换,因此保持不变。", - "fandomFounderLeaveBlocked": "行星诞生后才能退出。", + "planetLeave": "退出", + "planetLeaveConfirmTitle": "要退出这个行星吗?", + "planetLeaveConfirmBody": "已贡献的POP和活动\n都无法撤回。", "sortPopular": "热门", "campaignSortContributing": "贡献中", "campaignStatusReview": "该活动正在接受运营审核。", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index 430454e..f87bcf2 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -658,9 +658,6 @@ class RemoteAppLocalizations extends AppLocalizations { @override String get evoTransition => _remote['evoTransition'] ?? _base.evoTransition; - @override - String get fandomFounderLeaveBlocked => _remote['fandomFounderLeaveBlocked'] ?? _base.fandomFounderLeaveBlocked; - @override String get focusActive => _remote['focusActive'] ?? _base.focusActive; @@ -1426,6 +1423,15 @@ class RemoteAppLocalizations extends AppLocalizations { @override String planetLastUpdated(String time) => _fill('planetLastUpdated', {'time': time}) ?? _base.planetLastUpdated(time); + @override + String get planetLeave => _remote['planetLeave'] ?? _base.planetLeave; + + @override + String get planetLeaveConfirmBody => _remote['planetLeaveConfirmBody'] ?? _base.planetLeaveConfirmBody; + + @override + String get planetLeaveConfirmTitle => _remote['planetLeaveConfirmTitle'] ?? _base.planetLeaveConfirmTitle; + @override String get planetLiveRanking => _remote['planetLiveRanking'] ?? _base.planetLiveRanking; diff --git a/app/test/f3_screens_test.dart b/app/test/f3_screens_test.dart index a378a96..fe8a4bb 100644 --- a/app/test/f3_screens_test.dart +++ b/app/test/f3_screens_test.dart @@ -95,17 +95,21 @@ class _NoTopPercentRepository extends SamplePlanetRepository { class _Joinable extends SamplePlanetRepository { _Joinable(); int joins = 0; + int leaves = 0; @override Future join() async => joins++; + @override + Future leave() async => leaves++; + @override Future load({required bool nebula}) async { final d = await super.load(nebula: nebula); return PlanetDetail( fandomId: d.fandomId, name: d.name, stage: d.stage, isNebula: d.isNebula, totalFpi: d.totalFpi, scores: d.scores, members: d.members, posts: d.posts, - joined: joins > 0, + joined: joins > leaves, ); } } @@ -120,11 +124,33 @@ void main() { await tester.pumpAndSettle(); expect(repo.joins, 1); expect(find.text('가입중'), findsOneWidget); - // 가입중 알약은 다시 눌러도 호출하지 않는다. + // 가입중 알약은 다시 눌러도 가입을 부르지 않는다(탈퇴 시트가 열린다). await tester.tap(find.text('가입중')); await tester.pumpAndSettle(); expect(repo.joins, 1); }); + testWidgets('「가입중」 → 탈퇴하기 → 확인 창(343:213582)에서 확인해야 fn-fandom-leave 를 부른다', (tester) async { + final repo = _Joinable()..joins = 1; + await _pumpTall(tester, PlanetDetailScreen(repository: repo, eclipse: const NoEclipseRepository())); + Future openConfirm() async { + await tester.tap(find.text('가입중')); + await tester.pumpAndSettle(); + await tester.tap(find.text('탈퇴하기')); + await tester.pumpAndSettle(); + expect(find.text('행성 탈퇴를 할까요?'), findsOneWidget); + } + + await openConfirm(); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(repo.leaves, 0); + + await openConfirm(); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + expect(repo.leaves, 1); + expect(find.text('가입하기'), findsOneWidget); + }); group('행성 상세 · FPI (F3 · 343:213428)', () { testWidgets('TOTAL FPI 값과 갱신 주기를 띄운다', (tester) async { await _pumpTall(tester, const PlanetDetailScreen(