326 lines
14 KiB
Dart
326 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:qr/qr.dart';
|
|
|
|
import '../app/app_controller.dart';
|
|
import '../core/wifi_share.dart';
|
|
import '../l10n/tr.dart';
|
|
import 'theme.dart';
|
|
import '../core/wifi_share_net.dart';
|
|
import 'jj_title_bar.dart';
|
|
|
|
/// 89: Wi-Fi 공유 화면 - 고른 폴더 하나를 같은 Wi-Fi 의 다른 기기 브라우저에서 보기 · 받기 (읽기 전용).
|
|
/// 켜기 전: 폴더 · 저절로 끄는 시간 · 안내 · [켜기]. 켠 뒤: 6자리 코드 (크게) · 주소 · QR · 남은 시간 [+30분] · [끄기] · 접속 기록.
|
|
/// 사용자 결정 (10/9): 기본 꺼짐 · 읽기 전용 · 비밀번호 (켤 때마다 새 코드). 화면을 닫아도 공유는 켜진 채 (탐색기 위 줄 · 알림으로 보임)
|
|
class WifiSharePage extends StatefulWidget {
|
|
final AppController c;
|
|
|
|
/// 공유할 폴더 (이미 켜져 있으면 그 공유를 보인다)
|
|
final String folder;
|
|
|
|
/// 열 주소 · Windows 설정 · 제어판 열기 (처음 값 = 이 기기)
|
|
final WifiShareNet net;
|
|
|
|
/// 접속 코드를 만드는 난수 (처음 값 = 안전한 난수, [WifiShare.start] 와 같음)
|
|
final Random? random;
|
|
const WifiSharePage({super.key, required this.c, required this.folder, this.net = const RealWifiShareNet(), this.random});
|
|
|
|
static Future<void> open(BuildContext context, AppController c, String folder) =>
|
|
Navigator.of(context).push(MaterialPageRoute(builder: (_) => WifiSharePage(c: c, folder: folder)));
|
|
|
|
@override
|
|
State<WifiSharePage> createState() => _WifiSharePageState();
|
|
}
|
|
|
|
class _WifiSharePageState extends State<WifiSharePage> {
|
|
WifiShare? _s = WifiShare.instance;
|
|
Timer? _tick;
|
|
String? _error;
|
|
String? _lastReason;
|
|
bool _starting = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_s?.addListener(_changed);
|
|
// 남은 시간 표시
|
|
_tick = Timer.periodic(const Duration(seconds: 15), (_) {
|
|
if (mounted) setState(() {});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tick?.cancel();
|
|
_s?.removeListener(_changed);
|
|
super.dispose();
|
|
}
|
|
|
|
void _changed() {
|
|
if (!mounted) return;
|
|
final s = _s;
|
|
if (s != null && !s.running) {
|
|
_lastReason = s.stopReason;
|
|
s.removeListener(_changed);
|
|
_s = null;
|
|
}
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _start() async {
|
|
setState(() {
|
|
_starting = true;
|
|
_error = null;
|
|
_lastReason = null;
|
|
});
|
|
try {
|
|
final mins = widget.c.settings.tools.wifiShareMinutes;
|
|
final s = await WifiShare.start(widget.folder,
|
|
autoOff: mins > 0 ? Duration(minutes: mins) : null, tailscale: widget.c.settings.tools.wifiShareTailscale, net: widget.net, random: widget.random);
|
|
await widget.c.updateSettings((x) => x.tools.wifiShareFolder = widget.folder);
|
|
_s?.removeListener(_changed);
|
|
_s = s..addListener(_changed);
|
|
// 작업 기록에는 켜고 끈 것만 (코드 · 주소 · 접속한 곳은 남기지 않는다)
|
|
widget.c.note(tr('Wi-Fi 공유를 켰습니다'));
|
|
} catch (e) {
|
|
_error = trf('켜지 못했습니다: {0}', [e is SocketException ? e.message : '$e']);
|
|
} finally {
|
|
if (mounted) setState(() => _starting = false);
|
|
}
|
|
}
|
|
|
|
Future<void> _stop() async {
|
|
await _s?.stop();
|
|
widget.c.note(tr('Wi-Fi 공유를 껐습니다'));
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final s = _s;
|
|
return Scaffold(
|
|
appBar: jjTitleBar(context, title: tr('Wi-Fi 공유')),
|
|
body: SafeArea(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: s == null ? _offView() : _onView(s),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _offView() {
|
|
final mins = widget.c.settings.tools.wifiShareMinutes;
|
|
return [
|
|
Text(tr('공유할 폴더'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
|
|
const SizedBox(height: 4),
|
|
SelectableText(widget.folder, style: const TextStyle(fontSize: 15)),
|
|
const SizedBox(height: 16),
|
|
Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 12, runSpacing: 4, children: [
|
|
Text(tr('저절로 끄기')),
|
|
DropdownButton<int>(
|
|
value: mins,
|
|
items: [
|
|
for (final m in const [10, 30, 60, 120]) DropdownMenuItem(value: m, child: Text(trf('{0}분 뒤', [m]))),
|
|
DropdownMenuItem(value: 0, child: Text(tr('끄지 않음 (직접 끔)'))),
|
|
],
|
|
onChanged: (v) async {
|
|
if (v == null) return;
|
|
await widget.c.updateSettings((x) => x.tools.wifiShareMinutes = v);
|
|
if (mounted) setState(() {});
|
|
},
|
|
),
|
|
]),
|
|
// 보안 검토 3 (관리자 결정 10/18): Tailscale 주소에서도 열지 (처음 켬)
|
|
SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(tr('Tailscale 에서도 열기')),
|
|
subtitle: Text(tr('내 Tailscale 망 (tailnet) 의 기기도 이 주소로 볼 수 있습니다. 끄면 같은 Wi-Fi · 유선 망에서만')),
|
|
value: widget.c.settings.tools.wifiShareTailscale,
|
|
onChanged: (v) async {
|
|
await widget.c.updateSettings((x) => x.tools.wifiShareTailscale = v);
|
|
if (mounted) setState(() {});
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
_note(Icons.lock_outline, tr('켤 때마다 6자리 코드를 새로 만듭니다. 주소와 코드를 아는 같은 Wi-Fi 의 기기만 이 폴더를 볼 수 있습니다.')),
|
|
_note(Icons.visibility_outlined, tr('읽기 전용입니다: 보기와 받기만 되고, 올리기 · 지우기 · 이름 바꾸기는 되지 않습니다. 이 폴더 밖과 링크는 열리지 않습니다.')),
|
|
if (Platform.isWindows) ...[
|
|
_note(Icons.shield_outlined,
|
|
tr('켜면 Windows 방화벽 창이 뜰 수 있습니다: "개인 네트워크" 에 체크하고 [허용] 을 누르세요. "공용 네트워크" 는 체크하지 않는 것이 안전합니다.')),
|
|
_firewallHelp(),
|
|
],
|
|
if (_lastReason != null) _note(Icons.info_outline, _lastReason!, color: JjColors.danger),
|
|
if (_error != null) _note(Icons.error_outline, _error!, color: JjColors.danger),
|
|
const SizedBox(height: 16),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: FilledButton.icon(
|
|
onPressed: _starting ? null : _start,
|
|
icon: const Icon(Icons.wifi_tethering),
|
|
label: Text(tr('켜기')),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Widget> _onView(WifiShare s) {
|
|
final hosts = s.addresses.isEmpty ? <String>[] : s.addresses;
|
|
final left = s.endsAt?.difference(DateTime.now());
|
|
return [
|
|
Row(children: [
|
|
Icon(Icons.wifi_tethering, color: JjColors.success),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(tr('공유 중'), style: TextStyle(fontSize: 16, color: JjColors.success))),
|
|
FilledButton.icon(
|
|
style: FilledButton.styleFrom(backgroundColor: JjColors.danger),
|
|
onPressed: _stop,
|
|
icon: const Icon(Icons.stop),
|
|
label: Text(tr('끄기')),
|
|
),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
SelectableText(s.root, style: TextStyle(color: JjColors.textDim, fontSize: 12)),
|
|
const SizedBox(height: 16),
|
|
Text(tr('접속 코드'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
|
|
SelectableText(s.code, style: const TextStyle(fontSize: 40, letterSpacing: 8, fontWeight: FontWeight.w600)),
|
|
const SizedBox(height: 12),
|
|
if (hosts.isEmpty)
|
|
_note(Icons.wifi_off, tr('이 기기의 Wi-Fi 주소를 찾지 못했습니다. Wi-Fi 에 연결돼 있는지 확인하세요.'), color: JjColors.danger)
|
|
else ...[
|
|
Text(tr('다른 기기의 브라우저에서 이 주소를 여세요'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
|
|
for (final h in hosts)
|
|
Row(children: [
|
|
Expanded(child: SelectableText('http://$h:${s.port}', style: const TextStyle(fontSize: 16))),
|
|
IconButton(
|
|
tooltip: tr('주소 복사'),
|
|
icon: const Icon(Icons.copy, size: 18),
|
|
onPressed: () => Clipboard.setData(ClipboardData(text: 'http://$h:${s.port}')),
|
|
),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
Text(tr('또는 다른 기기의 카메라로 QR 을 찍으세요 (코드가 들어 있습니다)'), style: TextStyle(color: JjColors.textDim, fontSize: 12)),
|
|
const SizedBox(height: 8),
|
|
Center(child: QrView(data: s.urlWithCode(hosts.first), size: 200)),
|
|
],
|
|
// 관리자 지시 (10/19): 공유 중에도 다른 기기에서 안 열릴 때 볼 곳
|
|
if (Platform.isWindows) _firewallHelp(),
|
|
const SizedBox(height: 12),
|
|
Row(children: [
|
|
Expanded(
|
|
child: Text(left == null
|
|
? tr('저절로 끄지 않습니다 - 다 쓰면 [끄기] 를 누르세요')
|
|
: trf('{0}분 뒤에 저절로 꺼집니다', [(left.inSeconds / 60).ceil().clamp(0, 9999)])),
|
|
),
|
|
if (left != null) TextButton(onPressed: () => s.extend(const Duration(minutes: 30)), child: Text(tr('+30분'))),
|
|
]),
|
|
const Divider(height: 32),
|
|
Text(trf('접속 기록 ({0}) - 이 화면에만 보이고 저장하지 않습니다', [s.log.length]),
|
|
style: TextStyle(color: JjColors.textDim, fontSize: 12)),
|
|
if (s.log.isEmpty)
|
|
Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Text(tr('아직 접속한 기기가 없습니다'), style: TextStyle(color: JjColors.textDim)))
|
|
else
|
|
for (final a in s.log.take(50))
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 3),
|
|
child: Text(
|
|
'${_hm(a.at)} ${a.ip} ${a.status} ${Uri.decodeComponent(a.path)}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 12, color: a.status >= 400 ? JjColors.danger : JjColors.text),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
static String _hm(DateTime t) => '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
|
|
|
|
/// 관리자 지시 (10/19): Windows 방화벽이 막았을 때 "개인 네트워크 허용" 하는 법 (펼쳐 봄) · 그 화면을 바로 여는 버튼
|
|
Widget _firewallHelp() => ExpansionTile(
|
|
tilePadding: EdgeInsets.zero,
|
|
childrenPadding: const EdgeInsets.only(left: 8, bottom: 8),
|
|
leading: const Icon(Icons.help_outline, size: 20),
|
|
title: Text(tr('다른 기기에서 열리지 않으면 (Windows 방화벽)'), style: const TextStyle(fontSize: 14)),
|
|
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_note(Icons.looks_one_outlined,
|
|
tr('이 PC 가 연결한 망이 "개인 네트워크" 인지 봅니다: 설정 > 네트워크 및 인터넷 > Wi-Fi (또는 이더넷) > 연결한 망 > 네트워크 프로필 유형 = 개인. 집 · 믿을 수 있는 망에서만 개인으로 하세요.')),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 26),
|
|
child: TextButton.icon(
|
|
onPressed: () => widget.net.launch('explorer.exe', ['ms-settings:network-status']),
|
|
icon: const Icon(Icons.open_in_new, size: 16),
|
|
label: Text(tr('네트워크 설정 열기')),
|
|
),
|
|
),
|
|
_note(Icons.looks_two_outlined,
|
|
tr('방화벽에서 이 앱을 허용합니다: 제어판 > Windows Defender 방화벽 > Windows Defender 방화벽을 통해 앱 또는 기능 허용 > [설정 변경] > JJ_MKVMaker 의 "개인" 에 체크 > [확인].')),
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 26),
|
|
child: TextButton.icon(
|
|
onPressed: () => widget.net.launch('control.exe', ['/name', 'Microsoft.WindowsFirewall', '/page', 'pageConfigureApps']),
|
|
icon: const Icon(Icons.open_in_new, size: 16),
|
|
label: Text(tr('방화벽 허용 앱 열기')),
|
|
),
|
|
),
|
|
_note(Icons.looks_3_outlined,
|
|
tr('목록에 JJ_MKVMaker 가 없으면 [다른 앱 허용] 으로 JJ_MKVMaker.exe 를 고르고 "개인" 에 체크합니다. 바꾼 뒤 공유를 끄고 다시 켜세요.')),
|
|
_note(Icons.info_outline, tr('회사 · 학교 PC 는 관리자가 방화벽을 막아 두어 바꿀 수 없을 수 있습니다.')),
|
|
],
|
|
);
|
|
|
|
Widget _note(IconData icon, String text, {Color? color}) => _noteIn(icon, text, color ?? JjColors.textDim);
|
|
|
|
Widget _noteIn(IconData icon, String text, Color color) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Icon(icon, size: 18, color: color),
|
|
const SizedBox(width: 8),
|
|
Expanded(child: Text(text, style: TextStyle(color: color == JjColors.textDim ? JjColors.text : color, fontSize: 13))),
|
|
]),
|
|
);
|
|
}
|
|
|
|
/// 89: QR (흰 바탕 · 검은 칸, 둘레 여백 4칸 - 휴대폰 카메라가 읽게)
|
|
class QrView extends StatelessWidget {
|
|
final String data;
|
|
final double size;
|
|
const QrView({super.key, required this.data, this.size = 200});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final img = QrImage(QrCode.fromData(data: data, errorCorrectLevel: QrErrorCorrectLevel.M));
|
|
return Semantics(
|
|
label: tr('접속 QR'),
|
|
child: CustomPaint(size: Size.square(size), painter: _QrPainter(img)),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QrPainter extends CustomPainter {
|
|
final QrImage img;
|
|
_QrPainter(this.img);
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
const quiet = 4;
|
|
final n = img.moduleCount + quiet * 2;
|
|
final cell = size.width / n;
|
|
canvas.drawRect(Offset.zero & size, Paint()..color = Colors.white);
|
|
final dark = Paint()..color = Colors.black;
|
|
for (var r = 0; r < img.moduleCount; r++) {
|
|
for (var col = 0; col < img.moduleCount; col++) {
|
|
if (img.isDark(r, col)) {
|
|
canvas.drawRect(Rect.fromLTWH((col + quiet) * cell, (r + quiet) * cell, cell + 0.5, cell + 0.5), dark);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_QrPainter old) => old.img != img;
|
|
}
|