219 lines
7.5 KiB
Dart
219 lines
7.5 KiB
Dart
// version: 1.4.3c (Bug Fix: PDF layout error)
|
|
import 'dart:io';
|
|
import 'dart:typed_data';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:open_filex/open_filex.dart';
|
|
import 'package:pdf/widgets.dart' as pw;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:crypto/crypto.dart';
|
|
import 'package:intl/intl.dart';
|
|
|
|
void main() => runApp(const MaterialApp(home: InvoiceApp()));
|
|
|
|
class InvoiceApp extends StatefulWidget {
|
|
const InvoiceApp({super.key});
|
|
@override
|
|
State<InvoiceApp> createState() => _InvoiceAppState();
|
|
}
|
|
|
|
class _InvoiceAppState extends State<InvoiceApp> {
|
|
final _clientController = TextEditingController(text: "佐々木製作所");
|
|
final _amountController = TextEditingController(text: "250000");
|
|
String _status = "内容を入力してPDFを発行してください";
|
|
String? _lastFilePath;
|
|
|
|
Future<void> _pickContact() async {
|
|
setState(() => _status = "連絡先をスキャン中...");
|
|
try {
|
|
if (await FlutterContacts.requestPermission()) {
|
|
final List<Contact> contacts = await FlutterContacts.getContacts(
|
|
withProperties: false,
|
|
withThumbnail: false,
|
|
);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (contacts.isEmpty) {
|
|
setState(() => _status = "連絡先が空、または取得できませんでした。");
|
|
return;
|
|
}
|
|
|
|
final Contact? selected = await showDialog<Contact>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text("取引先を選択 (${contacts.length}件)"),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
height: 400,
|
|
child: ListView.builder(
|
|
itemCount: contacts.length,
|
|
itemBuilder: (c, i) => ListTile(
|
|
leading: const CircleAvatar(child: Icon(Icons.person)),
|
|
title: Text(contacts[i].displayName),
|
|
onTap: () => Navigator.pop(c, contacts[i]),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
if (selected != null) {
|
|
setState(() {
|
|
_clientController.text = selected.displayName;
|
|
_status = "「${selected.displayName}」をセットしました";
|
|
});
|
|
}
|
|
} else {
|
|
setState(() => _status = "電話帳の権限が拒否されています。");
|
|
}
|
|
} catch (e) {
|
|
setState(() => _status = "エラーが発生しました: $e");
|
|
}
|
|
}
|
|
|
|
Future<void> _methodDirectOpen() async {
|
|
if (_lastFilePath != null) {
|
|
await OpenFilex.open(_lastFilePath!);
|
|
}
|
|
}
|
|
|
|
Future<void> _methodShare() async {
|
|
if (_lastFilePath != null) {
|
|
await Share.shareXFiles([XFile(_lastFilePath!)], text: '請求書送付');
|
|
}
|
|
}
|
|
|
|
Future<void> _generateInvoice() async {
|
|
final pdf = pw.Document();
|
|
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
|
final ttf = pw.Font.ttf(fontData);
|
|
|
|
final clientName = _clientController.text;
|
|
final int unitPrice = int.tryParse(_amountController.text) ?? 0;
|
|
final int total = (unitPrice * 1.1).floor();
|
|
|
|
final now = DateTime.now();
|
|
final dateFileStr = DateFormat('yyyyMMdd').format(now);
|
|
final amountFormatter = NumberFormat("#,###");
|
|
|
|
pdf.addPage(pw.Page(
|
|
theme: pw.ThemeData.withFont(base: ttf, bold: ttf),
|
|
build: (context) => pw.Center(
|
|
child: pw.Column(
|
|
// --- ここを修正しました ---
|
|
mainAxisAlignment: pw.MainAxisAlignment.center,
|
|
children: [
|
|
pw.Text("請求書", style: pw.TextStyle(fontSize: 24)),
|
|
pw.SizedBox(height: 20),
|
|
pw.Text("宛名: $clientName 様"),
|
|
pw.Text("合計金額: ${amountFormatter.format(total)} 円 (税込)"),
|
|
pw.SizedBox(height: 10),
|
|
pw.Text("日付: ${DateFormat('yyyy/MM/dd').format(now)}"),
|
|
],
|
|
)
|
|
),
|
|
));
|
|
|
|
final Uint8List bytes = await pdf.save();
|
|
final String hash = sha256.convert(bytes).toString().substring(sha256.convert(bytes).toString().length - 8);
|
|
|
|
String fileName = "${dateFileStr}(請求)${clientName}_${amountFormatter.format(total)}円_${hash}.pdf";
|
|
|
|
final directory = await getExternalStorageDirectory();
|
|
if (directory == null) return;
|
|
|
|
final file = File("${directory.path}/$fileName");
|
|
await file.writeAsBytes(bytes);
|
|
|
|
setState(() {
|
|
_lastFilePath = file.path;
|
|
_status = "【原本保存完了】\n$fileName";
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text("じぇみエモン V1.4.3c"), backgroundColor: Colors.blueGrey),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: SingleChildScrollView(
|
|
child: Column(children: [
|
|
Row(children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _clientController,
|
|
decoration: const InputDecoration(labelText: "取引先名", border: OutlineInputBorder())
|
|
)
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const Icon(Icons.person_search, color: Colors.blue, size: 40),
|
|
onPressed: _pickContact,
|
|
),
|
|
]),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _amountController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: "単価 (税抜)", border: OutlineInputBorder())
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
ElevatedButton.icon(
|
|
onPressed: _generateInvoice,
|
|
icon: const Icon(Icons.save),
|
|
label: const Text("原本PDFをローカル保存"),
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size(double.infinity, 60),
|
|
backgroundColor: Colors.indigo,
|
|
foregroundColor: Colors.white
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 20),
|
|
Row(children: [
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _lastFilePath == null ? null : _methodDirectOpen,
|
|
icon: const Icon(Icons.launch),
|
|
label: const Text("内容確認(A)"),
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size(0, 50),
|
|
backgroundColor: Colors.orange,
|
|
foregroundColor: Colors.white
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: _lastFilePath == null ? null : _methodShare,
|
|
icon: const Icon(Icons.share),
|
|
label: const Text("外部共有(B)"),
|
|
style: ElevatedButton.styleFrom(
|
|
minimumSize: const Size(0, 50),
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white
|
|
),
|
|
),
|
|
),
|
|
]),
|
|
|
|
const SizedBox(height: 24),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
color: Colors.grey[100],
|
|
child: Text(_status, style: const TextStyle(fontSize: 12, color: Colors.black54)),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|