207 lines
6.9 KiB
Dart
207 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);
|
|
}
|
|
}
|