From 191711803bead7e943ae164a99032377633d8d28 Mon Sep 17 00:00:00 2001 From: joe Date: Sat, 14 Feb 2026 19:36:40 +0900 Subject: [PATCH] feat: Implement customer master management with local storage, GPS history recording, and a new invoice history screen. --- analyze_output.txt | 62 ++++ ios/Runner/GeneratedPluginRegistrant.m | 7 + lib/main.dart | 56 +++- lib/models/customer_model.dart | 49 ++- lib/models/invoice_models.dart | 57 +++- lib/screens/customer_picker_modal.dart | 111 ++++--- lib/screens/invoice_detail_page.dart | 27 +- lib/screens/invoice_history_screen.dart | 304 ++++++++++++++++++ lib/screens/invoice_input_screen.dart | 39 ++- lib/screens/management_screen.dart | 125 +++++++ lib/services/customer_repository.dart | 64 ++++ lib/services/database_helper.dart | 94 ++++++ lib/services/invoice_repository.dart | 113 ++++++- lib/services/location_service.dart | 37 +++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 50 ++- pubspec.yaml | 2 + 目標.md | 27 +- 18 files changed, 1130 insertions(+), 96 deletions(-) create mode 100644 analyze_output.txt create mode 100644 lib/screens/invoice_history_screen.dart create mode 100644 lib/screens/management_screen.dart create mode 100644 lib/services/customer_repository.dart create mode 100644 lib/services/database_helper.dart create mode 100644 lib/services/location_service.dart diff --git a/analyze_output.txt b/analyze_output.txt new file mode 100644 index 0000000..ef08291 --- /dev/null +++ b/analyze_output.txt @@ -0,0 +1,62 @@ +Analyzing gemi_invoice_backup2... + +warning • The value of the field '_lastGeneratedInvoice' isn't used • lib/main.dart:43:12 • unused_field + error • The body might complete normally, causing 'null' to be returned, but the return type, 'Widget', is a potentially non-nullable type • lib/main.dart:61:10 • body_might_complete_normally +warning • The label 'appBar' isn't used • lib/main.dart:62:7 • unused_label + error • Expected to find ';' • lib/main.dart:65:7 • expected_token + error • Expected an identifier • lib/main.dart:65:8 • missing_identifier + error • Unexpected text ';' • lib/main.dart:65:8 • unexpected_token +warning • The label 'drawer' isn't used • lib/main.dart:66:7 • unused_label + error • Expected to find ';' • lib/main.dart:92:7 • expected_token + error • Expected an identifier • lib/main.dart:92:8 • missing_identifier + error • Unexpected text ';' • lib/main.dart:92:8 • unexpected_token +warning • The label 'body' isn't used • lib/main.dart:94:7 • unused_label + error • Expected to find ';' • lib/main.dart:106:7 • expected_token + error • Expected an identifier • lib/main.dart:106:8 • missing_identifier + error • Expected to find ';' • lib/main.dart:106:8 • expected_token + error • Unexpected text ';' • lib/main.dart:106:8 • unexpected_token + error • Expected an identifier • lib/main.dart:107:5 • missing_identifier + error • Unexpected text ';' • lib/main.dart:107:5 • unexpected_token + info • Unnecessary empty statement • lib/main.dart:107:6 • empty_statements + info • The imported package 'uuid' isn't a dependency of the importing package • lib/models/customer_model.dart:1:8 • depend_on_referenced_packages +warning • Unused import: 'package:uuid/uuid.dart' • lib/models/customer_model.dart:1:8 • unused_import + error • The named parameter 'unitPrice' is required, but there's no corresponding argument • lib/models/invoice_models.dart:30:12 • missing_required_argument + error • The named parameter 'unit_price' isn't defined • lib/models/invoice_models.dart:34:7 • undefined_named_parameter +warning • The value of the local variable 'amountFormatter' isn't used • lib/models/invoice_models.dart:88:11 • unused_local_variable + info • The imported package 'uuid' isn't a dependency of the importing package • lib/screens/customer_picker_modal.dart:3:8 • depend_on_referenced_packages + error • Undefined class 'Customer' • lib/screens/customer_picker_modal.dart:8:18 • undefined_class + info • Parameter 'key' could be a super parameter • lib/screens/customer_picker_modal.dart:10:9 • use_super_parameters + error • The name 'Customer' isn't a type, so it can't be used as a type argument • lib/screens/customer_picker_modal.dart:22:8 • non_type_as_type_argument + error • The name 'Customer' isn't a type, so it can't be used as a type argument • lib/screens/customer_picker_modal.dart:23:8 • non_type_as_type_argument + error • The property 'formalName' can't be unconditionally accessed because the receiver can be 'null' • lib/screens/customer_picker_modal.dart:47:25 • unchecked_use_of_nullable_value + error • The property 'displayName' can't be unconditionally accessed because the receiver can be 'null' • lib/screens/customer_picker_modal.dart:48:22 • unchecked_use_of_nullable_value + info • Don't use 'BuildContext's across async gaps • lib/screens/customer_picker_modal.dart:77:28 • use_build_context_synchronously + error • Undefined class 'Customer' • lib/screens/customer_picker_modal.dart:87:5 • undefined_class + error • The method 'Customer' isn't defined for the type '_CustomerPickerModalState' • lib/screens/customer_picker_modal.dart:141:19 • undefined_method + info • Don't use 'BuildContext's across async gaps • lib/screens/customer_picker_modal.dart:150:29 • use_build_context_synchronously + error • Undefined class 'Customer' • lib/screens/customer_picker_modal.dart:164:23 • undefined_class + info • Don't use 'BuildContext's across async gaps • lib/screens/customer_picker_modal.dart:175:29 • use_build_context_synchronously +warning • Unused import: 'dart:io' • lib/screens/invoice_detail_page.dart:1:8 • unused_import +warning • Unused import: '../models/customer_model.dart' • lib/screens/invoice_detail_page.dart:7:8 • unused_import + info • Parameter 'key' could be a super parameter • lib/screens/invoice_detail_page.dart:16:9 • use_super_parameters + info • Don't use 'BuildContext's across async gaps • lib/screens/invoice_detail_page.dart:120:28 • use_build_context_synchronously + info • 'Share' is deprecated and shouldn't be used. Use SharePlus instead • lib/screens/invoice_detail_page.dart:128:5 • deprecated_member_use + info • 'share' is deprecated and shouldn't be used. Use SharePlus.instance.share() instead • lib/screens/invoice_detail_page.dart:128:11 • deprecated_member_use + info • Use a 'SizedBox' to add whitespace to a layout • lib/screens/invoice_detail_page.dart:283:14 • sized_box_for_whitespace + info • 'Share' is deprecated and shouldn't be used. Use SharePlus instead • lib/screens/invoice_detail_page.dart:327:43 • deprecated_member_use + info • 'shareXFiles' is deprecated and shouldn't be used. Use SharePlus.instance.share() instead • lib/screens/invoice_detail_page.dart:327:49 • deprecated_member_use +warning • Unused import: '../models/customer_model.dart' • lib/screens/invoice_history_screen.dart:4:8 • unused_import + info • Parameter 'key' could be a super parameter • lib/screens/invoice_history_screen.dart:10:9 • use_super_parameters + info • The imported package 'uuid' isn't a dependency of the importing package • lib/screens/invoice_input_screen.dart:2:8 • depend_on_referenced_packages + info • Parameter 'key' could be a super parameter • lib/screens/invoice_input_screen.dart:14:9 • use_super_parameters + info • The private field _customerBuffer could be 'final' • lib/screens/invoice_input_screen.dart:29:18 • prefer_final_fields +warning • The value of the field '_customerBuffer' isn't used • lib/screens/invoice_input_screen.dart:29:18 • unused_field + error • A value of type 'Object?' can't be assigned to a variable of type 'Customer?' • lib/screens/invoice_input_screen.dart:87:35 • invalid_assignment + error • The property 'formalName' can't be unconditionally accessed because the receiver can be 'null' • lib/screens/invoice_input_screen.dart:88:49 • unchecked_use_of_nullable_value + error • The property 'formalName' can't be unconditionally accessed because the receiver can be 'null' • lib/screens/invoice_input_screen.dart:89:38 • unchecked_use_of_nullable_value + info • Parameter 'key' could be a super parameter • lib/screens/product_picker_modal.dart:8:9 • use_super_parameters + info • 'desiredAccuracy' is deprecated and shouldn't be used. use settings parameter with AndroidSettings, AppleSettings, WebSettings, or LocationSettings • lib/services/location_service.dart:28:9 • deprecated_member_use + info • 'timeLimit' is deprecated and shouldn't be used. use settings parameter with AndroidSettings, AppleSettings, WebSettings, or LocationSettings • lib/services/location_service.dart:29:9 • deprecated_member_use + info • The import of 'dart:typed_data' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/services.dart' • lib/services/pdf_generator.dart:2:8 • unnecessary_import + +58 issues found. (ran in 23.0s) diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index 9fb36c5..c419b3f 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -12,6 +12,12 @@ @import flutter_contacts; #endif +#if __has_include() +#import +#else +@import geolocator_apple; +#endif + #if __has_include() #import #else @@ -46,6 +52,7 @@ + (void)registerWithRegistry:(NSObject*)registry { [FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]]; + [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; diff --git a/lib/main.dart b/lib/main.dart index 8bbb9c4..b80c696 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,6 +6,9 @@ import 'package:flutter/material.dart'; import 'models/invoice_models.dart'; // Invoice, InvoiceItem モデル import 'screens/invoice_input_screen.dart'; // 入力フォーム画面 import 'screens/invoice_detail_page.dart'; // 詳細表示・編集画面 +import 'screens/invoice_history_screen.dart'; // 履歴画面 +import 'services/location_service.dart'; // 位置情報サービス +import 'services/customer_repository.dart'; // 顧客リポジトリ void main() { runApp(const MyApp()); @@ -23,28 +26,23 @@ class MyApp extends StatelessWidget { visualDensity: VisualDensity.adaptivePlatformDensity, useMaterial3: true, ), - home: const InvoiceFlowScreen(), + home: const InvoiceHistoryScreen(), ); } } +// 従来の InvoiceFlowScreen は新規作成用ウィジェットとして維持 class InvoiceFlowScreen extends StatefulWidget { - const InvoiceFlowScreen({super.key}); + final VoidCallback? onComplete; + const InvoiceFlowScreen({super.key, this.onComplete}); @override State createState() => _InvoiceFlowScreenState(); } class _InvoiceFlowScreenState extends State { - // 最後に生成されたデータを保持(必要に応じて) - Invoice? _lastGeneratedInvoice; - // PDF 生成後に呼び出され、詳細ページへ遷移するコールバック void _handleInvoiceGenerated(Invoice generatedInvoice, String filePath) { - setState(() { - _lastGeneratedInvoice = generatedInvoice; - }); - // 詳細ページへ遷移 Navigator.push( context, @@ -61,9 +59,47 @@ class _InvoiceFlowScreenState extends State { title: const Text("販売アシスト1号 V1.4.3c"), 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: _handleInvoiceGenerated, + 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 e9e5d06..6ce27fb 100644 --- a/lib/models/customer_model.dart +++ b/lib/models/customer_model.dart @@ -1,4 +1,3 @@ -import 'package:uuid/uuid.dart'; class Customer { final String id; @@ -7,6 +6,10 @@ class Customer { final String title; // 敬称(様、殿など) final String? department; // 部署名 final String? address; // 住所 + final String? tel; // 電話番号 + final String? odooId; // Odoo側のID + final bool isSynced; // 同期フラグ + final DateTime updatedAt; // 最終更新日時 Customer({ required this.id, @@ -15,7 +18,11 @@ class Customer { this.title = "様", this.department, this.address, - }); + this.tel, + this.odooId, + this.isSynced = false, + DateTime? updatedAt, + }) : updatedAt = updatedAt ?? DateTime.now(); String get invoiceName { String name = formalName; @@ -25,6 +32,36 @@ class Customer { return "$name $title"; } + Map toMap() { + return { + 'id': id, + 'display_name': displayName, + 'formal_name': formalName, + 'title': title, + 'department': department, + 'address': address, + 'tel': tel, + 'odoo_id': odooId, + 'is_synced': isSynced ? 1 : 0, + 'updated_at': updatedAt.toIso8601String(), + }; + } + + factory Customer.fromMap(Map map) { + return Customer( + id: map['id'], + displayName: map['display_name'], + formalName: map['formal_name'], + title: map['title'] ?? "様", + department: map['department'], + address: map['address'], + tel: map['tel'], + odooId: map['odoo_id'], + isSynced: map['is_synced'] == 1, + updatedAt: DateTime.parse(map['updated_at']), + ); + } + Customer copyWith({ String? id, String? displayName, @@ -32,6 +69,10 @@ class Customer { String? title, String? department, String? address, + String? tel, + String? odooId, + bool? isSynced, + DateTime? updatedAt, }) { return Customer( id: id ?? this.id, @@ -40,6 +81,10 @@ class Customer { title: title ?? this.title, department: department ?? this.department, address: address ?? this.address, + tel: tel ?? this.tel, + odooId: odooId ?? this.odooId, + isSynced: isSynced ?? this.isSynced, + updatedAt: updatedAt ?? this.updatedAt, ); } } diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index a8ff9ea..dac26aa 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -2,17 +2,38 @@ import 'customer_model.dart'; import 'package:intl/intl.dart'; class InvoiceItem { + final String? id; String description; int quantity; int unitPrice; InvoiceItem({ + this.id, required this.description, required this.quantity, required this.unitPrice, }); int get subtotal => quantity * unitPrice; + + Map toMap(String invoiceId) { + return { + 'id': id ?? DateTime.now().microsecondsSinceEpoch.toString(), + 'invoice_id': invoiceId, + 'description': description, + 'quantity': quantity, + 'unit_price': unitPrice, + }; + } + + factory InvoiceItem.fromMap(Map map) { + return InvoiceItem( + id: map['id'], + description: map['description'], + quantity: map['quantity'], + unitPrice: map['unit_price'], + ); + } } class Invoice { @@ -22,6 +43,9 @@ class Invoice { final List items; final String? notes; final String? filePath; + final String? odooId; + final bool isSynced; + final DateTime updatedAt; Invoice({ String? id, @@ -30,7 +54,11 @@ class Invoice { required this.items, this.notes, this.filePath, - }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(); + this.odooId, + this.isSynced = false, + DateTime? updatedAt, + }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), + updatedAt = updatedAt ?? DateTime.now(); String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; @@ -38,12 +66,27 @@ class Invoice { int get tax => (subtotal * 0.1).floor(); int get totalAmount => subtotal + tax; + Map toMap() { + return { + 'id': id, + 'customer_id': customer.id, + 'date': date.toIso8601String(), + 'notes': notes, + 'file_path': filePath, + 'total_amount': totalAmount, + 'odoo_id': odooId, + 'is_synced': isSynced ? 1 : 0, + 'updated_at': updatedAt.toIso8601String(), + }; + } + + // 注: fromMap には Customer オブジェクトが必要なため、 + // リポジトリ層で構築することを想定し、ここでは factory は定義しません。 + String toCsv() { final dateFormatter = DateFormat('yyyy/MM/dd'); - final amountFormatter = NumberFormat("###"); StringBuffer buffer = StringBuffer(); - // ヘッダー (例) buffer.writeln("日付,請求番号,取引先,合計金額,備考"); buffer.writeln("${dateFormatter.format(date)},$invoiceNumber,${customer.formalName},$totalAmount,${notes ?? ""}"); buffer.writeln(""); @@ -63,14 +106,20 @@ class Invoice { List? items, String? notes, String? filePath, + String? odooId, + bool? isSynced, + DateTime? updatedAt, }) { return Invoice( id: id ?? this.id, customer: customer ?? this.customer, date: date ?? this.date, - items: items ?? List.from(this.items), // コピーを作成 + items: items ?? List.from(this.items), notes: notes ?? this.notes, filePath: filePath ?? this.filePath, + odooId: odooId ?? this.odooId, + isSynced: isSynced ?? this.isSynced, + updatedAt: updatedAt ?? this.updatedAt, ); } } diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index 5f37e3c..d51ab52 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -2,18 +2,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:uuid/uuid.dart'; import '../models/customer_model.dart'; +import '../services/customer_repository.dart'; /// 顧客マスターからの選択、登録、編集、削除を行うモーダル class CustomerPickerModal extends StatefulWidget { - final List existingCustomers; final Function(Customer) onCustomerSelected; - final Function(Customer)? onCustomerDeleted; // 削除通知用(オプション) const CustomerPickerModal({ Key? key, - required this.existingCustomers, required this.onCustomerSelected, - this.onCustomerDeleted, }) : super(key: key); @override @@ -21,20 +18,33 @@ class CustomerPickerModal extends StatefulWidget { } class _CustomerPickerModalState extends State { + final CustomerRepository _repository = CustomerRepository(); String _searchQuery = ""; + List _allCustomers = []; List _filteredCustomers = []; bool _isImportingFromContacts = false; + bool _isLoading = true; @override void initState() { super.initState(); - _filteredCustomers = widget.existingCustomers; + _loadCustomers(); + } + + Future _loadCustomers() async { + setState(() => _isLoading = true); + final customers = await _repository.getAllCustomers(); + setState(() { + _allCustomers = customers; + _filteredCustomers = customers; + _isLoading = false; + }); } void _filterCustomers(String query) { setState(() { _searchQuery = query.toLowerCase(); - _filteredCustomers = widget.existingCustomers.where((customer) { + _filteredCustomers = _allCustomers.where((customer) { return customer.formalName.toLowerCase().contains(_searchQuery) || customer.displayName.toLowerCase().contains(_searchQuery); }).toList(); @@ -50,7 +60,7 @@ class _CustomerPickerModalState extends State { if (!mounted) return; setState(() => _isImportingFromContacts = false); - final Contact? selectedContact = await showModalBottomSheet( + final Contact? selectedContact = await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => _PhoneContactListSelector(contacts: contacts), @@ -121,11 +131,13 @@ class _CustomerPickerModalState extends State { actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), ElevatedButton( - onPressed: () { + onPressed: () async { final updatedCustomer = existingCustomer?.copyWith( formalName: formalNameController.text.trim(), department: departmentController.text.trim(), address: addressController.text.trim(), + updatedAt: DateTime.now(), + isSynced: false, ) ?? Customer( id: const Uuid().v4(), @@ -134,10 +146,15 @@ class _CustomerPickerModalState extends State { department: departmentController.text.trim(), address: addressController.text.trim(), ); - Navigator.pop(context); - widget.onCustomerSelected(updatedCustomer); + + await _repository.saveCustomer(updatedCustomer); + Navigator.pop(context); // エディットダイアログを閉じる + _loadCustomers(); // リストを再読込 + + // 保存のついでに選択状態にするなら以下を有効化(今回は明示的にリストから選ばせる) + // widget.onCustomerSelected(updatedCustomer); }, - child: const Text("保存して確定"), + child: const Text("保存してマスターに登録"), ), ], ), @@ -154,14 +171,10 @@ class _CustomerPickerModalState extends State { actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), TextButton( - onPressed: () { + onPressed: () async { + await _repository.deleteCustomer(customer.id); Navigator.pop(context); - if (widget.onCustomerDeleted != null) { - widget.onCustomerDeleted!(customer); - setState(() { - _filterCustomers(_searchQuery); // リスト更新 - }); - } + _loadCustomers(); }, child: const Text("削除する", style: TextStyle(color: Colors.red)), ), @@ -213,37 +226,39 @@ class _CustomerPickerModalState extends State { ), const Divider(), Expanded( - child: _filteredCustomers.isEmpty - ? const Center(child: Text("該当する顧客がいません")) - : ListView.builder( - itemCount: _filteredCustomers.length, - itemBuilder: (context, index) { - final customer = _filteredCustomers[index]; - return ListTile( - leading: const CircleAvatar(child: Icon(Icons.business)), - title: Text(customer.formalName), - subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), - onTap: () => widget.onCustomerSelected(customer), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), - onPressed: () => _showCustomerEditDialog( - displayName: customer.displayName, - initialFormalName: customer.formalName, - existingCustomer: customer, - ), + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredCustomers.isEmpty + ? const Center(child: Text("該当する顧客がいません")) + : ListView.builder( + itemCount: _filteredCustomers.length, + itemBuilder: (context, index) { + final customer = _filteredCustomers[index]; + return ListTile( + leading: const CircleAvatar(child: Icon(Icons.business)), + title: Text(customer.formalName), + subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"), + onTap: () => widget.onCustomerSelected(customer), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20), + onPressed: () => _showCustomerEditDialog( + displayName: customer.displayName, + initialFormalName: customer.formalName, + existingCustomer: customer, + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), + onPressed: () => _confirmDelete(customer), + ), + ], ), - IconButton( - icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20), - onPressed: () => _confirmDelete(customer), - ), - ], - ), - ); - }, - ), + ); + }, + ), ), ], ), diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index 8b4f3fc..9a8edbb 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -1,11 +1,11 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; import 'package:open_filex/open_filex.dart'; import '../models/invoice_models.dart'; -import '../models/customer_model.dart'; import '../services/pdf_generator.dart'; +import '../services/invoice_repository.dart'; +import '../services/customer_repository.dart'; import 'product_picker_modal.dart'; class InvoiceDetailPage extends StatefulWidget { @@ -24,6 +24,8 @@ class _InvoiceDetailPageState extends State { late bool _isEditing; late Invoice _currentInvoice; String? _currentFilePath; + final _invoiceRepo = InvoiceRepository(); + final _customerRepo = CustomerRepository(); @override void initState() { @@ -94,16 +96,27 @@ class _InvoiceDetailPageState extends State { notes: _notesController.text, ); + // データベースに保存 + await _invoiceRepo.saveInvoice(updatedInvoice); + + // 顧客の正式名称が変更されている可能性があるため、マスターも更新 + if (updatedCustomer.formalName != widget.invoice.customer.formalName) { + await _customerRepo.saveCustomer(updatedCustomer); + } + setState(() => _isEditing = false); final newPath = await generateInvoicePdf(updatedInvoice); if (newPath != null) { + final finalInvoice = updatedInvoice.copyWith(filePath: newPath); + await _invoiceRepo.saveInvoice(finalInvoice); // パスを更新して再保存 + setState(() { - _currentInvoice = updatedInvoice.copyWith(filePath: newPath); + _currentInvoice = finalInvoice; _currentFilePath = newPath; }); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('A4請求書PDFを更新しました')), + const SnackBar(content: Text('データベースとPDFを更新しました')), ); } } @@ -309,7 +322,11 @@ class _InvoiceDetailPageState extends State { } Future _openPdf() async => await OpenFilex.open(_currentFilePath!); - Future _sharePdf() async => await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付'); + Future _sharePdf() async { + if (_currentFilePath != null) { + await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付'); + } + } } class _TableCell extends StatelessWidget { diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart new file mode 100644 index 0000000..a5f43a9 --- /dev/null +++ b/lib/screens/invoice_history_screen.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../models/invoice_models.dart'; +import '../models/customer_model.dart'; +import '../services/invoice_repository.dart'; +import '../services/customer_repository.dart'; +import 'invoice_detail_page.dart'; +import 'management_screen.dart'; +import '../main.dart'; // InvoiceFlowScreen 用 + +class InvoiceHistoryScreen extends StatefulWidget { + const InvoiceHistoryScreen({Key? key}) : super(key: key); + + @override + State createState() => _InvoiceHistoryScreenState(); +} + +class _InvoiceHistoryScreenState extends State { + final InvoiceRepository _invoiceRepo = InvoiceRepository(); + final CustomerRepository _customerRepo = CustomerRepository(); + List _invoices = []; + List _filteredInvoices = []; + bool _isLoading = true; + bool _isUnlocked = false; // 保護解除フラグ + String _searchQuery = ""; + String _sortBy = "date"; // "date", "amount", "customer" + DateTime? _startDate; + DateTime? _endDate; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _isLoading = true); + final customers = await _customerRepo.getAllCustomers(); + final invoices = await _invoiceRepo.getAllInvoices(customers); + setState(() { + _invoices = invoices; + _applyFilterAndSort(); + _isLoading = false; + }); + } + + void _applyFilterAndSort() { + setState(() { + _filteredInvoices = _invoices.where((inv) { + final query = _searchQuery.toLowerCase(); + final matchesQuery = inv.customer.formalName.toLowerCase().contains(query) || + inv.invoiceNumber.toLowerCase().contains(query) || + (inv.notes?.toLowerCase().contains(query) ?? false); + + bool matchesDate = true; + if (_startDate != null && inv.date.isBefore(_startDate!)) matchesDate = false; + if (_endDate != null && inv.date.isAfter(_endDate!.add(const Duration(days: 1)))) matchesDate = false; + + return matchesQuery && matchesDate; + }).toList(); + + if (_sortBy == "date") { + _filteredInvoices.sort((a, b) => b.date.compareTo(a.date)); + } 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)); + } + }); + } + + void _toggleUnlock() { + setState(() { + _isUnlocked = !_isUnlocked; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(_isUnlocked ? "編集プロテクトを解除しました" : "編集プロテクトを有効にしました")), + ); + } + + @override + Widget build(BuildContext context) { + final amountFormatter = NumberFormat("#,###"); + final dateFormatter = DateFormat('yyyy/MM/dd'); + + return Scaffold( + appBar: AppBar( + 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 ? "プロテクトする" : "アンロックする", + ), + IconButton( + icon: const Icon(Icons.sort), + onPressed: () { + showMenu( + context: context, + position: const RelativeRect.fromLTRB(100, 80, 0, 0), + items: [ + const PopupMenuItem(value: "date", child: Text("日付順")), + const PopupMenuItem(value: "amount", child: Text("金額順")), + const PopupMenuItem(value: "customer", child: Text("顧客名順")), + ], + ).then((val) { + if (val != null) { + setState(() => _sortBy = val); + _applyFilterAndSort(); + } + }); + }, + tooltip: "ソート切り替え", + ), + IconButton( + icon: Icon(Icons.date_range, color: (_startDate != null || _endDate != null) ? Colors.orange : Colors.white), + onPressed: () async { + final picked = await showDateRangePicker( + context: context, + initialDateRange: (_startDate != null && _endDate != null) + ? DateTimeRange(start: _startDate!, end: _endDate!) + : null, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + setState(() { + _startDate = picked.start; + _endDate = picked.end; + }); + _applyFilterAndSort(); + } else if (_startDate != null) { + // リセット + setState(() { + _startDate = null; + _endDate = null; + }); + _applyFilterAndSort(); + } + }, + tooltip: "日付範囲で絞り込み", + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadData, + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: TextField( + decoration: InputDecoration( + hintText: "検索 (顧客名、伝票番号...)", + prefixIcon: const Icon(Icons.search), + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + isDense: true, + ), + onChanged: (val) { + _searchQuery = val; + _applyFilterAndSort(); + }, + ), + ), + ), + ), + drawer: Drawer( + child: ListView( + padding: EdgeInsets.zero, + children: [ + const DrawerHeader( + decoration: BoxDecoration(color: Colors.blueGrey), + child: Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 24)), + ), + ListTile( + leading: const Icon(Icons.history), + title: const Text("伝票マスター一覧"), + onTap: () => Navigator.pop(context), + ), + ListTile( + leading: const Icon(Icons.add_task), + title: const Text("新規伝票作成"), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => InvoiceFlowScreen(onComplete: _loadData)), + ); + }, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.admin_panel_settings), + title: const Text("マスター管理・同期"), + onTap: () { + Navigator.pop(context); + Navigator.push( + context, + MaterialPageRoute(builder: (context) => const ManagementScreen()), + ); + }, + ), + ], + ), + ), + 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)), + ); + 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( + context, + MaterialPageRoute( + builder: (context) => InvoiceFlowScreen(onComplete: _loadData), + ), + ); + }, + label: const Text("新規伝票作成"), + icon: const Icon(Icons.add), + backgroundColor: Colors.indigo, + ), + ); + } +} diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 0259d29..aa014fd 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -4,6 +4,7 @@ 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'; /// 請求書の初期入力(ヘッダー部分)を管理するウィジェット @@ -25,26 +26,42 @@ class _InvoiceInputFormState extends State { final _repository = InvoiceRepository(); String _status = "取引先を選択してPDFを生成してください"; - List _customerBuffer = []; Customer? _selectedCustomer; @override void initState() { super.initState(); - _selectedCustomer = Customer( - id: const Uuid().v4(), - displayName: "佐々木製作所", - formalName: "株式会社 佐々木製作所", - ); - _customerBuffer.add(_selectedCustomer!); - _clientController.text = _selectedCustomer!.formalName; + _loadInitialData(); + } + Future _loadInitialData() async { // 起動時に不要なPDFを掃除する _repository.cleanupOrphanedPdfs().then((count) { if (count > 0) { debugPrint('Cleaned up $count orphaned PDF files.'); } }); + + 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; + }); + } } @override @@ -64,14 +81,8 @@ class _InvoiceInputFormState extends State { builder: (context) => FractionallySizedBox( heightFactor: 0.9, child: CustomerPickerModal( - existingCustomers: _customerBuffer, onCustomerSelected: (customer) { setState(() { - bool exists = _customerBuffer.any((c) => c.id == customer.id); - if (!exists) { - _customerBuffer.add(customer); - } - _selectedCustomer = customer; _clientController.text = customer.formalName; _status = "「${customer.formalName}」を選択しました"; diff --git a/lib/screens/management_screen.dart b/lib/screens/management_screen.dart new file mode 100644 index 0000000..40ab99b --- /dev/null +++ b/lib/screens/management_screen.dart @@ -0,0 +1,125 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart' as p; +import '../models/invoice_models.dart'; +import '../services/invoice_repository.dart'; +import '../services/customer_repository.dart'; + +class ManagementScreen extends StatelessWidget { + const ManagementScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("マスター管理・同期"), + backgroundColor: Colors.blueGrey, + ), + body: ListView( + children: [ + _buildSectionHeader("データ入出力"), + _buildMenuTile( + context, + Icons.upload_file, + "伝票マスター・エクスポート", + "全データをCSV形式で出力します", + () => _exportAllInvoicesCsv(context), + ), + _buildMenuTile( + context, + Icons.download, + "伝票マスター・インポート", + "外部ファイルからデータを取り込みます", + () => _showComingSoon(context), + ), + const Divider(), + _buildSectionHeader("バックアップ & セキュリティ"), + _buildMenuTile( + context, + Icons.backup, + "データベース・バックアップ", + "SQLiteファイルを外部へ保存・シェアします", + () => _backupDatabase(context), + ), + _buildMenuTile( + context, + Icons.settings_backup_restore, + "データベース・リストア", + "バックアップから全てのデータを復元します", + () => _showComingSoon(context), + ), + const Divider(), + _buildSectionHeader("外部同期 (将来のOdoo連携)"), + _buildMenuTile( + context, + Icons.sync, + "クラウド同期を実行", + "未同期の伝票をクラウドマスターへ送信します", + () => _showComingSoon(context), + ), + ], + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + title, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.blueGrey), + ), + ); + } + + Widget _buildMenuTile(BuildContext context, IconData icon, String title, String subtitle, VoidCallback onTap) { + return ListTile( + leading: Icon(icon, color: Colors.blueGrey), + title: Text(title), + subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } + + void _showComingSoon(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("この機能は次期バージョンで実装予定です。同期フラグ等の基盤は準備済みです。")), + ); + } + + Future _exportAllInvoicesCsv(BuildContext context) async { + final invoiceRepo = InvoiceRepository(); + final customerRepo = CustomerRepository(); + + final customers = await customerRepo.getAllCustomers(); + final invoices = await invoiceRepo.getAllInvoices(customers); + + if (invoices.isEmpty) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("エクスポートするデータがありません"))); + return; + } + + StringBuffer buffer = StringBuffer(); + buffer.writeln("日付,請求番号,取引先,合計金額,備考"); + for (var inv in invoices) { + buffer.writeln("${inv.date},$inv.invoiceNumber,${inv.customer.formalName},${inv.totalAmount},${inv.notes ?? ""}"); + } + + await Share.share(buffer.toString(), subject: '販売アシスト1号_全伝票マスター'); + } + + Future _backupDatabase(BuildContext context) async { + final dbPath = p.join(await getDatabasesPath(), 'gemi_invoice.db'); + final file = File(dbPath); + if (await file.exists()) { + await Share.shareXFiles([XFile(dbPath)], text: '販売アシスト1号_DBバックアップ'); + } else { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("データベースファイルが見つかりません"))); + } + } +} diff --git a/lib/services/customer_repository.dart b/lib/services/customer_repository.dart new file mode 100644 index 0000000..21287ee --- /dev/null +++ b/lib/services/customer_repository.dart @@ -0,0 +1,64 @@ +import 'package:sqflite/sqflite.dart'; +import '../models/customer_model.dart'; +import 'database_helper.dart'; + +class CustomerRepository { + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future> getAllCustomers() async { + final db = await _dbHelper.database; + final List> maps = await db.query('customers', orderBy: 'display_name ASC'); + return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); + } + + Future saveCustomer(Customer customer) async { + final db = await _dbHelper.database; + await db.insert( + 'customers', + customer.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future deleteCustomer(String id) async { + final db = await _dbHelper.database; + await db.delete('customers', where: 'id = ?', whereArgs: [id]); + } + + // GPS履歴の保存 (直近10件を自動管理) + Future addGpsHistory(String customerId, double latitude, double longitude) async { + final db = await _dbHelper.database; + final now = DateTime.now().toIso8601String(); + + await db.transaction((txn) async { + // 履歴を追加 + await txn.insert('customer_gps_history', { + 'customer_id': customerId, + 'latitude': latitude, + 'longitude': longitude, + 'timestamp': now, + }); + + // 10件を超えた古い履歴を削除 + await txn.execute(''' + DELETE FROM customer_gps_history + WHERE id IN ( + SELECT id FROM customer_gps_history + WHERE customer_id = ? + ORDER BY timestamp DESC + LIMIT -1 OFFSET 10 + ) + ''', [customerId]); + }); + } + + Future>> getGpsHistory(String customerId) async { + final db = await _dbHelper.database; + return await db.query( + 'customer_gps_history', + where: 'customer_id = ?', + whereArgs: [customerId], + orderBy: 'timestamp DESC', + ); + } +} diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart new file mode 100644 index 0000000..6cee31c --- /dev/null +++ b/lib/services/database_helper.dart @@ -0,0 +1,94 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; + +class DatabaseHelper { + static final DatabaseHelper _instance = DatabaseHelper._internal(); + static Database? _database; + + factory DatabaseHelper() => _instance; + + DatabaseHelper._internal(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDatabase(); + return _database!; + } + + Future _initDatabase() async { + String path = join(await getDatabasesPath(), 'gemi_invoice.db'); + return await openDatabase( + path, + version: 1, + onCreate: _onCreate, + ); + } + + Future _onCreate(Database db, int version) async { + // 顧客マスター + await db.execute(''' + CREATE TABLE customers ( + id TEXT PRIMARY KEY, + display_name TEXT NOT NULL, + formal_name TEXT NOT NULL, + title TEXT DEFAULT '様', + department TEXT, + address TEXT, + tel TEXT, + odoo_id TEXT, + is_synced INTEGER DEFAULT 0, + updated_at TEXT NOT NULL + ) + '''); + + // GPS履歴 (直近10件想定だがDB上は保持) + await db.execute(''' + CREATE TABLE customer_gps_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + customer_id TEXT NOT NULL, + latitude REAL NOT NULL, + longitude REAL NOT NULL, + timestamp TEXT NOT NULL, + FOREIGN KEY (customer_id) REFERENCES customers (id) ON DELETE CASCADE + ) + '''); + + // 商品マスター + await db.execute(''' + CREATE TABLE products ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + default_unit_price INTEGER, + odoo_id TEXT + ) + '''); + + // 伝票マスター + await db.execute(''' + CREATE TABLE invoices ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL, + date TEXT NOT NULL, + notes TEXT, + file_path TEXT, + total_amount INTEGER, + odoo_id TEXT, + is_synced INTEGER DEFAULT 0, + updated_at TEXT NOT NULL, + FOREIGN KEY (customer_id) REFERENCES customers (id) + ) + '''); + + // 伝票明細 + await db.execute(''' + CREATE TABLE invoice_items ( + id TEXT PRIMARY KEY, + invoice_id TEXT NOT NULL, + description TEXT NOT NULL, + quantity INTEGER NOT NULL, + unit_price INTEGER NOT NULL, + FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE + ) + '''); + } +} diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index 1c84d5c..a8b369c 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -1,15 +1,101 @@ import 'dart:io'; -import 'package:flutter/foundation.dart'; +import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; import '../models/invoice_models.dart'; +import '../models/customer_model.dart'; +import 'database_helper.dart'; class InvoiceRepository { - // 注: 本来は SQLite (sqflite) を使用しますが、現時点ではリファクタリングを優先し、 - // スタブ実装、または簡単な保存ロジックを提供します。 - + final DatabaseHelper _dbHelper = DatabaseHelper(); + Future saveInvoice(Invoice invoice) async { - debugPrint("Saving invoice: ${invoice.invoiceNumber} for ${invoice.customer.formalName}"); - // TODO: ここに SQLite への保存処理を実装予定 + final db = await _dbHelper.database; + + await db.transaction((txn) async { + // 伝票ヘッダーの保存 + await txn.insert( + 'invoices', + invoice.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + + // 既存の明細を一旦削除(更新対応) + await txn.delete( + 'invoice_items', + where: 'invoice_id = ?', + whereArgs: [invoice.id], + ); + + // 新しい明細の保存 + for (var item in invoice.items) { + await txn.insert('invoice_items', item.toMap(invoice.id)); + } + }); + } + + Future> getAllInvoices(List customers) async { + final db = await _dbHelper.database; + final List> invoiceMaps = await db.query('invoices', orderBy: 'date DESC'); + + List invoices = []; + for (var iMap in invoiceMaps) { + final customer = customers.firstWhere( + (c) => c.id == iMap['customer_id'], + orElse: () => Customer(id: iMap['customer_id'], displayName: "不明", formalName: "不明"), + ); + + final List> itemMaps = await db.query( + 'invoice_items', + where: 'invoice_id = ?', + whereArgs: [iMap['id']], + ); + + final items = List.generate(itemMaps.length, (i) => InvoiceItem.fromMap(itemMaps[i])); + + invoices.add(Invoice( + id: iMap['id'], + customer: customer, + date: DateTime.parse(iMap['date']), + items: items, + notes: iMap['notes'], + filePath: iMap['file_path'], + odooId: iMap['odoo_id'], + isSynced: iMap['is_synced'] == 1, + updatedAt: DateTime.parse(iMap['updated_at']), + )); + } + return invoices; + } + + Future deleteInvoice(String id) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + // PDFパスの取得(削除用) + final List> maps = await txn.query( + 'invoices', + columns: ['file_path'], + where: 'id = ?', + whereArgs: [id], + ); + + if (maps.isNotEmpty && maps.first['file_path'] != null) { + final file = File(maps.first['file_path']); + if (await file.exists()) { + await file.delete(); + } + } + + await txn.delete( + 'invoice_items', + where: 'invoice_id = ?', + whereArgs: [id], + ); + await txn.delete( + 'invoices', + where: 'id = ?', + whereArgs: [id], + ); + }); } Future cleanupOrphanedPdfs() async { @@ -17,13 +103,20 @@ class InvoiceRepository { final directory = await getExternalStorageDirectory(); if (directory == null) return 0; - final List files = directory.listSync(); + final files = directory.listSync().whereType().toList(); + final db = await _dbHelper.database; + final List> results = await db.query('invoices', columns: ['file_path']); + final activePaths = results.map((r) => r['file_path'] as String?).where((p) => p != null).toSet(); + int count = 0; - // シンプルなクリーンアップロジック(例:古いファイルを消すなど、必要に応じて実装) - // 現時点ではスタブとして 0 を返します。 + for (var file in files) { + if (file.path.endsWith('.pdf') && !activePaths.contains(file.path)) { + await file.delete(); + count++; + } + } return count; } catch (e) { - debugPrint("Cleanup error: $e"); return 0; } } diff --git a/lib/services/location_service.dart b/lib/services/location_service.dart new file mode 100644 index 0000000..cebe2a3 --- /dev/null +++ b/lib/services/location_service.dart @@ -0,0 +1,37 @@ +import 'package:geolocator/geolocator.dart'; + +class LocationService { + Future getCurrentLocation() async { + bool serviceEnabled; + LocationPermission permission; + + serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return null; + } + + permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return null; + } + } + + if (permission == LocationPermission.deniedForever) { + return null; + } + + try { + const locationSettings = LocationSettings( + accuracy: LocationAccuracy.high, + timeLimit: Duration(seconds: 5), + ); + return await Geolocator.getCurrentPosition( + locationSettings: locationSettings, + ); + } catch (e) { + return null; + } + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8ebfff7..7e63982 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,11 +5,13 @@ import FlutterMacOS import Foundation +import geolocator_apple import share_plus import sqflite_darwin import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) 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 274efa0..1128ea6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -160,6 +160,54 @@ packages: description: flutter source: sdk version: "0.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: @@ -646,7 +694,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 diff --git a/pubspec.yaml b/pubspec.yaml index 3cee66e..858bea8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,8 @@ dependencies: open_filex: ^4.7.0 sqflite: ^2.3.0 path: ^1.8.3 + geolocator: ^13.0.1 + uuid: ^4.5.1 dev_dependencies: flutter_test: diff --git a/目標.md b/目標.md index aa1a509..caec3a6 100644 --- a/目標.md +++ b/目標.md @@ -1,2 +1,25 @@ -サンプルを編集できる状態ですが -顧客マスターを実装します +### サンプルを編集できる状態ですが +- 顧客マスターを実装します +- 伝票マスターを実装します + - 将来odooと同期する時に配慮 + - 完全にスタンドアローンで稼働するのを強く意識する + - GoogleDrive等にDBをバックアップする可能性 + - 伝票マスター一覧画面を実装する + − 起動したら伝票マスター一覧がトップになる様にする + - 伝票マスターを編集する画面を実装する + - 伝票マスターを新規作成する画面を実装する + - 伝票マスターを削除する画面を実装する + - 伝票マスターを検索する画面を実装する + - 伝票マスターをソートする画面を実装する + - 伝票マスターをフィルタリングする画面を実装する + - 伝票マスターをインポートする画面を実装する + - 伝票マスターをエクスポートする画面を実装する + - 伝票マスターをバックアップする画面を実装する + - 伝票マスターをリストアする画面を実装する + - 伝票マスターを同期する画面を実装する + - 伝票マスターは保護しなければならないのでアンロックする仕組みを作る + - ロックは鍵のマークを横に大きくスワイプして解除したい + - 商品マスター管理画面の実装 + - 伝票入力画面の実装 + - 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装 +