diff --git a/lib/main.dart b/lib/main.dart index 081f2d5..01e7b17 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -22,9 +22,43 @@ class MyApp extends StatelessWidget { return MaterialApp( title: '販売アシスト1号', theme: ThemeData( - primarySwatch: Colors.blueGrey, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith( + primary: Colors.indigo.shade700, + secondary: Colors.deepOrange.shade400, + surface: Colors.grey.shade50, + onSurface: Colors.blueGrey.shade900, + ), + scaffoldBackgroundColor: Colors.grey.shade50, + appBarTheme: AppBarTheme( + backgroundColor: Colors.indigo.shade700, + foregroundColor: Colors.white, + elevation: 0, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + textStyle: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: BorderSide(color: Colors.indigo.shade700), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + textStyle: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4), + ), + ), visualDensity: VisualDensity.adaptivePlatformDensity, useMaterial3: true, + fontFamily: 'IPAexGothic', ), home: const InvoiceHistoryScreen(), ); @@ -54,54 +88,20 @@ class _InvoiceFlowScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - leading: const BackButton(), - title: const Text("販売アシスト1号 V1.5.02"), - backgroundColor: Colors.blueGrey, - ), - drawer: Drawer( - child: ListView( - padding: EdgeInsets.zero, - children: [ - const DrawerHeader( - decoration: BoxDecoration(color: Colors.blueGrey), - child: Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 24)), - ), - ListTile( - leading: const Icon(Icons.add_task), - title: const Text("新規伝票作成"), - onTap: () => Navigator.pop(context), - ), - ListTile( - leading: const Icon(Icons.history), - title: const Text("伝票履歴"), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const InvoiceHistoryScreen()), - ); - }, - ), - ], - ), - ), - // 入力フォームを表示 - body: InvoiceInputForm( - onInvoiceGenerated: (invoice, path) async { - // GPSの記録を試みる - final locationService = LocationService(); - final position = await locationService.getCurrentLocation(); - if (position != null) { - final customerRepo = CustomerRepository(); - await customerRepo.addGpsHistory(invoice.customer.id, position.latitude, position.longitude); - debugPrint("GPS recorded for customer ${invoice.customer.id}"); - } - _handleInvoiceGenerated(invoice, path); - if (widget.onComplete != null) widget.onComplete!(); - }, - ), + // 入力フォーム自身が Scaffold を持つため、ここではそのまま返す + return InvoiceInputForm( + onInvoiceGenerated: (invoice, path) async { + // GPSの記録を試みる + final locationService = LocationService(); + final position = await locationService.getCurrentLocation(); + if (position != null) { + final customerRepo = CustomerRepository(); + await customerRepo.addGpsHistory(invoice.customer.id, position.latitude, position.longitude); + debugPrint("GPS recorded for customer ${invoice.customer.id}"); + } + _handleInvoiceGenerated(invoice, path); + if (widget.onComplete != null) widget.onComplete!(); + }, ); } } diff --git a/lib/models/customer_model.dart b/lib/models/customer_model.dart index 6ce27fb..a992d86 100644 --- a/lib/models/customer_model.dart +++ b/lib/models/customer_model.dart @@ -10,6 +10,7 @@ class Customer { final String? odooId; // Odoo側のID final bool isSynced; // 同期フラグ final DateTime updatedAt; // 最終更新日時 + final bool isLocked; // ロック Customer({ required this.id, @@ -22,6 +23,7 @@ class Customer { this.odooId, this.isSynced = false, DateTime? updatedAt, + this.isLocked = false, }) : updatedAt = updatedAt ?? DateTime.now(); String get invoiceName { @@ -42,6 +44,7 @@ class Customer { 'address': address, 'tel': tel, 'odoo_id': odooId, + 'is_locked': isLocked ? 1 : 0, 'is_synced': isSynced ? 1 : 0, 'updated_at': updatedAt.toIso8601String(), }; @@ -57,6 +60,7 @@ class Customer { address: map['address'], tel: map['tel'], odooId: map['odoo_id'], + isLocked: (map['is_locked'] ?? 0) == 1, isSynced: map['is_synced'] == 1, updatedAt: DateTime.parse(map['updated_at']), ); @@ -73,6 +77,7 @@ class Customer { String? odooId, bool? isSynced, DateTime? updatedAt, + bool? isLocked, }) { return Customer( id: id ?? this.id, @@ -85,6 +90,7 @@ class Customer { odooId: odooId ?? this.odooId, isSynced: isSynced ?? this.isSynced, updatedAt: updatedAt ?? this.updatedAt, + isLocked: isLocked ?? this.isLocked, ); } } diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index f36afd3..3b9e148 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -83,6 +83,7 @@ class Invoice { final String terminalId; // 追加: 端末識別子 final bool isDraft; // 追加: 下書きフラグ final String? subject; // 追加: 案件名 + final bool isLocked; // 追加: ロック Invoice({ String? id, @@ -102,6 +103,7 @@ class Invoice { String? terminalId, // 追加 this.isDraft = false, // 追加: デフォルトは通常 this.subject, // 追加: 案件 + this.isLocked = false, }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), terminalId = terminalId ?? "T1", // デフォルト端末ID updatedAt = updatedAt ?? DateTime.now(); @@ -160,6 +162,7 @@ class Invoice { 'content_hash': contentHash, // 追加 'is_draft': isDraft ? 1 : 0, // 追加 'subject': subject, // 追加 + 'is_locked': isLocked ? 1 : 0, }; } @@ -181,6 +184,7 @@ class Invoice { String? terminalId, bool? isDraft, String? subject, + bool? isLocked, }) { return Invoice( id: id ?? this.id, @@ -200,6 +204,7 @@ class Invoice { terminalId: terminalId ?? this.terminalId, isDraft: isDraft ?? this.isDraft, subject: subject ?? this.subject, + isLocked: isLocked ?? this.isLocked, ); } diff --git a/lib/models/product_model.dart b/lib/models/product_model.dart index c5dfda0..6ba76c9 100644 --- a/lib/models/product_model.dart +++ b/lib/models/product_model.dart @@ -6,6 +6,7 @@ class Product { final String? category; final int stockQuantity; // 追加 final String? odooId; + final bool isLocked; // ロック Product({ required this.id, @@ -15,6 +16,7 @@ class Product { this.category, this.stockQuantity = 0, // 追加 this.odooId, + this.isLocked = false, }); Map toMap() { @@ -25,6 +27,7 @@ class Product { 'barcode': barcode, 'category': category, 'stock_quantity': stockQuantity, // 追加 + 'is_locked': isLocked ? 1 : 0, 'odoo_id': odooId, }; } @@ -37,6 +40,7 @@ class Product { barcode: map['barcode'], category: map['category'], stockQuantity: map['stock_quantity'] ?? 0, // 追加 + isLocked: (map['is_locked'] ?? 0) == 1, odooId: map['odoo_id'], ); } @@ -47,12 +51,14 @@ class Product { int? defaultUnitPrice, String? barcode, String? odooId, + bool? isLocked, }) { return Product( id: id ?? this.id, name: name ?? this.name, defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice, odooId: odooId ?? this.odooId, + isLocked: isLocked ?? this.isLocked, ); } } diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index afe78bf..269dd11 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -327,6 +327,7 @@ class _InvoiceHistoryScreenState extends State { label: const Text("新規伝票作成"), icon: const Icon(Icons.add), backgroundColor: Colors.indigo, + foregroundColor: Colors.white, ), ); } diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index a85d1dc..7ffb4eb 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -129,24 +129,31 @@ class _InvoiceInputFormState extends State { ); setState(() => _isSaving = true); - - // PDF生成有無に関わらず、まずは保存 - if (generatePdf) { - setState(() => _status = "PDFを生成中..."); - final path = await generateInvoicePdf(invoice); - if (path != null) { - final updatedInvoice = invoice.copyWith(filePath: path); - await _repository.saveInvoice(updatedInvoice); - if (mounted) widget.onInvoiceGenerated(updatedInvoice, path); - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました"))); + try { + // PDF生成有無に関わらず、まずは保存 + if (generatePdf) { + setState(() => _status = "PDFを生成中..."); + final path = await generateInvoicePdf(invoice); + if (path != null) { + final updatedInvoice = invoice.copyWith(filePath: path); + await _repository.saveInvoice(updatedInvoice); + if (mounted) widget.onInvoiceGenerated(updatedInvoice, path); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました"))); + } else { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("PDF生成に失敗しました"))); + } + } else { + await _repository.saveInvoice(invoice); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しました(PDF未生成)"))); + if (mounted) Navigator.pop(context); } - } else { - await _repository.saveInvoice(invoice); - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しました(PDF未生成)"))); - if (mounted) Navigator.pop(context); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); + } + } finally { + if (mounted) setState(() => _isSaving = false); } - - if (mounted) setState(() => _isSaving = false); } void _showPreview() { @@ -202,7 +209,7 @@ class _InvoiceInputFormState extends State { backgroundColor: themeColor, appBar: AppBar( leading: const BackButton(), - title: Text(_isDraft ? "伝票作成 (下書き)" : "販売アシスト1号 V1.5.05"), + title: Text(_isDraft ? "伝票作成 (下書き)" : "販売アシスト1号 V1.5.06"), backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey, ), body: Stack( @@ -374,102 +381,98 @@ class _InvoiceInputFormState extends State { 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)), - const SizedBox(width: 8), - if (idx > 0) - IconButton( - icon: const Icon(Icons.arrow_upward, size: 20), - onPressed: () => setState(() { - final temp = _items[idx]; - _items[idx] = _items[idx - 1]; - _items[idx - 1] = temp; - }), - tooltip: "上へ", - ), - if (idx < _items.length - 1) - IconButton( - icon: const Icon(Icons.arrow_downward, size: 20), - onPressed: () => setState(() { - final temp = _items[idx]; - _items[idx] = _items[idx + 1]; - _items[idx + 1] = temp; - }), - tooltip: "下へ", - ), - IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent), - onPressed: () => setState(() => _items.removeAt(idx)), - tooltip: "削除", - ), - ], - ), - onTap: () { - // 簡易編集ダイアログ - final descCtrl = TextEditingController(text: item.description); - final qtyCtrl = TextEditingController(text: item.quantity.toString()); - final priceCtrl = TextEditingController(text: item.unitPrice.toString()); - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("明細の編集"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField(controller: descCtrl, decoration: const InputDecoration(labelText: "品名 / 項目")), - TextField(controller: qtyCtrl, decoration: const InputDecoration(labelText: "数量"), keyboardType: TextInputType.number), - TextField(controller: priceCtrl, decoration: const InputDecoration(labelText: "単価"), keyboardType: TextInputType.number), - ], - ), - actions: [ - TextButton.icon( - icon: const Icon(Icons.search, size: 18), - label: const Text("マスター参照"), - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => ProductPickerModal( - onItemSelected: (selected) { - descCtrl.text = selected.description; - priceCtrl.text = selected.unitPrice.toString(); - Navigator.pop(context); // close picker - }, - ), - ); - }, - ), - TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), - ElevatedButton( - onPressed: () { - setState(() { - _items[idx] = item.copyWith( - description: descCtrl.text, - quantity: int.tryParse(qtyCtrl.text) ?? item.quantity, - unitPrice: int.tryParse(priceCtrl.text) ?? item.unitPrice, - ); - }); - Navigator.pop(context); - }, - child: const Text("更新"), + ReorderableListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _items.length, + onReorder: (oldIndex, newIndex) { + setState(() { + if (newIndex > oldIndex) newIndex -= 1; + final item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + }, + buildDefaultDragHandles: false, + itemBuilder: (context, idx) { + final item = _items[idx]; + return ReorderableDelayedDragStartListener( + key: ValueKey('item_${idx}_${item.description}'), + index: idx, + child: 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)), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent), + onPressed: () => setState(() => _items.removeAt(idx)), + tooltip: "削除", ), ], ), - ); - }, - ), - ); - }), + onTap: () { + // 簡易編集ダイアログ + final descCtrl = TextEditingController(text: item.description); + final qtyCtrl = TextEditingController(text: item.quantity.toString()); + final priceCtrl = TextEditingController(text: item.unitPrice.toString()); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("明細の編集"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: descCtrl, decoration: const InputDecoration(labelText: "品名 / 項目")), + TextField(controller: qtyCtrl, decoration: const InputDecoration(labelText: "数量"), keyboardType: TextInputType.number), + TextField(controller: priceCtrl, decoration: const InputDecoration(labelText: "単価"), keyboardType: TextInputType.number), + ], + ), + actions: [ + TextButton.icon( + icon: const Icon(Icons.search, size: 18), + label: const Text("マスター参照"), + onPressed: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => ProductPickerModal( + onItemSelected: (selected) { + descCtrl.text = selected.description; + priceCtrl.text = selected.unitPrice.toString(); + Navigator.pop(context); // close picker + }, + ), + ); + }, + ), + TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), + ElevatedButton( + onPressed: () { + setState(() { + _items[idx] = item.copyWith( + description: descCtrl.text, + quantity: int.tryParse(qtyCtrl.text) ?? item.quantity, + unitPrice: int.tryParse(priceCtrl.text) ?? item.unitPrice, + ); + }); + Navigator.pop(context); + }, + child: const Text("更新"), + ), + ], + ), + ); + }, + ), + ), + ); + }, + ), ], ); } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index e9ec4fe..682ac4c 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; class DatabaseHelper { - static const _databaseVersion = 13; + static const _databaseVersion = 15; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -97,6 +97,14 @@ class DatabaseHelper { if (oldVersion < 13) { await db.execute('ALTER TABLE company_info ADD COLUMN registration_number TEXT'); } + if (oldVersion < 14) { + await _safeAddColumn(db, 'invoices', 'subject TEXT'); + } + if (oldVersion < 15) { + await _safeAddColumn(db, 'invoices', 'is_locked INTEGER DEFAULT 0'); + await _safeAddColumn(db, 'customers', 'is_locked INTEGER DEFAULT 0'); + await _safeAddColumn(db, 'products', 'is_locked INTEGER DEFAULT 0'); + } } Future _onCreate(Database db, int version) async { @@ -110,6 +118,7 @@ class DatabaseHelper { address TEXT, tel TEXT, odoo_id TEXT, + is_locked INTEGER DEFAULT 0, is_synced INTEGER DEFAULT 0, updated_at TEXT NOT NULL ) @@ -135,6 +144,7 @@ class DatabaseHelper { barcode TEXT, category TEXT, stock_quantity INTEGER DEFAULT 0, + is_locked INTEGER DEFAULT 0, odoo_id TEXT ) '''); @@ -148,6 +158,7 @@ class DatabaseHelper { customer_id TEXT NOT NULL, date TEXT NOT NULL, notes TEXT, + subject TEXT, file_path TEXT, total_amount INTEGER, tax_rate REAL DEFAULT 0.10, @@ -161,6 +172,7 @@ class DatabaseHelper { terminal_id TEXT DEFAULT "T1", content_hash TEXT, is_draft INTEGER DEFAULT 0, + is_locked INTEGER DEFAULT 0, FOREIGN KEY (customer_id) REFERENCES customers (id) ) '''); @@ -212,4 +224,12 @@ class DatabaseHelper { ) '''); } + + Future _safeAddColumn(Database db, String table, String columnDef) async { + try { + await db.execute('ALTER TABLE ' + table + ' ADD COLUMN ' + columnDef); + } catch (_) { + // Ignore if the column already exists. + } + } } diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index d1e3234..1464ca0 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -13,6 +13,9 @@ class InvoiceRepository { Future saveInvoice(Invoice invoice) async { final db = await _dbHelper.database; + // 正式発行(下書きでない)場合はロックを掛ける + final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true); + await db.transaction((txn) async { // 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く) final List> oldItems = await txn.query( @@ -34,7 +37,7 @@ class InvoiceRepository { // 伝票ヘッダーの保存 await txn.insert( 'invoices', - invoice.toMap(), + toSave.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); @@ -53,8 +56,16 @@ class InvoiceRepository { 'UPDATE products SET stock_quantity = stock_quantity - ? WHERE id = ?', [item.quantity, item.productId], ); + if (!invoice.isDraft) { + await txn.execute('UPDATE products SET is_locked = 1 WHERE id = ?', [item.productId]); + } } } + + // 顧客をロック + if (!invoice.isDraft) { + await txn.execute('UPDATE customers SET is_locked = 1 WHERE id = ?', [invoice.customer.id]); + } }); await _logRepo.logAction( @@ -114,6 +125,7 @@ class InvoiceRepository { terminalId: iMap['terminal_id'] ?? "T1", isDraft: (iMap['is_draft'] ?? 0) == 1, subject: iMap['subject'], + isLocked: (iMap['is_locked'] ?? 0) == 1, )); } return invoices; diff --git a/lib/widgets/slide_to_unlock.dart b/lib/widgets/slide_to_unlock.dart index 7aa023f..d5eda64 100644 --- a/lib/widgets/slide_to_unlock.dart +++ b/lib/widgets/slide_to_unlock.dart @@ -18,7 +18,7 @@ class SlideToUnlock extends StatefulWidget { class _SlideToUnlockState extends State { double _position = 0.0; - final double _thumbSize = 50.0; + final double _thumbSize = 56.0; @override Widget build(BuildContext context) { @@ -27,7 +27,7 @@ class _SlideToUnlockState extends State { return LayoutBuilder( builder: (context, constraints) { final double maxWidth = constraints.maxWidth; - final double trackWidth = maxWidth - _thumbSize - 8; // 余白を考慮 + final double trackWidth = (maxWidth - _thumbSize - 12).clamp(0, maxWidth); return Container( height: 64, @@ -81,7 +81,7 @@ class _SlideToUnlockState extends State { } }, child: Container( - width: maxWidth * 0.25, // 少し横長に + width: _thumbSize, height: 56, decoration: BoxDecoration( gradient: const LinearGradient(