feat: Introduce invoice preview, separate save and PDF generation options, and gate invoice detail editing by unlock status, updating the build version to 1.0.1.

This commit is contained in:
joe 2026-02-14 20:25:35 +09:00
parent 4bc682f887
commit f98fed8c44
10 changed files with 280 additions and 33 deletions

View file

@ -4,7 +4,7 @@ FLUTTER_APPLICATION_PATH=/home/user/dev/inv/gemi_invoice_backup2
COCOAPODS_PARALLEL_CODE_SIGN=true COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_TARGET=lib/main.dart FLUTTER_TARGET=lib/main.dart
FLUTTER_BUILD_DIR=build FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0 FLUTTER_BUILD_NAME=1.0.1
FLUTTER_BUILD_NUMBER=1 FLUTTER_BUILD_NUMBER=1
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 EXCLUDED_ARCHS[sdk=iphoneos*]=armv7

View file

@ -5,7 +5,7 @@ export "FLUTTER_APPLICATION_PATH=/home/user/dev/inv/gemi_invoice_backup2"
export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_TARGET=lib/main.dart" export "FLUTTER_TARGET=lib/main.dart"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0" export "FLUTTER_BUILD_NAME=1.0.1"
export "FLUTTER_BUILD_NUMBER=1" export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true" export "TRACK_WIDGET_CREATION=true"

View file

@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../models/customer_model.dart';
import '../services/customer_repository.dart';
class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({Key? key}) : super(key: key);
@override
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
}
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final CustomerRepository _customerRepo = CustomerRepository();
List<Customer> _customers = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCustomers();
}
Future<void> _loadCustomers() async {
setState(() => _isLoading = true);
final customers = await _customerRepo.getAllCustomers();
setState(() {
_customers = customers;
_isLoading = false;
});
}
Future<void> _addOrEditCustomer({Customer? customer}) async {
final isEdit = customer != null;
final displayNameController = TextEditingController(text: customer?.displayName ?? "");
final formalNameController = TextEditingController(text: customer?.formalName ?? "");
final departmentController = TextEditingController(text: customer?.department ?? "");
final addressController = TextEditingController(text: customer?.address ?? "");
final telController = TextEditingController(text: customer?.tel ?? "");
String selectedTitle = customer?.title ?? "";
final result = await showDialog<Customer>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(isEdit ? "顧客を編集" : "顧客を新規登録"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: displayNameController,
decoration: const InputDecoration(labelText: "表示名(略称)", hintText: "例: 佐々木製作所"),
),
TextField(
controller: formalNameController,
decoration: const InputDecoration(labelText: "正式名称", hintText: "例: 株式会社 佐々木製作所"),
),
DropdownButtonFormField<String>(
value: selectedTitle,
decoration: const InputDecoration(labelText: "敬称"),
items: ["", "御中", "殿", "貴社"].map((t) => DropdownMenuItem(value: t, child: Text(t))).toList(),
onChanged: (val) => selectedTitle = val ?? "",
),
TextField(
controller: departmentController,
decoration: const InputDecoration(labelText: "部署名", hintText: "例: 営業部"),
),
TextField(
controller: addressController,
decoration: const InputDecoration(labelText: "住所"),
),
TextField(
controller: telController,
decoration: const InputDecoration(labelText: "電話番号"),
keyboardType: TextInputType.phone,
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(
onPressed: () {
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
return;
}
final newCustomer = Customer(
id: customer?.id ?? const Uuid().v4(),
displayName: displayNameController.text,
formalName: formalNameController.text,
title: selectedTitle,
department: departmentController.text.isEmpty ? null : departmentController.text,
address: addressController.text.isEmpty ? null : addressController.text,
tel: telController.text.isEmpty ? null : telController.text,
odooId: customer?.odooId,
isSynced: false,
);
Navigator.pop(context, newCustomer);
},
child: const Text("保存"),
),
],
),
),
);
if (result != null) {
await _customerRepo.saveCustomer(result);
_loadCustomers();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("顧客マスター管理"),
backgroundColor: Colors.blueGrey,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _customers.isEmpty
? const Center(child: Text("顧客が登録されていません"))
: ListView.builder(
itemCount: _customers.length,
itemBuilder: (context, index) {
final c = _customers[index];
return ListTile(
title: Text(c.displayName),
subtitle: Text("${c.formalName} ${c.title}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _addOrEditCustomer(customer: c)),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("削除確認"),
content: Text("${c.displayName}」を削除しますか?"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
],
),
);
if (confirm == true) {
await _customerRepo.deleteCustomer(c.id);
_loadCustomers();
}
},
),
],
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: () => _addOrEditCustomer(),
child: const Icon(Icons.person_add),
backgroundColor: Colors.indigo,
),
);
}
}

