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