INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user