INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
/// STATION 탭이 그리는 값의 형태.
|
||||
///
|
||||
/// **STATION 은 Figma 에 화면이 없다.** 있는 것은 Bottom Navigation 의 탭 하나뿐이며,
|
||||
/// 근거는 견적서 v2.1 의 5탭 구성과 Firestore 설계서의 `stationLinks` ·
|
||||
/// `stationSuggestions` 두 컬렉션, 그리고 화면 정의 S-S-HUB · S-S-SUGGEST 다.
|
||||
/// 그래서 이 화면은 **데이터 모델이 정의한 것까지만** 그린다 — 디자인이 확정되면
|
||||
/// 그 시안으로 다시 맞춘다.
|
||||
///
|
||||
/// 하는 일은 큐레이션이다. 운영이 검증한 판매처·공식 링크를 모아 보여 주고,
|
||||
/// 사용자가 새 판매처를 제안하면 운영이 심사한다(`stationSuggestions`).
|
||||
library;
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 링크 분류. `stationLinks` 의 `category` 값이다.
|
||||
enum StationCategory { official, shop, ticket, media }
|
||||
|
||||
extension StationCategoryLabel on StationCategory {
|
||||
String get label => switch (this) {
|
||||
StationCategory.official => appL10n.stationOfficial,
|
||||
StationCategory.shop => appL10n.stationShop,
|
||||
StationCategory.ticket => appL10n.stationTicket,
|
||||
StationCategory.media => appL10n.stationMedia,
|
||||
};
|
||||
}
|
||||
|
||||
/// 큐레이션 링크 한 줄.
|
||||
class StationLink {
|
||||
const StationLink({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.url,
|
||||
required this.category,
|
||||
this.description = '',
|
||||
this.verified = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
|
||||
/// 바깥 브라우저로 여는 주소. 앱 안에서 열지 않는다 — 결제·로그인이 걸린
|
||||
/// 외부 사이트를 인앱 웹뷰에 담으면 심사에서 문제가 된다.
|
||||
final String url;
|
||||
|
||||
final StationCategory category;
|
||||
final String description;
|
||||
|
||||
/// 운영이 검증한 판매처. 명세가 없어 표시만 하고 정렬에는 쓰지 않는다.
|
||||
final bool verified;
|
||||
}
|
||||
|
||||
class StationSnapshot {
|
||||
const StationSnapshot({required this.links, this.canSuggest = true});
|
||||
|
||||
final List<StationLink> links;
|
||||
|
||||
/// 제안 버튼 노출. 로그인 사용자만 제안할 수 있다(규칙 `authorIsSelf()`).
|
||||
final bool canSuggest;
|
||||
}
|
||||
|
||||
abstract interface class StationRepository {
|
||||
Future<StationSnapshot> load();
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전에 쓰는 표본.
|
||||
class SampleStationRepository implements StationRepository {
|
||||
const SampleStationRepository();
|
||||
|
||||
@override
|
||||
Future<StationSnapshot> load() async => const StationSnapshot(
|
||||
links: [
|
||||
StationLink(
|
||||
id: 'official-site',
|
||||
title: 'FANUZU 공식 사이트',
|
||||
url: 'https://fanuzu.co.kr',
|
||||
category: StationCategory.official,
|
||||
description: '공지와 일정이 가장 먼저 올라옵니다.',
|
||||
verified: true,
|
||||
),
|
||||
StationLink(
|
||||
id: 'shop-official',
|
||||
title: '공식 스토어',
|
||||
url: 'https://fanuzu.co.kr',
|
||||
category: StationCategory.shop,
|
||||
description: '앨범과 응원봉을 파는 공식 판매처입니다.',
|
||||
verified: true,
|
||||
),
|
||||
StationLink(
|
||||
id: 'ticket',
|
||||
title: '팬미팅 예매',
|
||||
url: 'https://fanuzu.co.kr',
|
||||
category: StationCategory.ticket,
|
||||
description: '예매 일정과 대기 순서를 확인하십시오.',
|
||||
),
|
||||
StationLink(
|
||||
id: 'media',
|
||||
title: '공식 미디어 채널',
|
||||
url: 'https://fanuzu.co.kr',
|
||||
category: StationCategory.media,
|
||||
description: '무대 영상과 비하인드가 올라옵니다.',
|
||||
verified: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import '../../core/data/backend.dart';
|
||||
import 'station_data.dart';
|
||||
|
||||
/// STATION 허브의 실 서버 구현 — S-S-HUB.
|
||||
///
|
||||
/// `stationLinks` 는 운영만 쓰고 로그인 사용자는 읽기만 한다(보안 규칙 §7).
|
||||
/// 정렬은 팬덤별 `rank` 오름차순이며, 이 순서가 곧 운영이 정한 노출 순서다.
|
||||
/// 앱에서 다시 정렬하지 않는다 — 그러면 운영 화면과 앱이 다르게 보인다.
|
||||
class FirestoreStationRepository implements StationRepository {
|
||||
FirestoreStationRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
/// 소속 팬덤. 라우터가 모르는 값이라 사용자 문서에서 한 번 읽어 들고 있는다.
|
||||
String? _fandomId;
|
||||
|
||||
@override
|
||||
Future<StationSnapshot> load() async {
|
||||
final cached = _fandomId;
|
||||
final String id;
|
||||
if (cached != null) {
|
||||
id = cached;
|
||||
} else {
|
||||
final snap = await _backend.db.doc('users/${_backend.requireUid}').get();
|
||||
id = _fandomId = (snap.data() ?? const {}).strOr('fandomId');
|
||||
}
|
||||
// 팬덤이 없으면 큐레이션 대상이 정해지지 않는다. 빈 상태로 두고 화면이 안내한다.
|
||||
if (id.isEmpty) {
|
||||
return const StationSnapshot(links: [], canSuggest: false);
|
||||
}
|
||||
|
||||
final snap = await _backend.db
|
||||
.collection('stationLinks')
|
||||
.where('fandomId', isEqualTo: id)
|
||||
.orderBy('rank')
|
||||
.limit(30)
|
||||
.get();
|
||||
|
||||
return StationSnapshot(
|
||||
links: [
|
||||
for (final doc in snap.docs)
|
||||
StationLink(
|
||||
id: doc.id,
|
||||
title: doc.data().strOr('title'),
|
||||
url: doc.data().strOr('url'),
|
||||
category: _category(doc.data().strOr('category')),
|
||||
description: doc.data().strOr('description'),
|
||||
verified: doc.data()['verified'] == true,
|
||||
),
|
||||
],
|
||||
canSuggest: _backend.uid != null,
|
||||
);
|
||||
}
|
||||
|
||||
/// 모르는 값은 「공식」으로 떨어뜨리지 않고 판매처로 본다 — 공식 배지를
|
||||
/// 검증하지 않은 링크에 붙이는 쪽이 더 위험하다.
|
||||
StationCategory _category(String raw) => switch (raw) {
|
||||
'official' => StationCategory.official,
|
||||
'ticket' => StationCategory.ticket,
|
||||
'media' => StationCategory.media,
|
||||
_ => StationCategory.shop,
|
||||
};
|
||||
}
|
||||
@@ -1,15 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/tab_placeholder.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'station_data.dart';
|
||||
import 'station_suggest_screen.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// STATION 탭 루트 — S-S-HUB STATION 허브.
|
||||
class StationScreen extends StatelessWidget {
|
||||
const StationScreen({super.key});
|
||||
/// STATION 탭 루트 — S-S-HUB.
|
||||
///
|
||||
/// **Figma 에 이 화면의 시안이 없다.** 근거는 견적서 v2.1 의 5탭 구성과
|
||||
/// Firestore 설계서의 `stationLinks` · `stationSuggestions` 다. 그래서 데이터가
|
||||
/// 정의한 것까지만 그렸고, 시안이 확정되면 그때 맞춘다.
|
||||
class StationScreen extends StatefulWidget {
|
||||
const StationScreen({
|
||||
super.key,
|
||||
this.repository = const SampleStationRepository(),
|
||||
});
|
||||
|
||||
final StationRepository repository;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const TabPlaceholder(
|
||||
tab: 'STATION',
|
||||
screenId: 'S-S-HUB',
|
||||
screenName: 'STATION 허브',
|
||||
);
|
||||
State<StationScreen> createState() => _StationScreenState();
|
||||
}
|
||||
|
||||
class _StationScreenState extends State<StationScreen> {
|
||||
StationSnapshot? _snapshot;
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
StationCategory? _filter;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final snap = await widget.repository.load();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_snapshot = snap;
|
||||
_loading = false;
|
||||
});
|
||||
} on Object {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = context.l10n.stationLoadFailed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<StationLink> get _visible {
|
||||
final links = _snapshot?.links ?? const <StationLink>[];
|
||||
final filter = _filter;
|
||||
if (filter == null) return links;
|
||||
return links.where((l) => l.category == filter).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final snapshot = _snapshot;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('STATION'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(48),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
child: Row(
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: Text(context.l10n.commonAll),
|
||||
selected: _filter == null,
|
||||
onSelected: (_) => setState(() => _filter = null),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
for (final c in StationCategory.values) ...[
|
||||
ChoiceChip(
|
||||
label: Text(c.label),
|
||||
selected: _filter == c,
|
||||
onSelected: (_) => setState(() => _filter = c),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: (snapshot?.canSuggest ?? false)
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => Navigator.of(context, rootNavigator: true).push<void>(
|
||||
MaterialPageRoute(builder: (_) => const StationSuggestScreen()),
|
||||
),
|
||||
icon: const Icon(Icons.add_link),
|
||||
label: Text(context.l10n.stationSuggest),
|
||||
)
|
||||
: null,
|
||||
body: switch ((_loading, _error, _visible.isEmpty)) {
|
||||
(true, _, _) => const Center(child: CircularProgressIndicator()),
|
||||
(_, final String message, _) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message, style: t.titleMedium),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
FilledButton(onPressed: _load, child: Text(context.l10n.commonRetry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
(_, _, true) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s5),
|
||||
child: Text(
|
||||
snapshot == null || snapshot.links.isEmpty
|
||||
? context.l10n.stationEmpty
|
||||
: context.l10n.stationCategoryEmpty,
|
||||
style: t.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ => RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s4,
|
||||
AppSpace.s4,
|
||||
AppSpace.s8,
|
||||
),
|
||||
itemCount: _visible.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: AppSpace.s3),
|
||||
itemBuilder: (context, i) => StationLinkTile(link: _visible[i]),
|
||||
),
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 링크 한 줄.
|
||||
class StationLinkTile extends StatelessWidget {
|
||||
const StationLinkTile({super.key, required this.link, this.onTap});
|
||||
|
||||
final StationLink link;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(link.category.label, style: t.labelSmall),
|
||||
if (link.verified) ...[
|
||||
const SizedBox(width: AppSpace.s2),
|
||||
Icon(
|
||||
Icons.verified_outlined,
|
||||
size: 14,
|
||||
color: AppColors.neonCyan,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(link.title, style: t.titleMedium),
|
||||
if (link.description.isNotEmpty) ...[
|
||||
const SizedBox(height: AppSpace.s1),
|
||||
Text(link.description, style: t.bodySmall),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpace.s3),
|
||||
Icon(Icons.open_in_new, size: 18, color: t.labelSmall?.color),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import 'station_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 판매처 제안 — S-S-SUGGEST.
|
||||
///
|
||||
/// 제안은 `stationSuggestions` 에 `status: 'pending'` 으로 들어가고 운영이 심사한다.
|
||||
/// 보안 규칙이 생성 시 status 를 pending 으로 못박고 있으므로(§7), 화면이 다른 값을
|
||||
/// 보내면 거부당한다. 등록 경로는 6주차에 잇는다.
|
||||
class StationSuggestScreen extends StatefulWidget {
|
||||
const StationSuggestScreen({super.key});
|
||||
|
||||
@override
|
||||
State<StationSuggestScreen> createState() => _StationSuggestScreenState();
|
||||
}
|
||||
|
||||
class _StationSuggestScreenState extends State<StationSuggestScreen> {
|
||||
final _form = GlobalKey<FormState>();
|
||||
final _title = TextEditingController();
|
||||
final _url = TextEditingController();
|
||||
final _reason = TextEditingController();
|
||||
StationCategory _category = StationCategory.shop;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_title.dispose();
|
||||
_url.dispose();
|
||||
_reason.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!(_form.currentState?.validate() ?? false)) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.stationSuggestLater)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.stationSuggest),
|
||||
actions: [TextButton(onPressed: _submit, child: Text(context.l10n.commonSubmit))],
|
||||
),
|
||||
body: Form(
|
||||
key: _form,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _title,
|
||||
decoration: InputDecoration(labelText: context.l10n.commonName),
|
||||
maxLength: 40,
|
||||
validator: (v) =>
|
||||
(v ?? '').trim().isEmpty ? context.l10n.commonNameRequired : null,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
TextFormField(
|
||||
controller: _url,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.commonUrl,
|
||||
hintText: 'https://',
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
validator: (v) {
|
||||
final raw = (v ?? '').trim();
|
||||
if (raw.isEmpty) return context.l10n.commonUrlRequired;
|
||||
final uri = Uri.tryParse(raw);
|
||||
// http 도 막는다. 검증한 판매처를 평문으로 안내할 이유가 없다.
|
||||
if (uri == null || uri.scheme != 'https' || uri.host.isEmpty) {
|
||||
return context.l10n.commonUrlHttps;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
DropdownButtonFormField<StationCategory>(
|
||||
initialValue: _category,
|
||||
decoration: InputDecoration(labelText: context.l10n.commonCategory),
|
||||
items: [
|
||||
for (final c in StationCategory.values)
|
||||
DropdownMenuItem(value: c, child: Text(c.label)),
|
||||
],
|
||||
onChanged: (v) => setState(() => _category = v ?? _category),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
TextFormField(
|
||||
controller: _reason,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.stationReason,
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
maxLines: 5,
|
||||
maxLength: 500,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
Text(
|
||||
context.l10n.stationSuggestNote,
|
||||
style: t.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user