263 lines
9.4 KiB
Dart
263 lines
9.4 KiB
Dart
|
|
import 'dart:async';
|
|||
|
|
import 'dart:io';
|
|||
|
|
import 'dart:math' as math;
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:pdfx/pdfx.dart';
|
|||
|
|
import 'package:path_provider/path_provider.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/toast_util.dart';
|
|||
|
|
import 'package:qhd_prevention/pages/my_appbar.dart';
|
|||
|
|
import 'package:qhd_prevention/customWidget/custom_button.dart';
|
|||
|
|
import 'package:dio/dio.dart';
|
|||
|
|
import 'package:qhd_prevention/tools/tools.dart';
|
|||
|
|
|
|||
|
|
class RemoteFilePage extends StatefulWidget {
|
|||
|
|
final String fileUrl;
|
|||
|
|
final int countdownSeconds;
|
|||
|
|
|
|||
|
|
const RemoteFilePage({
|
|||
|
|
Key? key,
|
|||
|
|
required this.fileUrl,
|
|||
|
|
this.countdownSeconds = 3,
|
|||
|
|
}) : super(key: key);
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
_RemoteFilePageState createState() => _RemoteFilePageState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _RemoteFilePageState extends State<RemoteFilePage> {
|
|||
|
|
String? _localPath;
|
|||
|
|
bool _isLoading = true;
|
|||
|
|
bool _hasScrolledToBottom = false;
|
|||
|
|
bool _timerFinished = false;
|
|||
|
|
late int _secondsRemaining;
|
|||
|
|
Timer? _countdownTimer;
|
|||
|
|
PdfControllerPinch? _pdfController;
|
|||
|
|
int _totalPages = 0;
|
|||
|
|
|
|||
|
|
// ========== 新增用于方案1的字段 ==========
|
|||
|
|
Size? _viewportSize; // 当前 viewport 大小,用于计算 scale
|
|||
|
|
final List<double> _pageTopOffsets = []; // 每页顶部偏移(相对于内容起点)
|
|||
|
|
double _totalContentHeight = 0; // 计算得到的滚动内容总高度
|
|||
|
|
double _pageSpacing = 0.0; // 如果你在 PdfViewPinch 布局里有页间距,可设置
|
|||
|
|
// ======================================
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
_secondsRemaining = widget.countdownSeconds;
|
|||
|
|
_startCountdown();
|
|||
|
|
_downloadAndLoad();
|
|||
|
|
LoadingDialogHelper.hide();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _downloadAndLoad() async {
|
|||
|
|
try {
|
|||
|
|
final url = widget.fileUrl;
|
|||
|
|
final filename = url.split('/').last;
|
|||
|
|
final dir = await getTemporaryDirectory();
|
|||
|
|
final filePath = '${dir.path}/$filename';
|
|||
|
|
|
|||
|
|
final dio = Dio();
|
|||
|
|
final response = await dio.get<List<int>>(
|
|||
|
|
url,
|
|||
|
|
options: Options(responseType: ResponseType.bytes),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
final file = File(filePath);
|
|||
|
|
await file.writeAsBytes(response.data!);
|
|||
|
|
|
|||
|
|
// 直接传 Future<PdfDocument> 给 controller(不要 await)
|
|||
|
|
final futureDoc = PdfDocument.openFile(filePath); // Future<PdfDocument>
|
|||
|
|
_pdfController = PdfControllerPinch(document: futureDoc);
|
|||
|
|
|
|||
|
|
// 注册 pageListenable listener(pageListenable 是 ValueListenable<int>,1-based)
|
|||
|
|
_pdfController!.pageListenable.addListener(_onPageListenableChanged);
|
|||
|
|
|
|||
|
|
if (!mounted) return;
|
|||
|
|
setState(() {
|
|||
|
|
_localPath = filePath;
|
|||
|
|
_isLoading = false;
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
if (mounted) {
|
|||
|
|
setState(() {
|
|||
|
|
_isLoading = false;
|
|||
|
|
});
|
|||
|
|
ToastUtil.showNormal(context, '文件加载失败: $e');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _onPageListenableChanged() {
|
|||
|
|
// pageListenable.value 是当前页(1-based)
|
|||
|
|
final controller = _pdfController;
|
|||
|
|
if (controller == null) return;
|
|||
|
|
final currentPage = controller.pageListenable.value;
|
|||
|
|
// 如果 totalPages 已知且到达最后一页,则标记为已看完
|
|||
|
|
if (_totalPages > 0 && currentPage >= _totalPages - 3) {
|
|||
|
|
if (!_hasScrolledToBottom && mounted) {
|
|||
|
|
setState(() {
|
|||
|
|
_hasScrolledToBottom = true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
void _startCountdown() {
|
|||
|
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|||
|
|
if (!mounted) return;
|
|||
|
|
setState(() {
|
|||
|
|
if (_secondsRemaining > 1) {
|
|||
|
|
_secondsRemaining--;
|
|||
|
|
} else {
|
|||
|
|
_secondsRemaining = 0;
|
|||
|
|
_timerFinished = true;
|
|||
|
|
_countdownTimer?.cancel();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
_countdownTimer?.cancel();
|
|||
|
|
// 移除 listener 再释放 controller(注意 controller 和其 listenable 可能为 null)
|
|||
|
|
try {
|
|||
|
|
if (_pdfController != null) {
|
|||
|
|
_pdfController!.pageListenable.removeListener(_onPageListenableChanged);
|
|||
|
|
_pdfController!.dispose();
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
// ignore dispose errors
|
|||
|
|
}
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 当滚动通知到达底部(或接近底部)时调用 — 作为备选方案/调试用
|
|||
|
|
bool _handleScrollNotification(ScrollNotification notification) {
|
|||
|
|
final metrics = notification.metrics;
|
|||
|
|
|
|||
|
|
// 优先使用我们计算的 totalContentHeight + viewport 判断(更准确)
|
|||
|
|
if (_totalContentHeight > 0 && _viewportSize != null) {
|
|||
|
|
final viewportH = _viewportSize!.height;
|
|||
|
|
final threshold = math.min(50.0, viewportH * 0.1); // 灵活阈值
|
|||
|
|
final scrolledToBottom = (metrics.pixels + viewportH) >= (_totalContentHeight - threshold);
|
|||
|
|
|
|||
|
|
if (scrolledToBottom) {
|
|||
|
|
if (!_hasScrolledToBottom && mounted) {
|
|||
|
|
setState(() {
|
|||
|
|
_hasScrolledToBottom = true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 兜底:使用 maxScrollExtent 判断(保留你原来的逻辑)
|
|||
|
|
if ((metrics.maxScrollExtent - metrics.pixels) <= 5.0 ||
|
|||
|
|
(metrics.atEdge && metrics.pixels == metrics.maxScrollExtent)) {
|
|||
|
|
if (!_hasScrolledToBottom && mounted) {
|
|||
|
|
setState(() {
|
|||
|
|
_hasScrolledToBottom = true;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return false; // 让事件继续传递
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
final isButtonEnabled = _timerFinished && _hasScrolledToBottom;
|
|||
|
|
return Scaffold(
|
|||
|
|
appBar: MyAppbar(title: '资料学习'),
|
|||
|
|
backgroundColor: Colors.white,
|
|||
|
|
body: SafeArea(
|
|||
|
|
child: Column(
|
|||
|
|
children: [
|
|||
|
|
Expanded(
|
|||
|
|
child: _isLoading
|
|||
|
|
? const Center(child: CircularProgressIndicator())
|
|||
|
|
: LayoutBuilder(builder: (context, constraints) {
|
|||
|
|
// 保存 viewport size(用于计算 scale)
|
|||
|
|
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
|
|||
|
|
|
|||
|
|
return NotificationListener<ScrollNotification>(
|
|||
|
|
onNotification: _handleScrollNotification,
|
|||
|
|
child: PdfViewPinch(
|
|||
|
|
controller: _pdfController!,
|
|||
|
|
scrollDirection: Axis.vertical,
|
|||
|
|
onDocumentLoaded: (document) async {
|
|||
|
|
// 原有行为:保存页数
|
|||
|
|
_totalPages = document.pagesCount;
|
|||
|
|
|
|||
|
|
// ======= 新增:计算每页渲染高度并累加 totalContentHeight =======
|
|||
|
|
// 注意:document.getPage(i) 后要 close()
|
|||
|
|
try {
|
|||
|
|
final viewportW = _viewportSize?.width ?? MediaQuery.of(context).size.width;
|
|||
|
|
final viewportH = _viewportSize?.height ?? MediaQuery.of(context).size.height;
|
|||
|
|
|
|||
|
|
_pageTopOffsets.clear();
|
|||
|
|
double acc = 0;
|
|||
|
|
for (int i = 1; i <= _totalPages; i++) {
|
|||
|
|
final page = await document.getPage(i);
|
|||
|
|
// page.width / page.height 可能是 num/double
|
|||
|
|
final pw = (page.width is num) ? (page.width as num).toDouble() : page.width.toDouble();
|
|||
|
|
final ph = (page.height is num) ? (page.height as num).toDouble() : page.height.toDouble();
|
|||
|
|
|
|||
|
|
// 模拟 contained 缩放行为:scale = min(viewportW / pw, viewportH / ph)
|
|||
|
|
final scale = math.min(viewportW / pw, viewportH / ph);
|
|||
|
|
final renderedH = ph * scale;
|
|||
|
|
|
|||
|
|
_pageTopOffsets.add(acc);
|
|||
|
|
acc += renderedH + _pageSpacing;
|
|||
|
|
|
|||
|
|
await page.close();
|
|||
|
|
}
|
|||
|
|
_totalContentHeight = acc;
|
|||
|
|
} catch (e) {
|
|||
|
|
// 计算失败则保留 _totalContentHeight 为 0,使用兜底判断
|
|||
|
|
_totalContentHeight = 0;
|
|||
|
|
}
|
|||
|
|
// ==============================================================
|
|||
|
|
|
|||
|
|
// 保留你原来:如果页少,直接视为已看完
|
|||
|
|
if (_totalPages <= 3) {
|
|||
|
|
if (!_hasScrolledToBottom && mounted) {
|
|||
|
|
setState(() => _hasScrolledToBottom = true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (mounted) setState(() {});
|
|||
|
|
},
|
|||
|
|
// 作为兜底:当 page 到最后一页时也标记为已看完(注意 page 是 1-based)
|
|||
|
|
onPageChanged: (page) {
|
|||
|
|
if (_totalPages > 0 && page == _totalPages) {
|
|||
|
|
if (!_hasScrolledToBottom && mounted) {
|
|||
|
|
setState(() => _hasScrolledToBottom = true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}),
|
|||
|
|
),
|
|||
|
|
Padding(
|
|||
|
|
padding: const EdgeInsets.all(16),
|
|||
|
|
child: CustomButton(
|
|||
|
|
backgroundColor: isButtonEnabled ? Colors.blue : Colors.grey,
|
|||
|
|
text: isButtonEnabled
|
|||
|
|
? '我已学习完毕'
|
|||
|
|
: _secondsRemaining == 0 ? '我已学习完毕' : '($_secondsRemaining s)我已学习完毕',
|
|||
|
|
onPressed: isButtonEnabled
|
|||
|
|
? () {
|
|||
|
|
Navigator.pop(context, true);
|
|||
|
|
}
|
|||
|
|
: null,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|