diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index dac26aa..6bc3497 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -43,6 +43,7 @@ class Invoice { final List items; final String? notes; final String? filePath; + final double taxRate; // 追加 final String? odooId; final bool isSynced; final DateTime updatedAt; @@ -54,6 +55,7 @@ class Invoice { required this.items, this.notes, this.filePath, + this.taxRate = 0.10, // デフォルト10% this.odooId, this.isSynced = false, DateTime? updatedAt, @@ -63,7 +65,7 @@ class Invoice { String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal); - int get tax => (subtotal * 0.1).floor(); + int get tax => (subtotal * taxRate).floor(); // taxRateを使用 int get totalAmount => subtotal + tax; Map toMap() { @@ -74,6 +76,7 @@ class Invoice { 'notes': notes, 'file_path': filePath, 'total_amount': totalAmount, + 'tax_rate': taxRate, // 追加 'odoo_id': odooId, 'is_synced': isSynced ? 1 : 0, 'updated_at': updatedAt.toIso8601String(), @@ -106,6 +109,7 @@ class Invoice { List? items, String? notes, String? filePath, + double? taxRate, String? odooId, bool? isSynced, DateTime? updatedAt, @@ -117,6 +121,7 @@ class Invoice { items: items ?? List.from(this.items), notes: notes ?? this.notes, filePath: filePath ?? this.filePath, + taxRate: taxRate ?? this.taxRate, 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 new file mode 100644 index 0000000..bb9f747 --- /dev/null +++ b/lib/models/product_model.dart @@ -0,0 +1,45 @@ +class Product { + final String id; + final String name; + final int defaultUnitPrice; + final String? odooId; + + Product({ + required this.id, + required this.name, + this.defaultUnitPrice = 0, + this.odooId, + }); + + Map toMap() { + return { + 'id': id, + 'name': name, + 'default_unit_price': defaultUnitPrice, + 'odoo_id': odooId, + }; + } + + factory Product.fromMap(Map map) { + return Product( + id: map['id'], + name: map['name'], + defaultUnitPrice: map['default_unit_price'] ?? 0, + odooId: map['odoo_id'], + ); + } + + Product copyWith({ + String? id, + String? name, + int? defaultUnitPrice, + String? odooId, + }) { + return Product( + id: id ?? this.id, + name: name ?? this.name, + defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice, + odooId: odooId ?? this.odooId, + ); + } +} diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index 9a8edbb..7eb75f7 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -276,16 +276,21 @@ class _InvoiceDetailPageState extends State { } Widget _buildSummarySection(NumberFormat formatter) { + final double currentTaxRate = _isEditing ? _currentInvoice.taxRate : _currentInvoice.taxRate; // 編集時も元の税率を維持 + final int subtotal = _isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal; + final int tax = (subtotal * currentTaxRate).floor(); + final int total = subtotal + tax; + return Align( alignment: Alignment.centerRight, child: Container( width: 200, child: Column( children: [ - _SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)), - _SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)), + _SummaryRow("小計 (税抜)", formatter.format(subtotal)), + _SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)), const Divider(), - _SummaryRow("合計 (税込)", "¥${formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount)}", isBold: true), + _SummaryRow("合計 (税込)", "¥${formatter.format(total)}", isBold: true), ], ), ), diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index a5f43a9..42bbe14 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -6,6 +6,7 @@ import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; import 'invoice_detail_page.dart'; import 'management_screen.dart'; +import '../widgets/slide_to_unlock.dart'; import '../main.dart'; // InvoiceFlowScreen 用 class InvoiceHistoryScreen extends StatefulWidget { @@ -73,9 +74,11 @@ class _InvoiceHistoryScreenState extends State { setState(() { _isUnlocked = !_isUnlocked; }); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_isUnlocked ? "編集プロテクトを解除しました" : "編集プロテクトを有効にしました")), - ); + if (!_isUnlocked) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("編集プロテクトを有効にしました")), + ); + } } @override @@ -88,11 +91,12 @@ class _InvoiceHistoryScreenState extends State { title: const Text("伝票マスター一覧"), backgroundColor: _isUnlocked ? Colors.blueGrey : Colors.blueGrey.shade800, actions: [ - IconButton( - icon: Icon(_isUnlocked ? Icons.lock_open : Icons.lock, color: _isUnlocked ? Colors.orangeAccent : Colors.white70), - onPressed: _toggleUnlock, - tooltip: _isUnlocked ? "プロテクトする" : "アンロックする", - ), + if (_isUnlocked) + IconButton( + icon: const Icon(Icons.lock_open, color: Colors.orangeAccent), + onPressed: _toggleUnlock, + tooltip: "再度プロテクトする", + ), IconButton( icon: const Icon(Icons.sort), onPressed: () { @@ -206,86 +210,97 @@ class _InvoiceHistoryScreenState extends State { ], ), ), - body: _isLoading - ? const Center(child: CircularProgressIndicator()) - : _filteredInvoices.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.folder_open, size: 64, color: Colors.grey), - const SizedBox(height: 16), - Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"), - ], - ), - ) - : ListView.builder( - itemCount: _filteredInvoices.length, - itemBuilder: (context, index) { - final invoice = _filteredInvoices[index]; - return ListTile( - leading: CircleAvatar( - backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200, - child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey), - ), - title: Text(invoice.customer.formalName), - subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text("¥${amountFormatter.format(invoice.totalAmount)}", - style: const TextStyle(fontWeight: FontWeight.bold)), - if (invoice.isSynced) - const Icon(Icons.sync, size: 16, color: Colors.green) - else - const Icon(Icons.sync_disabled, size: 16, color: Colors.orange), - ], - ), - onTap: () async { - if (!_isUnlocked) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("詳細の閲覧・編集にはアンロックが必要です"), duration: Duration(seconds: 1)), + body: Column( + children: [ + SlideToUnlock( + isLocked: !_isUnlocked, + onUnlocked: _toggleUnlock, + text: "スライドして編集モード解除", + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredInvoices.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.folder_open, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"), + ], + ), + ) + : ListView.builder( + itemCount: _filteredInvoices.length, + itemBuilder: (context, index) { + final invoice = _filteredInvoices[index]; + return ListTile( + leading: CircleAvatar( + backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200, + child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey), + ), + title: Text(invoice.customer.formalName), + subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text("¥${amountFormatter.format(invoice.totalAmount)}", + style: const TextStyle(fontWeight: FontWeight.bold)), + if (invoice.isSynced) + const Icon(Icons.sync, size: 16, color: Colors.green) + else + const Icon(Icons.sync_disabled, size: 16, color: Colors.orange), + ], + ), + onTap: () async { + if (!_isUnlocked) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("詳細の閲覧・編集にはアンロックが必要です"), duration: Duration(seconds: 1)), + ); + return; + } + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoiceDetailPage(invoice: invoice), + ), + ); + _loadData(); // 戻ってきたら再読込 + }, + onLongPress: () async { + if (!_isUnlocked) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("削除するにはアンロックが必要です")), + ); + return; + } + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("伝票の削除"), + content: Text("「${invoice.customer.formalName}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"), + 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 _invoiceRepo.deleteInvoice(invoice.id); + _loadData(); + } + }, ); - return; - } - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => InvoiceDetailPage(invoice: invoice), - ), - ); - _loadData(); // 戻ってきたら再読込 - }, - onLongPress: () async { - if (!_isUnlocked) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("削除するにはアンロックが必要です")), - ); - return; - } - final confirm = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("伝票の削除"), - content: Text("「${invoice.customer.formalName}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"), - 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 _invoiceRepo.deleteInvoice(invoice.id); - _loadData(); - } - }, - ); - }, - ), + }, + ), + ), + ], + ), floatingActionButton: FloatingActionButton.extended( onPressed: () async { await Navigator.push( diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index aa014fd..246c516 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -1,13 +1,14 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; +import 'package:intl/intl.dart'; import '../models/customer_model.dart'; import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; import 'customer_picker_modal.dart'; +import 'product_picker_modal.dart'; -/// 請求書の初期入力(ヘッダー部分)を管理するウィジェット class InvoiceInputForm extends StatefulWidget { final Function(Invoice invoice, String filePath) onInvoiceGenerated; @@ -21,12 +22,15 @@ class InvoiceInputForm extends StatefulWidget { } class _InvoiceInputFormState extends State { - final _clientController = TextEditingController(); - final _amountController = TextEditingController(text: "250000"); final _repository = InvoiceRepository(); - String _status = "取引先を選択してPDFを生成してください"; - Customer? _selectedCustomer; + final List _items = []; + double _taxRate = 0.10; + bool _includeTax = true; + String _status = "取引先と商品を入力してください"; + + // 署名用の実験的パス + List _signaturePath = []; @override void initState() { @@ -35,176 +39,299 @@ class _InvoiceInputFormState extends State { } Future _loadInitialData() async { - // 起動時に不要なPDFを掃除する - _repository.cleanupOrphanedPdfs().then((count) { - if (count > 0) { - debugPrint('Cleaned up $count orphaned PDF files.'); - } - }); - + _repository.cleanupOrphanedPdfs(); final customerRepo = CustomerRepository(); final customers = await customerRepo.getAllCustomers(); if (customers.isNotEmpty) { - setState(() { - _selectedCustomer = customers.first; - _clientController.text = _selectedCustomer!.formalName; - }); - } else { - // マスターが空の場合は、デフォルトのサンプルを登録しておく - final defaultCustomer = Customer( - id: const Uuid().v4(), - displayName: "佐々木製作所", - formalName: "株式会社 佐々木製作所", - ); - await customerRepo.saveCustomer(defaultCustomer); - setState(() { - _selectedCustomer = defaultCustomer; - _clientController.text = _selectedCustomer!.formalName; - }); + setState(() => _selectedCustomer = customers.first); } } - @override - void dispose() { - _clientController.dispose(); - _amountController.dispose(); - super.dispose(); - } - - Future _openCustomerPicker() async { - setState(() => _status = "顧客マスターを開いています..."); - - await showModalBottomSheet( + void _addItem() { + showModalBottomSheet( context: context, isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => FractionallySizedBox( - heightFactor: 0.9, - child: CustomerPickerModal( - onCustomerSelected: (customer) { - setState(() { - _selectedCustomer = customer; - _clientController.text = customer.formalName; - _status = "「${customer.formalName}」を選択しました"; - }); - Navigator.pop(context); - }, - ), + builder: (context) => ProductPickerModal( + onItemSelected: (item) { + setState(() => _items.add(item)); + Navigator.pop(context); + }, ), ); } - Future _handleInitialGenerate() async { + int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity)); + int get _tax => _includeTax ? (_subTotal * _taxRate).round() : 0; + int get _total => _subTotal + _tax; + + Future _handleGenerate() async { if (_selectedCustomer == null) { - setState(() => _status = "取引先を選択してください"); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください"))); + return; + } + if (_items.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください"))); return; } - - final unitPrice = int.tryParse(_amountController.text) ?? 0; - - final initialItems = [ - InvoiceItem( - description: "ご請求分", - quantity: 1, - unitPrice: unitPrice, - ) - ]; final invoice = Invoice( customer: _selectedCustomer!, date: DateTime.now(), - items: initialItems, + items: _items, + taxRate: _includeTax ? _taxRate : 0.0, // 追加 + notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", ); - setState(() => _status = "A4請求書を生成中..."); + setState(() => _status = "PDFを生成中..."); final path = await generateInvoicePdf(invoice); - if (path != null) { final updatedInvoice = invoice.copyWith(filePath: path); - - // オリジナルDBに保存 await _repository.saveInvoice(updatedInvoice); - widget.onInvoiceGenerated(updatedInvoice, path); - setState(() => _status = "PDFを生成しDBに登録しました。"); - } else { - setState(() => _status = "PDFの生成に失敗しました"); } } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column(children: [ - const Text( - "ステップ1: 宛先と基本金額の設定", - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), + final fmt = NumberFormat("#,###"); + + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCustomerSection(), + const SizedBox(height: 20), + _buildItemsSection(fmt), + const SizedBox(height: 20), + _buildExperimentalSection(), + const SizedBox(height: 20), + _buildSummarySection(fmt), + const SizedBox(height: 20), + _buildSignatureSection(), + ], + ), ), - const SizedBox(height: 16), - Row(children: [ - Expanded( - child: TextField( - controller: _clientController, - readOnly: true, - onTap: _openCustomerPicker, - decoration: const InputDecoration( - labelText: "取引先名 (タップして選択)", - hintText: "電話帳から取り込むか、マスターから選択", - prefixIcon: Icon(Icons.business), - border: OutlineInputBorder(), + ), + _buildBottomActionBar(), + ], + ); + } + + Widget _buildCustomerSection() { + return Card( + elevation: 0, + color: Colors.blueGrey.shade50, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: ListTile( + leading: const Icon(Icons.business, color: Colors.blueGrey), + title: Text(_selectedCustomer?.formalName ?? "取引先を選択してください", + style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)), + subtitle: const Text("請求先マスターから選択"), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => FractionallySizedBox( + heightFactor: 0.9, + child: CustomerPickerModal(onCustomerSelected: (c) { + setState(() => _selectedCustomer = c); + Navigator.pop(context); + }), + ), + ); + }, + ), + ); + } + + Widget _buildItemsSection(NumberFormat fmt) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")), + ], + ), + if (_items.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 20), + child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))), + ) + else + ..._items.asMap().entries.map((entry) { + final idx = entry.key; + final item = entry.value; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(item.description), + subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)), + IconButton( + icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent), + onPressed: () => setState(() => _items.removeAt(idx)), + ), + ], ), ), - ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40), - onPressed: _openCustomerPicker, - tooltip: "顧客を選択・登録", - ), - ]), - const SizedBox(height: 16), - TextField( - controller: _amountController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: "基本金額 (税抜)", - hintText: "明細の1行目として登録されます", - prefixIcon: Icon(Icons.currency_yen), - border: OutlineInputBorder(), + ); + }), + ], + ); + } + + Widget _buildExperimentalSection() { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(12)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("実験的オプション", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)), + const SizedBox(height: 8), + Row( + children: [ + const Text("消費税: "), + ChoiceChip( + label: const Text("10%"), + selected: _taxRate == 0.10, + onSelected: (val) => setState(() => _taxRate = 0.10), + ), + const SizedBox(width: 8), + ChoiceChip( + label: const Text("8%"), + selected: _taxRate == 0.08, + onSelected: (val) => setState(() => _taxRate = 0.08), + ), + const Spacer(), + Switch( + value: _includeTax, + onChanged: (val) => setState(() => _includeTax = val), + ), + Text(_includeTax ? "税込表示" : "非課税"), + ], + ), + ], + ), + ); + } + + Widget _buildSummarySection(NumberFormat fmt) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)), + child: Column( + children: [ + _buildSummaryRow("小計", "¥${fmt.format(_subTotal)}", Colors.white70), + _buildSummaryRow("消費税", "¥${fmt.format(_tax)}", Colors.white70), + const Divider(color: Colors.white24), + _buildSummaryRow("合計金額", "¥${fmt.format(_total)}", Colors.white, fontSize: 24), + ], + ), + ); + } + + Widget _buildSummaryRow(String label, String value, Color color, {double fontSize = 16}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: color, fontSize: fontSize)), + Text(value, style: TextStyle(color: color, fontSize: fontSize, fontWeight: FontWeight.bold)), + ], + ), + ); + } + + Widget _buildSignatureSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text("手書き署名 (実験的)", style: TextStyle(fontWeight: FontWeight.bold)), + TextButton(onPressed: () => setState(() => _signaturePath.clear()), child: const Text("クリア")), + ], + ), + Container( + height: 150, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: GestureDetector( + onPanUpdate: (details) { + setState(() { + RenderBox renderBox = context.findRenderObject() as RenderBox; + _signaturePath.add(renderBox.globalToLocal(details.globalPosition)); + }); + }, + onPanEnd: (details) => _signaturePath.add(null), + child: CustomPaint( + painter: SignaturePainter(_signaturePath), + size: Size.infinite, ), ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: _handleInitialGenerate, - icon: const Icon(Icons.description), - label: const Text("A4請求書を作成して詳細編集へ"), - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 60), - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - elevation: 4, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - const SizedBox(height: 24), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Text( - _status, - style: const TextStyle(fontSize: 12, color: Colors.black54), - textAlign: TextAlign.center, - ), - ), - ]), + ), + ], + ); + } + + Widget _buildBottomActionBar() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))], + ), + child: ElevatedButton.icon( + onPressed: _handleGenerate, + icon: const Icon(Icons.picture_as_pdf), + label: const Text("伝票を確定してPDF生成"), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 60), + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), ), ); } } + +class SignaturePainter extends CustomPainter { + final List points; + SignaturePainter(this.points); + + @override + void paint(Canvas canvas, Size size) { + Paint paint = Paint() + ..color = Colors.black + ..strokeCap = StrokeCap.round + ..strokeWidth = 3.0; + + for (int i = 0; i < points.length - 1; i++) { + if (points[i] != null && points[i + 1] != null) { + canvas.drawLine(points[i]!, points[i + 1]!, paint); + } + } + } + + @override + bool shouldRepaint(SignaturePainter oldDelegate) => true; +} diff --git a/lib/screens/management_screen.dart b/lib/screens/management_screen.dart index 40ab99b..6ce9778 100644 --- a/lib/screens/management_screen.dart +++ b/lib/screens/management_screen.dart @@ -6,6 +6,7 @@ import 'package:path/path.dart' as p; import '../models/invoice_models.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; +import 'product_master_screen.dart'; class ManagementScreen extends StatelessWidget { const ManagementScreen({Key? key}) : super(key: key); @@ -20,6 +21,13 @@ class ManagementScreen extends StatelessWidget { body: ListView( children: [ _buildSectionHeader("データ入出力"), + _buildMenuTile( + context, + Icons.inventory_2, + "商品マスター管理", + "販売商品の名称や単価を管理します", + () => Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())), + ), _buildMenuTile( context, Icons.upload_file, diff --git a/lib/screens/product_master_screen.dart b/lib/screens/product_master_screen.dart new file mode 100644 index 0000000..5e72641 --- /dev/null +++ b/lib/screens/product_master_screen.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import '../models/product_model.dart'; +import '../services/product_repository.dart'; + +class ProductMasterScreen extends StatefulWidget { + const ProductMasterScreen({Key? key}) : super(key: key); + + @override + State createState() => _ProductMasterScreenState(); +} + +class _ProductMasterScreenState extends State { + final ProductRepository _productRepo = ProductRepository(); + List _products = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadProducts(); + } + + Future _loadProducts() async { + setState(() => _isLoading = true); + final products = await _productRepo.getAllProducts(); + setState(() { + _products = products; + _isLoading = false; + }); + } + + Future _addItem({Product? product}) async { + final isEdit = product != null; + final nameController = TextEditingController(text: product?.name ?? ""); + final priceController = TextEditingController(text: product?.defaultUnitPrice.toString() ?? "0"); + + 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, + ), + ], + ), + 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("保存"), + ), + ], + ), + ); + + if (result != null) { + await _productRepo.saveProduct(result); + _loadProducts(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("商品マスター管理"), + backgroundColor: Colors.blueGrey, + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _products.isEmpty + ? const Center(child: Text("商品が登録されていません")) + : ListView.builder( + itemCount: _products.length, + itemBuilder: (context, index) { + final p = _products[index]; + return ListTile( + title: Text(p.name), + subtitle: Text("初期単価: ¥${p.defaultUnitPrice}"), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton(icon: const Icon(Icons.edit), onPressed: () => _addItem(product: p)), + IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("削除確認"), + content: Text("「${p.name}」を削除しますか?"), + 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 _productRepo.deleteProduct(p.id); + _loadProducts(); + } + }, + ), + ], + ), + ); + }, + ), + floatingActionButton: FloatingActionButton( + onPressed: _addItem, + child: const Icon(Icons.add), + backgroundColor: Colors.indigo, + ), + ); + } +} diff --git a/lib/screens/product_picker_modal.dart b/lib/screens/product_picker_modal.dart index 80c127d..6341801 100644 --- a/lib/screens/product_picker_modal.dart +++ b/lib/screens/product_picker_modal.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; import '../models/invoice_models.dart'; +import '../models/product_model.dart'; +import '../services/product_repository.dart'; +import 'product_master_screen.dart'; /// 商品マスターから項目を選択するためのモーダル(スタブ実装) class ProductPickerModal extends StatefulWidget { @@ -12,14 +15,24 @@ class ProductPickerModal extends StatefulWidget { } class _ProductPickerModalState extends State { - // 本来はデータベースから取得しますが、現時点ではスタブデータを表示します - final List _masterProducts = [ - InvoiceItem(description: "技術料", quantity: 1, unitPrice: 50000), - InvoiceItem(description: "部品代 A", quantity: 1, unitPrice: 15000), - InvoiceItem(description: "部品代 B", quantity: 1, unitPrice: 3000), - InvoiceItem(description: "出張費", quantity: 1, unitPrice: 10000), - InvoiceItem(description: "諸経費", quantity: 1, unitPrice: 5000), - ]; + final ProductRepository _productRepo = ProductRepository(); + List _products = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadProducts(); + } + + Future _loadProducts() async { + setState(() => _isLoading = true); + final products = await _productRepo.getAllProducts(); + setState(() { + _products = products; + _isLoading = false; + }); + } @override Widget build(BuildContext context) { @@ -42,19 +55,55 @@ class _ProductPickerModalState extends State { ), const Divider(), Expanded( - child: ListView.builder( - itemCount: _masterProducts.length, - itemBuilder: (context, index) { - final product = _masterProducts[index]; - return ListTile( - leading: const Icon(Icons.inventory_2_outlined), - title: Text(product.description), - subtitle: Text("単価: ¥${product.unitPrice}"), - onTap: () => widget.onItemSelected(product), - ); - }, - ), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _products.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("商品マスターが空です"), + TextButton( + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())); + _loadProducts(); + }, + child: const Text("商品マスターを編集する"), + ), + ], + ), + ) + : ListView.builder( + itemCount: _products.length, + itemBuilder: (context, index) { + final product = _products[index]; + return ListTile( + leading: const Icon(Icons.inventory_2_outlined), + title: Text(product.name), + subtitle: Text("初期単価: ¥${product.defaultUnitPrice}"), + onTap: () => widget.onItemSelected( + InvoiceItem( + description: product.name, + quantity: 1, + unitPrice: product.defaultUnitPrice, + ), + ), + ); + }, + ), ), + if (_products.isNotEmpty) + Padding( + padding: const EdgeInsets.all(8.0), + child: TextButton.icon( + icon: const Icon(Icons.edit), + label: const Text("商品マスターの管理"), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())); + _loadProducts(); + }, + ), + ), ], ), ); diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 6cee31c..4ba9f70 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -19,11 +19,18 @@ class DatabaseHelper { String path = join(await getDatabasesPath(), 'gemi_invoice.db'); return await openDatabase( path, - version: 1, + version: 2, onCreate: _onCreate, + onUpgrade: _onUpgrade, ); } + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + await db.execute('ALTER TABLE invoices ADD COLUMN tax_rate REAL DEFAULT 0.10'); + } + } + Future _onCreate(Database db, int version) async { // 顧客マスター await db.execute(''' @@ -72,6 +79,7 @@ class DatabaseHelper { notes TEXT, file_path TEXT, total_amount INTEGER, + tax_rate REAL DEFAULT 0.10, odoo_id TEXT, is_synced INTEGER DEFAULT 0, updated_at TEXT NOT NULL, diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index a8b369c..5bc5941 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -59,6 +59,7 @@ class InvoiceRepository { items: items, notes: iMap['notes'], filePath: iMap['file_path'], + taxRate: iMap['tax_rate'] ?? 0.10, // 追加 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 78d059a..a3ca81a 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -133,7 +133,7 @@ Future generateInvoicePdf(Invoice invoice) async { children: [ pw.SizedBox(height: 10), _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), - _buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)), + _buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)), pw.Divider(), _buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true), ], diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart new file mode 100644 index 0000000..7766ca5 --- /dev/null +++ b/lib/services/product_repository.dart @@ -0,0 +1,27 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/product_model.dart'; +import 'database_helper.dart'; + +class ProductRepository { + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future> getAllProducts() async { + final db = await _dbHelper.database; + final List> maps = await db.query('products', orderBy: 'name ASC'); + return List.generate(maps.length, (i) => Product.fromMap(maps[i])); + } + + Future saveProduct(Product product) async { + final db = await _dbHelper.database; + await db.insert( + 'products', + product.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future deleteProduct(String id) async { + final db = await _dbHelper.database; + await db.delete('products', where: 'id = ?', whereArgs: [id]); + } +} diff --git a/lib/widgets/slide_to_unlock.dart b/lib/widgets/slide_to_unlock.dart new file mode 100644 index 0000000..94936c2 --- /dev/null +++ b/lib/widgets/slide_to_unlock.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class SlideToUnlock extends StatefulWidget { + final VoidCallback onUnlocked; + final String text; + final bool isLocked; + + const SlideToUnlock({ + Key? key, + required this.onUnlocked, + this.text = "スライドして解除", + this.isLocked = true, + }) : super(key: key); + + @override + State createState() => _SlideToUnlockState(); +} + +class _SlideToUnlockState extends State { + double _position = 0.0; + final double _thumbSize = 50.0; + + @override + Widget build(BuildContext context) { + if (!widget.isLocked) return const SizedBox.shrink(); + + return LayoutBuilder( + builder: (context, constraints) { + final double maxWidth = constraints.maxWidth; + final double trackWidth = maxWidth - _thumbSize; + + return Container( + height: 60, + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blueGrey.shade100, + borderRadius: BorderRadius.circular(30), + ), + child: Stack( + children: [ + Center( + child: Text( + widget.text, + style: TextStyle(color: Colors.blueGrey.shade700, fontWeight: FontWeight.bold), + ), + ), + Positioned( + left: _position, + child: GestureDetector( + onHorizontalDragUpdate: (details) { + setState(() { + _position += details.delta.dx; + if (_position < 0) _position = 0; + if (_position > trackWidth) _position = trackWidth; + }); + }, + onHorizontalDragEnd: (details) { + if (_position >= trackWidth * 0.9) { + widget.onUnlocked(); + setState(() => _position = 0); // 念のためリセット + } else { + setState(() => _position = 0); + } + }, + child: Container( + width: _thumbSize, + height: 60, + decoration: BoxDecoration( + color: Colors.orangeAccent, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(2, 2)), + ], + ), + child: const Icon(Icons.arrow_forward_ios, color: Colors.white), + ), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/目標.md b/目標.md index caec3a6..75ba27f 100644 --- a/目標.md +++ b/目標.md @@ -22,4 +22,6 @@ - 商品マスター管理画面の実装 - 伝票入力画面の実装 - 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装 - + − 商品マスター編集画面の実装 + - 顧客マスター編集画面の実装 +