You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Flutter生成PDF上传Firebase Storage:MacOS正常Web端报错

Flutter Web端PDF上传Firebase Storage报错问题解决

我正在为公司开发一款基于Flutter的报价单生成应用,应用可根据用户选择生成PDF文档并存储到Firebase Storage。功能在MacOS平台运行完全正常,但在Flutter Web端运行时,尝试将生成的PDF文件存储到Firebase Storage时抛出completer.complete错误,错误信息不足以定位根因。

问题核心原因

  1. Web端不支持dart:io的File类:Web环境没有本地文件系统访问权限,getApplicationDocumentsDirectory和File操作在Web上无法正常工作,这是导致报错的主要原因。
  2. 重复初始化Firebase:generateQuotationPDF函数中再次调用await Firebase.initializeApp(),但应用入口main函数已经完成初始化,重复初始化会引发异常。
  3. 上传方式不兼容Web:使用putFile方法上传文件,Web端应该直接使用字节数据上传,而非本地文件对象。

解决方案

针对Web端的特性,修改PDF生成和上传逻辑,移除IO相关依赖,直接操作字节数据:

修改后的pdf_generator.dart代码

import 'package:flutter/services.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:firebase_core/firebase_core.dart';

// 获取当前报价单号
Future<int> _getQuotationNumber() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  int quotationNumber = prefs.getInt('quotation_number') ?? 0;
  return quotationNumber;
}

// 递增报价单号
Future<void> _incrementQuotationNumber() async {
  SharedPreferences prefs = await SharedPreferences.getInstance();
  int quotationNumber = await _getQuotationNumber();
  quotationNumber++;
  prefs.setInt('quotation_number', quotationNumber);
}

