Compare commits

..

No commits in common. "3cfbe9dcb762ca4e13e0a19fa09d82f340a0eace" and "632a95bd0e6fe7559305e694789ff89f1e1c81a4" have entirely different histories.

28 changed files with 448 additions and 3443 deletions

View file

@ -84,17 +84,13 @@
```bash
flutter pub get
```
2. 90 日寿命 APK の生成`scripts/build_with_expiry.sh [mode] [flavor]`
2. 90 日寿命 APK の生成
```bash
chmod +x scripts/build_with_expiry.sh
# 例: debug×client フレーバー販売アシスト1号
./scripts/build_with_expiry.sh debug client
# 例: release×mothership フレーバー(お局様)
./scripts/build_with_expiry.sh release mothership
./scripts/build_with_expiry.sh [debug|profile|release]
```
- `APP_BUILD_TIMESTAMP` を UTC で自動付与し、`ENABLE_DEBUG_FEATURES=true` で全機能を有効化
- `flutter analyze``flutter build apk --flavor ... --dart-define=...` を連続実行
- スクリプト内で `APP_BUILD_TIMESTAMP` を UTC で自動付与
- `flutter analyze``flutter build apk` を連続実行
3. 実機/エミュレータで起動すると、寿命切れ時には `ExpiredApp` が自動表示されます。
### 機能フラグ(モジュール)
@ -103,15 +99,12 @@
| 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 送信先を上書きしたい場合に指定 |
1: 売上管理と debug ログ送信を同時に試す場合
例: 売上管理と debug ログ送信を同時に試す場合
```bash
flutter run \
@ -122,38 +115,6 @@ 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 アップデートにより、画面遷移ルールと画面タイトルの表記を統一しました。

View file

@ -20,6 +20,8 @@ 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
@ -28,20 +30,6 @@ 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.

View file

@ -35,7 +35,7 @@
</queries>
<application
android:label="${appName}"
android:label="h-1"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">販売アシスト1号</string>
</resources>

View file

@ -6,23 +6,15 @@ class AppConfig {
static const String version = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0');
/// --dart-define
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 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 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: '');
@ -31,7 +23,6 @@ class AppConfig {
'enableBillingDocs': enableBillingDocs,
'enableSalesManagement': enableSalesManagement,
'enableSalesOperations': enableSalesOperations,
'enablePurchaseManagement': enablePurchaseManagement,
'enableDebugWebhookLogging': enableDebugWebhookLogging,
};
@ -50,9 +41,6 @@ class AppConfig {
if (enableSalesOperations) {
routes.addAll({'sales_orders', 'shipments', 'inventory', 'receivables'});
}
if (enablePurchaseManagement) {
routes.addAll({'purchase_entries', 'purchase_receipts'});
}
return routes;
}
}

View file

@ -1,288 +0,0 @@
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<String, dynamic> 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<String, dynamic> 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<PurchaseLineItem> 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<PurchaseLineItem>? 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<String, dynamic> 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<String, dynamic> map, {List<PurchaseLineItem> 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<int>(0, (sum, item) => sum + item.lineTotal);
final tax = items.fold<double>(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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toMap() => {
'receipt_id': receiptId,
'purchase_entry_id': purchaseEntryId,
'allocated_amount': allocatedAmount,
};
factory PurchaseReceiptLink.fromMap(Map<String, dynamic> map) => PurchaseReceiptLink(
receiptId: map['receipt_id'] as String,
purchaseEntryId: map['purchase_entry_id'] as String,
allocatedAmount: map['allocated_amount'] as int? ?? 0,
);
}

View file

@ -2,39 +2,8 @@ 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({
@ -191,9 +160,6 @@ class SalesEntry {
this.taxAmount = 0,
this.amountTaxIncl = 0,
this.notes,
this.settlementMethod,
this.settlementCardCompany,
this.settlementDueDate,
this.items = const [],
});
@ -207,9 +173,6 @@ 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<SalesLineItem> items;
@ -228,9 +191,6 @@ class SalesEntry {
DateTime? createdAt,
DateTime? updatedAt,
List<SalesLineItem>? items,
Object? settlementMethod = _unset,
Object? settlementCardCompany = _unset,
Object? settlementDueDate = _unset,
}) {
return SalesEntry(
id: id ?? this.id,
@ -243,9 +203,6 @@ 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,
@ -263,9 +220,6 @@ 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(),
};
@ -284,11 +238,6 @@ 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,

View file

@ -1,6 +1,5 @@
import 'billing_docs_module.dart';
import 'feature_module.dart';
import 'purchase_management_module.dart';
import 'sales_management_module.dart';
import 'sales_operations_module.dart';
@ -13,7 +12,6 @@ class ModuleRegistry {
BillingDocsModule(),
SalesManagementModule(),
SalesOperationsModule(),
PurchaseManagementModule(),
];
Iterable<FeatureModule> get modules => _modules;

View file

@ -1,40 +0,0 @@
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<ModuleDashboardCard> 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()));
},
),
];
}

View file

@ -5,7 +5,6 @@ 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 {
@ -224,7 +223,6 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
@override
Widget build(BuildContext context) {
final body = KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 16),
extraBottom: 32,
child: CustomScrollView(
@ -309,20 +307,15 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
);
return SafeArea(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
title: const ScreenAppBarTitle(screenId: 'UC', title: '顧客選択'),
child: Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
body: body,
title: const Text('U2:取引先選択'),
),
body: body,
),
);
}

View file

@ -149,23 +149,6 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
);
}
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("スライドでロック解除してください")));
@ -230,10 +213,9 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
Widget build(BuildContext context) {
final amountFormatter = NumberFormat("#,###");
final dateFormatter = DateFormat('yyyy/MM/dd');
final hasDrawer = !_useDashboardHome && _isUnlocked;
return Scaffold(
resizeToAvoidBottomInset: false,
drawer: (!hasDrawer)
drawer: (_useDashboardHome || !_isUnlocked)
? null
: Drawer(
child: SafeArea(
@ -306,7 +288,14 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
),
appBar: AppBar(
automaticallyImplyLeading: false,
leading: _buildLeading(context),
leading: _useDashboardHome
? Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
)
: const BackButton(),
title: GestureDetector(
onLongPress: () {
Navigator.push(

View file

@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import '../models/invoice_models.dart';
import '../models/product_model.dart';
import '../services/product_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/screen_id_title.dart';
import 'product_master_screen.dart';
///
@ -40,137 +38,136 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
title: const ScreenAppBarTitle(screenId: 'P6', title: '商品・サービス選択'),
),
body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
extraBottom: 24,
child: Column(
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
child: Row(
children: [
TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: "商品名・カテゴリ・バーコードで検索",
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearch("");
},
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
),
onChanged: _onSearch,
),
const SizedBox(height: 12),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _products.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("商品が見つかりません"),
TextButton(
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
_onSearch(_searchController.text);
},
child: const Text("マスターに追加する"),
),
],
),
)
: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text(product.name),
subtitle: Text("${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
onTap: () {
widget.onProductSelected?.call(product);
widget.onItemSelected(
InvoiceItem(
productId: product.id,
description: product.name,
quantity: 1,
unitPrice: product.defaultUnitPrice,
),
);
Navigator.pop(context);
},
onLongPress: () async {
await showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text("編集"),
onTap: () async {
Navigator.pop(ctx);
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
);
_onSearch(_searchController.text);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
onTap: () async {
Navigator.pop(ctx);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text("削除の確認"),
content: Text("${product.name} を削除しますか?"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
],
),
);
if (confirmed == true) {
await _productRepo.deleteProduct(product.id);
if (mounted) _onSearch(_searchController.text);
}
},
),
],
),
),
);
},
);
},
),
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
const SizedBox(width: 4),
const Text("商品・サービス選択", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
],
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _searchController,
autofocus: true,
decoration: InputDecoration(
hintText: "商品名・カテゴリ・バーコードで検索",
prefixIcon: const Icon(Icons.search),
suffixIcon: IconButton(
icon: const Icon(Icons.clear),
onPressed: () { _searchController.clear(); _onSearch(""); },
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(vertical: 0),
),
onChanged: _onSearch,
),
),
const SizedBox(height: 8),
const Divider(),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _products.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("商品が見つかりません"),
TextButton(
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
_onSearch(_searchController.text);
},
child: const Text("マスターに追加する"),
),
],
),
)
: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text(product.name),
subtitle: Text("${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
onTap: () {
widget.onProductSelected?.call(product);
widget.onItemSelected(
InvoiceItem(
productId: product.id,
description: product.name,
quantity: 1,
unitPrice: product.defaultUnitPrice,
),
);
Navigator.pop(context);
},
onLongPress: () async {
await showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: const Text("編集"),
onTap: () async {
Navigator.pop(ctx);
await Navigator.push(
context,
MaterialPageRoute(builder: (_) => const ProductMasterScreen()),
);
_onSearch(_searchController.text);
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
title: const Text("削除", style: TextStyle(color: Colors.redAccent)),
onTap: () async {
Navigator.pop(ctx);
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text("削除の確認"),
content: Text("${product.name} を削除しますか?"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
],
),
);
if (confirmed == true) {
await _productRepo.deleteProduct(product.id);
if (mounted) _onSearch(_searchController.text);
}
},
),
],
),
),
);
},
);
},
),
),
],
),
);
}

View file

@ -1,441 +0,0 @@
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/keyboard_inset_wrapper.dart';
import '../widgets/line_item_editor.dart';
import '../widgets/modal_utils.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<PurchaseEntriesScreen> createState() => _PurchaseEntriesScreenState();
}
class _PurchaseEntriesScreenState extends State<PurchaseEntriesScreen> {
final PurchaseEntryService _service = PurchaseEntryService();
final NumberFormat _currencyFormat = NumberFormat.currency(locale: 'ja_JP', symbol: '¥');
bool _isLoading = true;
bool _isRefreshing = false;
PurchaseEntryStatus? _filterStatus;
List<PurchaseEntry> _entries = const [];
@override
void initState() {
super.initState();
_loadEntries();
}
Future<void> _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<void> _handleRefresh() async {
setState(() => _isRefreshing = true);
await _loadEntries();
}
Future<void> _openEditor({PurchaseEntry? entry}) async {
final saved = await Navigator.push<PurchaseEntry>(
context,
MaterialPageRoute(builder: (_) => PurchaseEntryEditorPage(entry: entry)),
);
if (saved != null) {
await _loadEntries();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('仕入伝票を保存しました')));
}
}
Future<void> _deleteEntry(PurchaseEntry entry) async {
final confirmed = await showDialog<bool>(
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<PurchaseEntryStatus?>(
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<PurchaseEntryEditorPage> createState() => _PurchaseEntryEditorPageState();
}
class _PurchaseEntryEditorPageState extends State<PurchaseEntryEditorPage> {
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<LineItemFormData> _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<void> _pickSupplier() async {
final selected = await showFeatureModalBottomSheet<Supplier>(
context: context,
builder: (ctx) => SupplierPickerModal(
onSupplierSelected: (supplier) {
Navigator.pop(ctx, supplier);
},
),
);
if (selected == null) return;
setState(() {
_supplier = selected;
_supplierSnapshot = selected.name;
});
}
Future<void> _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<void> _pickProduct(int index) async {
await showFeatureModalBottomSheet<void>(
context: context,
builder: (_) => ProductPickerModal(
onItemSelected: (_) {},
onProductSelected: (product) {
setState(() => _lines[index].applyProduct(product));
},
),
);
}
Future<void> _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,
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const BackButton(),
title: ScreenAppBarTitle(
screenId: 'P2',
title: widget.entry == null ? '仕入伝票作成' : '仕入伝票編集',
),
actions: [
TextButton(onPressed: _isSaving ? null : _save, child: const Text('保存')),
],
),
body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
extraBottom: 32,
child: SingleChildScrollView(
padding: EdgeInsets.zero,
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: KeyboardInsetWrapper(
basePadding: EdgeInsets.zero,
safeAreaTop: false,
safeAreaBottom: false,
child: TextField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'メモ'),
minLines: 2,
maxLines: 4,
),
),
),
),
],
),
),
),
);
}
}

View file

@ -1,745 +0,0 @@
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/keyboard_inset_wrapper.dart';
import '../widgets/screen_id_title.dart';
import 'supplier_picker_modal.dart';
class PurchaseReceiptsScreen extends StatefulWidget {
const PurchaseReceiptsScreen({super.key});
@override
State<PurchaseReceiptsScreen> createState() => _PurchaseReceiptsScreenState();
}
class _PurchaseReceiptsScreenState extends State<PurchaseReceiptsScreen> {
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<PurchaseReceipt> _receipts = const [];
Map<String, int> _receiptAllocations = const {};
Map<String, String> _supplierNames = const {};
DateTime? _startDate;
DateTime? _endDate;
@override
void initState() {
super.initState();
_loadReceipts();
}
Future<void> _loadReceipts() async {
if (!_isRefreshing) {
setState(() => _isLoading = true);
}
try {
final receipts = await _receiptService.fetchReceipts(startDate: _startDate, endDate: _endDate);
final allocationMap = <String, int>{};
for (final receipt in receipts) {
final links = await _receiptService.fetchLinks(receipt.id);
allocationMap[receipt.id] = links.fold<int>(0, (sum, link) => sum + link.allocatedAmount);
}
final supplierIds = receipts.map((r) => r.supplierId).whereType<String>().toSet();
final supplierNames = Map<String, String>.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<void> _handleRefresh() async {
setState(() => _isRefreshing = true);
await _loadReceipts();
}
Future<void> _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<void> _openEditor({PurchaseReceipt? receipt}) async {
final updated = await Navigator.of(context).push<PurchaseReceipt>(
MaterialPageRoute(builder: (_) => PurchaseReceiptEditorPage(receipt: receipt)),
);
if (updated != null) {
await _loadReceipts();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('支払データを保存しました')));
}
}
Future<void> _confirmDelete(PurchaseReceipt receipt) async {
final confirmed = await showDialog<bool>(
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<PurchaseReceiptEditorPage> createState() => _PurchaseReceiptEditorPageState();
}
class _PurchaseReceiptEditorPageState extends State<PurchaseReceiptEditorPage> {
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<PurchaseEntry> _entries = [];
Map<String, int> _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<void> _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<void> _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<void> _pickSupplier() async {
final selected = await showModalBottomSheet<Supplier>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => FractionallySizedBox(
heightFactor: 0.9,
child: SupplierPickerModal(
onSupplierSelected: (supplier) {
Navigator.pop(ctx, supplier);
},
),
),
);
if (selected == null) return;
setState(() {
_supplierId = selected.id;
_supplierName = selected.name;
});
}
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _paymentDate,
firstDate: DateTime(2015),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() => _paymentDate = picked);
}
}
Future<void> _addAllocation() async {
if (_entries.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('割当対象となる仕入伝票がありません')));
return;
}
final entry = await showModalBottomSheet<PurchaseEntry>(
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<int>(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<int>(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<void> _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())
: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.all(16),
extraBottom: 24,
child: SingleChildScrollView(
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<String>(
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<PurchaseEntry> 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),
);
},
),
),
],
),
),
);
}
}

View file

@ -1,21 +1,19 @@
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';
import '../services/sales_entry_service.dart';
import '../widgets/line_item_editor.dart';
import '../widgets/modal_utils.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 {
@ -156,7 +154,6 @@ class _SalesEntriesScreenState extends State<SalesEntriesScreen> {
);
return Scaffold(
backgroundColor: Colors.grey.shade200,
appBar: AppBar(
leading: const BackButton(),
title: const ScreenAppBarTitle(screenId: 'U1', title: '売上伝票'),
@ -216,7 +213,6 @@ class _SalesEntriesScreenState extends State<SalesEntriesScreen> {
);
return Card(
color: Colors.white,
child: InkWell(
onTap: () => _openEditor(entry: entry),
child: Padding(
@ -277,113 +273,6 @@ class _SalesEntriesScreenState extends State<SalesEntriesScreen> {
}
}
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});
@ -406,9 +295,6 @@ 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<LineItemFormData> _lines = [];
@ -421,10 +307,6 @@ 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 = [];
@ -435,13 +317,9 @@ 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 = _withHonorific(entry?.customerNameSnapshot);
_settlementMethod = entry?.settlementMethod;
_cardCompanyController.text = entry?.settlementCardCompany ?? '';
_settlementDueDate = entry?.settlementDueDate;
_customerSnapshot = entry?.customerNameSnapshot;
_subjectController.text = entry?.subject ?? '';
_notesController.text = entry?.notes ?? '';
_entryId = entry?.id;
@ -473,10 +351,9 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
if (_entryId != null) {
_loadEditLogs();
}
_loadEditorPreferences();
_loadGrossSettings();
_subjectController.addListener(_scheduleHistorySnapshot);
_notesController.addListener(_scheduleHistorySnapshot);
_cardCompanyController.addListener(_scheduleHistorySnapshot);
WidgetsBinding.instance.addPostFrameCallback((_) => _initializeHistory());
}
@ -485,10 +362,8 @@ 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();
@ -496,39 +371,6 @@ 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();
}
@ -583,9 +425,6 @@ 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),
);
}
@ -605,15 +444,12 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
return form;
}));
_selectedCustomer = snapshot.customer;
_customerSnapshot = _withHonorific(snapshot.customerSnapshot, fallbackHonorific: snapshot.customer?.title);
_customerSnapshot = snapshot.customerSnapshot;
_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(() {});
}
@ -641,25 +477,16 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
});
}
Future<void> _loadEditorPreferences() async {
Future<void> _loadGrossSettings() 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;
if (!_cashSaleModeUserOverride && widget.entry == null) {
_toggleCashSaleMode(defaultCash, userAction: false);
}
if (!_showGrossUserOverride) {
_showGross = enabled && showGross;
} else if (!enabled) {
_showGross = false;
}
_showGross = enabled;
});
}
@ -678,7 +505,6 @@ 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(
@ -759,8 +585,9 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
}
Future<void> _pickCustomer() async {
final selected = await showFeatureModalBottomSheet<Customer?>(
final selected = await showModalBottomSheet<Customer?>(
context: context,
isScrollControlled: true,
builder: (ctx) => CustomerPickerModal(
onCustomerSelected: (customer) {
Navigator.pop(ctx, customer);
@ -770,7 +597,7 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
if (selected == null) return;
setState(() {
_selectedCustomer = selected;
_customerSnapshot = _withHonorific(selected.invoiceName, fallbackHonorific: selected.title);
_customerSnapshot = selected.invoiceName;
});
_logEdit('取引先を「${selected.invoiceName}」に設定');
}
@ -787,18 +614,6 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
_logEdit('計上日を${_dateFormat.format(picked)}に更新');
}
Future<void> _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);
@ -832,8 +647,6 @@ 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 = <SalesLineItem>[];
@ -872,9 +685,6 @@ 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,
@ -889,11 +699,6 @@ 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);
@ -918,20 +723,12 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
final scrollPadding = (keyboardInset > 0 ? keyboardInset : 0) + 48.0;
return Scaffold(
backgroundColor: Colors.grey.shade200,
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: _buildBackButton(),
title: Tooltip(
message: '長押しで表示モード設定ドロワーを開きます',
child: InkWell(
onLongPress: _openQuickSettingsDrawer,
borderRadius: BorderRadius.circular(8),
child: ScreenAppBarTitle(
screenId: 'U2',
title: widget.entry == null ? '売上伝票作成' : '売上伝票編集',
),
),
leading: const BackButton(),
title: ScreenAppBarTitle(
screenId: 'U2',
title: widget.entry == null ? '売上伝票作成' : '売上伝票編集',
),
actions: [
IconButton(
@ -968,66 +765,48 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
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),
),
),
TextField(
controller: _subjectController,
decoration: const InputDecoration(labelText: '件名'),
),
const SizedBox(height: 12),
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('日付を選択')),
],
),
),
Row(
children: [
Expanded(child: Text('計上日: ${_dateFormat.format(_issueDate)}')),
TextButton(onPressed: _pickDate, child: const Text('日付を選択')),
],
),
const SizedBox(height: 12),
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,
),
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),
),
const SizedBox(height: 12),
_buildSettlementCard(),
const Divider(height: 32),
Text('明細', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
if (_grossEnabled && _grossToggleVisible)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'粗利の表示・非表示はタイトル長押しの表示モードドロワーから切り替えられます。',
style: Theme.of(context).textTheme.bodySmall,
),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('粗利を表示'),
subtitle: const Text('仕入値が入っている明細のみ粗利を計算します'),
value: _showGross,
onChanged: (value) => setState(() => _showGross = value),
),
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,
),
@ -1042,16 +821,10 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
const Divider(height: 32),
if (_shouldShowGross) _buildGrossSummary(),
if (_shouldShowGross) const Divider(height: 32),
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,
),
),
TextField(
controller: _notesController,
decoration: const InputDecoration(labelText: 'メモ'),
maxLines: 3,
),
_buildEditLogPanel(),
const SizedBox(height: 80),
@ -1087,211 +860,22 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
);
}
void _toggleCashSaleMode(bool enabled, {bool userAction = true}) {
if (_cashSaleMode == enabled) return;
void _toggleCashSaleMode(bool enabled) {
setState(() {
_cashSaleMode = enabled;
if (enabled) {
_selectedCustomer = null;
_customerSnapshot = _cashSaleLabel;
} else {
_customerSnapshot = _withHonorific(
_selectedCustomer?.invoiceName,
fallbackHonorific: _selectedCustomer?.title,
);
}
if (userAction) {
_cashSaleModeUserOverride = true;
_customerSnapshot = _selectedCustomer?.invoiceName;
}
});
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<void> _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()));
},
),
],
),
);
_logEdit(enabled ? '現金売上モードに切り替え' : '現金売上モードを解除');
_pushHistory(clearRedo: true);
}
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<SettlementMethod>(
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;
@ -1300,10 +884,7 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
int _lineCost(LineItemFormData line) => _lineQuantity(line) * line.costAmount;
int _lineGross(LineItemFormData line) {
if (_isProvisional(line)) return 0;
return _lineRevenue(line) - _lineCost(line);
}
int _lineGross(LineItemFormData line) => _lineRevenue(line) - _lineCost(line);
bool _isProvisional(LineItemFormData line) => line.costIsProvisional || line.costAmount <= 0;
@ -1317,12 +898,12 @@ class _SalesEntryEditorPageState extends State<_SalesEntryEditorPage> {
: gross >= 0
? Colors.green
: Colors.redAccent;
final label = provisional ? '粗利(暫定0円)' : '粗利';
final label = provisional ? '粗利(暫定)' : '粗利';
return Padding(
padding: const EdgeInsets.only(right: 8),
child: Chip(
label: Text('$label ${_formatYen(gross)}'),
backgroundColor: color.withValues(alpha: 0.12),
backgroundColor: color.withOpacity(0.12),
labelStyle: TextStyle(color: color, fontSize: 12, fontWeight: FontWeight.w600),
),
);
@ -1332,7 +913,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,
@ -1411,7 +992,7 @@ class _SummaryTile extends StatelessWidget {
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
color: theme.colorScheme.surfaceVariant.withOpacity(0.4),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View file

@ -13,9 +13,6 @@ import '../services/order_service.dart';
import '../services/receivable_service.dart';
import '../services/shipment_service.dart';
import '../services/shipping_label_service.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/modal_utils.dart';
import '../widgets/screen_id_title.dart';
import 'customer_picker_modal.dart';
class SalesOrdersScreen extends StatefulWidget {
@ -232,17 +229,11 @@ class _ShipmentEditorPageState extends State<ShipmentEditorPage> {
@override
Widget build(BuildContext context) {
final isCreating = widget.shipment == null;
final appBarTitle = isCreating ? '出荷指示作成' : '出荷情報編集';
final mediaQuery = MediaQuery.of(context);
final bottomInset = mediaQuery.viewInsets.bottom;
final title = widget.shipment == null ? '出荷指示の作成' : '出荷情報を編集';
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const BackButton(),
title: ScreenAppBarTitle(screenId: 'S2', title: appBarTitle),
title: Text(title == '出荷指示の作成' ? 'S2:出荷指示作成' : 'S2:出荷情報編集'),
actions: [
TextButton(
onPressed: _isSaving ? null : _save,
@ -252,95 +243,80 @@ class _ShipmentEditorPageState extends State<ShipmentEditorPage> {
),
],
),
body: MediaQuery(
data: mediaQuery.removeViewInsets(removeBottom: true),
child: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
body: SafeArea(
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
TextField(
controller: _orderIdController,
decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _orderNumberController,
decoration: const InputDecoration(labelText: '受注番号スナップショット', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _customerNameController,
decoration: const InputDecoration(labelText: '顧客名スナップショット', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
Row(
children: [
TextField(
controller: _orderIdController,
decoration: const InputDecoration(labelText: '受注ID (任意)', border: OutlineInputBorder()),
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('予定出荷日'),
subtitle: Text(_scheduledDate != null ? _dateFormat.format(_scheduledDate!) : '未設定'),
trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickScheduledDate),
),
),
const SizedBox(height: 12),
TextField(
controller: _orderNumberController,
decoration: const InputDecoration(labelText: '受注番号スナップショット', border: OutlineInputBorder()),
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('実績出荷日'),
subtitle: Text(_actualDate != null ? _dateFormat.format(_actualDate!) : '未設定'),
trailing: IconButton(icon: const Icon(Icons.calendar_month), onPressed: _pickActualDate),
),
),
const SizedBox(height: 12),
TextField(
controller: _customerNameController,
decoration: const InputDecoration(labelText: '顧客名スナップショット', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('予定出荷日'),
subtitle: Text(_scheduledDate != null ? _dateFormat.format(_scheduledDate!) : '未設定'),
trailing: IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: _pickScheduledDate,
),
),
),
Expanded(
child: ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('実績出荷日'),
subtitle: Text(_actualDate != null ? _dateFormat.format(_actualDate!) : '未設定'),
trailing: IconButton(
icon: const Icon(Icons.calendar_month),
onPressed: _pickActualDate,
),
),
),
],
),
const SizedBox(height: 12),
TextField(
controller: _carrierController,
decoration: const InputDecoration(labelText: '配送業者', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _trackingController,
decoration: const InputDecoration(labelText: '追跡番号', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _trackingUrlController,
decoration: const InputDecoration(labelText: '追跡URL', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()),
),
const SizedBox(height: 24),
const Text('出荷明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
for (int i = 0; i < _lines.length; i++) _buildLineCard(i),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _addLine,
icon: const Icon(Icons.add),
label: const Text('明細を追加'),
),
const SizedBox(height: 32),
],
),
),
const SizedBox(height: 12),
TextField(
controller: _carrierController,
decoration: const InputDecoration(labelText: '配送業者', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _trackingController,
decoration: const InputDecoration(labelText: '追跡番号', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _trackingUrlController,
decoration: const InputDecoration(labelText: '追跡URL', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()),
),
const SizedBox(height: 24),
const Text('出荷明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
for (int i = 0; i < _lines.length; i++) _buildLineCard(i),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _addLine,
icon: const Icon(Icons.add),
label: const Text('明細を追加'),
),
const SizedBox(height: 32),
],
),
),
),
@ -1051,7 +1027,7 @@ class _SalesInventoryScreenState extends State<SalesInventoryScreen> {
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const ScreenAppBarTitle(screenId: 'S4', title: '在庫管理'),
title: const Text('S4:在庫管理'),
actions: [
IconButton(
tooltip: '更新',
@ -1298,9 +1274,9 @@ class _MovementFormDialog extends StatefulWidget {
const _MovementFormDialog();
static Future<_MovementFormResult?> show(BuildContext context) {
return showFeatureModalBottomSheet<_MovementFormResult>(
return showDialog<_MovementFormResult>(
context: context,
builder: (_) => const _MovementFormDialog(),
builder: (_) => const Dialog(child: _MovementFormDialog()),
);
}
@ -1344,62 +1320,52 @@ class _MovementFormDialogState extends State<_MovementFormDialog> {
@override
Widget build(BuildContext context) {
final isAdjustment = _type == InventoryMovementType.adjustment;
return SafeArea(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: Colors.white,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('入出庫を記録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
DropdownButtonFormField<InventoryMovementType>(
initialValue: _type,
decoration: const InputDecoration(labelText: '区分', border: OutlineInputBorder()),
onChanged: (val) => setState(() => _type = val ?? InventoryMovementType.receipt),
items: InventoryMovementType.values
.map((type) => DropdownMenuItem(value: type, child: Text(type.displayName)))
.toList(),
),
const SizedBox(height: 12),
TextField(
controller: _quantityController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: _type == InventoryMovementType.adjustment ? '数量差分 (マイナス可)' : '数量',
border: const OutlineInputBorder(),
),
title: const ScreenAppBarTitle(screenId: 'S4', title: '入出庫を記録'),
actions: [
TextButton(onPressed: _submit, child: const Text('登録')),
),
const SizedBox(height: 12),
TextField(
controller: _referenceController,
decoration: const InputDecoration(labelText: '参照 (任意)', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 2,
decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')),
const SizedBox(width: 12),
FilledButton(onPressed: _submit, child: const Text('登録')),
],
),
body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
extraBottom: 24,
child: ListView(
children: [
DropdownButtonFormField<InventoryMovementType>(
value: _type,
decoration: const InputDecoration(labelText: '区分', border: OutlineInputBorder()),
onChanged: (val) => setState(() => _type = val ?? InventoryMovementType.receipt),
items: InventoryMovementType.values
.map((type) => DropdownMenuItem(value: type, child: Text(type.displayName)))
.toList(),
),
const SizedBox(height: 16),
TextField(
controller: _quantityController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: isAdjustment ? '数量差分 (マイナス可)' : '数量',
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _referenceController,
decoration: const InputDecoration(labelText: '参照 (任意)', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
TextField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'メモ', border: OutlineInputBorder()),
),
],
),
),
),
],
),
);
}
@ -1799,9 +1765,9 @@ class _PaymentFormDialog extends StatefulWidget {
const _PaymentFormDialog();
static Future<_PaymentFormResult?> show(BuildContext context) {
return showFeatureModalBottomSheet<_PaymentFormResult>(
return showDialog<_PaymentFormResult>(
context: context,
builder: (ctx) => const _PaymentFormDialog(),
builder: (ctx) => const Dialog(child: _PaymentFormDialog()),
);
}
@ -1814,7 +1780,6 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> {
final TextEditingController _notesController = TextEditingController();
DateTime _paymentDate = DateTime.now();
PaymentMethod _method = PaymentMethod.bankTransfer;
bool _isSubmitting = false;
@override
void dispose() {
@ -1836,13 +1801,11 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> {
}
void _submit() {
if (_isSubmitting) return;
final amount = int.tryParse(_amountController.text.trim());
if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('金額を入力してください')));
return;
}
setState(() => _isSubmitting = true);
Navigator.of(context).pop(
_PaymentFormResult(
amount: amount,
@ -1856,80 +1819,50 @@ class _PaymentFormDialogState extends State<_PaymentFormDialog> {
@override
Widget build(BuildContext context) {
final dateLabel = DateFormat('yyyy/MM/dd').format(_paymentDate);
return SafeArea(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
),
title: const ScreenAppBarTitle(screenId: 'S5', title: '入金登録'),
actions: [
TextButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('保存'),
),
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('入金を登録', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
TextField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('入金日'),
subtitle: Text(dateLabel),
trailing: IconButton(icon: const Icon(Icons.calendar_today), onPressed: _pickDate),
),
DropdownButtonFormField<PaymentMethod>(
initialValue: _method,
decoration: const InputDecoration(labelText: '入金方法', border: OutlineInputBorder()),
onChanged: (val) => setState(() => _method = val ?? PaymentMethod.bankTransfer),
items: PaymentMethod.values
.map((method) => DropdownMenuItem(value: method, child: Text(method.displayName)))
.toList(),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 2,
decoration: const InputDecoration(labelText: 'メモ (任意)', border: OutlineInputBorder()),
),
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('キャンセル')),
const SizedBox(width: 12),
FilledButton(onPressed: _submit, child: const Text('登録')),
],
),
body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
extraBottom: 24,
child: ListView(
children: [
TextField(
controller: _amountController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(labelText: '入金額 (円)', border: OutlineInputBorder()),
),
const SizedBox(height: 16),
InputDecorator(
decoration: const InputDecoration(labelText: '入金日', border: OutlineInputBorder()),
child: InkWell(
onTap: _pickDate,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(dateLabel),
const Icon(Icons.calendar_today, size: 18),
],
),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<PaymentMethod>(
value: _method,
decoration: const InputDecoration(labelText: '入金方法', border: OutlineInputBorder()),
onChanged: (val) => setState(() => _method = val ?? PaymentMethod.bankTransfer),
items: PaymentMethod.values
.map((method) => DropdownMenuItem(value: method, child: Text(method.displayName)))
.toList(),
),
const SizedBox(height: 16),
TextField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'メモ (任意)', border: OutlineInputBorder()),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _isSubmitting ? null : _submit,
child: const Text('入金を登録'),
),
],
),
),
),
],
),
);
}
@ -1992,8 +1925,9 @@ class _SalesOrderEditorPageState extends State<SalesOrderEditorPage> {
}
Future<void> _pickCustomer() async {
final selected = await showFeatureModalBottomSheet<Customer?>(
final selected = await showModalBottomSheet<Customer?>(
context: context,
isScrollControlled: true,
builder: (ctx) => CustomerPickerModal(
onCustomerSelected: (customer) {
Navigator.pop(ctx, customer);
@ -2088,16 +2022,10 @@ class _SalesOrderEditorPageState extends State<SalesOrderEditorPage> {
@override
Widget build(BuildContext context) {
final title = widget.order == null ? '受注の新規登録' : '受注を編集';
final screenTitle = widget.order == null ? '受注登録' : '受注編集';
final mediaQuery = MediaQuery.of(context);
final bottomInset = mediaQuery.viewInsets.bottom;
return Scaffold(
backgroundColor: Colors.grey.shade200,
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: const BackButton(),
title: ScreenAppBarTitle(screenId: 'S6', title: screenTitle),
title: Text(title == '受注の新規登録' ? 'S6:受注登録' : 'S6:受注編集'),
actions: [
TextButton(
onPressed: _isSaving ? null : _save,
@ -2107,64 +2035,55 @@ class _SalesOrderEditorPageState extends State<SalesOrderEditorPage> {
),
],
),
body: MediaQuery(
data: mediaQuery.removeViewInsets(removeBottom: true),
child: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => FocusScope.of(context).unfocus(),
child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + 32 + bottomInset),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('取引先'),
subtitle: Text(_customerName ?? '未選択'),
trailing: OutlinedButton.icon(
onPressed: _pickCustomer,
icon: const Icon(Icons.search),
label: Text(_customerName == null ? '選択' : '変更'),
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('希望出荷日'),
subtitle: Text(_requestedShipDate != null ? _dateFormat.format(_requestedShipDate!) : '未設定'),
trailing: IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: _pickDate,
),
),
const SizedBox(height: 12),
TextField(
controller: _assigneeController,
decoration: const InputDecoration(labelText: '担当者 (任意)', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'メモ / 特記事項', border: OutlineInputBorder()),
),
const SizedBox(height: 24),
const Text('受注明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
for (int i = 0; i < _lines.length; i++) _buildLineCard(i),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _addLine,
icon: const Icon(Icons.add),
label: const Text('明細を追加'),
),
const SizedBox(height: 32),
],
body: SafeArea(
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: ListView(
padding: const EdgeInsets.all(20),
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('取引先'),
subtitle: Text(_customerName ?? '未選択'),
trailing: OutlinedButton.icon(
onPressed: _pickCustomer,
icon: const Icon(Icons.search),
label: Text(_customerName == null ? '選択' : '変更'),
),
),
),
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('希望出荷日'),
subtitle: Text(_requestedShipDate != null ? _dateFormat.format(_requestedShipDate!) : '未設定'),
trailing: IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: _pickDate,
),
),
const SizedBox(height: 12),
TextField(
controller: _assigneeController,
decoration: const InputDecoration(labelText: '担当者 (任意)', border: OutlineInputBorder()),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 3,
decoration: const InputDecoration(labelText: 'メモ / 特記事項', border: OutlineInputBorder()),
),
const SizedBox(height: 24),
const Text('受注明細', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
for (int i = 0; i < _lines.length; i++) _buildLineCard(i),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _addLine,
icon: const Icon(Icons.add),
label: const Text('明細を追加'),
),
const SizedBox(height: 32),
],
),
),
),

View file

@ -86,8 +86,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
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';
@ -368,28 +366,14 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<void> _setSalesEntryDefaultCashMode(bool value) async {
setState(() => _salesEntryDefaultCashMode = value);
await _appSettingsRepo.setSalesEntryCashModeDefault(value);
}
Future<void> _setSalesEntryShowGross(bool value) async {
setState(() => _salesEntryShowGross = value);
await _appSettingsRepo.setSalesEntryShowGross(value);
}
Future<void> _handleCalendarEnabledChanged(bool enabled) async {
if (_calendarBusy) return;
setState(() => _calendarBusy = true);
@ -790,31 +774,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
],
),
),
_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: '会社・担当者・振込口座・電話帳取り込み',

View file

@ -1,309 +0,0 @@
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';
import '../widgets/modal_utils.dart';
import '../widgets/screen_id_title.dart';
class SupplierPickerModal extends StatefulWidget {
const SupplierPickerModal({super.key, required this.onSupplierSelected});
final ValueChanged<Supplier> onSupplierSelected;
@override
State<SupplierPickerModal> createState() => _SupplierPickerModalState();
}
class _SupplierPickerModalState extends State<SupplierPickerModal> {
final SupplierRepository _repository = SupplierRepository();
final TextEditingController _searchController = TextEditingController();
final Uuid _uuid = const Uuid();
List<Supplier> _suppliers = const [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSuppliers();
}
Future<void> _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<void> _openEditor({Supplier? supplier}) async {
final result = await showFeatureModalBottomSheet<Supplier>(
context: context,
builder: (ctx) => _SupplierFormSheet(
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);
}
Future<void> _deleteSupplier(Supplier supplier) async {
final confirmed = await showDialog<bool>(
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) {
final theme = Theme.of(context);
return SafeArea(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
automaticallyImplyLeading: false,
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
title: const ScreenAppBarTitle(screenId: 'P5', title: '仕入先選択'),
actions: [
IconButton(
tooltip: '仕入先を追加',
onPressed: _openEditor,
icon: const Icon(Icons.add_circle_outline),
),
],
),
body: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
extraBottom: 24,
child: Column(
children: [
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
? Center(
child: Text(
'仕入先が見つかりません。右上の + から追加できます。',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
)
: 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<String>(
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 _SupplierFormSheet extends StatefulWidget {
const _SupplierFormSheet({required this.onSubmit, this.supplier});
final Supplier? supplier;
final ValueChanged<Supplier> onSubmit;
@override
State<_SupplierFormSheet> createState() => _SupplierFormSheetState();
}
class _SupplierFormSheetState extends State<_SupplierFormSheet> {
late final TextEditingController _nameController;
late final TextEditingController _contactController;
late final TextEditingController _telController;
late final TextEditingController _emailController;
late final TextEditingController _notesController;
final _formKey = GlobalKey<FormState>();
bool _isSaving = false;
@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
void dispose() {
_nameController.dispose();
_contactController.dispose();
_telController.dispose();
_emailController.dispose();
_notesController.dispose();
super.dispose();
}
void _handleSubmit() {
if (_isSaving) return;
if (!_formKey.currentState!.validate()) return;
setState(() => _isSaving = true);
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(),
),
);
}
@override
Widget build(BuildContext context) {
final title = widget.supplier == null ? '仕入先を追加' : '仕入先を編集';
return SafeArea(
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Material(
color: Colors.white,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
children: [
IconButton(onPressed: () => Navigator.pop(context), icon: const Icon(Icons.close)),
const SizedBox(width: 4),
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const Spacer(),
TextButton(
onPressed: _isSaving ? null : _handleSubmit,
child: _isSaving
? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('保存'),
),
],
),
),
Expanded(
child: KeyboardInsetWrapper(
safeAreaTop: false,
basePadding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
extraBottom: 24,
child: Form(
key: _formKey,
child: ListView(
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),
],
),
),
),
),
],
),
),
),
);
}
}

View file

@ -16,8 +16,6 @@ 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();
@ -144,22 +142,6 @@ class AppSettingsRepository {
await setBool(_kGrossProfitIncludeProvisional, include);
}
Future<bool> getSalesEntryCashModeDefault({bool defaultValue = false}) async {
return getBool(_kSalesEntryCashModeDefault, defaultValue: defaultValue);
}
Future<void> setSalesEntryCashModeDefault(bool enabled) async {
await setBool(_kSalesEntryCashModeDefault, enabled);
}
Future<bool> getSalesEntryShowGross({bool defaultValue = true}) async {
return getBool(_kSalesEntryShowGross, defaultValue: defaultValue);
}
Future<void> setSalesEntryShowGross(bool display) async {
await setBool(_kSalesEntryShowGross, display);
}
// Generic helpers
Future<String?> getString(String key) async => _getValue(key);
Future<void> setString(String key, String value) async => _setValue(key, value);

View file

@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
static const _databaseVersion = 37;
static const _databaseVersion = 35;
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database;
@ -245,14 +245,6 @@ 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<void> _onCreate(Database db, int version) async {
@ -442,7 +434,6 @@ 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'");
@ -655,9 +646,6 @@ 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)
@ -724,70 +712,4 @@ class DatabaseHelper {
)
''');
}
Future<void> _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)');
}
}

View file

@ -1,66 +0,0 @@
import 'package:sqflite/sqflite.dart';
import '../models/purchase_entry_models.dart';
import 'database_helper.dart';
class PurchaseEntryRepository {
PurchaseEntryRepository();
final DatabaseHelper _dbHelper = DatabaseHelper();
Future<void> 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<PurchaseEntry?> 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<List<PurchaseEntry>> fetchEntries({PurchaseEntryStatus? status, int? limit}) async {
final db = await _dbHelper.database;
final where = <String>[];
final args = <Object?>[];
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 = <PurchaseEntry>[];
for (final row in rows) {
final items = await _fetchItems(db, row['id'] as String);
result.add(PurchaseEntry.fromMap(row, items: items));
}
return result;
}
Future<void> 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<List<PurchaseLineItem>> _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();
}
}

View file

@ -1,61 +0,0 @@
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<List<PurchaseEntry>> fetchEntries({PurchaseEntryStatus? status, int? limit}) {
return _entryRepository.fetchEntries(status: status, limit: limit);
}
Future<PurchaseEntry?> findById(String id) {
return _entryRepository.findById(id);
}
Future<void> deleteEntry(String id) {
return _entryRepository.deleteEntry(id);
}
Future<PurchaseEntry> saveEntry(PurchaseEntry entry) async {
final updated = entry.recalcTotals().copyWith(updatedAt: DateTime.now());
await _entryRepository.upsertEntry(updated);
return updated;
}
Future<PurchaseEntry> createQuickEntry({
String? supplierId,
String? supplierNameSnapshot,
String? subject,
DateTime? issueDate,
List<PurchaseLineItem>? 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<Map<String, int>> fetchAllocatedTotals(Iterable<String> entryIds) {
return _receiptRepository.fetchAllocatedTotals(entryIds);
}
}

View file

@ -1,81 +0,0 @@
import 'package:sqflite/sqflite.dart';
import '../models/purchase_entry_models.dart';
import 'database_helper.dart';
class PurchaseReceiptRepository {
PurchaseReceiptRepository();
final DatabaseHelper _dbHelper = DatabaseHelper();
Future<void> upsertReceipt(PurchaseReceipt receipt, List<PurchaseReceiptLink> 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<List<PurchaseReceipt>> fetchReceipts({DateTime? startDate, DateTime? endDate}) async {
final db = await _dbHelper.database;
final where = <String>[];
final args = <Object?>[];
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<PurchaseReceipt?> 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<List<PurchaseReceiptLink>> 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<Map<String, int>> fetchAllocatedTotals(Iterable<String> 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 = <String, int>{};
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<void> 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]);
});
}
}

View file

@ -1,135 +0,0 @@
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<List<PurchaseReceipt>> fetchReceipts({DateTime? startDate, DateTime? endDate}) {
return _receiptRepository.fetchReceipts(startDate: startDate, endDate: endDate);
}
Future<Map<String, int>> fetchAllocatedTotals(Iterable<String> entryIds) {
return _receiptRepository.fetchAllocatedTotals(entryIds);
}
Future<List<PurchaseReceiptLink>> fetchLinks(String receiptId) {
return _receiptRepository.fetchLinks(receiptId);
}
Future<PurchaseReceipt?> findById(String id) {
return _receiptRepository.findById(id);
}
Future<void> deleteReceipt(String id) {
return _receiptRepository.deleteReceipt(id);
}
Future<PurchaseReceipt> createReceipt({
String? supplierId,
required DateTime paymentDate,
required int amount,
String? method,
String? notes,
List<PurchaseReceiptAllocationInput> 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<PurchaseReceipt> updateReceipt({
required PurchaseReceipt receipt,
List<PurchaseReceiptAllocationInput> allocations = const [],
}) {
final updated = receipt.copyWith(updatedAt: DateTime.now());
return _saveReceipt(receipt: updated, allocations: allocations);
}
Future<PurchaseReceipt> _saveReceipt({
required PurchaseReceipt receipt,
required List<PurchaseReceiptAllocationInput> allocations,
}) async {
final entries = await _loadEntries(allocations.map((a) => a.purchaseEntryId));
final allocatedTotals = await _receiptRepository.fetchAllocatedTotals(entries.keys);
final links = <PurchaseReceiptLink>[];
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<int>(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<void> _updateEntryStatuses(Iterable<PurchaseEntry> entries, Map<String, int> 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<Map<String, PurchaseEntry>> _loadEntries(Iterable<String> entryIds) async {
final map = <String, PurchaseEntry>{};
for (final id in entryIds) {
final entry = await _entryRepository.findById(id);
if (entry != null) {
map[id] = entry;
}
}
return map;
}
}

View file

@ -8,8 +8,6 @@ class KeyboardInsetWrapper extends StatelessWidget {
final double extraBottom;
final Duration duration;
final Curve curve;
final bool safeAreaTop;
final bool safeAreaBottom;
const KeyboardInsetWrapper({
super.key,
@ -18,8 +16,6 @@ class KeyboardInsetWrapper extends StatelessWidget {
this.extraBottom = 0,
this.duration = const Duration(milliseconds: 180),
this.curve = Curves.easeOut,
this.safeAreaTop = true,
this.safeAreaBottom = true,
});
@override
@ -27,15 +23,15 @@ class KeyboardInsetWrapper extends StatelessWidget {
final mediaQuery = MediaQuery.of(context);
final bottomInset = mediaQuery.viewInsets.bottom;
final padding = basePadding.add(EdgeInsets.only(bottom: bottomInset + extraBottom));
final applyBottomSafeArea = safeAreaBottom && bottomInset == 0;
return SafeArea(
top: safeAreaTop,
bottom: applyBottomSafeArea,
child: AnimatedPadding(
duration: duration,
curve: curve,
padding: padding,
child: child,
return MediaQuery(
data: mediaQuery.removeViewInsets(removeBottom: true),
child: SafeArea(
child: AnimatedPadding(
duration: duration,
curve: curve,
padding: padding,
child: child,
),
),
);
}

View file

@ -85,17 +85,14 @@ class LineItemCard extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 8),
color: Colors.white,
margin: const EdgeInsets.symmetric(vertical: 8),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
padding: const EdgeInsets.all(12),
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,
@ -108,24 +105,21 @@ class LineItemCard extends StatelessWidget {
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [meta, const Icon(Icons.chevron_right)]
.whereType<Widget>()
.toList(growable: false),
children: [
if (meta != null) meta!,
const Icon(Icons.chevron_right),
],
),
onTap: onPickProduct,
),
const SizedBox(height: 2),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: TextField(
controller: data.quantityController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '数量',
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8),
),
decoration: const InputDecoration(labelText: '数量'),
scrollPadding: const EdgeInsets.only(bottom: 160),
),
),
@ -134,21 +128,17 @@ class LineItemCard extends StatelessWidget {
child: TextField(
controller: data.unitPriceController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '単価(税抜)',
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 8, horizontal: 8),
),
decoration: const InputDecoration(labelText: '単価(税抜)'),
scrollPadding: const EdgeInsets.only(bottom: 160),
),
),
IconButton(onPressed: onRemove, icon: const Icon(Icons.close)),
],
),
...[
footer == null ? null : const SizedBox(height: 8),
footer,
].whereType<Widget>(),
if (footer != null) ...[
const SizedBox(height: 8),
footer!,
],
],
),
),

View file

@ -1,20 +0,0 @@
import 'package:flutter/material.dart';
/// Presents a rounded feature modal with consistent safe-area handling.
Future<T?> showFeatureModalBottomSheet<T>({
required BuildContext context,
required WidgetBuilder builder,
double heightFactor = 0.9,
bool isScrollControlled = true,
Color backgroundColor = Colors.transparent,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: isScrollControlled,
backgroundColor: backgroundColor,
builder: (sheetContext) => FractionallySizedBox(
heightFactor: heightFactor,
child: builder(sheetContext),
),
);
}

View file

@ -3,22 +3,12 @@ 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] [client|mothership]" >&2
exit 1
;;
esac
case "${FLAVOR}" in
client|mothership)
;;
*)
echo "Invalid flavor '${FLAVOR}'. Use 'client' or 'mothership'." >&2
echo "Usage: $0 [debug|profile|release]" >&2
exit 1
;;
esac
@ -26,30 +16,23 @@ esac
cd "${PROJECT_ROOT}"
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
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
DART_DEFINE="APP_BUILD_TIMESTAMP=${timestamp}"
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}, flavor=${FLAVOR})..."
echo "[build_with_expiry] Building APK (${BUILD_MODE})..."
case "${BUILD_MODE}" in
debug)
flutter build apk --debug --flavor "${FLAVOR}" "${dart_define_args[@]}"
flutter build apk --debug --dart-define="${DART_DEFINE}"
;;
profile)
flutter build apk --profile --flavor "${FLAVOR}" "${dart_define_args[@]}"
flutter build apk --profile --dart-define="${DART_DEFINE}"
;;
release)
flutter build apk --release --flavor "${FLAVOR}" "${dart_define_args[@]}"
flutter build apk --release --dart-define="${DART_DEFINE}"
;;
esac
echo "[build_with_expiry] Done. APK with 90-day lifespan & full features generated."
echo "[build_with_expiry] Done. APK with 90-day lifespan generated."