feat: add lock flags and refresh theme

This commit is contained in:
joe 2026-02-25 19:13:34 +09:00
parent 5426751978
commit 145f0d7cad
9 changed files with 217 additions and 164 deletions

View file

@ -22,9 +22,43 @@ class MyApp extends StatelessWidget {
return MaterialApp(
title: '販売アシスト1号',
theme: ThemeData(
primarySwatch: Colors.blueGrey,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
primary: Colors.indigo.shade700,
secondary: Colors.deepOrange.shade400,
surface: Colors.grey.shade50,
onSurface: Colors.blueGrey.shade900,
),
scaffoldBackgroundColor: Colors.grey.shade50,
appBarTheme: AppBarTheme(
backgroundColor: Colors.indigo.shade700,
foregroundColor: Colors.white,
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: BorderSide(color: Colors.indigo.shade700),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4),
),
),
visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true,
fontFamily: 'IPAexGothic',
),
home: const InvoiceHistoryScreen(),
);
@ -54,54 +88,20 @@ class _InvoiceFlowScreenState extends State<InvoiceFlowScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: const BackButton(),
title: const Text("販売アシスト1号 V1.5.02"),
backgroundColor: Colors.blueGrey,
),
drawer: Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
const DrawerHeader(
decoration: BoxDecoration(color: Colors.blueGrey),
child: Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 24)),
),
ListTile(
leading: const Icon(Icons.add_task),
title: const Text("新規伝票作成"),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.history),
title: const Text("伝票履歴"),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const InvoiceHistoryScreen()),
);
},
),
],
),
),
//
body: InvoiceInputForm(
onInvoiceGenerated: (invoice, path) async {
// GPSの記録を試みる
final locationService = LocationService();
final position = await locationService.getCurrentLocation();
if (position != null) {
final customerRepo = CustomerRepository();
await customerRepo.addGpsHistory(invoice.customer.id, position.latitude, position.longitude);
debugPrint("GPS recorded for customer ${invoice.customer.id}");
}
_handleInvoiceGenerated(invoice, path);
if (widget.onComplete != null) widget.onComplete!();
},
),
// Scaffold
return InvoiceInputForm(
onInvoiceGenerated: (invoice, path) async {
// GPSの記録を試みる
final locationService = LocationService();
final position = await locationService.getCurrentLocation();
if (position != null) {
final customerRepo = CustomerRepository();
await customerRepo.addGpsHistory(invoice.customer.id, position.latitude, position.longitude);
debugPrint("GPS recorded for customer ${invoice.customer.id}");
}
_handleInvoiceGenerated(invoice, path);
if (widget.onComplete != null) widget.onComplete!();
},
);
}
}

View file

