팬덤 탈퇴 · 성운 창립자 권한 승계 · 대표 팬덤 자동 지정

This commit is contained in:
Sn-Kinos
2026-10-05 17:41:20 +09:00
parent 8a1b824307
commit 3a3be4c3d3
21 changed files with 229 additions and 54 deletions
+39 -14
View File
@@ -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 } : {}),
};
});
});
@@ -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<ErrorCode, Status> = {
'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',
@@ -129,25 +129,62 @@ describe('가입 · 탈퇴 · 대표 팬덤', () => {
});
});
describe('창립자 탈퇴 · 대표 팬덤 자동 지정(Q-PN-03 · Q-PN-04 권고안)', () => {
describe('창립자 탈퇴 승계 · 대표 팬덤 자동 지정(원장 C-100 · C-101 · S20)', () => {
const setPolicy = (patch: Record<string, unknown>) => 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 로 못박는다. */
-2
View File
@@ -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 &&
+6
View File
@@ -329,6 +329,9 @@ abstract interface class PlanetRepository {
/// 이 행성/성운에 가입 — `fn-fandom-join`. 이미 멤버면 서버가 아무것도 하지 않는다.
Future<void> join();
/// 탈퇴 — `fn-fandom-leave`. 대표 팬덤 자동 지정(C-101) · 성운 창립자 승계(C-100)는 서버가 한다.
Future<void> leave();
/// 명세 — *최초 5개, 스크롤 시 5개씩 호출*.
Future<List<LoungePost>> morePosts({required int offset, int limit = 5});
@@ -391,6 +394,9 @@ class SamplePlanetRepository implements PlanetRepository {
@override
Future<void> join() async {}
@override
Future<void> leave() async {}
@override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async => const PlanetHistoryPage(items: []);
@override
@@ -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<PlanetDetailScreen> {
}
}
/// 「가입중」 알약 → 탈퇴 시트 → 확인 창 — 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<PlanetDetail>(
future: _detail,
@@ -127,7 +152,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
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<void>(
builder: (_) => PlanetHistoryScreen(
@@ -103,6 +103,9 @@ class FirestorePlanetRepository implements PlanetRepository {
@override
Future<void> join() => _backend.call('fnFandomJoin', {'fandomId': fandomId});
@override
Future<void> leave() => _backend.call('fnFandomLeave', {'fandomId': fandomId});
@override
Future<PlanetDetail> load({required bool nebula}) async {
final db = _backend.db;
@@ -43,7 +43,7 @@ class PlanetDetailHero extends StatelessWidget {
final bool joined;
final VoidCallback? onHeroParticipate;
/// 「가입하기」 알약을 눌렀을 때. 이미 가입했으면 null 이고 알약은 상태만 보인다.
/// 알약을 눌렀을 때 — 「가입하기」면 가입, 「가입중」이면 탈퇴 시트. null 이면 상태만 보인다.
final VoidCallback? onJoin;
/// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다.
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -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.",
+3 -1
View File
@@ -630,7 +630,9 @@
"nebulaCreateFailed": "星雲を作成できませんでした。しばらくしてから再度お試しください。",
"nebulaAlreadyToday": "今日はすでに星雲を作成しました。",
"nebulaCreatedKeepPrimary": "星雲を作成しました。メイン惑星は前回の変更から30日経過後に変更できるため、そのままです。",
"fandomFounderLeaveBlocked": "惑星が誕生した後に脱退できます。",
"planetLeave": "脱退する",
"planetLeaveConfirmTitle": "惑星から脱退しますか?",
"planetLeaveConfirmBody": "貢献したPOPと活動は\n元に戻せません。",
"sortPopular": "人気順",
"campaignSortContributing": "貢献中",
"campaignStatusReview": "運営が審査中のキャンペーンです。",
+3 -1
View File
@@ -1248,7 +1248,9 @@
"nebulaCreateFailed": "성운을 만들지 못했습니다. 잠시 후 다시 시도해 주세요.",
"nebulaAlreadyToday": "이미 오늘 성운을 생성하셨습니다.",
"nebulaCreatedKeepPrimary": "성운을 만들었습니다. 기본 행성은 최근 변경 후 30일이 지나야 바꿀 수 있어 그대로 유지됩니다.",
"fandomFounderLeaveBlocked": "행성이 탄생한 뒤에 탈퇴할 수 있어요.",
"planetLeave": "탈퇴하기",
"planetLeaveConfirmTitle": "행성 탈퇴를 할까요?",
"planetLeaveConfirmBody": "기여한 POP와 활동은 모두\n되돌릴 수 없어요.",
"sortPopular": "인기순",
"campaignSortContributing": "기여중",
"campaignStatusReview": "운영 검수 중인 캠페인입니다.",
+15 -3
View File
@@ -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.
///
+8 -2
View File
@@ -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';
+8 -2
View File
@@ -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';
+7 -1
View File
@@ -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 => '人気順';
+7 -1
View File
@@ -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 => '인기순';
+7 -1
View File
@@ -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 => '热门';
+3 -1
View File
@@ -630,7 +630,9 @@
"nebulaCreateFailed": "无法创建星云,请稍后再试。",
"nebulaAlreadyToday": "你今天已创建过星云。",
"nebulaCreatedKeepPrimary": "星云已创建。默认星球需在上次更改 30 天后才能更换,因此保持不变。",
"fandomFounderLeaveBlocked": "行星诞生后才能退出。",
"planetLeave": "退出",
"planetLeaveConfirmTitle": "要退出这个行星吗?",
"planetLeaveConfirmBody": "已贡献的POP和活动\n都无法撤回。",
"sortPopular": "热门",
"campaignSortContributing": "贡献中",
"campaignStatusReview": "该活动正在接受运营审核。",
+9 -3
View File
@@ -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;
+28 -2
View File
@@ -95,17 +95,21 @@ class _NoTopPercentRepository extends SamplePlanetRepository {
class _Joinable extends SamplePlanetRepository {
_Joinable();
int joins = 0;
int leaves = 0;
@override
Future<void> join() async => joins++;
@override
Future<void> leave() async => leaves++;
@override
Future<PlanetDetail> 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<void> 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(