import 'dart:convert'; import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../galaxy_data.dart'; import 'galaxy_mini_map.dart'; /// HOME GALAXY 미니맵 · 「GALAXY 맵 확장」 — 발주사 프로토타입 v6.2 HTML 을 그대로 띄운다 /// (`assets/galaxy/uzu_hud.html` · 9/22 D-6 · 2026-10-05 대표 지시 「html 을 클론코딩하듯」). /// /// 한 위젯이 두 모드를 다 맡는다. [expanded] 가 바뀌면 WebView 를 새로 만들지 않고 JS /// `setMode` 만 부른다 — HOME 은 같은 [GlobalKey] 로 미니맵 자리와 확장 자리 사이를 옮긴다. /// /// - 데이터: [GalaxySnapshot] 의 FPI 상위 10 + 내 행성 → `setGalaxy(json)`. /// - 이벤트: HTML 이 `FanuzuBridge.postMessage` 로 보낸다 — `open`(미니맵 탭) · `hud`(HUD 탭 → /// [onOpenPlanet]) · `planet`(행성 탭, 중심 전환은 HTML 이 이미 했다). /// - 화면 밖 · 다른 화면에 가림 · 앱 백그라운드면 `fanuzuPause(true)` 로 rAF 를 멈춘다. /// - 플랫폼 뷰가 없는 곳(위젯 테스트 · 데스크톱)에서는 같은 장면을 옮긴 [GalaxyMiniMap] 으로 대신한다. class GalaxyWebView extends StatefulWidget { const GalaxyWebView({ super.key, required this.data, required this.expanded, required this.onOpen, required this.onOpenPlanet, this.miniSize = 93, }); final GalaxySnapshot data; final bool expanded; final double miniSize; final VoidCallback onOpen; final ValueChanged onOpenPlanet; /// WebView 를 쓸 수 있는가. 테스트에서 끄거나 켤 수 있다. static bool enabled = !kIsWeb && (Platform.isAndroid || Platform.isIOS) && !Platform.environment.containsKey('FLUTTER_TEST'); /// HTML 로 넘기는 행성 목록 — FPI 상위 10 + 순위 밖 내 행성(미니맵 painter 와 같은 규칙). @visibleForTesting static List> payload(GalaxySnapshot data) { final byFpi = [...data.planets]..sort((a, b) => b.fpi.compareTo(a.fpi)); final shown = byFpi.take(GalaxyMiniMapPainter.maxPlanets).toList(); final mine = data.mine; if (mine != null && !shown.contains(mine)) shown.add(mine); return [ for (final (i, p) in shown.indexed) { 'id': p.id, 'name': p.name, 'rank': p.rank, 'fpi': p.fpi, 'live': p.online, 'color': galaxyPlanetColor(p, i).toARGB32() & 0xFFFFFF, 'mine': p.isMine, }, ]; } @override State createState() => _GalaxyWebViewState(); } class _GalaxyWebViewState extends State with WidgetsBindingObserver { WebViewController? _web; bool _ready = false; bool? _paused; bool _foreground = true; ScrollPosition? _scroll; @override void initState() { super.initState(); if (!GalaxyWebView.enabled) return; WidgetsBinding.instance.addObserver(this); _web = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(Colors.transparent) ..addJavaScriptChannel('FanuzuBridge', onMessageReceived: _onMessage) ..loadFlutterAsset('assets/galaxy/uzu_hud.html'); } void _js(String code) { if (_ready) _web?.runJavaScript(code); } void _push() { _js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})'); _js('setGalaxy(${jsonEncode(GalaxyWebView.payload(widget.data))})'); } void _onMessage(JavaScriptMessage m) { final msg = jsonDecode(m.message) as Map; switch (msg['type']) { case 'ready': _ready = true; _push(); _paused = null; _syncPause(); case 'open': widget.onOpen(); case 'hud': final p = widget.data.planets .where((p) => p.id == msg['id']) .firstOrNull; if (p != null) widget.onOpenPlanet(p); } } @override void didUpdateWidget(GalaxyWebView old) { super.didUpdateWidget(old); if (_web == null) return; if (!identical(old.data, widget.data)) { _push(); } else if (old.expanded != widget.expanded) { _js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})'); } WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause()); } @override void didChangeDependencies() { super.didChangeDependencies(); if (_web == null) return; final scroll = Scrollable.maybeOf(context)?.position; if (!identical(scroll, _scroll)) { _scroll?.removeListener(_syncPause); _scroll = scroll?..addListener(_syncPause); } // 다른 화면이 위에 올라오거나(ModalRoute) 탭이 바뀌면(TickerMode) 다시 판단한다. ModalRoute.of(context); TickerMode.valuesOf(context).enabled; WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause()); } @override void didChangeAppLifecycleState(AppLifecycleState state) { _foreground = state == AppLifecycleState.resumed; _syncPause(); } bool get _visible { if (!mounted || !_foreground || !TickerMode.valuesOf(context).enabled) return false; if (ModalRoute.of(context)?.isCurrent == false) return false; final box = context.findRenderObject(); if (box is! RenderBox || !box.attached || !box.hasSize) return true; final top = box.localToGlobal(Offset.zero).dy; return top + box.size.height > 0 && top < MediaQuery.sizeOf(context).height; } void _syncPause() { if (!_ready || !mounted) return; final pause = !_visible; if (pause == _paused) return; _paused = pause; _js('fanuzuPause($pause)'); } @override void dispose() { _scroll?.removeListener(_syncPause); if (_web != null) WidgetsBinding.instance.removeObserver(this); super.dispose(); } @override Widget build(BuildContext context) { final web = _web; if (web == null) { return widget.expanded ? GalaxyMiniMap.expanded( data: widget.data, onOpenPlanet: widget.onOpenPlanet, ) : GalaxyMiniMap( size: widget.miniSize, data: widget.data, onOpen: widget.onOpen, ); } final view = WebViewWidget( controller: web, // 확장 맵은 끌기 · 핀치를 모두 장면이 받는다(설명 프레임 2008:14486). 미니맵은 가로 끌기 // (회전)와 탭만 — 세로 끌기는 HOME 목록 스크롤로 남긴다. gestureRecognizers: widget.expanded ? {Factory(EagerGestureRecognizer.new)} : { Factory( HorizontalDragGestureRecognizer.new, ), Factory(TapGestureRecognizer.new), }, ); return widget.expanded ? view : SizedBox.square(dimension: widget.miniSize, child: view); } }