사용자 화면 문구·표본 데이터 · 퀘스트 참여 흐름 · HOME 진입 구획 · 미니맵 · 공통 확인 창 배치 수정

This commit is contained in:
Sn-Kinos
2026-10-05 00:44:39 +09:00
parent c8d29d032b
commit 8a0297eb92
66 changed files with 1812 additions and 959 deletions
+2 -2
View File
@@ -115,8 +115,8 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
switch (outcome) {
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured(:final missing):
_toast(context.l10n.settingsLinkNotReady(_name, missing.join(' · ')));
case AuthNotConfigured():
_toast(context.l10n.settingsLinkNotReady(_name));
case AuthSucceeded() || AuthCancelled():
break;
}
+29 -45
View File
@@ -33,10 +33,18 @@ enum MyListKind {
enum _MySort { latest, pop, solved, achieved }
class MyListsScreen extends StatefulWidget {
const MyListsScreen({super.key, required this.kind, this.campaigns});
const MyListsScreen({
super.key,
required this.kind,
this.campaigns,
this.insides,
});
final MyListKind kind;
/// 내 인사이드의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
final InsideRepository? insides;
/// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
final CampaignRepository? campaigns;
@@ -47,44 +55,6 @@ class MyListsScreen extends StatefulWidget {
class _MyListsScreenState extends State<MyListsScreen> {
_MySort _sort = _MySort.latest;
/// 표본 — 시안 `370:228289` 문구. 서버가 붙으면 INSIDE 저장소의 내 글로 옮긴다.
static final _insides = () {
final now = DateTime.now();
return <InsideQuestion>[
InsideQuestion(
id: 'i1',
title: 'BTS 컴백 알려주세요!',
body: 'BTS 한국 컴백 시기가 궁급합니다!\n아시는 분들 댓글 부탁드려요 ㅠㅠ',
authorName: 'BTS_Hong',
createdAt: now,
bountyPop: 100,
isMine: true,
),
InsideQuestion(
id: 'i2',
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(minutes: 10)),
bountyPop: 500,
viewCount: 3,
isMine: true,
),
InsideQuestion(
id: 'i3',
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
authorName: 'BTS_Hong',
createdAt: now.subtract(const Duration(days: 30)),
bountyPop: 800,
answerCount: 24,
viewCount: 82,
isMine: true,
isSolved: true,
),
];
}();
List<_MySort> get _sorts => switch (widget.kind) {
MyListKind.inside => const [_MySort.latest, _MySort.pop, _MySort.solved],
MyListKind.campaign => const [_MySort.latest, _MySort.achieved],
@@ -132,13 +102,27 @@ class _MyListsScreenState extends State<MyListsScreen> {
},
);
Widget _insideList(BuildContext context) {
/// 내가 쓴 질문 — 화면을 열 때 한 번 읽고, 정렬 칩은 그 목록을 다시 거른다.
late final Future<List<InsideQuestion>> _mine =
(widget.insides ?? const SampleInsideRepository()).mine();
Widget _insideList(BuildContext context) =>
FutureBuilder<List<InsideQuestion>>(
future: _mine,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
return _insideItems(context, snap.data ?? const []);
},
);
Widget _insideItems(BuildContext context, List<InsideQuestion> mine) {
final items = switch (_sort) {
_MySort.pop => [
..._insides,
]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
_MySort.solved => _insides.where((q) => q.isSolved).toList(),
_ => _insides,
_MySort.pop => [...mine]
..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
_MySort.solved => mine.where((q) => q.isSolved).toList(),
_ => mine,
};
if (items.isEmpty) {
return Center(
+25 -10
View File
@@ -9,6 +9,7 @@ import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
import '../campaign/campaign_data.dart' show CampaignRepository;
import '../inside/inside_data.dart' show InsideRepository;
import 'my_data.dart';
import 'my_lists_screen.dart';
import 'profile_edit_screen.dart';
@@ -29,6 +30,7 @@ class MyScreen extends StatefulWidget {
this.push,
this.auth,
this.campaigns,
this.insides,
});
final MyRepository repository;
@@ -36,6 +38,9 @@ class MyScreen extends StatefulWidget {
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
final CampaignRepository? campaigns;
/// 「내 인사이드」가 읽는 저장소. null 이면 표본.
final InsideRepository? insides;
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
final AccountLinker? linker;
@@ -251,7 +256,10 @@ class _MyScreenState extends State<MyScreen> {
_Row(
label: context.l10n.myInside,
onTap: () => _push(
const MyListsScreen(kind: MyListKind.inside),
MyListsScreen(
kind: MyListKind.inside,
insides: widget.insides,
),
),
),
_Row(
@@ -314,16 +322,23 @@ class _MyScreenState extends State<MyScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
child: ProfileCard(
profile: profile,
onShare: (target) {
onShare: (target) async {
final l10n = context.l10n;
final messenger = ScaffoldMessenger.of(this.context);
Navigator.of(context).pop();
ScaffoldMessenger.of(this.context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.myShareLater(target)),
behavior: SnackBarBehavior.floating,
),
);
try {
if (!await shareProfile(profile, target)) return;
messenger
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(l10n.shareCopied),
behavior: SnackBarBehavior.floating,
),
);
} catch (_) {
messenger.showSnackBar(SnackBar(content: Text(l10n.commonRequestFailed)));
}
},
),
),
+10 -133
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/push/push_messaging.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
@@ -281,13 +282,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
await _notice(context.l10n.settingsSameEmailOnly);
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured(:final missing):
_toast(
context.l10n.settingsLinkNotReady(
_snsName(context, provider),
missing.join(' · '),
),
);
case AuthNotConfigured():
_toast(context.l10n.settingsLinkNotReady(_snsName(context, provider)));
case AuthSucceeded() || AuthCancelled():
break;
}
@@ -344,19 +340,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
/// 안내 팝업(확인 하나) — 시안 `554:23103`.
Future<void> _notice(String message) => showDialog<void>(
context: context,
builder: (context) => _MyDialog(
title: message,
actions: [
_DialogButton(
label: context.l10n.commonConfirm,
filled: true,
onTap: () => Navigator.of(context).pop(),
),
],
),
);
Future<void> _notice(String message) => showAlertDialog(context, message);
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
Future<bool> _confirm({
@@ -366,30 +350,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool withdraw = false,
Future<void> Function()? onConfirm,
}) async {
final ok = await showDialog<bool>(
context: context,
builder: (context) => _MyDialog(
title: title,
body: body,
actions: [
_DialogButton(
label: context.l10n.commonCancel,
onTap: () => Navigator.of(context).pop(false),
),
_DialogButton(
label: action,
filled: true,
onTap: () => Navigator.of(context).pop(true),
),
],
),
final ok = await showConfirmDialog(
context,
title,
body: body,
confirmLabel: action,
);
if (ok == true && onConfirm != null) {
await onConfirm();
} else if (ok == true && withdraw && mounted) {
_toast(context.l10n.settingsWithdrawLater);
}
return ok ?? false;
return ok;
}
}
@@ -522,101 +494,6 @@ class _SnsRow extends StatelessWidget {
}
/// 팝업 — 바탕 #2d2d2d · 좌우 31.5 · 안쪽 24/16 · 제목 16/600 가운데 · 버튼 높이 48 간격 8.
class _MyDialog extends StatelessWidget {
const _MyDialog({required this.title, this.body, required this.actions});
final String title;
final String? body;
final List<Widget> actions;
@override
Widget build(BuildContext context) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w600),
),
if (body case final b?) ...[
const SizedBox(height: 16),
Text(
b,
textAlign: TextAlign.center,
style: myText(16, FontWeight.w500),
),
],
const SizedBox(height: 24),
Row(
children: [
for (final (i, a) in actions.indexed) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: a),
],
],
),
],
),
),
);
}
class _DialogButton extends StatelessWidget {
const _DialogButton({
required this.label,
required this.onTap,
this.filled = false,
});
final String label;
final VoidCallback onTap;
final bool filled;
@override
Widget build(BuildContext context) {
final shape = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
);
final text = Text(
label,
style: myText(
16,
FontWeight.w600,
height: 1,
color: filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
),
);
return SizedBox(
height: 48,
child: filled
? FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
backgroundColor: AppColors.cosmicAccent,
shape: shape,
),
child: text,
)
: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(48),
padding: EdgeInsets.zero,
side: const BorderSide(color: AppColors.onCosmic),
shape: shape,
),
child: text,
),
);
}
}
/// 언어 설정 시트 — 시안 `357:224379`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」.
class _LanguageSheet extends StatefulWidget {
const _LanguageSheet({required this.current});
+32 -1
View File
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/format.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../my_data.dart';
import 'my_cards.dart';
import '../../../core/i18n/app_locale.dart';
import '../../campaign/campaign_share.dart' show shareHashtags;
/// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`).
///
@@ -17,11 +21,12 @@ class ProfileCard extends StatelessWidget {
final void Function(String target) onShare;
/// 공유 대상과 시안 아이콘. 명세가 적은 순서 그대로다.
/// 프로필 웹 주소가 아직 없어 마지막 칸은 「링크 복사」가 아니라 「문구 복사」다.
static List<String> get shareTargets => [
appL10n.shareInstagram,
appL10n.shareTiktok,
'X',
appL10n.shareCopyLink,
appL10n.shareCopyText,
];
static const _shareIcons = [
'assets/figma/share-instagram.png',
@@ -106,3 +111,29 @@ class ProfileCard extends StatelessWidget {
),
);
}
/// 프로필 공유 문구 — 공유 시트와 복사가 같은 문구를 쓴다.
String profileShareText(MyProfile profile, [AppLocalizations? l10n]) => [
(l10n ?? appL10n).myShareText(
profile.nickname,
'${profile.userLevel}',
formatPop(profile.contributedPop),
),
shareHashtags().join(' '),
].join('\n');
/// 프로필 카드 공유. 앱별 전용 SDK 없이 OS 공유 시트로 보내고(사용자가 그 안에서
/// 인스타 · 틱톡 · X 를 고른다), 마지막 칸은 문구를 복사한다. 복사했으면 true.
Future<bool> shareProfile(
MyProfile profile,
String target, {
Future<ShareResult> Function(ShareParams)? share,
}) async {
final text = profileShareText(profile);
if (target == ProfileCard.shareTargets.last) {
await Clipboard.setData(ClipboardData(text: text));
return true;
}
await (share ?? SharePlus.instance.share)(ShareParams(text: text));
return false;
}