View file

@ -10,8 +10,9 @@ import 'product_picker_modal.dart';
class InvoiceDetailPage extends StatefulWidget { class InvoiceDetailPage extends StatefulWidget {
final Invoice invoice; final Invoice invoice;
final bool isUnlocked;
const InvoiceDetailPage({Key? key, required this.invoice}) : super(key: key); const InvoiceDetailPage({Key? key, required this.invoice, this.isUnlocked = false}) : super(key: key);
@override @override
State<InvoiceDetailPage> createState() => _InvoiceDetailPageState(); State<InvoiceDetailPage> createState() => _InvoiceDetailPageState();
@ -137,7 +138,8 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
actions: [ actions: [
if (!_isEditing) ...[ if (!_isEditing) ...[
IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"), IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"),
IconButton(icon: const Icon(Icons.edit), onPressed: () => setState(() => _isEditing = true)), if (widget.isUnlocked)
IconButton(icon: const Icon(Icons.edit), onPressed: () => setState(() => _isEditing = true)),
] else ...[ ] else ...[
IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges), IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges),
IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)), IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)),

View file

@ -274,19 +274,16 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
], ],
), ),
onTap: () async { onTap: () async {
if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("詳細の閲覧・編集にはアンロックが必要です"), duration: Duration(seconds: 1)),
);
return;
}
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => InvoiceDetailPage(invoice: invoice), builder: (context) => InvoiceDetailPage(
invoice: invoice,
isUnlocked: _isUnlocked, //
),
), ),
); );
_loadData(); // _loadData();
}, },
onLongPress: () async { onLongPress: () async {
if (!_isUnlocked) { if (!_isUnlocked) {

View file

@ -64,7 +64,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
int get _tax => _includeTax ? (_subTotal * _taxRate).round() : 0; int get _tax => _includeTax ? (_subTotal * _taxRate).round() : 0;
int get _total => _subTotal + _tax; int get _total => _subTotal + _tax;
Future<void> _handleGenerate() async { Future<void> _saveInvoice({bool generatePdf = true}) async {
if (_selectedCustomer == null) { if (_selectedCustomer == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください"))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください")));
return; return;
@ -79,19 +79,53 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
date: DateTime.now(), date: DateTime.now(),
items: _items, items: _items,
taxRate: _includeTax ? _taxRate : 0.0, taxRate: _includeTax ? _taxRate : 0.0,
customerFormalNameSnapshot: _selectedCustomer!.formalName, // customerFormalNameSnapshot: _selectedCustomer!.formalName,
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
); );
setState(() => _status = "PDFを生成中..."); if (generatePdf) {
final path = await generateInvoicePdf(invoice); setState(() => _status = "PDFを生成中...");
if (path != null) { final path = await generateInvoicePdf(invoice);
final updatedInvoice = invoice.copyWith(filePath: path); if (path != null) {
await _repository.saveInvoice(updatedInvoice); final updatedInvoice = invoice.copyWith(filePath: path);
widget.onInvoiceGenerated(updatedInvoice, path); await _repository.saveInvoice(updatedInvoice);
widget.onInvoiceGenerated(updatedInvoice, path);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
}
} else {
await _repository.saveInvoice(invoice);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しましたPDF未生成")));
Navigator.pop(context); //
} }
} }
void _showPreview() {
if (_selectedCustomer == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("伝票プレビュー(仮)"),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("宛名: ${_selectedCustomer!.formalName} ${_selectedCustomer!.title}"),
const Divider(),
..._items.map((it) => Text("${it.description} x ${it.quantity} = ¥${it.subtotal}")),
const Divider(),
Text("小計: ¥${NumberFormat("#,###").format(_subTotal)}"),
Text("消費税: ¥${NumberFormat("#,###").format(_tax)}"),
Text("合計: ¥${NumberFormat("#,###").format(_total)}", style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("閉じる")),
],
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fmt = NumberFormat("#,###"); final fmt = NumberFormat("#,###");
@ -295,20 +329,56 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
Widget _buildBottomActionBar() { Widget _buildBottomActionBar() {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))], boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
), ),
child: ElevatedButton.icon( child: SafeArea(
onPressed: _handleGenerate, child: Column(
icon: const Icon(Icons.picture_as_pdf), mainAxisSize: MainAxisSize.min,
label: const Text("伝票を確定してPDF生成"), children: [
style: ElevatedButton.styleFrom( Row(
minimumSize: const Size(double.infinity, 60), children: [
backgroundColor: Colors.indigo, Expanded(
foregroundColor: Colors.white, child: OutlinedButton.icon(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), onPressed: _showPreview,
icon: const Icon(Icons.remove_red_eye),
label: const Text("仮表示"),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
side: const BorderSide(color: Colors.indigo),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: () => _saveInvoice(generatePdf: false),
icon: const Icon(Icons.save),
label: const Text("保存のみ"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.blueGrey,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: () => _saveInvoice(generatePdf: true),
icon: const Icon(Icons.picture_as_pdf),
label: const Text("確定してPDF生成"),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 56),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
],
), ),
), ),
); );

View file

@ -3,10 +3,10 @@ import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import '../models/invoice_models.dart';
import '../services/invoice_repository.dart'; import '../services/invoice_repository.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import 'product_master_screen.dart'; import 'product_master_screen.dart';
import 'customer_master_screen.dart';
class ManagementScreen extends StatelessWidget { class ManagementScreen extends StatelessWidget {
const ManagementScreen({Key? key}) : super(key: key); const ManagementScreen({Key? key}) : super(key: key);
@ -28,6 +28,13 @@ class ManagementScreen extends StatelessWidget {
"販売商品の名称や単価を管理します", "販売商品の名称や単価を管理します",
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())), () => Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())),
), ),
_buildMenuTile(
context,
Icons.people,
"顧客マスター管理",
"取引先(請求先)の名称や敬称を管理します",
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const CustomerMasterScreen())),
),
_buildMenuTile( _buildMenuTile(
context, context,
Icons.upload_file, Icons.upload_file,

View file

@ -3,7 +3,7 @@ FLUTTER_ROOT=/home/user/development/flutter
FLUTTER_APPLICATION_PATH=/home/user/dev/inv/gemi_invoice_backup2 FLUTTER_APPLICATION_PATH=/home/user/dev/inv/gemi_invoice_backup2
COCOAPODS_PARALLEL_CODE_SIGN=true COCOAPODS_PARALLEL_CODE_SIGN=true
FLUTTER_BUILD_DIR=build FLUTTER_BUILD_DIR=build
FLUTTER_BUILD_NAME=1.0.0 FLUTTER_BUILD_NAME=1.0.1
FLUTTER_BUILD_NUMBER=1 FLUTTER_BUILD_NUMBER=1
DART_OBFUSCATION=false DART_OBFUSCATION=false
TRACK_WIDGET_CREATION=true TRACK_WIDGET_CREATION=true

View file

@ -4,7 +4,7 @@ export "FLUTTER_ROOT=/home/user/development/flutter"
export "FLUTTER_APPLICATION_PATH=/home/user/dev/inv/gemi_invoice_backup2" export "FLUTTER_APPLICATION_PATH=/home/user/dev/inv/gemi_invoice_backup2"
export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "COCOAPODS_PARALLEL_CODE_SIGN=true"
export "FLUTTER_BUILD_DIR=build" export "FLUTTER_BUILD_DIR=build"
export "FLUTTER_BUILD_NAME=1.0.0" export "FLUTTER_BUILD_NAME=1.0.1"
export "FLUTTER_BUILD_NUMBER=1" export "FLUTTER_BUILD_NUMBER=1"
export "DART_OBFUSCATION=false" export "DART_OBFUSCATION=false"
export "TRACK_WIDGET_CREATION=true" export "TRACK_WIDGET_CREATION=true"

View file

@ -32,4 +32,7 @@
- 自社情報編集の画面で消費税を設定可能にする - 自社情報編集の画面で消費税を設定可能にする
- 自社情報編集で印鑑を撮影出来る様にする - 自社情報編集で印鑑を撮影出来る様にする
- 商品マスター等でバーコードQRコードのスキャンが可能でありたい - 商品マスター等でバーコードQRコードのスキャンが可能でありたい
ロック機能はご動作対策であって削除と編集機能以外は全部使える様にする
- 顧客マスターの新規・編集・削除機能を実装する
- PDF作成と保存と仮表示は別ボタンで実装