TF-2764 Add PDFViewer to preview PDF attachment

This commit is contained in:
dab246
2024-05-17 02:19:00 +07:00
committed by Dat H. Pham
parent 89a489eb06
commit 7524a97bc8
25 changed files with 1018 additions and 111 deletions
@@ -0,0 +1,226 @@
import 'package:core/utils/app_logger.dart';
import 'package:flutter/material.dart';
import 'package:pdf_render/pdf_render_widgets.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
enum ZoomState {
activate,
inactivate
}
class PaginationPDFViewer extends StatefulWidget {
final PdfViewerController? pdfViewerController;
const PaginationPDFViewer({
super.key,
this.pdfViewerController,
});
@override
State<PaginationPDFViewer> createState() => _PaginationPDFViewerState();
}
class _PaginationPDFViewerState extends State<PaginationPDFViewer> {
static const double _maxZoomLevelDefault = 4.0;
static const double _minZoomLevelDefault = 1.0;
final ValueNotifier<ZoomState> _zoomInPageNotifier = ValueNotifier<ZoomState>(ZoomState.activate);
final ValueNotifier<ZoomState> _zoomOutPageNotifier = ValueNotifier<ZoomState>(ZoomState.inactivate);
final ValueNotifier<int> _pageCurrentNotifier = ValueNotifier<int>(1);
double _zoomLevel = 1.0;
@override
void initState() {
_zoomLevel = 1.0;
_pageCurrentNotifier.value = 1;
widget.pdfViewerController?.addListener(_pageChanged);
super.initState();
}
@override
void dispose() {
_zoomLevel = 1.0;
widget.pdfViewerController?.removeListener(_pageChanged);
_pageCurrentNotifier.dispose();
_zoomInPageNotifier.dispose();
_zoomOutPageNotifier.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.8),
borderRadius: const BorderRadius.all(Radius.circular(5))
),
height: 50,
padding: const EdgeInsetsDirectional.only(start: 16, end: 12),
margin: const EdgeInsets.only(bottom: 24),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
AppLocalizations.of(context).page,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white,
fontSize: 14
),
),
const SizedBox(width: 12),
ValueListenableBuilder(
valueListenable: _pageCurrentNotifier,
builder: (_, value, __) {
return Text(
'$value',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white,
fontSize: 14
),
);
}),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
'/',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white,
fontSize: 14
),
),
),
Text(
'${widget.pdfViewerController?.pageCount}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white,
fontSize: 14
),
),
const Padding(
padding: EdgeInsetsDirectional.only(start: 12, end: 8),
child: VerticalDivider(color: Colors.grey),
),
ValueListenableBuilder(
valueListenable: _zoomOutPageNotifier,
builder: (_, state, __) {
return IconButton(
onPressed: _zoomOutPage,
padding: const EdgeInsets.all(2),
icon: Icon(
Icons.horizontal_rule,
color: _getColorByZoomState(state),
size: 24,
),
focusColor: Colors.black.withOpacity(0.3),
hoverColor: Colors.black.withOpacity(0.3),
tooltip: AppLocalizations.of(context).zoomOut,
);
}
),
const Padding(
padding: EdgeInsets.all(5),
child: Icon(
Icons.zoom_in,
color: Colors.white,
size: 28,
),
),
ValueListenableBuilder(
valueListenable: _zoomInPageNotifier,
builder: (_, state, __) {
return IconButton(
onPressed: _zoomInPage,
padding: const EdgeInsets.all(2),
icon: Icon(
Icons.add,
color: _getColorByZoomState(state),
size: 24,
),
focusColor: Colors.black.withOpacity(0.3),
hoverColor: Colors.black.withOpacity(0.3),
tooltip: AppLocalizations.of(context).zoomIn,
);
}
)
],
),
);
}
Color _getColorByZoomState(ZoomState zoomState) {
switch (zoomState) {
case ZoomState.activate:
return Colors.white;
case ZoomState.inactivate:
return Colors.grey;
}
}
void _pageChanged({String? property}) {
_pageCurrentNotifier.value = widget.pdfViewerController?.currentPageNumber ?? 1;
_updateZoomState();
}
void _zoomInPage() {
if (_zoomLevel < _maxZoomLevelDefault) {
if (_zoomLevel >= 1.0 && _zoomLevel < 1.25) {
_zoomLevel = 1.25;
} else if (_zoomLevel >= 1.25 && _zoomLevel < 1.50) {
_zoomLevel = 1.50;
} else if (_zoomLevel >= 1.50 && _zoomLevel < 2.0) {
_zoomLevel = 2.0;
} else if (_zoomLevel >= 2.0 && _zoomLevel < 2.50) {
_zoomLevel = 2.5;
} else if (_zoomLevel >= 2.5 && _zoomLevel < 3.0) {
_zoomLevel = 3.0;
} else if (_zoomLevel >= 3.0 && _zoomLevel < 3.5) {
_zoomLevel = 3.5;
} else if (_zoomLevel >= 3.5 && _zoomLevel < 4.0) {
_zoomLevel = 4.0;
}
_updateZoomState();
widget.pdfViewerController?.setZoomRatio(zoomRatio: _zoomLevel);
}
}
void _zoomOutPage() {
if (_zoomLevel > _minZoomLevelDefault) {
if (_zoomLevel > 1.0 && _zoomLevel <= 1.25) {
_zoomLevel = 1.0;
} else if (_zoomLevel > 1.25 && _zoomLevel <= 1.50) {
_zoomLevel = 1.25;
} else if (_zoomLevel > 1.50 && _zoomLevel <= 2.0) {
_zoomLevel = 1.50;
} else if (_zoomLevel > 2.0 && _zoomLevel <= 2.50) {
_zoomLevel = 2.0;
} else if (_zoomLevel > 2.5 && _zoomLevel <= 3.0) {
_zoomLevel = 2.5;
} else if (_zoomLevel > 3.0 && _zoomLevel <= 3.5) {
_zoomLevel = 3.0;
} else if (_zoomLevel > 3.5 && _zoomLevel <= 4.0) {
_zoomLevel = 3.5;
}
_updateZoomState();
widget.pdfViewerController?.setZoomRatio(zoomRatio: _zoomLevel);
}
}
void _updateZoomState() {
final zoomLevel = widget.pdfViewerController?.zoomRatio ?? 1.0;
log('_PaginationPDFViewerState::_updateZoomState:zoomLevel = $zoomLevel');
if (zoomLevel <= _maxZoomLevelDefault && zoomLevel > _minZoomLevelDefault) {
_zoomOutPageNotifier.value = ZoomState.activate;
} else {
_zoomOutPageNotifier.value = ZoomState.inactivate;
}
if (zoomLevel < _maxZoomLevelDefault && zoomLevel >= _minZoomLevelDefault) {
_zoomInPageNotifier.value = ZoomState.activate;
} else {
_zoomInPageNotifier.value = ZoomState.inactivate;
}
}
}
@@ -0,0 +1,261 @@
import 'dart:async';
import 'dart:math';
import 'dart:typed_data';
import 'package:core/presentation/extensions/color_extension.dart';
import 'package:core/presentation/state/failure.dart';
import 'package:core/presentation/state/success.dart';
import 'package:core/utils/app_logger.dart';
import 'package:dartz/dartz.dart' as dartz;
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:jmap_dart_client/jmap/account_id.dart';
import 'package:model/download/download_task_id.dart';
import 'package:model/email/attachment.dart';
import 'package:pdf_render/pdf_render_widgets.dart';
import 'package:percent_indicator/circular_percent_indicator.dart';
import 'package:tmail_ui_user/features/base/widget/circle_loading_widget.dart';
import 'package:tmail_ui_user/features/email/domain/exceptions/download_attachment_exceptions.dart';
import 'package:tmail_ui_user/features/email/domain/state/download_attachment_for_web_state.dart';
import 'package:tmail_ui_user/features/email/domain/usecases/download_attachment_for_web_interactor.dart';
import 'package:tmail_ui_user/features/email/presentation/widgets/pdf_viewer/pagination_pdf_viewer.dart';
import 'package:tmail_ui_user/features/email/presentation/widgets/pdf_viewer/top_bar_pdf_viewer.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
import 'package:tmail_ui_user/main/routes/route_navigation.dart';
typedef DownloadPDFFileAction = Function(Uint8List bytes, String fileName);
typedef PrintPDFFileAction = Function(Uint8List bytes, String fileName);
class PDFViewer extends StatefulWidget {
final Attachment attachment;
final AccountId accountId;
final String downloadUrl;
final DownloadPDFFileAction? downloadAction;
final PrintPDFFileAction? printAction;
const PDFViewer({
super.key,
required this.attachment,
required this.accountId,
required this.downloadUrl,
this.downloadAction,
this.printAction,
});
@override
State<PDFViewer> createState() => _PDFViewerState();
}
class _PDFViewerState extends State<PDFViewer> {
late DownloadAttachmentForWebInteractor? _downloadAttachmentForWebInteractor;
late StreamController<dartz.Either<Failure, Success>> _downloadAttachmentStreamController;
late StreamSubscription<dartz.Either<Failure, Success>> _downloadAttachmentStreamSubscription;
StreamSubscription<dartz.Either<Failure, Success>>? _downloadInteractorStreamSubscription;
CancelToken? _downloadAttachmentCancelToken;
final PdfViewerController _pdfViewerController = PdfViewerController();
final ValueNotifier<Object?> _pdfViewStateNotifier = ValueNotifier<Object?>(null);
@override
void initState() {
super.initState();
_initialStreamListener();
_downloadAttachmentAction();
}
void _initialStreamListener() {
_downloadAttachmentStreamController = StreamController<dartz.Either<Failure, Success>>.broadcast();
_downloadAttachmentStreamSubscription = _downloadAttachmentStreamController.stream.listen((viewState) {
viewState.fold(
(failure) => _pdfViewStateNotifier.value = failure,
(success) => _pdfViewStateNotifier.value = success
);
});
}
void _downloadAttachmentAction() {
_downloadAttachmentForWebInteractor = getBinding<DownloadAttachmentForWebInteractor>();
if (_downloadAttachmentForWebInteractor == null) {
_pdfViewStateNotifier.value = DownloadAttachmentForWebFailure(exception: DownloadAttachmentInteractorIsNull());
return;
}
_downloadAttachmentCancelToken = CancelToken();
_downloadInteractorStreamSubscription = _downloadAttachmentForWebInteractor!.execute(
DownloadTaskId(widget.attachment.blobId!.value),
widget.attachment,
widget.accountId,
widget.downloadUrl,
_downloadAttachmentStreamController,
cancelToken: _downloadAttachmentCancelToken
).listen((viewState) {
viewState.fold(
(failure) => _pdfViewStateNotifier.value = failure,
(success) => _pdfViewStateNotifier.value = success
);
});
}
@override
void dispose() {
_pdfViewerController.dispose();
_pdfViewStateNotifier.dispose();
_downloadAttachmentStreamSubscription.cancel();
_downloadAttachmentStreamController.close();
_downloadInteractorStreamSubscription?.cancel();
_downloadInteractorStreamSubscription = null;
_downloadAttachmentCancelToken = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
return Center(
child: Stack(
children: [
Center(
child: SizedBox(
width: double.infinity,
child: ValueListenableBuilder(
valueListenable: _pdfViewStateNotifier,
builder: (context, viewState, widget) {
if (viewState is DownloadAttachmentForWebSuccess) {
return PdfViewer.openData(
viewState.bytes,
viewerController: _pdfViewerController,
onError: (error) {
logError('_PDFViewerState::build:openData:onError:: $error');
_pdfViewStateNotifier.value = DownloadAttachmentForWebFailure(exception: error);
},
params: PdfViewerParams(
panAxis: PanAxis.vertical,
scrollByMouseWheel: 0.5,
layoutPages: (viewSize, pages) {
List<Rect> rect = [];
final viewWidth = viewSize.width;
final viewHeight = viewSize.height;
final maxHeight = pages.fold<double>(0.0, (maxHeight, page) => max(maxHeight, page.height));
final maxWidth = pages.fold<double>(0.0, (maxWidth, page) => max(maxWidth, page.width));
final ratio = viewHeight / max(maxHeight, maxWidth);
log('_PDFViewerState::build: viewWidth = $viewWidth | viewHeight = $viewHeight | maxHeight = $maxHeight | ratio = $ratio');
var top = 0.0;
double padding = 16.0;
for (var page in pages) {
final width = page.width * ratio;
final height = page.height * ratio;
final left = viewWidth > viewHeight ? (viewWidth / 2) - (width / 2) : 0.0;
rect.add(Rect.fromLTWH(left, top, width, height));
top += height + padding;
}
return rect;
}
),
);
} else if (viewState is DownloadingAttachmentForWeb) {
return CircularPercentIndicator(
percent: viewState.progress / 100,
progressColor: AppColor.primaryColor,
lineWidth: 4.0,
backgroundColor: Colors.white,
radius: 40,
center: Text(
'${viewState.progress.round()}%',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w500
),
),
);
} else if (viewState is DownloadAttachmentForWebFailure) {
return Center(
child: Text(
AppLocalizations.of(context).noPreviewAvailable,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w500
),
),
);
} else {
return const Center(
child: CircleLoadingWidget(
size: 80,
strokeWidth: 4.0,
)
);
}
},
)
),
),
Align(
alignment: AlignmentDirectional.topCenter,
child: ValueListenableBuilder(
valueListenable: _pdfViewStateNotifier,
builder: (context, viewState, child) {
if (viewState is DownloadAttachmentForWebSuccess) {
return TopBarPDFViewer(
attachment: widget.attachment,
downloadAction: () => widget.downloadAction?.call(
viewState.bytes,
widget.attachment.generateFileName()
),
printAction: () => widget.printAction?.call(
viewState.bytes,
widget.attachment.generateFileName()
),
);
}
return child ?? const SizedBox.shrink();
},
child: TopBarPDFViewer(
attachment: widget.attachment,
closeAction: () {
_downloadAttachmentCancelToken?.cancel();
Navigator.maybeOf(context)?.pop();
},
),
),
),
ValueListenableBuilder(
valueListenable: _pdfViewerController,
builder: (_, __, ___) {
if (_pdfViewerController.isReady) {
return Align(
alignment: AlignmentDirectional.bottomCenter,
child: PaginationPDFViewer(
pdfViewerController: _pdfViewerController,
),
);
} else {
return const SizedBox.shrink();
}
}
),
ValueListenableBuilder(
valueListenable: _pdfViewerController,
builder: (context, m, child) {
if (!_pdfViewerController.isReady) return Container();
final v = _pdfViewerController.viewRect;
final all = _pdfViewerController.fullSize;
final top = v.top / all.height * v.height;
final height = v.height / all.height * v.height;
return Positioned(
right: 0,
top: top,
height: height,
width: 8,
child: Container(color: AppColor.colorTextBody),
);
},
),
],
),
);
}
}
@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
import 'package:model/email/attachment.dart';
import 'package:tmail_ui_user/main/localizations/app_localizations.dart';
class TopBarPDFViewer extends StatelessWidget {
final Attachment attachment;
final VoidCallback? downloadAction;
final VoidCallback? printAction;
final VoidCallback? closeAction;
const TopBarPDFViewer({
super.key,
required this.attachment,
this.downloadAction,
this.printAction,
this.closeAction,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
height: 52,
color: Colors.black.withOpacity(0.3),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: closeAction ?? Navigator.maybeOf(context)?.pop,
padding: const EdgeInsets.all(8),
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
size: 24,
),
focusColor: Colors.black.withOpacity(0.3),
hoverColor: Colors.black.withOpacity(0.3),
tooltip: AppLocalizations.of(context).close,
),
const SizedBox(width: 8),
Expanded(
child: Text(
attachment.generateFileName(),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.white,
fontSize: 17,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
if (printAction != null)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: IconButton(
onPressed: printAction,
padding: const EdgeInsets.all(8),
icon: const Icon(
Icons.print,
color: Colors.white,
size: 24,
),
focusColor: Colors.black.withOpacity(0.3),
hoverColor: Colors.black.withOpacity(0.3),
tooltip: AppLocalizations.of(context).print,
),
),
if (downloadAction != null)
Padding(
padding: const EdgeInsetsDirectional.only(start: 8),
child: IconButton(
onPressed: downloadAction,
padding: const EdgeInsets.all(8),
icon: const Icon(
Icons.download,
color: Colors.white,
size: 24,
),
focusColor: Colors.black.withOpacity(0.3),
hoverColor: Colors.black.withOpacity(0.3),
tooltip: AppLocalizations.of(context).download,
),
)
],
),
);
}
}