@ -10,6 +10,7 @@ class Customer {
final String? odooId; // Odoo側のID
final bool isSynced; //
final DateTime updatedAt; //
final bool isLocked; //
Customer({
required this.id,
@ -22,6 +23,7 @@ class Customer {
this.odooId,
this.isSynced = false,
DateTime? updatedAt,
this.isLocked = false,
}) : updatedAt = updatedAt ?? DateTime.now();
String get invoiceName {
@ -42,6 +44,7 @@ class Customer {
'address': address,
'tel': tel,
'odoo_id': odooId,
'is_locked': isLocked ? 1 : 0,
'is_synced': isSynced ? 1 : 0,
'updated_at': updatedAt.toIso8601String(),
};
@ -57,6 +60,7 @@ class Customer {
address: map['address'],
tel: map['tel'],
odooId: map['odoo_id'],
isLocked: (map['is_locked'] ?? 0) == 1,
isSynced: map['is_synced'] == 1,
updatedAt: DateTime.parse(map['updated_at']),
);
@ -73,6 +77,7 @@ class Customer {
String? odooId,
bool? isSynced,
DateTime? updatedAt,
bool? isLocked,
}) {
return Customer(
id: id ?? this.id,
@ -85,6 +90,7 @@ class Customer {
odooId: odooId ?? this.odooId,
isSynced: isSynced ?? this.isSynced,
updatedAt: updatedAt ?? this.updatedAt,
isLocked: isLocked ?? this.isLocked,
);
}
}

View file

@ -83,6 +83,7 @@ class Invoice {
final String terminalId; // :
final bool isDraft; // :
final String? subject; // :
final bool isLocked; // :
Invoice({
String? id,
@ -102,6 +103,7 @@ class Invoice {
String? terminalId, //
this.isDraft = false, // :
this.subject, // :
this.isLocked = false,
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
terminalId = terminalId ?? "T1", // ID
updatedAt = updatedAt ?? DateTime.now();
@ -160,6 +162,7 @@ class Invoice {
'content_hash': contentHash, //
'is_draft': isDraft ? 1 : 0, //
'subject': subject, //
'is_locked': isLocked ? 1 : 0,
};
}
@ -181,6 +184,7 @@ class Invoice {
String? terminalId,
bool? isDraft,
String? subject,
bool? isLocked,
}) {
return Invoice(
id: id ?? this.id,
@ -200,6 +204,7 @@ class Invoice {
terminalId: terminalId ?? this.terminalId,
isDraft: isDraft ?? this.isDraft,
subject: subject ?? this.subject,
isLocked: isLocked ?? this.isLocked,
);
}

View file

@ -6,6 +6,7 @@ class Product {
final String? category;
final int stockQuantity; //
final String? odooId;
final bool isLocked; //
Product({
required this.id,
@ -15,6 +16,7 @@ class Product {
this.category,
this.stockQuantity = 0, //
this.odooId,
this.isLocked = false,
});
Map<String, dynamic> toMap() {
@ -25,6 +27,7 @@ class Product {
'barcode': barcode,
'category': category,
'stock_quantity': stockQuantity, //
'is_locked': isLocked ? 1 : 0,
'odoo_id': odooId,
};
}
@ -37,6 +40,7 @@ class Product {
barcode: map['barcode'],
category: map['category'],
stockQuantity: map['stock_quantity'] ?? 0, //
isLocked: (map['is_locked'] ?? 0) == 1,
odooId: map['odoo_id'],
);
}
@ -47,12 +51,14 @@ class Product {
int? defaultUnitPrice,
String? barcode,
String? odooId,
bool? isLocked,
}) {
return Product(
id: id ?? this.id,
name: name ?? this.name,
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
odooId: odooId ?? this.odooId,
isLocked: isLocked ?? this.isLocked,
);
}
}

View file

@ -327,6 +327,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
label: const Text("新規伝票作成"),
icon: const Icon(Icons.add),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
);
}

View file

@ -129,24 +129,31 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
);
setState(() => _isSaving = true);
// PDF生成有無に関わらず
if (generatePdf) {
setState(() => _status = "PDFを生成中...");
final path = await generateInvoicePdf(invoice);
if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path);
await _repository.saveInvoice(updatedInvoice);
if (mounted) widget.onInvoiceGenerated(updatedInvoice, path);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
try {
// PDF生成有無に関わらず
if (generatePdf) {
setState(() => _status = "PDFを生成中...");
final path = await generateInvoicePdf(invoice);
if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path);
await _repository.saveInvoice(updatedInvoice);
if (mounted) widget.onInvoiceGenerated(updatedInvoice, path);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
} else {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("PDF生成に失敗しました")));
}
} else {
await _repository.saveInvoice(invoice);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しましたPDF未生成")));
if (mounted) Navigator.pop(context);
}
} else {
await _repository.saveInvoice(invoice);
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しましたPDF未生成")));
if (mounted) Navigator.pop(context);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
}
} finally {
if (mounted) setState(() => _isSaving = false);
}
if (mounted) setState(() => _isSaving = false);
}
void _showPreview() {
@ -202,7 +209,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
backgroundColor: themeColor,
appBar: AppBar(
leading: const BackButton(),
title: Text(_isDraft ? "伝票作成 (下書き)" : "販売アシスト1号 V1.5.05"),
title: Text(_isDraft ? "伝票作成 (下書き)" : "販売アシスト1号 V1.5.06"),
backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey,
),
body: Stack(
@ -374,102 +381,98 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))),
)
else
..._items.asMap().entries.map((entry) {
final idx = entry.key;
final item = entry.value;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(item.description),
subtitle: Text("${fmt.format(item.unitPrice)} x ${item.quantity}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
if (idx > 0)
IconButton(
icon: const Icon(Icons.arrow_upward, size: 20),
onPressed: () => setState(() {
final temp = _items[idx];
_items[idx] = _items[idx - 1];
_items[idx - 1] = temp;
}),
tooltip: "上へ",
),
if (idx < _items.length - 1)
IconButton(
icon: const Icon(Icons.arrow_downward, size: 20),
onPressed: () => setState(() {
final temp = _items[idx];
_items[idx] = _items[idx + 1];
_items[idx + 1] = temp;
}),
tooltip: "下へ",
),
IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent),
onPressed: () => setState(() => _items.removeAt(idx)),
tooltip: "削除",
),
],
),
onTap: () {
//
final descCtrl = TextEditingController(text: item.description);
final qtyCtrl = TextEditingController(text: item.quantity.toString());
final priceCtrl = TextEditingController(text: item.unitPrice.toString());
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("明細の編集"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: descCtrl, decoration: const InputDecoration(labelText: "品名 / 項目")),
TextField(controller: qtyCtrl, decoration: const InputDecoration(labelText: "数量"), keyboardType: TextInputType.number),
TextField(controller: priceCtrl, decoration: const InputDecoration(labelText: "単価"), keyboardType: TextInputType.number),
],
),
actions: [
TextButton.icon(
icon: const Icon(Icons.search, size: 18),
label: const Text("マスター参照"),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ProductPickerModal(
onItemSelected: (selected) {
descCtrl.text = selected.description;
priceCtrl.text = selected.unitPrice.toString();
Navigator.pop(context); // close picker
},
),
);
},
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
ElevatedButton(
onPressed: () {
setState(() {
_items[idx] = item.copyWith(
description: descCtrl.text,
quantity: int.tryParse(qtyCtrl.text) ?? item.quantity,
unitPrice: int.tryParse(priceCtrl.text) ?? item.unitPrice,
);
});
Navigator.pop(context);
},
child: const Text("更新"),
ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _items.length,
onReorder: (oldIndex, newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final item = _items.removeAt(oldIndex);
_items.insert(newIndex, item);
});
},
buildDefaultDragHandles: false,
itemBuilder: (context, idx) {
final item = _items[idx];
return ReorderableDelayedDragStartListener(
key: ValueKey('item_${idx}_${item.description}'),
index: idx,
child: Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
title: Text(item.description),
subtitle: Text("${fmt.format(item.unitPrice)} x ${item.quantity}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text("${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent),
onPressed: () => setState(() => _items.removeAt(idx)),
tooltip: "削除",
),
],
),
);
},
),
);
}),
onTap: () {
//
final descCtrl = TextEditingController(text: item.description);
final qtyCtrl = TextEditingController(text: item.quantity.toString());
final priceCtrl = TextEditingController(text: item.unitPrice.toString());
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("明細の編集"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: descCtrl, decoration: const InputDecoration(labelText: "品名 / 項目")),
TextField(controller: qtyCtrl, decoration: const InputDecoration(labelText: "数量"), keyboardType: TextInputType.number),
TextField(controller: priceCtrl, decoration: const InputDecoration(labelText: "単価"), keyboardType: TextInputType.number),
],
),
actions: [
TextButton.icon(
icon: const Icon(Icons.search, size: 18),
label: const Text("マスター参照"),
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ProductPickerModal(
onItemSelected: (selected) {
descCtrl.text = selected.description;
priceCtrl.text = selected.unitPrice.toString();
Navigator.pop(context); // close picker
},
),
);
},
),
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
ElevatedButton(
onPressed: () {
setState(() {
_items[idx] = item.copyWith(
description: descCtrl.text,
quantity: int.tryParse(qtyCtrl.text) ?? item.quantity,
unitPrice: int.tryParse(priceCtrl.text) ?? item.unitPrice,
);
});
Navigator.pop(context);
},
child: const Text("更新"),
),
],
),
);
},
),
),
);
},
),
],
);
}

