import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../core/charset_detector.dart'; import '../core/file_ops.dart' show formatSize; import '../core/text_codec.dart'; import '../l10n/tr.dart'; import 'theme.dart'; /// X-plore 비교 h: 글 뷰어 - 글 파일 전체를 읽기 전용으로. 큰 파일은 앞에서부터 나눠 읽고 [더 읽기], /// 글자 코드는 스스로 판별 (UTF-8 · UTF-16 · CP949 · Shift-JIS · GBK) 하고 위에서 바꿀 수 있다. 줄 번호 · 글자 크기 · 복사. class TextViewerPage extends StatefulWidget { final String path; /// 한 번에 읽는 양 (시험에서 줄인다) final int chunk; const TextViewerPage({super.key, required this.path, this.chunk = 256 * 1024}); @override State createState() => _TextViewerPageState(); } class _TextViewerPageState extends State { final _bytes = BytesBuilder(copy: false); int _size = 0; String? _charset; /// 사용자가 위에서 고른 글자 코드 (고르기 전에는 더 읽을 때마다 다시 판별 - 앞 조각만으로는 틀릴 수 있다) bool _picked = false; String _text = ''; Object? _error; double _font = 13; bool _lineNumbers = true; @override void initState() { super.initState(); _more(); } bool get _complete => _bytes.length >= _size; Future _more() async { try { final f = File(widget.path); _size = await f.length(); final from = _bytes.length; final to = (from + widget.chunk).clamp(0, _size); await for (final x in f.openRead(from, to)) { _bytes.add(x); } final all = _bytes.toBytes(); if (!_picked) _charset = detectCharset(Uint8List.sublistView(all, 0, all.length.clamp(0, 256 * 1024))); _decode(all); } catch (e) { _error = e; } if (mounted) setState(() {}); } void _decode(Uint8List all) { String t; // 나눠 읽은 끝에서 두 바이트 글자 (CP949 등) 가 잘리면 글자 코드 풀이가 실패한다 - 끝 바이트를 다음 조각까지 미룬다 var cut = 0; while (true) { try { t = decodeText(Uint8List.sublistView(all, 0, all.length - cut), _charset!); break; } on FormatException { if (_complete || cut >= 3 || cut >= all.length) rethrow; cut++; } } // 나눠 읽은 끝에서 글자가 반쯤 잘렸으면 다음 조각과 함께 읽을 때까지 뺀다 if (!_complete) { while (t.endsWith('�')) { t = t.substring(0, t.length - 1); } } _text = t; } @override Widget build(BuildContext context) { final lines = _text.split('\n'); // 좁은 화면 (폰 세로) 에서는 줄 번호 · 글자 크기를 ⋮ 메뉴로 (412 에서 파일 이름이 다 가려졌다) final narrow = MediaQuery.sizeOf(context).width < 600; return Scaffold( appBar: AppBar( title: Text(p.basename(widget.path), maxLines: 1, overflow: TextOverflow.ellipsis), actions: [ if (_charset != null) DropdownButton( value: supportedCharsets.containsKey(_charset) ? _charset : 'UTF-8', underline: const SizedBox(), items: [for (final e in supportedCharsets.entries) DropdownMenuItem(value: e.key, child: Text(e.value))], onChanged: (v) => setState(() { _charset = v; _picked = true; _decode(_bytes.toBytes()); }), ), if (!narrow) ...[ IconButton( tooltip: tr('줄 번호'), icon: Icon(_lineNumbers ? Icons.format_list_numbered : Icons.notes), onPressed: () => setState(() => _lineNumbers = !_lineNumbers), ), IconButton(tooltip: tr('글자 작게'), icon: const Icon(Icons.text_decrease), onPressed: () => setState(() => _font = (_font - 1).clamp(9, 30))), IconButton(tooltip: tr('글자 크게'), icon: const Icon(Icons.text_increase), onPressed: () => setState(() => _font = (_font + 1).clamp(9, 30))), ], IconButton( tooltip: tr('전체 복사'), icon: const Icon(Icons.copy), onPressed: () { Clipboard.setData(ClipboardData(text: _text)); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(tr('복사했습니다')))); }, ), if (narrow) PopupMenuButton( tooltip: tr('더 보기'), onSelected: (v) => setState(() { switch (v) { case 'lines': _lineNumbers = !_lineNumbers; case 'smaller': _font = (_font - 1).clamp(9, 30); case 'bigger': _font = (_font + 1).clamp(9, 30); } }), itemBuilder: (_) => [ CheckedPopupMenuItem(value: 'lines', checked: _lineNumbers, child: Text(tr('줄 번호'))), PopupMenuItem(value: 'smaller', child: Text(tr('글자 작게'))), PopupMenuItem(value: 'bigger', child: Text(tr('글자 크게'))), ], ), ], ), body: _error != null ? Center(child: Text(trf('읽지 못했습니다: {0}', [_error]))) : _charset == null ? const Center(child: CircularProgressIndicator()) : Column(children: [ Expanded( // 고르기 · 복사는 화면 전체에서 (줄마다 고르는 칸을 두면 큰 파일에서 무겁다) child: SelectionArea( child: Scrollbar( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), itemCount: lines.length, itemBuilder: (_, i) => Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_lineNumbers) SizedBox( width: 48, child: Text('${i + 1}', textAlign: TextAlign.right, style: TextStyle(fontSize: _font - 2, color: JjColors.textDim, fontFamily: 'monospace')), ), if (_lineNumbers) const SizedBox(width: 8), Expanded(child: Text(lines[i], style: TextStyle(fontSize: _font, fontFamily: 'monospace'))), ]), ), ), ), ), if (!_complete) SafeArea( top: false, child: Padding( padding: const EdgeInsets.all(6), child: Row(children: [ Expanded( child: Text(trf('{0} / {1} 읽음', [formatSize(_bytes.length), formatSize(_size)]), style: const TextStyle(fontSize: 12, color: JjColors.textDim)), ), FilledButton.tonal(onPressed: _more, child: Text(tr('더 읽기'))), ]), ), ), ]), ); } }