import 'dart:async'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../core/theme/tokens.dart'; import '../../core/data/backend.dart' show BackendException; import '../../core/ui/planet_25d.dart' show PlanetArt; import '../../core/ui/play_banner.dart'; import '../planet/widgets/fandom_size_badge.dart'; import 'galaxy_data.dart'; import '../../core/format.dart'; import '../../core/i18n/app_locale.dart'; /// 갤럭시 검색 — Figma `03_Wireframe` 「Galaxy_검색」(`353:217832`). /// /// 화면 대조 원장 F2 의 11번. 명세 *돋보기 — 검색페이지 이동*. /// /// Figma 검색 화면이 공통으로 갖는 것을 그대로 따랐다 — /// 「최근 검색어 / 전체 삭제 / 최근 검색어가 없습니다.」 class GalaxySearchScreen extends StatefulWidget { const GalaxySearchScreen({ super.key, this.repository = const SampleGalaxyRepository(), this.onOpen, this.onJoin, }); final GalaxyRepository repository; /// 결과 줄의 「가입」 — Figma `543:19694` 「가입 처리 후 가입중 버튼스타일로 변경」. final Future Function(PlanetNode planet)? onJoin; /// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다. final void Function(PlanetNode planet)? onOpen; @override State createState() => _GalaxySearchScreenState(); } class _GalaxySearchScreenState extends State { late Future _snapshot; final _controller = TextEditingController(); /// 최근 검색어 — 기기에 남긴다(재진입해도 유지). 명세 `523:18429` 「최신순 · 중복 제거」. final List _recent = []; static const _recentKey = 'galaxyRecentSearches'; String _query = ''; /// Figma `543:19672` 탭 — 디폴트 행성(false) · 성운(true). bool _nebulaTab = false; /// Figma `543:19683` 필터 — 디폴트 전체. 행성: 전체 · 가입자 수 · FPI / 성운: 전체 · 가입자 수. /// 성운의 「기여율」 필터 · 칩(`543:19704` · `543:19714`)은 기여율의 정의가 자료에 없어 해석 확인 후 붙인다. _Sort _sort = _Sort.all; /// 이 화면에서 가입한 행성 — 서버 확인 뒤 「가입중」으로 바꾼다. final _joined = {}; final _joining = {}; /// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다. Future>? _hits; Timer? _debounce; @override void initState() { super.initState(); _snapshot = widget.repository.load(); _controller.addListener(_onChanged); _loadRecent(); } Future _loadRecent() async { try { final saved = (await SharedPreferences.getInstance()).getStringList( _recentKey, ); if (mounted && saved != null) setState( () => _recent ..clear() ..addAll(saved), ); } catch (_) { // 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다. } } Future _saveRecent() async { try { await (await SharedPreferences.getInstance()).setStringList( _recentKey, _recent, ); } catch (_) {} } void _onChanged() { if (_controller.text == _query) return; setState(() => _query = _controller.text); _debounce?.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { if (!mounted) return; final q = _query.trim(); setState(() { _hits = q.isEmpty ? null : _search(q); }); }); } /// 서버 결과에 맵 스냅샷의 순위를 입힌다 — 맵 상위에 있는 행성은 같은 순위로 보인다. Future> _search(String q) async { final hits = await widget.repository.search(q); final known = {for (final p in (await _snapshot).planets) p.id: p}; return [for (final h in hits) known[h.id] ?? h]; } @override void dispose() { _debounce?.cancel(); _controller.dispose(); super.dispose(); } Future _join(PlanetNode p) async { final join = widget.onJoin; if (join == null || _joining.contains(p.id)) return; setState(() => _joining.add(p.id)); try { await join(p); if (mounted) setState(() => _joined.add(p.id)); } on BackendException catch (e) { if (mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(e.message))); } } finally { if (mounted) setState(() => _joining.remove(p.id)); } } void _submit(String q) { final term = q.trim(); if (term.isEmpty) return; setState(() { _recent ..remove(term) ..insert(0, term); if (_recent.length > 10) _recent.removeLast(); }); _saveRecent(); } @override Widget build(BuildContext context) => Scaffold( // 시안 `Top Navigation`(54 · 좌우 16) — 뒤로 42 + 검색 칸 #282828 r10 높이 42. appBar: AppBar( toolbarHeight: 54, automaticallyImplyLeading: false, titleSpacing: 16, centerTitle: false, title: Row( children: [ const SizedBox.square(dimension: 42, child: BackButton()), Expanded( child: Container( height: 42, padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: const Color(0xFF282828), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Image.asset( 'assets/figma/icon-search-20.png', width: 20, height: 20, ), const SizedBox(width: 6), Expanded( child: TextField( controller: _controller, autofocus: true, textInputAction: TextInputAction.search, onSubmitted: _submit, style: _t(16, AppColors.onCosmic, FontWeight.w500, 1.4), decoration: InputDecoration( hintText: context.l10n.galaxySearchHint, hintStyle: _t( 16, const Color(0xFF6E6E6E), FontWeight.w500, 1.4, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, filled: false, isDense: true, contentPadding: EdgeInsets.zero, ), ), ), if (_query.isNotEmpty) GestureDetector( onTap: _controller.clear, child: const Icon( Icons.close, size: 18, color: Color(0xFF6E6E6E), ), ), ], ), ), ), ], ), ), body: SafeArea( child: FutureBuilder( future: _snapshot, builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (_query.trim().isEmpty) { return _Recent( terms: _recent, onClear: () { setState(_recent.clear); _saveRecent(); }, onPick: (t) { _controller.text = t; _submit(t); }, ); } return FutureBuilder>( future: _hits, builder: (context, result) { if (_hits == null || result.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final all = result.data ?? const []; final joinedIds = { ...(snap.data?.joinedIds ?? const {}), ..._joined, }; final hits = [ for (final p in all) if ((p.stage == 0) == _nebulaTab) p, ]; switch (_sort) { case _Sort.all: break; case _Sort.members: hits.sort((a, b) => b.members.compareTo(a.members)); case _Sort.fpi: hits.sort((a, b) => b.fpi.compareTo(a.fpi)); } return Column( children: [ _Tabs( nebula: _nebulaTab, onPick: (v) => setState(() { _nebulaTab = v; if (v && _sort == _Sort.fpi) _sort = _Sort.all; }), ), _Filters( nebula: _nebulaTab, sort: _sort, onPick: (v) => setState(() => _sort = v), ), Expanded( child: hits.isEmpty ? const _NoHit() : ListView.separated( padding: const EdgeInsets.all(AppSpace.s4), itemCount: hits.length, separatorBuilder: (_, _) => const SizedBox(height: 24), itemBuilder: (context, i) { final p = hits[i]; final joined = p.isMine || joinedIds.contains(p.id); return _Hit( planet: p, joined: joined, busy: _joining.contains(p.id), onJoin: joined || widget.onJoin == null ? null : () => _join(p), onTap: widget.onOpen == null ? null : () => widget.onOpen!(p), ); }, ), ), ], ); }, ); }, ), ), ); } TextStyle _t(double size, Color color, FontWeight weight, double height) => TextStyle( fontFamily: AppFonts.body, fontSize: size, fontWeight: weight, height: height, color: color, ); /// 최근 검색어 — 시안 `Frame 4241`(안쪽 16 · 간격 24): 배너, 「최근 검색어 16/600 · /// 전체 삭제 14/500 #c2c2c2」, 비었으면 140 칸 가운데 「최근 검색어가 없습니다.」 14/600 #6e6e6e. class _Recent extends StatelessWidget { const _Recent({ required this.terms, required this.onClear, required this.onPick, }); final List terms; final VoidCallback onClear; final ValueChanged onPick; @override Widget build(BuildContext context) => ListView( // 시안 배너만 좌우 9(357 폭), 나머지는 16. padding: const EdgeInsets.fromLTRB(9, 16, 9, 16), children: [ const PlayBanner(), const SizedBox(height: 24), Padding( padding: const EdgeInsets.symmetric(horizontal: 7), child: _body(context), ), ], ); Widget _body(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( context.l10n.commonRecentSearches, style: _t(16, AppColors.onCosmic, FontWeight.w600, 20 / 16), ), GestureDetector( onTap: onClear, child: Text( context.l10n.commonClearAll, style: _t(14, const Color(0xFFC2C2C2), FontWeight.w500, 20 / 14), ), ), ], ), const SizedBox(height: AppSpace.s4), if (terms.isEmpty) SizedBox( height: 140, child: Center( child: Text( context.l10n.commonNoRecentSearches, style: _t(14, const Color(0xFF6E6E6E), FontWeight.w600, 20 / 14), ), ), ) else // Figma `543:19637` 「검색 결과 칩의 최대 높이는 3줄」 — 칩 33 · 줄 간격 10. ConstrainedBox( constraints: const BoxConstraints(maxHeight: 33 * 3 + 10 * 2), child: ClipRect( child: Wrap( spacing: AppSpace.s2, runSpacing: 10, children: [ for (final term in terms) ActionChip(label: Text(term), onPressed: () => onPick(term)), ], ), ), ), ], ); } class _NoHit extends StatelessWidget { const _NoHit(); @override Widget build(BuildContext context) => Center( child: Padding( padding: const EdgeInsets.all(AppSpace.s5), child: Text( context.l10n.galaxyNoHit, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, ), ), ); } enum _Sort { all, members, fpi } /// 탭 — 시안 `353:218091` 375×44 · 칸 171 · 16/500(선택 #4bc2ae 밑줄). class _Tabs extends StatelessWidget { const _Tabs({required this.nebula, required this.onPick}); final bool nebula; final ValueChanged onPick; @override Widget build(BuildContext context) => SizedBox( height: 44, child: Row( children: [ for (final (v, label) in [ (false, context.l10n.galaxyTabPlanet), (true, context.l10n.galaxyTabNebula), ]) Expanded( child: InkWell( onTap: () => onPick(v), child: Container( alignment: Alignment.center, decoration: BoxDecoration( border: Border( bottom: BorderSide( color: v == nebula ? AppColors.cosmicTeal : const Color(0xFF1A1A1A), width: v == nebula ? 2 : 1, ), ), ), child: Text( label, style: _t( 16, v == nebula ? AppColors.cosmicTeal : const Color(0xFFBBBBBB), FontWeight.w500, 1, ), ), ), ), ), ], ), ); } /// 필터 칩 — 시안 32 높이 r8 · 선택 #ffffff(글자 검정) · 비선택 #1a1a1a · 12/500. class _Filters extends StatelessWidget { const _Filters({ required this.nebula, required this.sort, required this.onPick, }); final bool nebula; final _Sort sort; final ValueChanged<_Sort> onPick; @override Widget build(BuildContext context) => SizedBox( height: 48, child: ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), children: [ for (final (v, label) in [ (_Sort.all, context.l10n.commonAll), (_Sort.members, context.l10n.galaxySortMembers), if (!nebula) (_Sort.fpi, 'FPI'), ]) ...[ GestureDetector( onTap: () => onPick(v), child: Container( padding: const EdgeInsets.symmetric(horizontal: 10), alignment: Alignment.center, decoration: BoxDecoration( color: v == sort ? Colors.white : const Color(0xFF1A1A1A), borderRadius: BorderRadius.circular(8), ), child: Text( label, style: _t( 12, v == sort ? Colors.black : AppColors.onCosmic, FontWeight.w500, 20 / 12, ), ), ), ), const SizedBox(width: 4), ], ], ), ); } /// 결과 한 줄 — 시안 `353:218091`: 그림 70 · 간격 15 · 이름 16/600 · 규모 칩 · 사람 14 + 수 12/500 · /// 「FPI : n」 12/500, 오른쪽 「가입」(#ff7ddd) / 「가입중」(#1a1a1a) 알약 30 높이. class _Hit extends StatelessWidget { const _Hit({ required this.planet, required this.joined, this.busy = false, this.onJoin, this.onTap, }); final PlanetNode planet; final bool joined; final bool busy; final VoidCallback? onJoin; final VoidCallback? onTap; @override Widget build(BuildContext context) { final nebula = planet.stage == 0; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Row( children: [ SizedBox.square( dimension: 70, child: PlanetArt( fallback: nebula ? 'assets/figma/nebula-card.png' : 'assets/figma/planet-detail-hero.png', planetType: nebula ? null : planet.planetType, colorKey: planet.colorKey, semanticLabel: planet.name, ), ), const SizedBox(width: 15), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( planet.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: _t(16, AppColors.onCosmic, FontWeight.w600, 1), ), const SizedBox(height: 6), Row( children: [ if (!nebula) ...[ FandomSizeBadge(members: planet.members), const SizedBox(width: 8), ], const Icon( Icons.person, size: 14, color: Color(0xFF777777), ), const SizedBox(width: 3), Text( formatPop(planet.members), style: _t(12, AppColors.onCosmic, FontWeight.w500, 1), ), if (!nebula) ...[ const SizedBox(width: 8), Text( 'FPI : ${formatPop(planet.fpi)}', style: _t(12, AppColors.onCosmic, FontWeight.w500, 1), ), ], ], ), ], ), ), const SizedBox(width: 8), GestureDetector( onTap: busy ? null : onJoin, child: Container( height: 30, padding: const EdgeInsets.symmetric(horizontal: 12), alignment: Alignment.center, decoration: BoxDecoration( color: joined ? const Color(0xFF1A1A1A) : AppColors.cosmicAccent, borderRadius: BorderRadius.circular(AppRadius.full), ), child: Text( joined ? context.l10n.planetJoined : context.l10n.galaxyJoin, style: _t(14, AppColors.onCosmic, FontWeight.w500, 1), ), ), ), ], ), ); } }