Future<String> generateQuotationPDF(
  Map<String, String> selectedItemNamesWithSKU,
  Map<String, int> quantities,
  Map<String, double> sellingPrices,
  Map<String, String> itemSku,
  Map<String, double> itemCostPrices,
  Map<String, double> itemPrices,
  double totalCost,
  double totalSellingPrice,
  double discount,
) async {
  final pdf = pw.Document();

  // 递增报价单号
  await _incrementQuotationNumber();
  int quotationNumber = await _getQuotationNumber();

  String fileName = 'quotation_pdfs/PC-${quotationNumber.toString().padLeft(3, '0')}.pdf';

  // 加载Logo图片
  final logoImage = pw.MemoryImage(
    (await rootBundle.load('assets/cazalogo.webp')).buffer.asUint8List(),
  );
  // 获取当前日期并格式化
  final currentDate = DateTime.now();
  final formattedDate = "${currentDate.year}-${currentDate.month.toString().padLeft(2, '0')}-${currentDate.day.toString().padLeft(2, '0')}";

  // 添加PDF内容
  pdf.addPage(
    pw.Page(
      build: (pw.Context context) {
        final List<List<String?>> tableData = [
          ['Item', 'SKU', 'Qty', 'Cost', 'Selling Price'], // 表头
          for (String type in selectedItemNamesWithSKU.keys)
            if (selectedItemNamesWithSKU[type] != null && selectedItemNamesWithSKU[type]!.isNotEmpty && quantities[selectedItemNamesWithSKU[type]] != 0)
              [
                selectedItemNamesWithSKU[type],
                itemSku[selectedItemNamesWithSKU[type]] ?? '',
                quantities[selectedItemNamesWithSKU[type]]?.toString() ?? '',
                '${(itemCostPrices[selectedItemNamesWithSKU[type]] ?? 0.0) * (quantities[selectedItemNamesWithSKU[type]] ?? 0)} BHD',
                '${(itemPrices[selectedItemNamesWithSKU[type]] ?? 0.0) * (quantities[selectedItemNamesWithSKU[type]] ?? 0)} BHD',
              ],
        ];

        return pw.Column(
          crossAxisAlignment: pw.CrossAxisAlignment.start,
          children: [
            pw.Center(child: pw.Image(logoImage, width: 50, height: 50)),
            pw.Text('Quotation #PC-${quotationNumber}', style: const pw.TextStyle(fontSize: 20)),
            pw.Divider(),
            pw.SizedBox(height: 6),
            pw.Text('Cazasouq Trading W.L.L', style: const pw.TextStyle(fontSize: 10)),
            pw.Text('Cazasouq Shop 983d Block 332 Road 3221 Bu ashira', style: const pw.TextStyle(fontSize: 10)),
            pw.Text(formattedDate, style: const pw.TextStyle(fontSize: 10)),
            pw.SizedBox(height: 20),
            pw.Text('Quotation Details', style: const pw.TextStyle(fontSize: 15)),
            pw.Divider(),
            pw.SizedBox(height: 10),
            // ignore: deprecated_member_use
            pw.Table.fromTextArray(
              headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, fontSize: 10),
              cellStyle: const pw.TextStyle(fontSize: 7),
              border: pw.TableBorder.all(),
              headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300),
              cellHeight: 25,
              cellAlignments: {
                0: pw.Alignment.centerLeft,
                1: pw.Alignment.center,
                2: pw.Alignment.center,
                3: pw.Alignment.center,
                4: pw.Alignment.center,
              },
              headerHeight: 20,
              headerPadding: const pw.EdgeInsets.all(5),
              cellPadding: const pw.EdgeInsets.all(5),
              data: tableData,
              columnWidths: {
                0: const pw.FlexColumnWidth(5),
                1: const pw.FlexColumnWidth(2),
                2: const pw.FlexColumnWidth(1),
                3: const pw.FlexColumnWidth(2),
                4: const pw.FlexColumnWidth(2),
              },
            ),
            pw.SizedBox(height: 10),
            pw.Row(
              mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
              children: [
                pw.Text('Total Cost: $totalCost BHD', style: const pw.TextStyle(fontSize: 9)),
                pw.Text('Total Selling Price: $totalSellingPrice BHD', style: const pw.TextStyle(fontSize: 9)),
              ],
            ),
            pw.SizedBox(height: 5),
            pw.Row(
              mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
              children: [
                pw.Text('Discount: $discount BHD', style: const pw.TextStyle(fontSize: 9)),
                pw.Text('Selling Price After Discount: ${totalSellingPrice - discount} BHD', style: const pw.TextStyle(fontSize: 9)),
              ],
            ),
            pw.SizedBox(height: 5),
            pw.Row(
              mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
              children: [
                pw.Text('Margin: ${totalSellingPrice - totalCost} BHD', style: const pw.TextStyle(fontSize: 9)),
                pw.Text('Margin After Discount: ${totalSellingPrice - totalCost - discount} BHD', style: const pw.TextStyle(fontSize: 9)),
              ],
            ),
            pw.SizedBox(height: 5),
            pw.Text('Selling Price After Discount + VAT: ${(totalSellingPrice - discount) * 1.1} BHD', style: const pw.TextStyle(fontSize: 9)),
          ],
        );
      },
    ),
  );

  // 生成PDF字节数据
  final pdfBytes = await pdf.save();

  // 上传到Firebase Storage
  final firebaseStorage = FirebaseStorage.instance;
  final reference = firebaseStorage.ref().child(fileName);
  // 使用putData直接上传字节数据,兼容Web端
  final uploadTask = reference.putData(pdfBytes);

  final TaskSnapshot snapshot = await uploadTask;

  if (snapshot.state == TaskState.success) {
    final downloadUrl = await snapshot.ref.getDownloadURL();
    print('PDF quotation uploaded to: $downloadUrl');
    return downloadUrl;
  } else {
    throw FirebaseException(
      plugin: 'firebase_storage',
      code: 'upload-failed',
      message: 'PDF上传失败,请检查Firebase配置和网络连接。',
    );
  }
}

关键修改点

  1. 移除IO相关依赖:删除dart:io导入、getApplicationDocumentsDirectory调用和本地文件保存逻辑,避免Web端不支持的API。
  2. 直接上传字节数据:使用pdf.save()获取PDF字节数组,通过putData方法上传到Firebase Storage,替代原有的putFile。
  3. 移除重复Firebase初始化:删除generateQuotationPDF中的await Firebase.initializeApp(),确保全局只初始化一次。
  4. 优化空值判断:在表格数据生成时增加selectedItemNamesWithSKU[type]!.isNotEmpty判断,避免空字符串导致的异常。

额外注意事项

  • 确保shared_preferences在Web端的兼容性,该插件已支持Web,但需要在pubspec.yaml中正确配置。
  • 检查Firebase Storage的规则,确保允许未认证用户上传(如果应用不需要登录),或者配置正确的认证逻辑。

内容的提问来源于stack exchange,提问作者Murtadha Alaali

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.14 13:37:04