diff --git a/README.md b/README.md index 1c2a7b2..0449d79 100644 --- a/README.md +++ b/README.md @@ -84,13 +84,17 @@ ```bash flutter pub get ``` -2. 90 日寿命 APK の生成 +2. 90 日寿命 APK の生成(`scripts/build_with_expiry.sh [mode] [flavor]`) ```bash chmod +x scripts/build_with_expiry.sh - ./scripts/build_with_expiry.sh [debug|profile|release] + # 例: debug×client フレーバー(販売アシスト1号) + ./scripts/build_with_expiry.sh debug client + + # 例: release×mothership フレーバー(お局様) + ./scripts/build_with_expiry.sh release mothership ``` - - スクリプト内で `APP_BUILD_TIMESTAMP` を UTC で自動付与 - - `flutter analyze` → `flutter build apk` を連続実行 + - `APP_BUILD_TIMESTAMP` を UTC で自動付与し、`ENABLE_DEBUG_FEATURES=true` で全機能を有効化 + - `flutter analyze` → `flutter build apk --flavor ... --dart-define=...` を連続実行 3. 実機/エミュレータで起動すると、寿命切れ時には `ExpiredApp` が自動表示されます。 ### 機能フラグ(モジュール) @@ -99,12 +103,15 @@ | Flag | 既定値 | 説明 | | --- | --- | --- | +| `ENABLE_DEBUG_FEATURES` | `false` | **マスター・スイッチ**。`true` にすると以下の各機能フラグ/Debug Webhook がすべて強制的に ON になる | | `ENABLE_BILLING_DOCS` | `true` | 伝票作成/履歴モジュール(A1/A2)の表示を制御 | | `ENABLE_SALES_MANAGEMENT` | `false` | 売上管理モジュール(年間カード・トップ顧客・月次サマリー)を有効化 | +| `ENABLE_SALES_OPERATIONS` | `false` | 受注/出荷/在庫など販売オペレーションモジュールを有効化 | +| `ENABLE_PURCHASE_MANAGEMENT` | `false` | 仕入伝票・支払管理(P1〜P4)モジュールを有効化 | | `ENABLE_DEBUG_WEBHOOK` | `false` | MatterMost Webhook へノード情報/日時の debug log を送信 | | `DEBUG_WEBHOOK_URL` | `https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze` | debug 送信先を上書きしたい場合に指定 | -例: 売上管理と debug ログ送信を同時に試す場合 +例1: 売上管理と debug ログ送信を同時に試す場合 ```bash flutter run \ @@ -115,6 +122,38 @@ flutter run \ `ENABLE_DEBUG_WEBHOOK=false`(既定値)に戻すと MatterMost への送信は行われません。フラグが有効なモジュールは `ModuleRegistry` 経由でダッシュボードカードに自動注入され、debug フラグはアプリ起動時の ping 送信のみを制御します。 +例2: すべての機能を一括で有効化したい場合(`ENABLE_DEBUG_FEATURES=true` を指定するだけで OK) + +```bash +flutter run \ + --dart-define=ENABLE_DEBUG_FEATURES=true +``` + +個別フラグを false のまま渡しても、このマスター・スイッチが ON の間は `AppConfig` 内で強制的に true として扱われます。 + +### Android ビルドフレーバー(Play 想定の二本立て) + +`android/app/build.gradle.kts` に `client` / `mothership` の 2 フレーバーを定義しました。これにより将来 Google Play で「販売アシスト1号」と「お局様」を別 APK として配布しやすくなります。 + +| Flavor | applicationId | `appName` (manifest placeholder) | 用途 | +| --- | --- | --- | --- | +| `client` | `com.example.assist1` | `販売アシスト1号` | 現場端末向けクライアントアプリ(既存機能) | +| `mothership` | `com.example.mothership` | `お局様` | 将来の母艦/監視用アプリ(まだ UI 未実装) | + +起動コマンド例: + +```bash +# 販売アシスト1号(既定) +flutter run --flavor client + +# お局様フレーバーを実行(まだ UI は同じだがパッケージ名とラベルが分離) +flutter run --flavor mothership +``` + +> メモ: 実機配布前に `applicationId` を本番用ドメインへ変更し、Play Console の keystore / サイン設定に合わせて `release` ビルドタイプの signingConfig を更新してください。 + +この構成により、販売アシスト1号・お局様それぞれを別ストアリスティングで公開、あるいはお局様だけ別配布チャネルで提供するといった運用が可能になります。 + ### 画面IDとナビゲーション指針 最新の UI アップデートにより、画面遷移ルールと画面タイトルの表記を統一しました。 diff --git a/android/.kotlin/sessions/kotlin-compiler-7097256903122497490.salive b/android/.kotlin/sessions/kotlin-compiler-7097256903122497490.salive new file mode 100644 index 0000000..e69de29 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 1a811b0..c5fbd86 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,8 +20,6 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.h_1" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion @@ -30,6 +28,20 @@ android { versionName = flutter.versionName } + flavorDimensions += listOf("distribution") + productFlavors { + create("client") { + dimension = "distribution" + applicationId = "com.example.assist1" + manifestPlaceholders["appName"] = "販売アシスト1号" + } + create("mothership") { + dimension = "distribution" + applicationId = "com.example.mothership" + manifestPlaceholders["appName"] = "お局様" + } + } + buildTypes { release { // TODO: Add your own signing config for the release build. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d9efe17..9e30df5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -35,7 +35,7 @@ diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..0f19d32 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + 販売アシスト1号 + diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 2f904da..664be03 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -6,15 +6,23 @@ class AppConfig { static const String version = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0'); /// 機能フラグ(ビルド時に --dart-define で上書き可能)。 - static const bool enableBillingDocs = bool.fromEnvironment('ENABLE_BILLING_DOCS', defaultValue: true); - static const bool enableSalesManagement = bool.fromEnvironment('ENABLE_SALES_MANAGEMENT', defaultValue: false); - static const bool enableSalesOperations = bool.fromEnvironment('ENABLE_SALES_OPERATIONS', defaultValue: false); - static const bool enableDebugWebhookLogging = bool.fromEnvironment('ENABLE_DEBUG_WEBHOOK', defaultValue: false); + static const bool _enableDebugFeatures = bool.fromEnvironment('ENABLE_DEBUG_FEATURES', defaultValue: false); + static const bool _enableBillingDocsFlag = bool.fromEnvironment('ENABLE_BILLING_DOCS', defaultValue: true); + static const bool _enableSalesManagementFlag = bool.fromEnvironment('ENABLE_SALES_MANAGEMENT', defaultValue: false); + static const bool _enableSalesOperationsFlag = bool.fromEnvironment('ENABLE_SALES_OPERATIONS', defaultValue: false); + static const bool _enablePurchaseManagementFlag = bool.fromEnvironment('ENABLE_PURCHASE_MANAGEMENT', defaultValue: false); + static const bool _enableDebugWebhookLoggingFlag = bool.fromEnvironment('ENABLE_DEBUG_WEBHOOK', defaultValue: false); static const String debugWebhookUrl = String.fromEnvironment( 'DEBUG_WEBHOOK_URL', defaultValue: 'https://mm.ka.sugeee.com/hooks/x6nxx8q35jdkuetbmh89ogt5ze', ); + static bool get enableBillingDocs => _enableDebugFeatures || _enableBillingDocsFlag; + static bool get enableSalesManagement => _enableDebugFeatures || _enableSalesManagementFlag; + static bool get enableSalesOperations => _enableDebugFeatures || _enableSalesOperationsFlag; + static bool get enablePurchaseManagement => _enableDebugFeatures || _enablePurchaseManagementFlag; + static bool get enableDebugWebhookLogging => _enableDebugFeatures || _enableDebugWebhookLoggingFlag; + /// APIエンドポイント(必要に応じて dart-define で注入)。 static const String apiEndpoint = String.fromEnvironment('API_ENDPOINT', defaultValue: ''); @@ -23,6 +31,7 @@ class AppConfig { 'enableBillingDocs': enableBillingDocs, 'enableSalesManagement': enableSalesManagement, 'enableSalesOperations': enableSalesOperations, + 'enablePurchaseManagement': enablePurchaseManagement, 'enableDebugWebhookLogging': enableDebugWebhookLogging, }; @@ -41,6 +50,9 @@ class AppConfig { if (enableSalesOperations) { routes.addAll({'sales_orders', 'shipments', 'inventory', 'receivables'}); } + if (enablePurchaseManagement) { + routes.addAll({'purchase_entries', 'purchase_receipts'}); + } return routes; } } diff --git a/lib/models/purchase_entry_models.dart b/lib/models/purchase_entry_models.dart new file mode 100644 index 0000000..17ba902 --- /dev/null +++ b/lib/models/purchase_entry_models.dart @@ -0,0 +1,288 @@ +import 'package:flutter/foundation.dart'; + +enum PurchaseEntryStatus { draft, confirmed, settled } + +extension PurchaseEntryStatusDisplay on PurchaseEntryStatus { + String get displayName { + switch (this) { + case PurchaseEntryStatus.draft: + return '下書き'; + case PurchaseEntryStatus.confirmed: + return '確定'; + case PurchaseEntryStatus.settled: + return '支払済み'; + } + } +} + +@immutable +class PurchaseLineItem { + const PurchaseLineItem({ + required this.id, + required this.purchaseEntryId, + required this.description, + required this.quantity, + required this.unitPrice, + required this.lineTotal, + this.productId, + this.taxRate = 0, + }); + + final String id; + final String purchaseEntryId; + final String description; + final int quantity; + final int unitPrice; + final int lineTotal; + final String? productId; + final double taxRate; + + Map toMap() => { + 'id': id, + 'purchase_entry_id': purchaseEntryId, + 'product_id': productId, + 'description': description, + 'quantity': quantity, + 'unit_price': unitPrice, + 'tax_rate': taxRate, + 'line_total': lineTotal, + }; + + factory PurchaseLineItem.fromMap(Map map) => PurchaseLineItem( + id: map['id'] as String, + purchaseEntryId: map['purchase_entry_id'] as String, + productId: map['product_id'] as String?, + description: map['description'] as String, + quantity: map['quantity'] as int? ?? 0, + unitPrice: map['unit_price'] as int? ?? 0, + taxRate: (map['tax_rate'] as num?)?.toDouble() ?? 0, + lineTotal: map['line_total'] as int? ?? 0, + ); + + PurchaseLineItem copyWith({ + String? id, + String? purchaseEntryId, + String? description, + int? quantity, + int? unitPrice, + int? lineTotal, + String? productId, + double? taxRate, + }) { + return PurchaseLineItem( + id: id ?? this.id, + purchaseEntryId: purchaseEntryId ?? this.purchaseEntryId, + description: description ?? this.description, + quantity: quantity ?? this.quantity, + unitPrice: unitPrice ?? this.unitPrice, + lineTotal: lineTotal ?? this.lineTotal, + productId: productId ?? this.productId, + taxRate: taxRate ?? this.taxRate, + ); + } +} + +@immutable +class PurchaseEntry { + const PurchaseEntry({ + required this.id, + required this.issueDate, + required this.status, + required this.createdAt, + required this.updatedAt, + this.supplierId, + this.supplierNameSnapshot, + this.subject, + this.amountTaxExcl = 0, + this.taxAmount = 0, + this.amountTaxIncl = 0, + this.notes, + this.items = const [], + }); + + final String id; + final String? supplierId; + final String? supplierNameSnapshot; + final String? subject; + final DateTime issueDate; + final PurchaseEntryStatus status; + final int amountTaxExcl; + final int taxAmount; + final int amountTaxIncl; + final String? notes; + final DateTime createdAt; + final DateTime updatedAt; + final List items; + + PurchaseEntry copyWith({ + String? id, + String? supplierId, + String? supplierNameSnapshot, + String? subject, + DateTime? issueDate, + PurchaseEntryStatus? status, + int? amountTaxExcl, + int? taxAmount, + int? amountTaxIncl, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + List? items, + }) { + return PurchaseEntry( + id: id ?? this.id, + supplierId: supplierId ?? this.supplierId, + supplierNameSnapshot: supplierNameSnapshot ?? this.supplierNameSnapshot, + subject: subject ?? this.subject, + issueDate: issueDate ?? this.issueDate, + status: status ?? this.status, + amountTaxExcl: amountTaxExcl ?? this.amountTaxExcl, + taxAmount: taxAmount ?? this.taxAmount, + amountTaxIncl: amountTaxIncl ?? this.amountTaxIncl, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + items: items ?? this.items, + ); + } + + Map toMap() => { + 'id': id, + 'supplier_id': supplierId, + 'supplier_name_snapshot': supplierNameSnapshot, + 'subject': subject, + 'issue_date': issueDate.toIso8601String(), + 'status': status.name, + 'amount_tax_excl': amountTaxExcl, + 'tax_amount': taxAmount, + 'amount_tax_incl': amountTaxIncl, + 'notes': notes, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory PurchaseEntry.fromMap(Map map, {List items = const []}) => PurchaseEntry( + id: map['id'] as String, + supplierId: map['supplier_id'] as String?, + supplierNameSnapshot: map['supplier_name_snapshot'] as String?, + subject: map['subject'] as String?, + issueDate: DateTime.parse(map['issue_date'] as String), + status: PurchaseEntryStatus.values.firstWhere( + (s) => s.name == map['status'], + orElse: () => PurchaseEntryStatus.draft, + ), + amountTaxExcl: map['amount_tax_excl'] as int? ?? 0, + taxAmount: map['tax_amount'] as int? ?? 0, + amountTaxIncl: map['amount_tax_incl'] as int? ?? 0, + notes: map['notes'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + items: items, + ); + + PurchaseEntry recalcTotals() { + final subtotal = items.fold(0, (sum, item) => sum + item.lineTotal); + final tax = items.fold(0, (sum, item) => sum + item.lineTotal * item.taxRate).round(); + return copyWith( + amountTaxExcl: subtotal, + taxAmount: tax, + amountTaxIncl: subtotal + tax, + ); + } +} + +@immutable +class PurchaseReceiptAllocationInput { + const PurchaseReceiptAllocationInput({required this.purchaseEntryId, required this.amount}); + + final String purchaseEntryId; + final int amount; +} + +@immutable +class PurchaseReceipt { + const PurchaseReceipt({ + required this.id, + required this.paymentDate, + required this.amount, + required this.createdAt, + required this.updatedAt, + this.supplierId, + this.method, + this.notes, + }); + + final String id; + final String? supplierId; + final DateTime paymentDate; + final String? method; + final int amount; + final String? notes; + final DateTime createdAt; + final DateTime updatedAt; + + Map toMap() => { + 'id': id, + 'supplier_id': supplierId, + 'payment_date': paymentDate.toIso8601String(), + 'method': method, + 'amount': amount, + 'notes': notes, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory PurchaseReceipt.fromMap(Map map) => PurchaseReceipt( + id: map['id'] as String, + supplierId: map['supplier_id'] as String?, + paymentDate: DateTime.parse(map['payment_date'] as String), + method: map['method'] as String?, + amount: map['amount'] as int? ?? 0, + notes: map['notes'] as String?, + createdAt: DateTime.parse(map['created_at'] as String), + updatedAt: DateTime.parse(map['updated_at'] as String), + ); + + PurchaseReceipt copyWith({ + String? id, + String? supplierId, + DateTime? paymentDate, + String? method, + int? amount, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return PurchaseReceipt( + id: id ?? this.id, + supplierId: supplierId ?? this.supplierId, + paymentDate: paymentDate ?? this.paymentDate, + method: method ?? this.method, + amount: amount ?? this.amount, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } +} + +@immutable +class PurchaseReceiptLink { + const PurchaseReceiptLink({required this.receiptId, required this.purchaseEntryId, required this.allocatedAmount}); + + final String receiptId; + final String purchaseEntryId; + final int allocatedAmount; + + Map toMap() => { + 'receipt_id': receiptId, + 'purchase_entry_id': purchaseEntryId, + 'allocated_amount': allocatedAmount, + }; + + factory PurchaseReceiptLink.fromMap(Map map) => PurchaseReceiptLink( + receiptId: map['receipt_id'] as String, + purchaseEntryId: map['purchase_entry_id'] as String, + allocatedAmount: map['allocated_amount'] as int? ?? 0, + ); +} diff --git a/lib/models/sales_entry_models.dart b/lib/models/sales_entry_models.dart index f606fcb..e6e7e70 100644 --- a/lib/models/sales_entry_models.dart +++ b/lib/models/sales_entry_models.dart @@ -2,8 +2,39 @@ import 'package:meta/meta.dart'; import '../models/invoice_models.dart'; +const _unset = Object(); + enum SalesEntryStatus { draft, confirmed, settled } +enum SettlementMethod { cash, bankTransfer, card, accountsReceivable, other } + +extension SettlementMethodX on SettlementMethod { + String get displayName { + switch (this) { + case SettlementMethod.cash: + return '現金'; + case SettlementMethod.bankTransfer: + return '銀行振込'; + case SettlementMethod.card: + return 'カード'; + case SettlementMethod.accountsReceivable: + return '売掛'; + case SettlementMethod.other: + return 'その他'; + } + } +} + +SettlementMethod? settlementMethodFromName(String? value) { + if (value == null) return null; + for (final method in SettlementMethod.values) { + if (method.name == value) { + return method; + } + } + return null; +} + @immutable class SalesInvoiceImportData { const SalesInvoiceImportData({ @@ -160,6 +191,9 @@ class SalesEntry { this.taxAmount = 0, this.amountTaxIncl = 0, this.notes, + this.settlementMethod, + this.settlementCardCompany, + this.settlementDueDate, this.items = const [], }); @@ -173,6 +207,9 @@ class SalesEntry { final int taxAmount; final int amountTaxIncl; final String? notes; + final SettlementMethod? settlementMethod; + final String? settlementCardCompany; + final DateTime? settlementDueDate; final DateTime createdAt; final DateTime updatedAt; final List items; @@ -191,6 +228,9 @@ class SalesEntry { DateTime? createdAt, DateTime? updatedAt, List? items, + Object? settlementMethod = _unset, + Object? settlementCardCompany = _unset, + Object? settlementDueDate = _unset, }) { return SalesEntry( id: id ?? this.id, @@ -203,6 +243,9 @@ class SalesEntry { taxAmount: taxAmount ?? this.taxAmount, amountTaxIncl: amountTaxIncl ?? this.amountTaxIncl, notes: notes ?? this.notes, + settlementMethod: settlementMethod == _unset ? this.settlementMethod : settlementMethod as SettlementMethod?, + settlementCardCompany: settlementCardCompany == _unset ? this.settlementCardCompany : settlementCardCompany as String?, + settlementDueDate: settlementDueDate == _unset ? this.settlementDueDate : settlementDueDate as DateTime?, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, items: items ?? this.items, @@ -220,6 +263,9 @@ class SalesEntry { 'tax_amount': taxAmount, 'amount_tax_incl': amountTaxIncl, 'notes': notes, + 'settlement_method': settlementMethod?.name, + 'settlement_card_company': settlementCardCompany, + 'settlement_due_date': settlementDueDate?.toIso8601String(), 'created_at': createdAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(), }; @@ -238,6 +284,11 @@ class SalesEntry { taxAmount: map['tax_amount'] as int? ?? 0, amountTaxIncl: map['amount_tax_incl'] as int? ?? 0, notes: map['notes'] as String?, + settlementMethod: settlementMethodFromName(map['settlement_method'] as String?), + settlementCardCompany: map['settlement_card_company'] as String?, + settlementDueDate: map['settlement_due_date'] != null && (map['settlement_due_date'] as String).isNotEmpty + ? DateTime.parse(map['settlement_due_date'] as String) + : null, createdAt: DateTime.parse(map['created_at'] as String), updatedAt: DateTime.parse(map['updated_at'] as String), items: items, diff --git a/lib/modules/module_registry.dart b/lib/modules/module_registry.dart index 4e31df5..f982325 100644 --- a/lib/modules/module_registry.dart +++ b/lib/modules/module_registry.dart @@ -1,5 +1,6 @@ import 'billing_docs_module.dart'; import 'feature_module.dart'; +import 'purchase_management_module.dart'; import 'sales_management_module.dart'; import 'sales_operations_module.dart'; @@ -12,6 +13,7 @@ class ModuleRegistry { BillingDocsModule(), SalesManagementModule(), SalesOperationsModule(), + PurchaseManagementModule(), ]; Iterable get modules => _modules; diff --git a/lib/modules/purchase_management_module.dart b/lib/modules/purchase_management_module.dart new file mode 100644 index 0000000..60e4547 --- /dev/null +++ b/lib/modules/purchase_management_module.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../config/app_config.dart'; +import '../modules/feature_module.dart'; +import '../screens/purchase_entries_screen.dart'; +import '../screens/purchase_receipts_screen.dart'; + +class PurchaseManagementModule extends FeatureModule { + PurchaseManagementModule(); + + @override + String get key => 'purchase_management'; + + @override + bool get isEnabled => AppConfig.enablePurchaseManagement; + + @override + List get dashboardCards => [ + ModuleDashboardCard( + id: 'purchase_entries', + route: 'purchase_entries', + title: '仕入伝票', + description: 'P1/P2:仕入伝票の一覧と編集', + iconName: 'shopping_cart', + onTap: (context) async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseEntriesScreen())); + }, + ), + ModuleDashboardCard( + id: 'purchase_receipts', + route: 'purchase_receipts', + title: '支払管理', + description: 'P3/P4:支払登録と割当', + iconName: 'payments', + onTap: (context) async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const PurchaseReceiptsScreen())); + }, + ), + ]; +} diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index 7459980..72e864f 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -5,6 +5,7 @@ import '../models/customer_model.dart'; import '../services/customer_repository.dart'; import '../widgets/keyboard_inset_wrapper.dart'; import '../widgets/contact_picker_sheet.dart'; +import '../widgets/screen_id_title.dart'; /// 顧客マスターからの選択、登録、編集、削除を行うモーダル class CustomerPickerModal extends StatefulWidget { @@ -306,17 +307,15 @@ class _CustomerPickerModalState extends State { ), ); - return SafeArea( - child: Scaffold( - appBar: AppBar( - leading: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - title: const Text('U2:取引先選択'), + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), ), - body: body, + title: const ScreenAppBarTitle(screenId: 'UC', title: '顧客選択'), ), + body: SafeArea(child: body), ); } } diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index a799089..826cb04 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -149,6 +149,23 @@ class _InvoiceHistoryScreenState extends State { ); } + Widget? _buildLeading(BuildContext context) { + final canPop = Navigator.canPop(context); + final hasDrawer = !_useDashboardHome && _isUnlocked; + if (!canPop && hasDrawer) { + return Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.menu), + onPressed: () => Scaffold.of(ctx).openDrawer(), + ), + ); + } + if (canPop) { + return const BackButton(); + } + return null; + } + bool _requireUnlock() { if (_isUnlocked) return true; ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("スライドでロック解除してください"))); @@ -213,9 +230,10 @@ class _InvoiceHistoryScreenState extends State { Widget build(BuildContext context) { final amountFormatter = NumberFormat("#,###"); final dateFormatter = DateFormat('yyyy/MM/dd'); + final hasDrawer = !_useDashboardHome && _isUnlocked; return Scaffold( resizeToAvoidBottomInset: false, - drawer: (_useDashboardHome || !_isUnlocked) + drawer: (!hasDrawer) ? null : Drawer( child: SafeArea( @@ -288,14 +306,7 @@ class _InvoiceHistoryScreenState extends State { ), appBar: AppBar( automaticallyImplyLeading: false, - leading: _useDashboardHome - ? Builder( - builder: (ctx) => IconButton( - icon: const Icon(Icons.menu), - onPressed: () => Scaffold.of(ctx).openDrawer(), - ), - ) - : const BackButton(), + leading: _buildLeading(context), title: GestureDetector( onLongPress: () { Navigator.push( diff --git a/lib/screens/purchase_entries_screen.dart b/lib/screens/purchase_entries_screen.dart new file mode 100644 index 0000000..b2697cc --- /dev/null +++ b/lib/screens/purchase_entries_screen.dart @@ -0,0 +1,432 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/purchase_entry_models.dart'; +import '../models/supplier_model.dart'; +import '../services/purchase_entry_service.dart'; +import '../widgets/line_item_editor.dart'; +import '../widgets/screen_id_title.dart'; +import 'product_picker_modal.dart'; +import 'supplier_picker_modal.dart'; + +class PurchaseEntriesScreen extends StatefulWidget { + const PurchaseEntriesScreen({super.key}); + + @override + State createState() => _PurchaseEntriesScreenState(); +} + +class _PurchaseEntriesScreenState extends State { + final PurchaseEntryService _service = PurchaseEntryService(); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + bool _isLoading = true; + bool _isRefreshing = false; + PurchaseEntryStatus? _filterStatus; + List _entries = const []; + + @override + void initState() { + super.initState(); + _loadEntries(); + } + + Future _loadEntries() async { + if (!_isRefreshing) { + setState(() => _isLoading = true); + } + try { + final entries = await _service.fetchEntries(status: _filterStatus); + if (!mounted) return; + setState(() { + _entries = entries; + _isLoading = false; + _isRefreshing = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _isRefreshing = false; + }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('仕入伝票の取得に失敗しました: $e'))); + } + } + + void _setFilter(PurchaseEntryStatus? status) { + setState(() => _filterStatus = status); + _loadEntries(); + } + + Future _handleRefresh() async { + setState(() => _isRefreshing = true); + await _loadEntries(); + } + + Future _openEditor({PurchaseEntry? entry}) async { + final saved = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => PurchaseEntryEditorPage(entry: entry)), + ); + if (saved != null) { + await _loadEntries(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入伝票を保存しました'))); + } + } + + Future _deleteEntry(PurchaseEntry entry) async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('仕入伝票を削除'), + content: Text('${entry.subject ?? '無題'} を削除しますか?'), + 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 (confirmed != true) return; + await _service.deleteEntry(entry.id); + if (!mounted) return; + await _loadEntries(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入伝票を削除しました'))); + } + + @override + Widget build(BuildContext context) { + final body = _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _handleRefresh, + child: _entries.isEmpty + ? ListView( + children: const [ + SizedBox(height: 140), + Icon(Icons.receipt_long, size: 64, color: Colors.grey), + SizedBox(height: 12), + Center(child: Text('仕入伝票がありません。右下のボタンから登録してください。')), + ], + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), + itemCount: _entries.length, + itemBuilder: (context, index) => _buildEntryCard(_entries[index]), + ), + ); + + return Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + leading: const BackButton(), + title: const ScreenAppBarTitle(screenId: 'P1', title: '仕入伝票一覧'), + actions: [ + PopupMenuButton( + icon: const Icon(Icons.filter_alt), + onSelected: _setFilter, + itemBuilder: (context) => [ + const PopupMenuItem(value: null, child: Text('すべて')), + ...PurchaseEntryStatus.values.map((status) => PopupMenuItem( + value: status, + child: Text(status.displayName), + )), + ], + ), + ], + ), + body: body, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openEditor(), + icon: const Icon(Icons.add), + label: const Text('仕入伝票を登録'), + ), + ); + } + + Widget _buildEntryCard(PurchaseEntry entry) { + return Card( + color: Colors.white, + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + onTap: () => _openEditor(entry: entry), + onLongPress: () => _deleteEntry(entry), + title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '無題の仕入伝票', + style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(entry.supplierNameSnapshot ?? '仕入先未設定'), + const SizedBox(height: 4), + Text('計上日: ${DateFormat('yyyy/MM/dd').format(entry.issueDate)}'), + ], + ), + ), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(entry.status.displayName, style: const TextStyle(fontSize: 12, color: Colors.black54)), + const SizedBox(height: 4), + Text( + _currencyFormat.format(entry.amountTaxIncl), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + ], + ), + ), + ); + } +} + +class PurchaseEntryEditorPage extends StatefulWidget { + const PurchaseEntryEditorPage({super.key, this.entry}); + + final PurchaseEntry? entry; + + @override + State createState() => _PurchaseEntryEditorPageState(); +} + +class _PurchaseEntryEditorPageState extends State { + final PurchaseEntryService _service = PurchaseEntryService(); + final TextEditingController _subjectController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + final uuid = const Uuid(); + + Supplier? _supplier; + String? _supplierSnapshot; + DateTime _issueDate = DateTime.now(); + bool _isSaving = false; + final List _lines = []; + + @override + void initState() { + super.initState(); + final entry = widget.entry; + if (entry != null) { + _subjectController.text = entry.subject ?? ''; + _notesController.text = entry.notes ?? ''; + _issueDate = entry.issueDate; + _supplierSnapshot = entry.supplierNameSnapshot; + _lines.addAll(entry.items + .map((item) => LineItemFormData( + id: item.id, + productId: item.productId, + productName: item.description, + quantity: item.quantity, + unitPrice: item.unitPrice, + taxRate: item.taxRate, + )) + .toList()); + } + if (_lines.isEmpty) { + _lines.add(LineItemFormData(quantity: 1, unitPrice: 0)); + } + } + + @override + void dispose() { + _subjectController.dispose(); + _notesController.dispose(); + for (final line in _lines) { + line.dispose(); + } + super.dispose(); + } + + Future _pickSupplier() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => SupplierPickerModal( + onSupplierSelected: (supplier) { + Navigator.pop(ctx, supplier); + }, + ), + ).then((selected) { + if (selected == null) return; + setState(() { + _supplier = selected; + _supplierSnapshot = selected.name; + }); + }); + } + + Future _pickIssueDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _issueDate, + firstDate: DateTime(2010), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() => _issueDate = picked); + } + + void _addLine() { + setState(() => _lines.add(LineItemFormData(quantity: 1, unitPrice: 0))); + } + + void _removeLine(int index) { + setState(() { + final removed = _lines.removeAt(index); + removed.dispose(); + if (_lines.isEmpty) { + _lines.add(LineItemFormData(quantity: 1, unitPrice: 0)); + } + }); + } + + Future _pickProduct(int index) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => ProductPickerModal( + onItemSelected: (_) {}, + onProductSelected: (product) { + setState(() => _lines[index].applyProduct(product)); + }, + ), + ); + } + + Future _save() async { + if (_isSaving) return; + if (_lines.every((line) => line.descriptionController.text.trim().isEmpty)) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('少なくとも1件の明細を入力してください'))); + return; + } + + setState(() => _isSaving = true); + try { + final now = DateTime.now(); + final entryId = widget.entry?.id ?? uuid.v4(); + final items = _lines.map((line) { + final quantity = line.quantityValue; + final unitPrice = line.unitPriceValue; + return PurchaseLineItem( + id: line.id ?? uuid.v4(), + purchaseEntryId: entryId, + description: line.description.isEmpty ? '商品' : line.description, + quantity: quantity, + unitPrice: unitPrice, + lineTotal: quantity * unitPrice, + productId: line.productId, + taxRate: line.taxRate ?? 0, + ); + }).toList(); + + final entry = PurchaseEntry( + id: entryId, + supplierId: _supplier?.id ?? widget.entry?.supplierId, + supplierNameSnapshot: _supplierSnapshot, + subject: _subjectController.text.trim().isEmpty ? '仕入伝票' : _subjectController.text.trim(), + issueDate: _issueDate, + status: widget.entry?.status ?? PurchaseEntryStatus.draft, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + createdAt: widget.entry?.createdAt ?? now, + updatedAt: now, + items: items, + ); + + final saved = await _service.saveEntry(entry); + if (!mounted) return; + Navigator.pop(context, saved); + } catch (e) { + if (!mounted) return; + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.grey.shade200, + appBar: AppBar( + leading: const BackButton(), + title: ScreenAppBarTitle( + screenId: 'P2', + title: widget.entry == null ? '仕入伝票作成' : '仕入伝票編集', + ), + actions: [ + TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')), + ], + ), + body: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(16, 16, 16, MediaQuery.of(context).viewInsets.bottom + 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + color: Colors.white, + child: ListTile( + title: Text(_supplierSnapshot ?? '仕入先を選択'), + subtitle: const Text('タップして仕入先を選択'), + trailing: const Icon(Icons.chevron_right), + onTap: _pickSupplier, + ), + ), + const SizedBox(height: 12), + Card( + color: Colors.white, + child: ListTile( + title: const Text('計上日'), + subtitle: Text(DateFormat('yyyy/MM/dd').format(_issueDate)), + trailing: TextButton(onPressed: _pickIssueDate, child: const Text('変更')), + ), + ), + const SizedBox(height: 12), + Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: _subjectController, + decoration: const InputDecoration(labelText: '件名'), + ), + ), + ), + const SizedBox(height: 20), + Text('明細', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ..._lines.asMap().entries.map( + (entry) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: LineItemCard( + data: entry.value, + onPickProduct: () => _pickProduct(entry.key), + onRemove: () => _removeLine(entry.key), + ), + ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon(onPressed: _addLine, icon: const Icon(Icons.add), label: const Text('明細を追加')), + ), + const SizedBox(height: 20), + Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.all(12), + child: TextField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'メモ'), + minLines: 2, + maxLines: 4, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/purchase_receipts_screen.dart b/lib/screens/purchase_receipts_screen.dart new file mode 100644 index 0000000..2f6d097 --- /dev/null +++ b/lib/screens/purchase_receipts_screen.dart @@ -0,0 +1,740 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../models/purchase_entry_models.dart'; +import '../models/supplier_model.dart'; +import '../services/purchase_entry_service.dart'; +import '../services/purchase_receipt_service.dart'; +import '../services/supplier_repository.dart'; +import '../widgets/screen_id_title.dart'; +import 'supplier_picker_modal.dart'; + +class PurchaseReceiptsScreen extends StatefulWidget { + const PurchaseReceiptsScreen({super.key}); + + @override + State createState() => _PurchaseReceiptsScreenState(); +} + +class _PurchaseReceiptsScreenState extends State { + final PurchaseReceiptService _receiptService = PurchaseReceiptService(); + final SupplierRepository _supplierRepository = SupplierRepository(); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + + bool _isLoading = true; + bool _isRefreshing = false; + List _receipts = const []; + Map _receiptAllocations = const {}; + Map _supplierNames = const {}; + DateTime? _startDate; + DateTime? _endDate; + + @override + void initState() { + super.initState(); + _loadReceipts(); + } + + Future _loadReceipts() async { + if (!_isRefreshing) { + setState(() => _isLoading = true); + } + try { + final receipts = await _receiptService.fetchReceipts(startDate: _startDate, endDate: _endDate); + final allocationMap = {}; + for (final receipt in receipts) { + final links = await _receiptService.fetchLinks(receipt.id); + allocationMap[receipt.id] = links.fold(0, (sum, link) => sum + link.allocatedAmount); + } + final supplierIds = receipts.map((r) => r.supplierId).whereType().toSet(); + final supplierNames = Map.from(_supplierNames); + for (final id in supplierIds) { + if (supplierNames.containsKey(id)) continue; + final supplier = await _supplierRepository.fetchSuppliers(includeHidden: true).then( + (list) => list.firstWhere( + (s) => s.id == id, + orElse: () => Supplier(id: id, name: '仕入先不明', updatedAt: DateTime.now()), + ), + ); + supplierNames[id] = supplier.name; + } + if (!mounted) return; + setState(() { + _receipts = receipts; + _receiptAllocations = allocationMap; + _supplierNames = supplierNames; + _isLoading = false; + _isRefreshing = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + _isRefreshing = false; + }); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('支払データの取得に失敗しました: $e'))); + } + } + + Future _handleRefresh() async { + setState(() => _isRefreshing = true); + await _loadReceipts(); + } + + Future _pickDate({required bool isStart}) async { + final initial = isStart ? (_startDate ?? DateTime.now().subtract(const Duration(days: 30))) : (_endDate ?? DateTime.now()); + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() { + if (isStart) { + _startDate = picked; + } else { + _endDate = picked; + } + }); + _loadReceipts(); + } + + void _clearFilters() { + setState(() { + _startDate = null; + _endDate = null; + }); + _loadReceipts(); + } + + Future _openEditor({PurchaseReceipt? receipt}) async { + final updated = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => PurchaseReceiptEditorPage(receipt: receipt)), + ); + if (updated != null) { + await _loadReceipts(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支払データを保存しました'))); + } + } + + Future _confirmDelete(PurchaseReceipt receipt) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('支払を削除'), + content: Text('${_dateFormat.format(receipt.paymentDate)}の${_currencyFormat.format(receipt.amount)}を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('削除')), + ], + ), + ); + if (confirmed != true) return; + try { + await _receiptService.deleteReceipt(receipt.id); + if (!mounted) return; + await _loadReceipts(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支払を削除しました'))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('削除に失敗しました: $e'))); + } + } + + String _supplierLabel(PurchaseReceipt receipt) { + if (receipt.supplierId == null) { + return '仕入先未設定'; + } + return _supplierNames[receipt.supplierId] ?? '仕入先読込中'; + } + + @override + Widget build(BuildContext context) { + final filterLabel = [ + if (_startDate != null) '開始: ${_dateFormat.format(_startDate!)}', + if (_endDate != null) '終了: ${_dateFormat.format(_endDate!)}', + ].join(' / '); + + final body = _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _handleRefresh, + child: _receipts.isEmpty + ? ListView( + children: const [ + SizedBox(height: 140), + Icon(Icons.account_balance_wallet_outlined, size: 64, color: Colors.grey), + SizedBox(height: 12), + Center(child: Text('支払データがありません。右下のボタンから登録してください。')), + ], + ) + : ListView.builder( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 120), + itemCount: _receipts.length, + itemBuilder: (context, index) => _buildReceiptCard(_receipts[index]), + ), + ); + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: const ScreenAppBarTitle(screenId: 'P3', title: '支払管理'), + actions: [ + IconButton( + tooltip: '開始日を選択', + icon: const Icon(Icons.calendar_today), + onPressed: () => _pickDate(isStart: true), + ), + IconButton( + tooltip: '終了日を選択', + icon: const Icon(Icons.event), + onPressed: () => _pickDate(isStart: false), + ), + IconButton( + tooltip: 'フィルターをクリア', + icon: const Icon(Icons.filter_alt_off), + onPressed: (_startDate == null && _endDate == null) ? null : _clearFilters, + ), + const SizedBox(width: 4), + ], + bottom: filterLabel.isEmpty + ? null + : PreferredSize( + preferredSize: const Size.fromHeight(32), + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text(filterLabel, style: const TextStyle(color: Colors.white70)), + ), + ), + ), + body: body, + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openEditor(), + icon: const Icon(Icons.add), + label: const Text('支払を登録'), + ), + ); + } + + Widget _buildReceiptCard(PurchaseReceipt receipt) { + final allocated = _receiptAllocations[receipt.id] ?? 0; + final allocationRatio = receipt.amount == 0 ? 0.0 : allocated / receipt.amount; + final statusColor = allocationRatio >= 0.999 + ? Colors.green + : allocationRatio <= 0 + ? Colors.orange + : Colors.blue; + final supplier = _supplierLabel(receipt); + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + onTap: () => _openEditor(receipt: receipt), + title: Text( + _currencyFormat.format(receipt.amount), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(supplier), + const SizedBox(height: 4), + Text('割当: ${_currencyFormat.format(allocated)} / ${_currencyFormat.format(receipt.amount)}'), + if (receipt.notes?.isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text(receipt.notes!, style: const TextStyle(fontSize: 12, color: Colors.black87)), + ], + ], + ), + ), + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(_dateFormat.format(receipt.paymentDate)), + const SizedBox(height: 4), + Text(receipt.method ?? '未設定', style: const TextStyle(fontSize: 12, color: Colors.black54)), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration(color: statusColor.withAlpha(32), borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Text( + allocationRatio >= 0.999 + ? '全額割当済' + : allocationRatio <= 0 + ? '未割当' + : '一部割当', + style: TextStyle(color: statusColor, fontSize: 12), + ), + ), + ], + ), + isThreeLine: true, + contentPadding: const EdgeInsets.all(16), + tileColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onLongPress: () => _confirmDelete(receipt), + ), + ); + } +} + +class PurchaseReceiptEditorPage extends StatefulWidget { + const PurchaseReceiptEditorPage({super.key, this.receipt}); + + final PurchaseReceipt? receipt; + + @override + State createState() => _PurchaseReceiptEditorPageState(); +} + +class _PurchaseReceiptEditorPageState extends State { + final PurchaseReceiptService _receiptService = PurchaseReceiptService(); + final PurchaseEntryService _entryService = PurchaseEntryService(); + final SupplierRepository _supplierRepository = SupplierRepository(); + final TextEditingController _amountController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + final DateFormat _dateFormat = DateFormat('yyyy/MM/dd'); + final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥'); + + DateTime _paymentDate = DateTime.now(); + String? _supplierId; + String? _supplierName; + String? _method = '銀行振込'; + bool _isSaving = false; + bool _isInitializing = true; + + List<_AllocationRow> _allocations = []; + List _entries = []; + Map _baseAllocated = {}; + + @override + void initState() { + super.initState(); + final receipt = widget.receipt; + if (receipt != null) { + _paymentDate = receipt.paymentDate; + _amountController.text = receipt.amount.toString(); + _notesController.text = receipt.notes ?? ''; + _method = receipt.method ?? '銀行振込'; + _supplierId = receipt.supplierId; + if (_supplierId != null) { + _loadSupplierName(_supplierId!); + } + } else { + _amountController.text = ''; + } + _amountController.addListener(() => setState(() {})); + _loadData(); + } + + @override + void dispose() { + _amountController.dispose(); + _notesController.dispose(); + for (final row in _allocations) { + row.dispose(); + } + super.dispose(); + } + + Future _loadSupplierName(String supplierId) async { + final suppliers = await _supplierRepository.fetchSuppliers(includeHidden: true); + final supplier = suppliers.firstWhere( + (s) => s.id == supplierId, + orElse: () => Supplier(id: supplierId, name: '仕入先不明', updatedAt: DateTime.now()), + ); + if (!mounted) return; + setState(() => _supplierName = supplier.name); + } + + Future _loadData() async { + try { + final entries = await _entryService.fetchEntries(); + final totals = await _receiptService.fetchAllocatedTotals(entries.map((e) => e.id)); + final allocationRows = <_AllocationRow>[]; + if (widget.receipt != null) { + final links = await _receiptService.fetchLinks(widget.receipt!.id); + for (final link in links) { + final current = totals[link.purchaseEntryId] ?? 0; + totals[link.purchaseEntryId] = current - link.allocatedAmount; + var entry = entries.firstWhere( + (e) => e.id == link.purchaseEntryId, + orElse: () => PurchaseEntry( + id: link.purchaseEntryId, + issueDate: DateTime.now(), + status: PurchaseEntryStatus.draft, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + allocationRows.add(_AllocationRow(entry: entry, amount: link.allocatedAmount)); + } + } + if (!mounted) return; + setState(() { + _entries = entries; + _baseAllocated = totals; + _allocations = allocationRows; + _isInitializing = false; + }); + } catch (e) { + if (!mounted) return; + setState(() => _isInitializing = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('支払フォームの読み込みに失敗しました: $e'))); + } + } + + Future _pickSupplier() async { + final selected = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => SupplierPickerModal( + onSupplierSelected: (supplier) { + Navigator.pop(ctx, supplier); + }, + ), + ); + if (selected == null) return; + setState(() { + _supplierId = selected.id; + _supplierName = selected.name; + }); + } + + Future _pickDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _paymentDate, + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _paymentDate = picked); + } + } + + Future _addAllocation() async { + if (_entries.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当対象となる仕入伝票がありません'))); + return; + } + final entry = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _PurchaseEntryPickerSheet( + entries: _entries, + dateFormat: _dateFormat, + currencyFormat: _currencyFormat, + getOutstanding: _availableForEntry, + ), + ); + if (!mounted) return; + if (entry == null) return; + final maxForEntry = _availableForEntry(entry); + if (maxForEntry <= 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('選択した仕入伝票には割当余力がありません'))); + return; + } + final receiptAmount = _receiptAmount; + final remainingReceipt = receiptAmount > 0 ? receiptAmount - _sumAllocations : maxForEntry; + final initial = remainingReceipt > 0 ? remainingReceipt.clamp(0, maxForEntry).toInt() : maxForEntry; + setState(() { + _allocations.add(_AllocationRow(entry: entry, amount: initial)); + }); + } + + int get _receiptAmount => int.tryParse(_amountController.text) ?? 0; + + int get _sumAllocations => _allocations.fold(0, (sum, row) => sum + row.amount); + + int _availableForEntry(PurchaseEntry entry, [_AllocationRow? excluding]) { + final base = _baseAllocated[entry.id] ?? 0; + final others = _allocations.where((row) => row.entry.id == entry.id && row != excluding).fold(0, (sum, row) => sum + row.amount); + return entry.amountTaxIncl - base - others; + } + + int _maxForRow(_AllocationRow row) { + return _availableForEntry(row.entry, row) + row.amount; + } + + void _handleAllocationChanged(_AllocationRow row) { + final value = row.amount; + final max = _maxForRow(row); + if (value > max) { + row.setAmount(max); + } else if (value < 0) { + row.setAmount(0); + } + setState(() {}); + } + + void _removeAllocation(_AllocationRow row) { + setState(() { + _allocations.remove(row); + row.dispose(); + }); + } + + Future _save() async { + if (_isSaving) return; + final amount = _receiptAmount; + if (amount <= 0) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支払額を入力してください'))); + return; + } + final totalAlloc = _sumAllocations; + if (totalAlloc > amount) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当総額が支払額を超えています'))); + return; + } + setState(() => _isSaving = true); + try { + PurchaseReceipt saved; + final allocations = _allocations + .where((row) => row.amount > 0) + .map((row) => PurchaseReceiptAllocationInput(purchaseEntryId: row.entry.id, amount: row.amount)) + .toList(); + if (widget.receipt == null) { + saved = await _receiptService.createReceipt( + supplierId: _supplierId, + paymentDate: _paymentDate, + amount: amount, + method: _method, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + allocations: allocations, + ); + } else { + final updated = widget.receipt!.copyWith( + supplierId: _supplierId, + paymentDate: _paymentDate, + amount: amount, + method: _method, + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + ); + saved = await _receiptService.updateReceipt(receipt: updated, allocations: allocations); + } + if (!mounted) return; + Navigator.pop(context, saved); + } catch (e) { + if (!mounted) return; + setState(() => _isSaving = false); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); + } + } + + @override + Widget build(BuildContext context) { + final title = widget.receipt == null ? '支払を登録' : '支払を編集'; + final receiptAmount = _receiptAmount; + final allocSum = _sumAllocations; + final remaining = (receiptAmount - allocSum).clamp(-999999999, 999999999).toInt(); + + return Scaffold( + appBar: AppBar( + leading: const BackButton(), + title: ScreenAppBarTitle( + screenId: 'P4', + title: title == '支払を登録' ? '支払登録' : '支払編集', + ), + actions: [ + TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')), + ], + ), + body: _isInitializing + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom + 24), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _amountController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: '支払額 (円)'), + ), + const SizedBox(height: 12), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('支払日'), + subtitle: Text(_dateFormat.format(_paymentDate)), + trailing: TextButton(onPressed: _pickDate, child: const Text('変更')), + ), + const Divider(), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(_supplierName ?? '仕入先を選択'), + trailing: const Icon(Icons.chevron_right), + onTap: _pickSupplier, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _method, + decoration: const InputDecoration(labelText: '支払方法'), + items: const [ + DropdownMenuItem(value: '銀行振込', child: Text('銀行振込')), + DropdownMenuItem(value: '現金', child: Text('現金')), + DropdownMenuItem(value: '振替', child: Text('口座振替')), + DropdownMenuItem(value: 'カード', child: Text('カード払い')), + DropdownMenuItem(value: 'その他', child: Text('その他')), + ], + onChanged: (val) => setState(() => _method = val), + ), + const SizedBox(height: 12), + TextField( + controller: _notesController, + maxLines: 3, + decoration: const InputDecoration(labelText: 'メモ (任意)'), + ), + const Divider(height: 32), + Row( + children: [ + Text('割当: ${_currencyFormat.format(allocSum)} / ${_currencyFormat.format(receiptAmount)}'), + const Spacer(), + Text( + remaining >= 0 ? '残り: ${_currencyFormat.format(remaining)}' : '超過: ${_currencyFormat.format(remaining.abs())}', + style: TextStyle(color: remaining >= 0 ? Colors.black87 : Colors.red), + ), + ], + ), + const SizedBox(height: 8), + for (final row in _allocations) + Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(row.entry.subject?.isNotEmpty == true ? row.entry.subject! : '仕入伝票', + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('${_dateFormat.format(row.entry.issueDate)} / ${_currencyFormat.format(row.entry.amountTaxIncl)}'), + ], + ), + ), + IconButton(onPressed: () => _removeAllocation(row), icon: const Icon(Icons.delete_outline)), + ], + ), + const SizedBox(height: 8), + TextField( + controller: row.controller, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: '割当額', + helperText: '残余 ${_currencyFormat.format((_maxForRow(row) - row.amount).clamp(0, double.infinity))}', + ), + onChanged: (_) => _handleAllocationChanged(row), + ), + ], + ), + ), + ), + TextButton.icon( + onPressed: _addAllocation, + icon: const Icon(Icons.playlist_add), + label: const Text('仕入伝票を割当'), + ), + ], + ), + ), + ), + ); + } +} + +class _AllocationRow { + _AllocationRow({required this.entry, required int amount}) + : controller = TextEditingController(text: amount.toString()), + _amount = amount; + + final PurchaseEntry entry; + final TextEditingController controller; + int _amount; + + int get amount => _amount; + + void setAmount(int value) { + _amount = value; + controller.text = value.toString(); + } + + void dispose() => controller.dispose(); +} + +class _PurchaseEntryPickerSheet extends StatelessWidget { + const _PurchaseEntryPickerSheet({ + required this.entries, + required this.dateFormat, + required this.currencyFormat, + required this.getOutstanding, + }); + + final List entries; + final DateFormat dateFormat; + final NumberFormat currencyFormat; + final int Function(PurchaseEntry entry) getOutstanding; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: const [ + Text('仕入伝票を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: entries.length, + itemBuilder: (context, index) { + final entry = entries[index]; + final outstanding = getOutstanding(entry); + return ListTile( + title: Text(entry.subject?.isNotEmpty == true ? entry.subject! : '仕入伝票'), + subtitle: Text( + '${entry.supplierNameSnapshot ?? '仕入先未設定'}\n${dateFormat.format(entry.issueDate)} / ${currencyFormat.format(entry.amountTaxIncl)}', + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Text('残余', style: TextStyle(fontSize: 12, color: Colors.black54)), + Text(currencyFormat.format(outstanding), + style: TextStyle(color: outstanding > 0 ? Colors.green.shade700 : Colors.redAccent)), + ], + ), + onTap: () => Navigator.pop(context, entry), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/sales_entries_screen.dart b/lib/screens/sales_entries_screen.dart index f996175..019ce5b 100644 --- a/lib/screens/sales_entries_screen.dart +++ b/lib/screens/sales_entries_screen.dart @@ -1,12 +1,12 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:uuid/uuid.dart'; import '../models/customer_model.dart'; import '../models/invoice_models.dart'; -import '../models/product_model.dart'; import '../models/sales_entry_models.dart'; import '../services/app_settings_repository.dart'; import '../services/edit_log_repository.dart'; @@ -14,6 +14,7 @@ import '../services/sales_entry_service.dart'; import '../widgets/line_item_editor.dart'; import '../widgets/screen_id_title.dart'; import 'customer_picker_modal.dart'; +import 'settings_screen.dart'; import 'product_picker_modal.dart'; class SalesEntriesScreen extends StatefulWidget { @@ -154,6 +155,7 @@ class _SalesEntriesScreenState extends State { ); return Scaffold( + backgroundColor: Colors.grey.shade200, appBar: AppBar( leading: const BackButton(), title: const ScreenAppBarTitle(screenId: 'U1', title: '売上伝票'), @@ -213,6 +215,7 @@ class _SalesEntriesScreenState extends State { ); return Card( + color: Colors.white, child: InkWell( onTap: () => _openEditor(entry: entry), child: Padding( @@ -273,6 +276,113 @@ class _SalesEntriesScreenState extends State { } } +class _EntrySnapshot { + const _EntrySnapshot({ + required this.customer, + required this.customerSnapshot, + required this.subject, + required this.notes, + required this.issueDate, + required this.status, + required this.cashSaleMode, + required this.settlementMethod, + required this.settlementCardCompany, + required this.settlementDueDate, + required this.lines, + }); + + final Customer? customer; + final String? customerSnapshot; + final String subject; + final String notes; + final DateTime issueDate; + final SalesEntryStatus status; + final bool cashSaleMode; + final SettlementMethod? settlementMethod; + final String settlementCardCompany; + final DateTime? settlementDueDate; + final List<_LineDraft> lines; + + bool isSame(_EntrySnapshot other) { + return customer == other.customer && + customerSnapshot == other.customerSnapshot && + subject == other.subject && + notes == other.notes && + issueDate == other.issueDate && + status == other.status && + cashSaleMode == other.cashSaleMode && + settlementMethod == other.settlementMethod && + settlementCardCompany == other.settlementCardCompany && + settlementDueDate == other.settlementDueDate && + listEquals(lines, other.lines); + } +} + +class _LineDraft { + const _LineDraft({ + this.id, + this.productId, + required this.description, + required this.quantity, + required this.unitPrice, + this.taxRate, + required this.costAmount, + required this.costIsProvisional, + }); + + final String? id; + final String? productId; + final String description; + final int quantity; + final int unitPrice; + final double? taxRate; + final int costAmount; + final bool costIsProvisional; + + factory _LineDraft.fromForm(LineItemFormData form) { + return _LineDraft( + id: form.id, + productId: form.productId, + description: form.description, + quantity: form.quantityValue, + unitPrice: form.unitPriceValue, + taxRate: form.taxRate, + costAmount: form.costAmount, + costIsProvisional: form.costIsProvisional, + ); + } + + LineItemFormData toFormData() { + return LineItemFormData( + id: id, + productId: productId, + productName: description, + quantity: quantity, + unitPrice: unitPrice, + taxRate: taxRate, + costAmount: costAmount, + costIsProvisional: costIsProvisional, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _LineDraft && + other.id == id && + other.productId == productId && + other.description == description && + other.quantity == quantity && + other.unitPrice == unitPrice && + other.taxRate == taxRate && + other.costAmount == costAmount && + other.costIsProvisional == costIsProvisional; + } + + @override + int get hashCode => Object.hash(id, productId, description, quantity, unitPrice, taxRate, costAmount, costIsProvisional); +} + class _SalesEntryEditorPage extends StatefulWidget { const _SalesEntryEditorPage({required this.service, this.entry}); @@ -295,6 +405,9 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { late DateTime _issueDate; Customer? _selectedCustomer; String? _customerSnapshot; + SettlementMethod? _settlementMethod; + final TextEditingController _cardCompanyController = TextEditingController(); + DateTime? _settlementDueDate; SalesEntryStatus _status = SalesEntryStatus.draft; bool _isSaving = false; final List _lines = []; @@ -307,6 +420,10 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { bool _showGross = true; bool _cashSaleMode = false; final String _cashSaleLabel = '現金売上'; + bool _cashSaleModeUserOverride = false; + bool _showGrossUserOverride = false; + bool _isQuickSettingsDrawerOpen = false; + static final RegExp _honorificPattern = RegExp(r'(様|さま|御中|殿|貴社|先生|氏)$'); final List<_EntrySnapshot> _undoStack = []; final List<_EntrySnapshot> _redoStack = []; @@ -317,9 +434,13 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { void initState() { super.initState(); final entry = widget.entry; + _cashSaleModeUserOverride = entry != null; _issueDate = entry?.issueDate ?? DateTime.now(); _status = entry?.status ?? SalesEntryStatus.draft; - _customerSnapshot = entry?.customerNameSnapshot; + _customerSnapshot = _withHonorific(entry?.customerNameSnapshot); + _settlementMethod = entry?.settlementMethod; + _cardCompanyController.text = entry?.settlementCardCompany ?? ''; + _settlementDueDate = entry?.settlementDueDate; _subjectController.text = entry?.subject ?? ''; _notesController.text = entry?.notes ?? ''; _entryId = entry?.id; @@ -351,9 +472,10 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { if (_entryId != null) { _loadEditLogs(); } - _loadGrossSettings(); + _loadEditorPreferences(); _subjectController.addListener(_scheduleHistorySnapshot); _notesController.addListener(_scheduleHistorySnapshot); + _cardCompanyController.addListener(_scheduleHistorySnapshot); WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory()); } @@ -362,8 +484,10 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { _historyDebounce?.cancel(); _subjectController.removeListener(_scheduleHistorySnapshot); _notesController.removeListener(_scheduleHistorySnapshot); + _cardCompanyController.removeListener(_scheduleHistorySnapshot); _subjectController.dispose(); _notesController.dispose(); + _cardCompanyController.dispose(); for (final line in _lines) { line.removeChangeListener(_scheduleHistorySnapshot); line.dispose(); @@ -371,6 +495,39 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { super.dispose(); } + Widget _buildBackButton() { + return Tooltip( + message: '戻る / 長押しで表示モード設定', + child: SizedBox( + width: kToolbarHeight, + height: kToolbarHeight, + child: InkResponse( + radius: 28, + containedInkWell: true, + highlightShape: BoxShape.circle, + onTap: () => Navigator.of(context).maybePop(), + onLongPress: _openQuickSettingsDrawer, + child: const Center(child: Icon(Icons.arrow_back)), + ), + ), + ); + } + + String? _withHonorific(String? value, {String? fallbackHonorific}) { + if (value == null) return null; + final trimmed = value.trimRight(); + if (trimmed.isEmpty) return value; + if (_honorificPattern.hasMatch(trimmed)) { + return trimmed; + } + final candidate = (fallbackHonorific ?? _selectedCustomer?.title ?? '様').trim(); + if (candidate.isEmpty) { + return trimmed; + } + return '$trimmed $candidate'; + } + + String _ensureEntryId() { return _entryId ??= widget.entry?.id ?? _uuid.v4(); } @@ -425,6 +582,9 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { issueDate: _issueDate, status: _status, cashSaleMode: _cashSaleMode, + settlementMethod: _settlementMethod, + settlementCardCompany: _cardCompanyController.text, + settlementDueDate: _settlementDueDate, lines: _lines.map(_LineDraft.fromForm).toList(growable: false), ); } @@ -444,12 +604,15 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { return form; })); _selectedCustomer = snapshot.customer; - _customerSnapshot = snapshot.customerSnapshot; + _customerSnapshot = _withHonorific(snapshot.customerSnapshot, fallbackHonorific: snapshot.customer?.title); _subjectController.text = snapshot.subject; _notesController.text = snapshot.notes; _issueDate = snapshot.issueDate; _status = snapshot.status; _cashSaleMode = snapshot.cashSaleMode; + _settlementMethod = snapshot.settlementMethod; + _cardCompanyController.text = snapshot.settlementCardCompany; + _settlementDueDate = snapshot.settlementDueDate; _isApplyingSnapshot = false; setState(() {}); } @@ -477,16 +640,25 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { }); } - Future _loadGrossSettings() async { + Future _loadEditorPreferences() async { final enabled = await _settingsRepo.getGrossProfitEnabled(); final toggleVisible = await _settingsRepo.getGrossProfitToggleVisible(); final includeProvisional = await _settingsRepo.getGrossProfitIncludeProvisional(); + final defaultCash = await _settingsRepo.getSalesEntryCashModeDefault(); + final showGross = await _settingsRepo.getSalesEntryShowGross(); if (!mounted) return; setState(() { _grossEnabled = enabled; _grossToggleVisible = toggleVisible; _grossIncludeProvisional = includeProvisional; - _showGross = enabled; + if (!_cashSaleModeUserOverride && widget.entry == null) { + _toggleCashSaleMode(defaultCash, userAction: false); + } + if (!_showGrossUserOverride) { + _showGross = enabled && showGross; + } else if (!enabled) { + _showGross = false; + } }); } @@ -505,6 +677,7 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { Widget _buildEditLogPanel() { final hasEntryId = _entryId != null; return Card( + color: Colors.grey.shade100, margin: const EdgeInsets.only(top: 24), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Padding( @@ -588,6 +761,7 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { final selected = await showModalBottomSheet( context: context, isScrollControlled: true, + useSafeArea: true, builder: (ctx) => CustomerPickerModal( onCustomerSelected: (customer) { Navigator.pop(ctx, customer); @@ -597,7 +771,7 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { if (selected == null) return; setState(() { _selectedCustomer = selected; - _customerSnapshot = selected.invoiceName; + _customerSnapshot = _withHonorific(selected.invoiceName, fallbackHonorific: selected.title); }); _logEdit('取引先を「${selected.invoiceName}」に設定'); } @@ -614,6 +788,18 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { _logEdit('計上日を${_dateFormat.format(picked)}に更新'); } + Future _pickSettlementDueDate() async { + final picked = await showDatePicker( + context: context, + initialDate: _settlementDueDate ?? DateTime.now(), + firstDate: DateTime(2015), + lastDate: DateTime(2100), + ); + if (picked == null) return; + setState(() => _settlementDueDate = picked); + _scheduleHistorySnapshot(); + } + void _addLine() { setState(() { final form = LineItemFormData(quantity: 1); @@ -647,6 +833,8 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { final notes = _notesController.text.trim(); if (_cashSaleMode && (_customerSnapshot == null || _customerSnapshot!.isEmpty)) { _customerSnapshot = _cashSaleLabel; + } else if (!_cashSaleMode) { + _customerSnapshot = _withHonorific(_customerSnapshot); } final entryId = _ensureEntryId(); final lines = []; @@ -685,6 +873,9 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { issueDate: _issueDate, status: _status, notes: notes.isEmpty ? null : notes, + settlementMethod: _settlementMethod, + settlementCardCompany: _cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim(), + settlementDueDate: _settlementDueDate, createdAt: DateTime.now(), updatedAt: DateTime.now(), items: lines, @@ -699,6 +890,11 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { status: _status, items: lines, updatedAt: DateTime.now(), + settlementMethod: _settlementMethod, + settlementCardCompany: _settlementMethod == SettlementMethod.card + ? (_cardCompanyController.text.trim().isEmpty ? null : _cardCompanyController.text.trim()) + : null, + settlementDueDate: _settlementMethod == SettlementMethod.accountsReceivable ? _settlementDueDate : null, ); setState(() => _isSaving = true); @@ -723,12 +919,20 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { final scrollPadding = (keyboardInset > 0 ? keyboardInset : 0) + 48.0; return Scaffold( + backgroundColor: Colors.grey.shade200, resizeToAvoidBottomInset: false, appBar: AppBar( - leading: const BackButton(), - title: ScreenAppBarTitle( - screenId: 'U2', - title: widget.entry == null ? '売上伝票作成' : '売上伝票編集', + leading: _buildBackButton(), + title: Tooltip( + message: '長押しで表示モード設定ドロワーを開きます', + child: InkWell( + onLongPress: _openQuickSettingsDrawer, + borderRadius: BorderRadius.circular(8), + child: ScreenAppBarTitle( + screenId: 'U2', + title: widget.entry == null ? '売上伝票作成' : '売上伝票編集', + ), + ), ), actions: [ IconButton( @@ -765,48 +969,66 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - TextField( - controller: _subjectController, - decoration: const InputDecoration(labelText: '件名'), + Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: TextField( + controller: _subjectController, + decoration: const InputDecoration(labelText: '件名', border: InputBorder.none), + ), + ), ), const SizedBox(height: 12), - Row( - children: [ - Expanded(child: Text('計上日: ${_dateFormat.format(_issueDate)}')), - TextButton(onPressed: _pickDate, child: const Text('日付を選択')), - ], + Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: InkWell( + onTap: _pickDate, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text('計上日: ${_dateFormat.format(_issueDate)}'), + ), + ), + ), + TextButton(onPressed: _pickDate, child: const Text('日付を選択')), + ], + ), + ), ), const SizedBox(height: 12), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(_customerSnapshot ?? '取引先を選択'), - trailing: const Icon(Icons.chevron_right), - onTap: _cashSaleMode ? null : _pickCustomer, - ), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('現金売上モード'), - subtitle: const Text('顧客登録なしで「現金売上」として計上します'), - value: _cashSaleMode, - onChanged: (value) => _toggleCashSaleMode(value), + Card( + color: Colors.white, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + title: Text(_customerSnapshot ?? '顧客を選択'), + trailing: const Icon(Icons.chevron_right), + onTap: _cashSaleMode ? null : _pickCustomer, + ), ), + const SizedBox(height: 12), + _buildSettlementCard(), const Divider(height: 32), Text('明細', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), if (_grossEnabled && _grossToggleVisible) - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - title: const Text('粗利を表示'), - subtitle: const Text('仕入値が入っている明細のみ粗利を計算します'), - value: _showGross, - onChanged: (value) => setState(() => _showGross = value), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + '粗利の表示・非表示はタイトル長押しの表示モードドロワーから切り替えられます。', + style: Theme.of(context).textTheme.bodySmall, + ), ), for (var i = 0; i < _lines.length; i++) LineItemCard( data: _lines[i], onRemove: () => _removeLine(i), onPickProduct: () => _pickProductForLine(i), - onChanged: _scheduleHistorySnapshot, meta: _shouldShowGross ? _buildLineMeta(_lines[i]) : null, footer: _shouldShowGross ? _buildLineFooter(_lines[i]) : null, ), @@ -821,10 +1043,16 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { const Divider(height: 32), if (_shouldShowGross) _buildGrossSummary(), if (_shouldShowGross) const Divider(height: 32), - TextField( - controller: _notesController, - decoration: const InputDecoration(labelText: 'メモ'), - maxLines: 3, + Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: TextField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'メモ', border: InputBorder.none), + maxLines: 3, + ), + ), ), _buildEditLogPanel(), const SizedBox(height: 80), @@ -860,22 +1088,211 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { ); } - void _toggleCashSaleMode(bool enabled) { + void _toggleCashSaleMode(bool enabled, {bool userAction = true}) { + if (_cashSaleMode == enabled) return; setState(() { _cashSaleMode = enabled; if (enabled) { _selectedCustomer = null; _customerSnapshot = _cashSaleLabel; } else { - _customerSnapshot = _selectedCustomer?.invoiceName; + _customerSnapshot = _withHonorific( + _selectedCustomer?.invoiceName, + fallbackHonorific: _selectedCustomer?.title, + ); + } + if (userAction) { + _cashSaleModeUserOverride = true; } }); - _logEdit(enabled ? '現金売上モードに切り替え' : '現金売上モードを解除'); - _pushHistory(clearRedo: true); + if (userAction) { + _logEdit(enabled ? '現金売上モードを有効化' : '現金売上モードを無効化'); + _pushHistory(clearRedo: true); + } + } + + void _setShowGross(bool enabled, {bool userAction = true}) { + if (!_grossEnabled || _showGross == enabled) return; + setState(() { + _showGross = enabled; + if (userAction) { + _showGrossUserOverride = true; + } + }); + if (userAction) { + _settingsRepo.setSalesEntryShowGross(enabled); + } + } + + void _setGrossIncludeProvisional(bool include) { + if (_grossIncludeProvisional == include) return; + setState(() => _grossIncludeProvisional = include); + _settingsRepo.setGrossProfitIncludeProvisional(include); + } + + Future _openQuickSettingsDrawer() async { + if (!mounted || _isQuickSettingsDrawerOpen) return; + _isQuickSettingsDrawerOpen = true; + final rootContext = context; + await showGeneralDialog( + context: context, + barrierLabel: '表示モード設定', + barrierDismissible: true, + barrierColor: Colors.black54, + transitionDuration: const Duration(milliseconds: 260), + pageBuilder: (dialogContext, animation, secondaryAnimation) { + final theme = Theme.of(dialogContext); + return SafeArea( + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.all(16), + child: Material( + color: theme.colorScheme.surface, + elevation: 6, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: _buildQuickSettingsContent(dialogContext, rootContext), + ), + ), + ), + ), + ); + }, + transitionBuilder: (context, animation, secondaryAnimation, child) { + final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic); + return SlideTransition( + position: Tween(begin: const Offset(0, -1), end: Offset.zero).animate(curved), + child: FadeTransition(opacity: curved, child: child), + ); + }, + ); + _isQuickSettingsDrawerOpen = false; + } + + Widget _buildQuickSettingsContent(BuildContext dialogContext, BuildContext rootContext) { + final textTheme = Theme.of(dialogContext).textTheme; + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(Icons.tune), + SizedBox(width: 8), + Text('表示モード設定', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 8), + Text('ここで切り替えた内容はこの伝票に即時反映されます。', style: textTheme.bodySmall), + const Divider(height: 24), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('現金売上モード'), + subtitle: const Text('顧客未選択で「現金売上」として登録します'), + value: _cashSaleMode, + onChanged: (value) => _toggleCashSaleMode(value), + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('粗利を表示'), + subtitle: const Text('各明細の粗利チップとサマリを表示します'), + value: _shouldShowGross, + onChanged: _grossEnabled ? (value) => _setShowGross(value) : null, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: const Text('暫定粗利を合計に含める'), + subtitle: const Text('仕入未確定(粗利=0扱い)の明細を粗利合計に含めます'), + value: _grossIncludeProvisional, + onChanged: (value) => _setGrossIncludeProvisional(value), + ), + const SizedBox(height: 12), + Text( + 'S1 > U2エディタ表示モード で既定値を変更すると、新規伝票の初期状態が更新されます。', + style: textTheme.bodySmall, + ), + const SizedBox(height: 12), + FilledButton.icon( + icon: const Icon(Icons.settings), + label: const Text('S1:設定で既定値を編集'), + onPressed: () { + Navigator.of(dialogContext).pop(); + Navigator.of(rootContext).push(MaterialPageRoute(builder: (_) => const SettingsScreen())); + }, + ), + ], + ), + ); } bool get _shouldShowGross => _grossEnabled && _showGross; + Widget _buildSettlementCard() { + final showCardCompany = _settlementMethod == SettlementMethod.card; + final showDueDate = _settlementMethod == SettlementMethod.accountsReceivable; + return Card( + color: Colors.white, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + initialValue: _settlementMethod, + decoration: const InputDecoration(labelText: '清算方法'), + items: SettlementMethod.values + .map((method) => DropdownMenuItem(value: method, child: Text(method.displayName))) + .toList(), + onChanged: (value) { + setState(() { + _settlementMethod = value; + if (value != SettlementMethod.card) { + _cardCompanyController.clear(); + } + if (value != SettlementMethod.accountsReceivable) { + _settlementDueDate = null; + } + }); + }, + ), + if (showCardCompany) ...[ + const SizedBox(height: 8), + TextField( + controller: _cardCompanyController, + decoration: const InputDecoration(labelText: 'カード会社'), + ), + ], + if (showDueDate) ...[ + const SizedBox(height: 8), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('入金予定日'), + subtitle: Text(_settlementDueDate == null ? '未設定' : _dateFormat.format(_settlementDueDate!)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_settlementDueDate != null) + IconButton( + tooltip: 'クリア', + icon: const Icon(Icons.clear), + onPressed: () => setState(() => _settlementDueDate = null), + ), + TextButton(onPressed: _pickSettlementDueDate, child: const Text('選択')), + ], + ), + ), + ], + ], + ), + ), + ); + } + int _lineQuantity(LineItemFormData line) => int.tryParse(line.quantityController.text) ?? 0; int _lineUnitPrice(LineItemFormData line) => int.tryParse(line.unitPriceController.text) ?? 0; @@ -884,7 +1301,10 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { int _lineCost(LineItemFormData line) => _lineQuantity(line) * line.costAmount; - int _lineGross(LineItemFormData line) => _lineRevenue(line) - _lineCost(line); + int _lineGross(LineItemFormData line) { + if (_isProvisional(line)) return 0; + return _lineRevenue(line) - _lineCost(line); + } bool _isProvisional(LineItemFormData line) => line.costIsProvisional || line.costAmount <= 0; @@ -898,12 +1318,12 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { : gross >= 0 ? Colors.green : Colors.redAccent; - final label = provisional ? '粗利(暫定)' : '粗利'; + final label = provisional ? '粗利(暫定0円)' : '粗利'; return Padding( padding: const EdgeInsets.only(right: 8), child: Chip( label: Text('$label ${_formatYen(gross)}'), - backgroundColor: color.withOpacity(0.12), + backgroundColor: color.withValues(alpha: 0.12), labelStyle: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600), ), ); @@ -913,7 +1333,7 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> { final cost = _lineCost(line); final provisional = _isProvisional(line); final text = provisional - ? '仕入: ${_formatYen(cost)} (暫定0扱い)' + ? '仕入: ${_formatYen(cost)} (粗利は暫定0円)' : '仕入: ${_formatYen(cost)}'; return Align( alignment: Alignment.centerRight, @@ -992,7 +1412,7 @@ class _SummaryTile extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), - color: theme.colorScheme.surfaceVariant.withOpacity(0.4), + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f1bf9a7..9f54e85 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -86,6 +86,8 @@ class _SettingsScreenState extends State { bool _grossProfitEnabled = true; bool _grossProfitToggleVisible = true; bool _grossProfitIncludeProvisional = false; + bool _salesEntryDefaultCashMode = false; + bool _salesEntryShowGross = true; static const _kExternalHost = 'external_host'; static const _kExternalPass = 'external_pass'; @@ -366,14 +368,28 @@ class _SettingsScreenState extends State { final enabled = await _appSettingsRepo.getGrossProfitEnabled(); final toggleVisible = await _appSettingsRepo.getGrossProfitToggleVisible(); final includeProvisional = await _appSettingsRepo.getGrossProfitIncludeProvisional(); + final defaultCash = await _appSettingsRepo.getSalesEntryCashModeDefault(); + final showGross = await _appSettingsRepo.getSalesEntryShowGross(); if (!mounted) return; setState(() { _grossProfitEnabled = enabled; _grossProfitToggleVisible = toggleVisible; _grossProfitIncludeProvisional = includeProvisional; + _salesEntryDefaultCashMode = defaultCash; + _salesEntryShowGross = showGross; }); } + Future _setSalesEntryDefaultCashMode(bool value) async { + setState(() => _salesEntryDefaultCashMode = value); + await _appSettingsRepo.setSalesEntryCashModeDefault(value); + } + + Future _setSalesEntryShowGross(bool value) async { + setState(() => _salesEntryShowGross = value); + await _appSettingsRepo.setSalesEntryShowGross(value); + } + Future _handleCalendarEnabledChanged(bool enabled) async { if (_calendarBusy) return; setState(() => _calendarBusy = true); @@ -774,6 +790,31 @@ class _SettingsScreenState extends State { ], ), ), + _section( + title: 'U2エディタ表示モード', + subtitle: '長押しドロワーや初期表示状態をここで統一できます', + child: Column( + children: [ + SwitchListTile.adaptive( + title: const Text('新規伝票を現金売上モードで開始'), + subtitle: const Text('顧客未選択で「現金売上」名義を自動入力します'), + value: _salesEntryDefaultCashMode, + onChanged: _setSalesEntryDefaultCashMode, + ), + SwitchListTile.adaptive( + title: const Text('新規伝票で粗利を初期表示'), + subtitle: const Text('U2/A1を開いた直後から粗利メタ情報を表示します'), + value: _salesEntryShowGross, + onChanged: _setSalesEntryShowGross, + ), + const SizedBox(height: 8), + Text( + 'U2のタイトルを長押しすると現場向けのクイックドロワーが開き、これらの設定を一時的に切り替えられます。', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), _section( title: '自社情報', subtitle: '会社・担当者・振込口座・電話帳取り込み', diff --git a/lib/screens/supplier_picker_modal.dart b/lib/screens/supplier_picker_modal.dart new file mode 100644 index 0000000..e9f4733 --- /dev/null +++ b/lib/screens/supplier_picker_modal.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/supplier_model.dart'; +import '../services/supplier_repository.dart'; +import '../widgets/keyboard_inset_wrapper.dart'; + +class SupplierPickerModal extends StatefulWidget { + const SupplierPickerModal({super.key, required this.onSupplierSelected}); + + final ValueChanged onSupplierSelected; + + @override + State createState() => _SupplierPickerModalState(); +} + +class _SupplierPickerModalState extends State { + final SupplierRepository _repository = SupplierRepository(); + final TextEditingController _searchController = TextEditingController(); + final Uuid _uuid = const Uuid(); + + List _suppliers = const []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSuppliers(); + } + + Future _loadSuppliers([String keyword = '']) async { + setState(() => _isLoading = true); + final all = await _repository.fetchSuppliers(includeHidden: true); + final filtered = keyword.trim().isEmpty + ? all + : all.where((s) => s.name.toLowerCase().contains(keyword.toLowerCase())).toList(); + if (!mounted) return; + setState(() { + _suppliers = filtered; + _isLoading = false; + }); + } + + Future _openEditor({Supplier? supplier}) async { + final result = await showDialog( + context: context, + builder: (ctx) => _SupplierFormDialog(supplier: supplier, onSubmit: (data) => Navigator.of(ctx).pop(data)), + ); + if (result == null) return; + final saving = result.copyWith(id: result.id.isEmpty ? _uuid.v4() : result.id, updatedAt: DateTime.now()); + await _repository.saveSupplier(saving); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を保存しました'))); + await _loadSuppliers(_searchController.text); + if (!mounted) return; + widget.onSupplierSelected(saving); + if (!mounted) return; + Navigator.pop(context); + } + + Future _deleteSupplier(Supplier supplier) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('仕入先を削除'), + content: Text('${supplier.name} を削除しますか?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('削除')), + ], + ), + ); + if (confirmed != true) return; + await _repository.deleteSupplier(supplier.id); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入先を削除しました'))); + await _loadSuppliers(_searchController.text); + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)), + const SizedBox(width: 8), + const Text('仕入先を選択', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const Spacer(), + IconButton(onPressed: () => _openEditor(), icon: const Icon(Icons.add_circle_outline)), + ], + ), + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: '仕入先名で検索', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _loadSuppliers(''); + }, + ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + isDense: true, + ), + onChanged: _loadSuppliers, + ), + const SizedBox(height: 12), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _suppliers.isEmpty + ? const Center(child: Text('仕入先が見つかりません。右上の + から追加できます。')) + : ListView.builder( + itemCount: _suppliers.length, + itemBuilder: (context, index) { + final supplier = _suppliers[index]; + return Card( + child: ListTile( + title: Text(supplier.name, style: const TextStyle(fontWeight: FontWeight.bold)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (supplier.contactPerson?.isNotEmpty == true) Text('担当: ${supplier.contactPerson}'), + if (supplier.tel?.isNotEmpty == true) Text('TEL: ${supplier.tel}'), + ], + ), + onTap: () { + widget.onSupplierSelected(supplier); + Navigator.pop(context); + }, + trailing: PopupMenuButton( + onSelected: (value) { + switch (value) { + case 'edit': + _openEditor(supplier: supplier); + break; + case 'delete': + _deleteSupplier(supplier); + break; + } + }, + itemBuilder: (context) => const [ + PopupMenuItem(value: 'edit', child: Text('編集')), + PopupMenuItem(value: 'delete', child: Text('削除')), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class _SupplierFormDialog extends StatefulWidget { + const _SupplierFormDialog({required this.onSubmit, this.supplier}); + + final Supplier? supplier; + final ValueChanged onSubmit; + + @override + State<_SupplierFormDialog> createState() => _SupplierFormDialogState(); +} + +class _SupplierFormDialogState extends State<_SupplierFormDialog> { + late final TextEditingController _nameController; + late final TextEditingController _contactController; + late final TextEditingController _telController; + late final TextEditingController _emailController; + late final TextEditingController _notesController; + + final _formKey = GlobalKey(); + + @override + void initState() { + super.initState(); + final supplier = widget.supplier; + _nameController = TextEditingController(text: supplier?.name ?? ''); + _contactController = TextEditingController(text: supplier?.contactPerson ?? ''); + _telController = TextEditingController(text: supplier?.tel ?? ''); + _emailController = TextEditingController(text: supplier?.email ?? ''); + _notesController = TextEditingController(text: supplier?.notes ?? ''); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.supplier == null ? '仕入先を追加' : '仕入先を編集'), + content: KeyboardInsetWrapper( + basePadding: const EdgeInsets.only(bottom: 8), + extraBottom: 24, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration(labelText: '仕入先名 *'), + validator: (value) => value == null || value.trim().isEmpty ? '必須項目です' : null, + ), + const SizedBox(height: 12), + TextFormField(controller: _contactController, decoration: const InputDecoration(labelText: '担当者')), + const SizedBox(height: 12), + TextFormField(controller: _telController, decoration: const InputDecoration(labelText: '電話番号')), + const SizedBox(height: 12), + TextFormField(controller: _emailController, decoration: const InputDecoration(labelText: 'メール')), + const SizedBox(height: 12), + TextFormField(controller: _notesController, decoration: const InputDecoration(labelText: '備考'), maxLines: 3), + ], + ), + ), + ), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + FilledButton( + onPressed: () { + if (!_formKey.currentState!.validate()) return; + widget.onSubmit( + Supplier( + id: widget.supplier?.id ?? '', + name: _nameController.text.trim(), + contactPerson: _contactController.text.trim().isEmpty ? null : _contactController.text.trim(), + tel: _telController.text.trim().isEmpty ? null : _telController.text.trim(), + email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), + notes: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(), + updatedAt: DateTime.now(), + ), + ); + }, + child: const Text('保存'), + ), + ], + ); + } +} diff --git a/lib/services/app_settings_repository.dart b/lib/services/app_settings_repository.dart index cec0f1f..ef1336b 100644 --- a/lib/services/app_settings_repository.dart +++ b/lib/services/app_settings_repository.dart @@ -16,6 +16,8 @@ class AppSettingsRepository { static const _kGrossProfitEnabled = 'gross_profit_enabled'; static const _kGrossProfitToggleVisible = 'gross_profit_toggle_visible'; static const _kGrossProfitIncludeProvisional = 'gross_profit_include_provisional'; + static const _kSalesEntryCashModeDefault = 'sales_entry_cash_mode_default'; + static const _kSalesEntryShowGross = 'sales_entry_show_gross'; final DatabaseHelper _dbHelper = DatabaseHelper(); @@ -142,6 +144,22 @@ class AppSettingsRepository { await setBool(_kGrossProfitIncludeProvisional, include); } + Future getSalesEntryCashModeDefault({bool defaultValue = false}) async { + return getBool(_kSalesEntryCashModeDefault, defaultValue: defaultValue); + } + + Future setSalesEntryCashModeDefault(bool enabled) async { + await setBool(_kSalesEntryCashModeDefault, enabled); + } + + Future getSalesEntryShowGross({bool defaultValue = true}) async { + return getBool(_kSalesEntryShowGross, defaultValue: defaultValue); + } + + Future setSalesEntryShowGross(bool display) async { + await setBool(_kSalesEntryShowGross, display); + } + // Generic helpers Future getString(String key) async => _getValue(key); Future setString(String key, String value) async => _setValue(key, value); diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 4b9e4e1..9e8e0e8 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 = 35; + static const _databaseVersion = 37; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -245,6 +245,14 @@ class DatabaseHelper { await _safeAddColumn(db, 'sales_line_items', 'cost_amount INTEGER DEFAULT 0'); await _safeAddColumn(db, 'sales_line_items', 'cost_is_provisional INTEGER DEFAULT 0'); } + if (oldVersion < 36) { + await _safeAddColumn(db, 'sales_entries', 'settlement_method TEXT'); + await _safeAddColumn(db, 'sales_entries', 'settlement_card_company TEXT'); + await _safeAddColumn(db, 'sales_entries', 'settlement_due_date TEXT'); + } + if (oldVersion < 37) { + await _createPurchaseEntryTables(db); + } } Future _onCreate(Database db, int version) async { @@ -434,6 +442,7 @@ class DatabaseHelper { await _createInventoryTables(db); await _createReceivableTables(db); await _createSalesEntryTables(db); + await _createPurchaseEntryTables(db); await _safeAddColumn(db, 'invoices', 'previous_chain_hash TEXT'); await _safeAddColumn(db, 'invoices', 'chain_hash TEXT'); await _safeAddColumn(db, 'invoices', "chain_status TEXT DEFAULT 'pending'"); @@ -646,6 +655,9 @@ class DatabaseHelper { tax_amount INTEGER DEFAULT 0, amount_tax_incl INTEGER DEFAULT 0, notes TEXT, + settlement_method TEXT, + settlement_card_company TEXT, + settlement_due_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(customer_id) REFERENCES customers(id) @@ -712,4 +724,70 @@ class DatabaseHelper { ) '''); } + + Future _createPurchaseEntryTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS purchase_entries ( + id TEXT PRIMARY KEY, + supplier_id TEXT, + supplier_name_snapshot TEXT, + subject TEXT, + issue_date TEXT NOT NULL, + status TEXT NOT NULL, + amount_tax_excl INTEGER DEFAULT 0, + tax_amount INTEGER DEFAULT 0, + amount_tax_incl INTEGER DEFAULT 0, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(supplier_id) REFERENCES suppliers(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_entries_supplier ON purchase_entries(supplier_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_entries_issue_date ON purchase_entries(issue_date)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS purchase_line_items ( + id TEXT PRIMARY KEY, + purchase_entry_id TEXT NOT NULL, + product_id TEXT, + description TEXT NOT NULL, + quantity INTEGER NOT NULL, + unit_price INTEGER NOT NULL, + tax_rate REAL DEFAULT 0, + line_total INTEGER DEFAULT 0, + FOREIGN KEY(purchase_entry_id) REFERENCES purchase_entries(id) ON DELETE CASCADE, + FOREIGN KEY(product_id) REFERENCES products(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_line_items_entry ON purchase_line_items(purchase_entry_id)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS purchase_receipts ( + id TEXT PRIMARY KEY, + supplier_id TEXT, + payment_date TEXT NOT NULL, + method TEXT, + amount INTEGER NOT NULL, + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(supplier_id) REFERENCES suppliers(id) + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_receipts_supplier ON purchase_receipts(supplier_id)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_receipts_payment_date ON purchase_receipts(payment_date)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS purchase_receipt_links ( + receipt_id TEXT NOT NULL, + purchase_entry_id TEXT NOT NULL, + allocated_amount INTEGER NOT NULL, + PRIMARY KEY(receipt_id, purchase_entry_id), + FOREIGN KEY(receipt_id) REFERENCES purchase_receipts(id) ON DELETE CASCADE, + FOREIGN KEY(purchase_entry_id) REFERENCES purchase_entries(id) ON DELETE CASCADE + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_purchase_receipt_links_entry ON purchase_receipt_links(purchase_entry_id)'); + } } diff --git a/lib/services/purchase_entry_repository.dart b/lib/services/purchase_entry_repository.dart new file mode 100644 index 0000000..ac5c42b --- /dev/null +++ b/lib/services/purchase_entry_repository.dart @@ -0,0 +1,66 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/purchase_entry_models.dart'; +import 'database_helper.dart'; + +class PurchaseEntryRepository { + PurchaseEntryRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future upsertEntry(PurchaseEntry entry) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.insert('purchase_entries', entry.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + await txn.delete('purchase_line_items', where: 'purchase_entry_id = ?', whereArgs: [entry.id]); + for (final item in entry.items) { + await txn.insert('purchase_line_items', item.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future findById(String id) async { + final db = await _dbHelper.database; + final rows = await db.query('purchase_entries', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + final items = await _fetchItems(db, id); + return PurchaseEntry.fromMap(rows.first, items: items); + } + + Future> fetchEntries({PurchaseEntryStatus? status, int? limit}) async { + final db = await _dbHelper.database; + final where = []; + final args = []; + if (status != null) { + where.add('status = ?'); + args.add(status.name); + } + final rows = await db.query( + 'purchase_entries', + where: where.isEmpty ? null : where.join(' AND '), + whereArgs: where.isEmpty ? null : args, + orderBy: 'issue_date DESC, updated_at DESC', + limit: limit, + ); + final result = []; + for (final row in rows) { + final items = await _fetchItems(db, row['id'] as String); + result.add(PurchaseEntry.fromMap(row, items: items)); + } + return result; + } + + Future deleteEntry(String id) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.delete('purchase_line_items', where: 'purchase_entry_id = ?', whereArgs: [id]); + await txn.delete('purchase_receipt_links', where: 'purchase_entry_id = ?', whereArgs: [id]); + await txn.delete('purchase_entries', where: 'id = ?', whereArgs: [id]); + }); + } + + Future> _fetchItems(DatabaseExecutor db, String entryId) async { + final rows = await db.query('purchase_line_items', where: 'purchase_entry_id = ?', whereArgs: [entryId]); + return rows.map(PurchaseLineItem.fromMap).toList(); + } +} diff --git a/lib/services/purchase_entry_service.dart b/lib/services/purchase_entry_service.dart new file mode 100644 index 0000000..d9163b2 --- /dev/null +++ b/lib/services/purchase_entry_service.dart @@ -0,0 +1,61 @@ +import 'package:uuid/uuid.dart'; + +import '../models/purchase_entry_models.dart'; +import 'purchase_entry_repository.dart'; +import 'purchase_receipt_repository.dart'; + +class PurchaseEntryService { + PurchaseEntryService({ + PurchaseEntryRepository? entryRepository, + PurchaseReceiptRepository? receiptRepository, + }) : _entryRepository = entryRepository ?? PurchaseEntryRepository(), + _receiptRepository = receiptRepository ?? PurchaseReceiptRepository(); + + final PurchaseEntryRepository _entryRepository; + final PurchaseReceiptRepository _receiptRepository; + final Uuid _uuid = const Uuid(); + + Future> fetchEntries({PurchaseEntryStatus? status, int? limit}) { + return _entryRepository.fetchEntries(status: status, limit: limit); + } + + Future findById(String id) { + return _entryRepository.findById(id); + } + + Future deleteEntry(String id) { + return _entryRepository.deleteEntry(id); + } + + Future saveEntry(PurchaseEntry entry) async { + final updated = entry.recalcTotals().copyWith(updatedAt: DateTime.now()); + await _entryRepository.upsertEntry(updated); + return updated; + } + + Future createQuickEntry({ + String? supplierId, + String? supplierNameSnapshot, + String? subject, + DateTime? issueDate, + List? items, + }) async { + final now = DateTime.now(); + final entry = PurchaseEntry( + id: _uuid.v4(), + supplierId: supplierId, + supplierNameSnapshot: supplierNameSnapshot, + subject: subject, + issueDate: issueDate ?? now, + status: PurchaseEntryStatus.draft, + createdAt: now, + updatedAt: now, + items: items ?? const [], + ); + return saveEntry(entry); + } + + Future> fetchAllocatedTotals(Iterable entryIds) { + return _receiptRepository.fetchAllocatedTotals(entryIds); + } +} diff --git a/lib/services/purchase_receipt_repository.dart b/lib/services/purchase_receipt_repository.dart new file mode 100644 index 0000000..954c369 --- /dev/null +++ b/lib/services/purchase_receipt_repository.dart @@ -0,0 +1,81 @@ +import 'package:sqflite/sqflite.dart'; + +import '../models/purchase_entry_models.dart'; +import 'database_helper.dart'; + +class PurchaseReceiptRepository { + PurchaseReceiptRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future upsertReceipt(PurchaseReceipt receipt, List links) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.insert('purchase_receipts', receipt.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + await txn.delete('purchase_receipt_links', where: 'receipt_id = ?', whereArgs: [receipt.id]); + for (final link in links) { + await txn.insert('purchase_receipt_links', link.toMap(), conflictAlgorithm: ConflictAlgorithm.replace); + } + }); + } + + Future> fetchReceipts({DateTime? startDate, DateTime? endDate}) async { + final db = await _dbHelper.database; + final where = []; + final args = []; + if (startDate != null) { + where.add('payment_date >= ?'); + args.add(startDate.toIso8601String()); + } + if (endDate != null) { + where.add('payment_date <= ?'); + args.add(endDate.toIso8601String()); + } + final rows = await db.query( + 'purchase_receipts', + where: where.isEmpty ? null : where.join(' AND '), + whereArgs: where.isEmpty ? null : args, + orderBy: 'payment_date DESC, updated_at DESC', + ); + return rows.map(PurchaseReceipt.fromMap).toList(); + } + + Future findById(String id) async { + final db = await _dbHelper.database; + final rows = await db.query('purchase_receipts', where: 'id = ?', whereArgs: [id], limit: 1); + if (rows.isEmpty) return null; + return PurchaseReceipt.fromMap(rows.first); + } + + Future> fetchLinks(String receiptId) async { + final db = await _dbHelper.database; + final rows = await db.query('purchase_receipt_links', where: 'receipt_id = ?', whereArgs: [receiptId]); + return rows.map(PurchaseReceiptLink.fromMap).toList(); + } + + Future> fetchAllocatedTotals(Iterable purchaseEntryIds) async { + final ids = purchaseEntryIds.where((id) => id.isNotEmpty).toSet().toList(); + if (ids.isEmpty) return {}; + final db = await _dbHelper.database; + final placeholders = List.filled(ids.length, '?').join(','); + final rows = await db.rawQuery( + 'SELECT purchase_entry_id, SUM(allocated_amount) AS total FROM purchase_receipt_links WHERE purchase_entry_id IN ($placeholders) GROUP BY purchase_entry_id', + ids, + ); + final result = {}; + for (final row in rows) { + final entryId = row['purchase_entry_id'] as String?; + if (entryId == null) continue; + result[entryId] = (row['total'] as num?)?.toInt() ?? 0; + } + return result; + } + + Future deleteReceipt(String id) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + await txn.delete('purchase_receipt_links', where: 'receipt_id = ?', whereArgs: [id]); + await txn.delete('purchase_receipts', where: 'id = ?', whereArgs: [id]); + }); + } +} diff --git a/lib/services/purchase_receipt_service.dart b/lib/services/purchase_receipt_service.dart new file mode 100644 index 0000000..b217836 --- /dev/null +++ b/lib/services/purchase_receipt_service.dart @@ -0,0 +1,135 @@ +import 'package:uuid/uuid.dart'; + +import '../models/purchase_entry_models.dart'; +import 'purchase_entry_repository.dart'; +import 'purchase_receipt_repository.dart'; + +class PurchaseReceiptService { + PurchaseReceiptService({ + PurchaseReceiptRepository? receiptRepository, + PurchaseEntryRepository? entryRepository, + }) : _receiptRepository = receiptRepository ?? PurchaseReceiptRepository(), + _entryRepository = entryRepository ?? PurchaseEntryRepository(); + + final PurchaseReceiptRepository _receiptRepository; + final PurchaseEntryRepository _entryRepository; + final Uuid _uuid = const Uuid(); + + Future> fetchReceipts({DateTime? startDate, DateTime? endDate}) { + return _receiptRepository.fetchReceipts(startDate: startDate, endDate: endDate); + } + + Future> fetchAllocatedTotals(Iterable entryIds) { + return _receiptRepository.fetchAllocatedTotals(entryIds); + } + + Future> fetchLinks(String receiptId) { + return _receiptRepository.fetchLinks(receiptId); + } + + Future findById(String id) { + return _receiptRepository.findById(id); + } + + Future deleteReceipt(String id) { + return _receiptRepository.deleteReceipt(id); + } + + Future createReceipt({ + String? supplierId, + required DateTime paymentDate, + required int amount, + String? method, + String? notes, + List allocations = const [], + }) async { + if (amount <= 0) { + throw ArgumentError('amount must be greater than 0'); + } + final receipt = PurchaseReceipt( + id: _uuid.v4(), + supplierId: supplierId, + paymentDate: paymentDate, + method: method, + amount: amount, + notes: notes, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + return _saveReceipt(receipt: receipt, allocations: allocations); + } + + Future updateReceipt({ + required PurchaseReceipt receipt, + List allocations = const [], + }) { + final updated = receipt.copyWith(updatedAt: DateTime.now()); + return _saveReceipt(receipt: updated, allocations: allocations); + } + + Future _saveReceipt({ + required PurchaseReceipt receipt, + required List allocations, + }) async { + final entries = await _loadEntries(allocations.map((a) => a.purchaseEntryId)); + final allocatedTotals = await _receiptRepository.fetchAllocatedTotals(entries.keys); + + final links = []; + for (final allocation in allocations) { + final entry = entries[allocation.purchaseEntryId]; + if (entry == null) { + throw StateError('仕入伝票が見つかりません: ${allocation.purchaseEntryId}'); + } + final currentAllocated = allocatedTotals[entry.id] ?? 0; + final outstanding = entry.amountTaxIncl - currentAllocated; + if (allocation.amount > outstanding) { + throw StateError('割当額が支払残を超えています: ${entry.id}'); + } + links.add( + PurchaseReceiptLink( + receiptId: receipt.id, + purchaseEntryId: entry.id, + allocatedAmount: allocation.amount, + ), + ); + allocatedTotals[entry.id] = currentAllocated + allocation.amount; + } + + final totalAllocated = links.fold(0, (sum, link) => sum + link.allocatedAmount); + if (totalAllocated > receipt.amount) { + throw StateError('割当総額が支払額を超えています'); + } + + await _receiptRepository.upsertReceipt(receipt, links); + await _updateEntryStatuses(entries.values, allocatedTotals); + return receipt; + } + + Future _updateEntryStatuses(Iterable entries, Map allocatedTotals) async { + for (final entry in entries) { + final allocated = allocatedTotals[entry.id] ?? 0; + PurchaseEntryStatus newStatus; + if (allocated >= entry.amountTaxIncl) { + newStatus = PurchaseEntryStatus.settled; + } else if (allocated > 0) { + newStatus = PurchaseEntryStatus.confirmed; + } else { + newStatus = entry.status; + } + if (newStatus != entry.status) { + await _entryRepository.upsertEntry(entry.copyWith(status: newStatus, updatedAt: DateTime.now())); + } + } + } + + Future> _loadEntries(Iterable entryIds) async { + final map = {}; + for (final id in entryIds) { + final entry = await _entryRepository.findById(id); + if (entry != null) { + map[id] = entry; + } + } + return map; + } +} diff --git a/lib/widgets/line_item_editor.dart b/lib/widgets/line_item_editor.dart index e09aa26..430a34b 100644 --- a/lib/widgets/line_item_editor.dart +++ b/lib/widgets/line_item_editor.dart @@ -85,14 +85,17 @@ class LineItemCard extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); return Card( - margin: const EdgeInsets.symmetric(vertical: 8), + margin: const EdgeInsets.only(bottom: 8), + color: Colors.white, child: Padding( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( + dense: true, contentPadding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, title: Text( data.descriptionController.text.isEmpty ? '商品を選択' : data.descriptionController.text, style: theme.textTheme.titleMedium, @@ -105,21 +108,24 @@ class LineItemCard extends StatelessWidget { ), trailing: Row( mainAxisSize: MainAxisSize.min, - children: [ - if (meta != null) meta!, - const Icon(Icons.chevron_right), - ], + children: [meta, const Icon(Icons.chevron_right)] + .whereType() + .toList(growable: false), ), onTap: onPickProduct, ), - const SizedBox(height: 4), + const SizedBox(height: 2), Row( children: [ Expanded( child: TextField( controller: data.quantityController, keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '数量'), + decoration: const InputDecoration( + labelText: '数量', + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8), + ), scrollPadding: const EdgeInsets.only(bottom: 160), ), ), @@ -128,17 +134,21 @@ class LineItemCard extends StatelessWidget { child: TextField( controller: data.unitPriceController, keyboardType: TextInputType.number, - decoration: const InputDecoration(labelText: '単価(税抜)'), + decoration: const InputDecoration( + labelText: '単価(税抜)', + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8), + ), scrollPadding: const EdgeInsets.only(bottom: 160), ), ), IconButton(onPressed: onRemove, icon: const Icon(Icons.close)), ], ), - if (footer != null) ...[ - const SizedBox(height: 8), - footer!, - ], + ...[ + footer == null ? null : const SizedBox(height: 8), + footer, + ].whereType(), ], ), ), diff --git a/scripts/build_with_expiry.sh b/scripts/build_with_expiry.sh index 7250ca1..3b287f0 100755 --- a/scripts/build_with_expiry.sh +++ b/scripts/build_with_expiry.sh @@ -3,12 +3,22 @@ set -euo pipefail PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" BUILD_MODE="${1:-debug}" +FLAVOR="${2:-client}" case "${BUILD_MODE}" in debug|profile|release) ;; *) - echo "Usage: $0 [debug|profile|release]" >&2 + echo "Usage: $0 [debug|profile|release] [client|mothership]" >&2 + exit 1 + ;; +esac + +case "${FLAVOR}" in + client|mothership) + ;; + *) + echo "Invalid flavor '${FLAVOR}'. Use 'client' or 'mothership'." >&2 exit 1 ;; esac @@ -16,23 +26,30 @@ esac cd "${PROJECT_ROOT}" timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" -DART_DEFINE="APP_BUILD_TIMESTAMP=${timestamp}" +DART_DEFINES=( + "APP_BUILD_TIMESTAMP=${timestamp}" + "ENABLE_DEBUG_FEATURES=true" +) +dart_define_args=() +for define in "${DART_DEFINES[@]}"; do + dart_define_args+=("--dart-define=${define}") +done echo "[build_with_expiry] Using timestamp: ${timestamp} (UTC)" echo "[build_with_expiry] Running flutter analyze..." flutter analyze -echo "[build_with_expiry] Building APK (${BUILD_MODE})..." +echo "[build_with_expiry] Building APK (${BUILD_MODE}, flavor=${FLAVOR})..." case "${BUILD_MODE}" in debug) - flutter build apk --debug --dart-define="${DART_DEFINE}" + flutter build apk --debug --flavor "${FLAVOR}" "${dart_define_args[@]}" ;; profile) - flutter build apk --profile --dart-define="${DART_DEFINE}" + flutter build apk --profile --flavor "${FLAVOR}" "${dart_define_args[@]}" ;; release) - flutter build apk --release --dart-define="${DART_DEFINE}" + flutter build apk --release --flavor "${FLAVOR}" "${dart_define_args[@]}" ;; esac -echo "[build_with_expiry] Done. APK with 90-day lifespan generated." +echo "[build_with_expiry] Done. APK with 90-day lifespan & full features generated."