diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index c419b3f..3c55531 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -18,12 +18,30 @@ @import geolocator_apple; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + +#if __has_include() +#import +#else +@import mobile_scanner; +#endif + #if __has_include() #import #else @import open_filex; #endif +#if __has_include() +#import +#else +@import package_info_plus; +#endif + #if __has_include() #import #else @@ -53,7 +71,10 @@ + (void)registerWithRegistry:(NSObject*)registry { [FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; + [MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]]; [OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; diff --git a/lib/models/company_model.dart b/lib/models/company_model.dart new file mode 100644 index 0000000..96d8a69 --- /dev/null +++ b/lib/models/company_model.dart @@ -0,0 +1,58 @@ +class CompanyInfo { + final String name; + final String? zipCode; + final String? address; + final String? tel; + final double defaultTaxRate; + final String? sealPath; // 角印(印鑑)の画像パス + + CompanyInfo({ + required this.name, + this.zipCode, + this.address, + this.tel, + this.defaultTaxRate = 0.10, + this.sealPath, + }); + + Map toMap() { + return { + 'id': 1, // 常に1行のみ保持 + 'name': name, + 'zip_code': zipCode, + 'address': address, + 'tel': tel, + 'default_tax_rate': defaultTaxRate, + 'seal_path': sealPath, + }; + } + + factory CompanyInfo.fromMap(Map map) { + return CompanyInfo( + name: map['name'] ?? "自社名未設定", + zipCode: map['zip_code'], + address: map['address'], + tel: map['tel'], + defaultTaxRate: map['default_tax_rate'] ?? 0.10, + sealPath: map['seal_path'], + ); + } + + CompanyInfo copyWith({ + String? name, + String? zipCode, + String? address, + String? tel, + double? defaultTaxRate, + String? sealPath, + }) { + return CompanyInfo( + name: name ?? this.name, + zipCode: zipCode ?? this.zipCode, + address: address ?? this.address, + tel: tel ?? this.tel, + defaultTaxRate: defaultTaxRate ?? this.defaultTaxRate, + sealPath: sealPath ?? this.sealPath, + ); + } +} diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index 6bc3497..8ee2c15 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -43,7 +43,8 @@ class Invoice { final List items; final String? notes; final String? filePath; - final double taxRate; // 追加 + final double taxRate; + final String? customerFormalNameSnapshot; // 追加 final String? odooId; final bool isSynced; final DateTime updatedAt; @@ -56,6 +57,7 @@ class Invoice { this.notes, this.filePath, this.taxRate = 0.10, // デフォルト10% + this.customerFormalNameSnapshot, // 追加 this.odooId, this.isSynced = false, DateTime? updatedAt, @@ -64,6 +66,9 @@ class Invoice { String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; + // 表示用の宛名(スナップショットがあれば優先) + String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName; + int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal); int get tax => (subtotal * taxRate).floor(); // taxRateを使用 int get totalAmount => subtotal + tax; @@ -77,6 +82,7 @@ class Invoice { 'file_path': filePath, 'total_amount': totalAmount, 'tax_rate': taxRate, // 追加 + 'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName, // 追加 'odoo_id': odooId, 'is_synced': isSynced ? 1 : 0, 'updated_at': updatedAt.toIso8601String(), @@ -91,7 +97,7 @@ class Invoice { StringBuffer buffer = StringBuffer(); buffer.writeln("日付,請求番号,取引先,合計金額,備考"); - buffer.writeln("${dateFormatter.format(date)},$invoiceNumber,${customer.formalName},$totalAmount,${notes ?? ""}"); + buffer.writeln("${dateFormatter.format(date)},$invoiceNumber,$customerNameForDisplay,$totalAmount,${notes ?? ""}"); buffer.writeln(""); buffer.writeln("品名,数量,単価,小計"); @@ -110,6 +116,7 @@ class Invoice { String? notes, String? filePath, double? taxRate, + String? customerFormalNameSnapshot, String? odooId, bool? isSynced, DateTime? updatedAt, @@ -122,6 +129,7 @@ class Invoice { notes: notes ?? this.notes, filePath: filePath ?? this.filePath, taxRate: taxRate ?? this.taxRate, + customerFormalNameSnapshot: customerFormalNameSnapshot ?? this.customerFormalNameSnapshot, odooId: odooId ?? this.odooId, isSynced: isSynced ?? this.isSynced, updatedAt: updatedAt ?? this.updatedAt, diff --git a/lib/models/product_model.dart b/lib/models/product_model.dart index bb9f747..35ed6a4 100644 --- a/lib/models/product_model.dart +++ b/lib/models/product_model.dart @@ -2,12 +2,14 @@ class Product { final String id; final String name; final int defaultUnitPrice; + final String? barcode; final String? odooId; Product({ required this.id, required this.name, this.defaultUnitPrice = 0, + this.barcode, this.odooId, }); @@ -16,6 +18,7 @@ class Product { 'id': id, 'name': name, 'default_unit_price': defaultUnitPrice, + 'barcode': barcode, 'odoo_id': odooId, }; } @@ -25,6 +28,7 @@ class Product { id: map['id'], name: map['name'], defaultUnitPrice: map['default_unit_price'] ?? 0, + barcode: map['barcode'], odooId: map['odoo_id'], ); } @@ -33,6 +37,7 @@ class Product { String? id, String? name, int? defaultUnitPrice, + String? barcode, String? odooId, }) { return Product( diff --git a/lib/screens/barcode_scanner_screen.dart b/lib/screens/barcode_scanner_screen.dart new file mode 100644 index 0000000..e0c7068 --- /dev/null +++ b/lib/screens/barcode_scanner_screen.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +class BarcodeScannerScreen extends StatelessWidget { + const BarcodeScannerScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("バーコードスキャン"), + backgroundColor: Colors.black, + ), + body: MobileScanner( + onDetect: (capture) { + final List barcodes = capture.barcodes; + if (barcodes.isNotEmpty) { + final String? code = barcodes.first.rawValue; + if (code != null) { + Navigator.pop(context, code); + } + } + }, + ), + ); + } +} diff --git a/lib/screens/company_info_screen.dart b/lib/screens/company_info_screen.dart new file mode 100644 index 0000000..bce2060 --- /dev/null +++ b/lib/screens/company_info_screen.dart @@ -0,0 +1,128 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import '../models/company_model.dart'; +import '../services/company_repository.dart'; + +class CompanyInfoScreen extends StatefulWidget { + const CompanyInfoScreen({Key? key}) : super(key: key); + + @override + State createState() => _CompanyInfoScreenState(); +} + +class _CompanyInfoScreenState extends State { + final CompanyRepository _companyRepo = CompanyRepository(); + late CompanyInfo _info; + bool _isLoading = true; + + final _nameController = TextEditingController(); + final _zipController = TextEditingController(); + final _addressController = TextEditingController(); + final _telController = TextEditingController(); + double _taxRate = 0.10; + + @override + void initState() { + super.initState(); + _loadInfo(); + } + + Future _loadInfo() async { + _info = await _companyRepo.getCompanyInfo(); + _nameController.text = _info.name; + _zipController.text = _info.zipCode ?? ""; + _addressController.text = _info.address ?? ""; + _telController.text = _info.tel ?? ""; + _taxRate = _info.defaultTaxRate; + setState(() => _isLoading = false); + } + + Future _pickImage() async { + final picker = ImagePicker(); + final image = await picker.pickImage(source: ImageSource.camera); + if (image != null) { + setState(() { + _info = _info.copyWith(sealPath: image.path); + }); + } + } + + Future _save() async { + final updated = _info.copyWith( + name: _nameController.text, + zipCode: _zipController.text, + address: _addressController.text, + tel: _telController.text, + defaultTaxRate: _taxRate, + ); + await _companyRepo.saveCompanyInfo(updated); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("自社情報を保存しました"))); + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); + + return Scaffold( + appBar: AppBar( + title: const Text("自社設定"), + backgroundColor: Colors.indigo, + actions: [ + IconButton(icon: const Icon(Icons.check), onPressed: _save), + ], + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTextField("自社名", _nameController), + const SizedBox(height: 12), + _buildTextField("郵便番号", _zipController), + const SizedBox(height: 12), + _buildTextField("住所", _addressController), + const SizedBox(height: 12), + _buildTextField("電話番号", _telController), + const SizedBox(height: 20), + const Text("デフォルト消費税率", style: TextStyle(fontWeight: FontWeight.bold)), + Row( + children: [ + ChoiceChip(label: const Text("10%"), selected: _taxRate == 0.10, onSelected: (_) => setState(() => _taxRate = 0.10)), + const SizedBox(width: 8), + ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)), + ], + ), + const SizedBox(height: 24), + const Text("印影(角印)撮影", style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 12), + GestureDetector( + onTap: _pickImage, + child: Container( + height: 150, + width: 150, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: _info.sealPath != null + ? Image.file(File(_info.sealPath!), fit: BoxFit.contain) + : const Center(child: Icon(Icons.camera_alt, size: 50, color: Colors.grey)), + ), + ), + const SizedBox(height: 8), + const Text("白い紙に押した判子を真上から撮影してください", style: TextStyle(fontSize: 12, color: Colors.grey)), + ], + ), + ), + ); + } + + Widget _buildTextField(String label, TextEditingController controller) { + return TextField( + controller: controller, + decoration: InputDecoration(labelText: label, border: const OutlineInputBorder()), + ); + } +} diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index 7eb75f7..799c557 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -205,7 +205,7 @@ class _InvoiceDetailPageState extends State { decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()), ), ] else ...[ - Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}", + Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)), diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index 42bbe14..093f059 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -6,8 +6,10 @@ import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; import 'invoice_detail_page.dart'; import 'management_screen.dart'; +import 'company_info_screen.dart'; import '../widgets/slide_to_unlock.dart'; import '../main.dart'; // InvoiceFlowScreen 用 +import 'package:package_info_plus/package_info_plus.dart'; class InvoiceHistoryScreen extends StatefulWidget { const InvoiceHistoryScreen({Key? key}) : super(key: key); @@ -27,11 +29,20 @@ class _InvoiceHistoryScreenState extends State { String _sortBy = "date"; // "date", "amount", "customer" DateTime? _startDate; DateTime? _endDate; + String _appVersion = "1.0.0"; @override void initState() { super.initState(); _loadData(); + _loadVersion(); + } + + Future _loadVersion() async { + final packageInfo = await PackageInfo.fromPlatform(); + setState(() { + _appVersion = packageInfo.version; + }); } Future _loadData() async { @@ -49,7 +60,7 @@ class _InvoiceHistoryScreenState extends State { setState(() { _filteredInvoices = _invoices.where((inv) { final query = _searchQuery.toLowerCase(); - final matchesQuery = inv.customer.formalName.toLowerCase().contains(query) || + final matchesQuery = inv.customerNameForDisplay.toLowerCase().contains(query) || inv.invoiceNumber.toLowerCase().contains(query) || (inv.notes?.toLowerCase().contains(query) ?? false); @@ -65,7 +76,7 @@ class _InvoiceHistoryScreenState extends State { } else if (_sortBy == "amount") { _filteredInvoices.sort((a, b) => b.totalAmount.compareTo(a.totalAmount)); } else if (_sortBy == "customer") { - _filteredInvoices.sort((a, b) => a.customer.formalName.compareTo(b.customer.formalName)); + _filteredInvoices.sort((a, b) => a.customerNameForDisplay.compareTo(b.customerNameForDisplay)); } }); } @@ -88,7 +99,15 @@ class _InvoiceHistoryScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text("伝票マスター一覧"), + title: GestureDetector( + onLongPress: () { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const CompanyInfoScreen()), + ).then((_) => _loadData()); + }, + child: Text("伝票マスター v$_appVersion"), + ), backgroundColor: _isUnlocked ? Colors.blueGrey : Colors.blueGrey.shade800, actions: [ if (_isUnlocked) @@ -240,7 +259,7 @@ class _InvoiceHistoryScreenState extends State { backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200, child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey), ), - title: Text(invoice.customer.formalName), + title: Text(invoice.customerNameForDisplay), subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -280,7 +299,7 @@ class _InvoiceHistoryScreenState extends State { context: context, builder: (context) => AlertDialog( title: const Text("伝票の削除"), - content: Text("「${invoice.customer.formalName}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"), + content: Text("「${invoice.customerNameForDisplay}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"), actions: [ TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")), TextButton( diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 246c516..db01ddf 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -78,7 +78,8 @@ class _InvoiceInputFormState extends State { customer: _selectedCustomer!, date: DateTime.now(), items: _items, - taxRate: _includeTax ? _taxRate : 0.0, // 追加 + taxRate: _includeTax ? _taxRate : 0.0, + customerFormalNameSnapshot: _selectedCustomer!.formalName, // 追加 notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", ); diff --git a/lib/screens/product_master_screen.dart b/lib/screens/product_master_screen.dart index 5e72641..f49227b 100644 --- a/lib/screens/product_master_screen.dart +++ b/lib/screens/product_master_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import '../models/product_model.dart'; import '../services/product_repository.dart'; +import 'barcode_scanner_screen.dart'; class ProductMasterScreen extends StatefulWidget { const ProductMasterScreen({Key? key}) : super(key: key); @@ -34,41 +35,70 @@ class _ProductMasterScreenState extends State { final isEdit = product != null; final nameController = TextEditingController(text: product?.name ?? ""); final priceController = TextEditingController(text: product?.defaultUnitPrice.toString() ?? "0"); + final barcodeController = TextEditingController(text: product?.barcode ?? ""); final result = await showDialog( context: context, - builder: (context) => AlertDialog( - title: Text(isEdit ? "商品を編集" : "商品を新規登録"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameController, - decoration: const InputDecoration(labelText: "商品名"), - ), - TextField( - controller: priceController, - decoration: const InputDecoration(labelText: "初期単価"), - keyboardType: TextInputType.number, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) => AlertDialog( + title: Text(isEdit ? "商品を編集" : "商品を新規登録"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: "商品名"), + ), + TextField( + controller: priceController, + decoration: const InputDecoration(labelText: "初期単価"), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: barcodeController, + decoration: const InputDecoration(labelText: "バーコード"), + ), + ), + IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: () async { + final code = await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()), + ); + if (code != null) { + setDialogState(() { + barcodeController.text = code; + }); + } + }, + ), + ], + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + TextButton( + onPressed: () { + if (nameController.text.isEmpty) return; + final newProduct = Product( + id: product?.id ?? const Uuid().v4(), + name: nameController.text, + defaultUnitPrice: int.tryParse(priceController.text) ?? 0, + barcode: barcodeController.text.isEmpty ? null : barcodeController.text, + odooId: product?.odooId, + ); + Navigator.pop(context, newProduct); + }, + child: const Text("保存"), ), ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), - TextButton( - onPressed: () { - if (nameController.text.isEmpty) return; - final newProduct = Product( - id: product?.id ?? const Uuid().v4(), - name: nameController.text, - defaultUnitPrice: int.tryParse(priceController.text) ?? 0, - odooId: product?.odooId, - ); - Navigator.pop(context, newProduct); - }, - child: const Text("保存"), - ), - ], ), ); diff --git a/lib/services/company_repository.dart b/lib/services/company_repository.dart new file mode 100644 index 0000000..eee4ee3 --- /dev/null +++ b/lib/services/company_repository.dart @@ -0,0 +1,26 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/company_model.dart'; +import 'database_helper.dart'; + +class CompanyRepository { + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future getCompanyInfo() async { + final db = await _dbHelper.database; + final List> maps = await db.query('company_info', where: 'id = 1'); + if (maps.isEmpty) { + // 初期値 + return CompanyInfo(name: "販売アシスト1号 登録企業"); + } + return CompanyInfo.fromMap(maps.first); + } + + Future saveCompanyInfo(CompanyInfo info) async { + final db = await _dbHelper.database; + await db.insert( + 'company_info', + info.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } +} diff --git a/lib/services/customer_repository.dart b/lib/services/customer_repository.dart index 21287ee..d977b88 100644 --- a/lib/services/customer_repository.dart +++ b/lib/services/customer_repository.dart @@ -1,6 +1,7 @@ import 'package:sqflite/sqflite.dart'; import '../models/customer_model.dart'; import 'database_helper.dart'; +import 'package:uuid/uuid.dart'; class CustomerRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); @@ -8,9 +9,33 @@ class CustomerRepository { Future> getAllCustomers() async { final db = await _dbHelper.database; final List> maps = await db.query('customers', orderBy: 'display_name ASC'); + + if (maps.isEmpty) { + await _generateSampleCustomers(); + return getAllCustomers(); // 再帰的に読み込み + } + return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); } + Future _generateSampleCustomers() async { + final samples = [ + Customer(id: const Uuid().v4(), displayName: "佐々木製作所", formalName: "株式会社 佐々木製作所", title: "御中"), + Customer(id: const Uuid().v4(), displayName: "田中商事", formalName: "田中商事 株式会社", title: "様"), + Customer(id: const Uuid().v4(), displayName: "山田建材", formalName: "有限会社 山田建材", title: "御中"), + Customer(id: const Uuid().v4(), displayName: "鈴木運送", formalName: "鈴木運送 合同会社", title: "様"), + Customer(id: const Uuid().v4(), displayName: "伊藤工務店", formalName: "伊藤工務店", title: "様"), + Customer(id: const Uuid().v4(), displayName: "渡辺興業", formalName: "株式会社 渡辺興業", title: "御中"), + Customer(id: const Uuid().v4(), displayName: "高橋電気", formalName: "高橋電気工業所", title: "様"), + Customer(id: const Uuid().v4(), displayName: "佐藤商店", formalName: "佐藤商店", title: "様"), + Customer(id: const Uuid().v4(), displayName: "中村機械", formalName: "中村機械製作所", title: "殿"), + Customer(id: const Uuid().v4(), displayName: "小林産業", formalName: "小林産業 株式会社", title: "御中"), + ]; + for (var s in samples) { + await saveCustomer(s); + } + } + Future saveCustomer(Customer customer) async { final db = await _dbHelper.database; await db.insert( diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 4ba9f70..ed6e1c6 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -19,7 +19,7 @@ class DatabaseHelper { String path = join(await getDatabasesPath(), 'gemi_invoice.db'); return await openDatabase( path, - version: 2, + version: 5, onCreate: _onCreate, onUpgrade: _onUpgrade, ); @@ -29,6 +29,26 @@ class DatabaseHelper { if (oldVersion < 2) { await db.execute('ALTER TABLE invoices ADD COLUMN tax_rate REAL DEFAULT 0.10'); } + if (oldVersion < 3) { + await db.execute(''' + CREATE TABLE company_info ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + zip_code TEXT, + address TEXT, + tel TEXT, + default_tax_rate REAL DEFAULT 0.10, + seal_path TEXT + ) + '''); + } + if (oldVersion < 4) { + await db.execute('ALTER TABLE products ADD COLUMN barcode TEXT'); + } + if (oldVersion < 5) { + // 顧客情報のスナップショット + await db.execute('ALTER TABLE invoices ADD COLUMN customer_formal_name TEXT'); + } } Future _onCreate(Database db, int version) async { @@ -66,6 +86,7 @@ class DatabaseHelper { id TEXT PRIMARY KEY, name TEXT NOT NULL, default_unit_price INTEGER, + barcode TEXT, odoo_id TEXT ) '''); @@ -80,6 +101,7 @@ class DatabaseHelper { file_path TEXT, total_amount INTEGER, tax_rate REAL DEFAULT 0.10, + customer_formal_name TEXT, odoo_id TEXT, is_synced INTEGER DEFAULT 0, updated_at TEXT NOT NULL, @@ -98,5 +120,18 @@ class DatabaseHelper { FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE ) '''); + + // 自社情報 + await db.execute(''' + CREATE TABLE company_info ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + zip_code TEXT, + address TEXT, + tel TEXT, + default_tax_rate REAL DEFAULT 0.10, + seal_path TEXT + ) + '''); } } diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index 5bc5941..f75827c 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -59,7 +59,8 @@ class InvoiceRepository { items: items, notes: iMap['notes'], filePath: iMap['file_path'], - taxRate: iMap['tax_rate'] ?? 0.10, // 追加 + taxRate: iMap['tax_rate'] ?? 0.10, + customerFormalNameSnapshot: iMap['customer_formal_name'], // 追加 odooId: iMap['odoo_id'], isSynced: iMap['is_synced'] == 1, updatedAt: DateTime.parse(iMap['updated_at']), diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index a3ca81a..6e5ca21 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -1,5 +1,4 @@ import 'dart:io'; -import 'dart:typed_data'; import 'package:flutter/material.dart' show debugPrint; import 'package:flutter/services.dart'; import 'package:pdf/pdf.dart'; @@ -8,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:crypto/crypto.dart'; import 'package:intl/intl.dart'; import '../models/invoice_models.dart'; +import 'company_repository.dart'; /// A4サイズのプロフェッショナルな請求書PDFを生成し、保存する Future generateInvoicePdf(Invoice invoice) async { @@ -22,6 +22,20 @@ Future generateInvoicePdf(Invoice invoice) async { final dateFormatter = DateFormat('yyyy年MM月dd日'); final amountFormatter = NumberFormat("#,###"); + // 自社情報の取得 + final companyRepo = CompanyRepository(); + final companyInfo = await companyRepo.getCompanyInfo(); + + // 印影画像のロード + pw.MemoryImage? sealImage; + if (companyInfo.sealPath != null) { + final file = File(companyInfo.sealPath!); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + sealImage = pw.MemoryImage(bytes); + } + } + pdf.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, @@ -68,13 +82,27 @@ Future generateInvoicePdf(Invoice invoice) async { ), ), pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, + child: pw.Stack( + alignment: pw.Alignment.topRight, children: [ - pw.Text("自社名が入ります", style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), - pw.Text("〒000-0000"), - pw.Text("住所がここに入ります"), - pw.Text("TEL: 00-0000-0000"), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"), + if (companyInfo.address != null) pw.Text(companyInfo.address!), + if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"), + ], + ), + if (sealImage != null) + pw.Positioned( + right: 10, + top: 0, + child: pw.Opacity( + opacity: 0.8, + child: pw.Image(sealImage, width: 40, height: 40), + ), + ), ], ), ), diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index 7766ca5..7d7ca61 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -1,6 +1,7 @@ import 'package:sqflite/sqflite.dart'; import '../models/product_model.dart'; import 'database_helper.dart'; +import 'package:uuid/uuid.dart'; class ProductRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); @@ -8,9 +9,33 @@ class ProductRepository { Future> getAllProducts() async { final db = await _dbHelper.database; final List> maps = await db.query('products', orderBy: 'name ASC'); + + if (maps.isEmpty) { + await _generateSampleProducts(); + return getAllProducts(); + } + return List.generate(maps.length, (i) => Product.fromMap(maps[i])); } + Future _generateSampleProducts() async { + final samples = [ + Product(id: const Uuid().v4(), name: "基本技術料", defaultUnitPrice: 50000), + Product(id: const Uuid().v4(), name: "出張診断費", defaultUnitPrice: 10000), + Product(id: const Uuid().v4(), name: "交換用ハードディスク (1TB)", defaultUnitPrice: 8500), + Product(id: const Uuid().v4(), name: "メモリ増設 (8GB)", defaultUnitPrice: 6000), + Product(id: const Uuid().v4(), name: "OSインストール作業", defaultUnitPrice: 15000), + Product(id: const Uuid().v4(), name: "データ復旧作業 (軽度)", defaultUnitPrice: 30000), + Product(id: const Uuid().v4(), name: "LANケーブル (5m)", defaultUnitPrice: 1200), + Product(id: const Uuid().v4(), name: "ウイルス除去作業", defaultUnitPrice: 20000), + Product(id: const Uuid().v4(), name: "液晶ディスプレイ (24インチ)", defaultUnitPrice: 25000), + Product(id: const Uuid().v4(), name: "定期保守契約料 (月額)", defaultUnitPrice: 5000), + ]; + for (var s in samples) { + await saveProduct(s); + } + } + Future saveProduct(Product product) async { final db = await _dbHelper.database; await db.insert( diff --git a/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux b/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux new file mode 120000 index 0000000..0a79eef --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/file_selector_linux @@ -0,0 +1 @@ +/home/user/.pub-cache/hosted/pub.dev/file_selector_linux-0.9.4/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux b/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux new file mode 120000 index 0000000..8ee11b3 --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/image_picker_linux @@ -0,0 +1 @@ +/home/user/.pub-cache/hosted/pub.dev/image_picker_linux-0.2.2/ \ No newline at end of file diff --git a/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus b/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus new file mode 120000 index 0000000..00f373a --- /dev/null +++ b/linux/flutter/ephemeral/.plugin_symlinks/package_info_plus @@ -0,0 +1 @@ +/home/user/.pub-cache/hosted/pub.dev/package_info_plus-9.0.0/ \ No newline at end of file diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index f6f23bf..7299b5c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f16b4c3..786ff5c 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 7e63982..1398df3 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,13 +5,19 @@ import FlutterMacOS import Foundation +import file_selector_macos import geolocator_apple +import mobile_scanner +import package_info_plus import share_plus import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/pubspec.lock b/pubspec.lock index 1128ea6..ef4b626 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -121,6 +121,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -150,6 +182,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1 + url: "https://pub.dev" + source: hosted + version: "2.0.33" flutter_test: dependency: "direct dev" description: flutter @@ -224,6 +264,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" image: dependency: transitive description: @@ -232,6 +288,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d" + url: "https://pub.dev" + source: hosted + version: "0.8.13+13" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -312,6 +432,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c6184bf2913dd66be244108c9c27ca04b01caf726321c44b0e7a7a1e32d41044 + url: "https://pub.dev" + source: hosted + version: "7.1.4" native_toolchain_c: dependency: transitive description: @@ -336,6 +464,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d + url: "https://pub.dev" + source: hosted + version: "9.0.0" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 858bea8..ad8ce6a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 +version: 1.0.1+1 environment: sdk: ^3.10.7 @@ -47,6 +47,9 @@ dependencies: path: ^1.8.3 geolocator: ^13.0.1 uuid: ^4.5.1 + image_picker: ^1.2.1 + mobile_scanner: ^7.1.4 + package_info_plus: ^9.0.0 dev_dependencies: flutter_test: diff --git a/目標.md b/目標.md index 75ba27f..840bf7b 100644 --- a/目標.md +++ b/目標.md @@ -24,4 +24,12 @@ - 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装 − 商品マスター編集画面の実装 - 顧客マスター編集画面の実装 - + - 各種マスターは内容を編集した時に伝票と整合性を保つ仕組みを作る + - 各種マスターはodoo側の編集作業により影響を受けるのでその対策を考える + - 各種マスターはデータが空の場合サンプルを10個入れておく + - アプリタイトルのバージョンは常に最新にする小数点第3位を最小バージョン単位に + − 自社情報編集はタイトルを長押しで表示 + - 自社情報編集の画面で消費税を設定可能にする + - 自社情報編集で印鑑を撮影出来る様にする + - 商品マスター等でバーコードQRコードのスキャンが可能でありたい +