View file

@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
static const _databaseVersion = 13;
static const _databaseVersion = 15;
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database;
@ -97,6 +97,14 @@ class DatabaseHelper {
if (oldVersion < 13) {
await db.execute('ALTER TABLE company_info ADD COLUMN registration_number TEXT');
}
if (oldVersion < 14) {
await _safeAddColumn(db, 'invoices', 'subject TEXT');
}
if (oldVersion < 15) {
await _safeAddColumn(db, 'invoices', 'is_locked INTEGER DEFAULT 0');
await _safeAddColumn(db, 'customers', 'is_locked INTEGER DEFAULT 0');
await _safeAddColumn(db, 'products', 'is_locked INTEGER DEFAULT 0');
}
}
Future<void> _onCreate(Database db, int version) async {
@ -110,6 +118,7 @@ class DatabaseHelper {
address TEXT,
tel TEXT,
odoo_id TEXT,
is_locked INTEGER DEFAULT 0,
is_synced INTEGER DEFAULT 0,
updated_at TEXT NOT NULL
)
@ -135,6 +144,7 @@ class DatabaseHelper {
barcode TEXT,
category TEXT,
stock_quantity INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0,
odoo_id TEXT
)
''');
@ -148,6 +158,7 @@ class DatabaseHelper {
customer_id TEXT NOT NULL,
date TEXT NOT NULL,
notes TEXT,
subject TEXT,
file_path TEXT,
total_amount INTEGER,
tax_rate REAL DEFAULT 0.10,
@ -161,6 +172,7 @@ class DatabaseHelper {
terminal_id TEXT DEFAULT "T1",
content_hash TEXT,
is_draft INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0,
FOREIGN KEY (customer_id) REFERENCES customers (id)
)
''');
@ -212,4 +224,12 @@ class DatabaseHelper {
)
''');
}
Future<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.
}
}
}

View file

@ -13,6 +13,9 @@ class InvoiceRepository {
Future<void> saveInvoice(Invoice invoice) async {
final db = await _dbHelper.database;
//
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
await db.transaction((txn) async {
// 調
final List<Map<String, dynamic>> oldItems = await txn.query(
@ -34,7 +37,7 @@ class InvoiceRepository {
//
await txn.insert(
'invoices',
invoice.toMap(),
toSave.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
@ -53,8 +56,16 @@ class InvoiceRepository {
'UPDATE products SET stock_quantity = stock_quantity - ? WHERE id = ?',
[item.quantity, item.productId],
);
if (!invoice.isDraft) {
await txn.execute('UPDATE products SET is_locked = 1 WHERE id = ?', [item.productId]);
}
}
}
//
if (!invoice.isDraft) {
await txn.execute('UPDATE customers SET is_locked = 1 WHERE id = ?', [invoice.customer.id]);
}
});
await _logRepo.logAction(
@ -114,6 +125,7 @@ class InvoiceRepository {
terminalId: iMap['terminal_id'] ?? "T1",
isDraft: (iMap['is_draft'] ?? 0) == 1,
subject: iMap['subject'],
isLocked: (iMap['is_locked'] ?? 0) == 1,
));
}
return invoices;

View file

@ -18,7 +18,7 @@ class SlideToUnlock extends StatefulWidget {
class _SlideToUnlockState extends State<SlideToUnlock> {
double _position = 0.0;
final double _thumbSize = 50.0;
final double _thumbSize = 56.0;
@override
Widget build(BuildContext context) {
@ -27,7 +27,7 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
return LayoutBuilder(
builder: (context, constraints) {
final double maxWidth = constraints.maxWidth;
final double trackWidth = maxWidth - _thumbSize - 8; //
final double trackWidth = (maxWidth - _thumbSize - 12).clamp(0, maxWidth);
return Container(
height: 64,
@ -81,7 +81,7 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
}
},
child: Container(
width: maxWidth * 0.25, //
width: _thumbSize,
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(