Files
fanuzu/app/lib/features/galaxy/widgets/galaxy_web_view.dart
T

208 lines
6.9 KiB
Dart

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<PlanetNode> onOpenPlanet;
/// WebView 를 쓸 수 있는가. 테스트에서 끄거나 켤 수 있다.
static bool enabled =
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS) &&
!Platform.environment.containsKey('FLUTTER_TEST');
/// HTML 로 넘기는 행성 목록 — FPI 상위 10 + 순위 밖 내 행성(미니맵 painter 와 같은 규칙).
@visibleForTesting
static List<Map<String, Object>> 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<GalaxyWebView> createState() => _GalaxyWebViewState();
}
class _GalaxyWebViewState extends State<GalaxyWebView>
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<String, dynamic>;
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<OneSequenceGestureRecognizer>(EagerGestureRecognizer.new)}
: {
Factory<OneSequenceGestureRecognizer>(
HorizontalDragGestureRecognizer.new,
),
Factory<OneSequenceGestureRecognizer>(TapGestureRecognizer.new),
},
);
return widget.expanded
? view
: SizedBox.square(dimension: widget.miniSize, child: view);
}
}