feat: Implement extensive database schema upgrades including new tables for activity logs and app GPS history, and add fields for product categories, stock, invoice document types, GPS coordinates, and company tax display

This commit is contained in:
joe 2026-02-14 22:37:39 +09:00
parent f98fed8c44
commit 035baf9078
32 changed files with 1582 additions and 356 deletions

View file

@ -4,6 +4,12 @@
>
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<queries>
<intent>

File diff suppressed because one or more lines are too long

View file

@ -48,6 +48,18 @@
@import permission_handler_apple;
#endif
#if __has_include(<print_bluetooth_thermal/PrintBluetoothThermalPlugin.h>)
#import <print_bluetooth_thermal/PrintBluetoothThermalPlugin.h>
#else
@import print_bluetooth_thermal;
#endif
#if __has_include(<printing/PrintingPlugin.h>)
#import <printing/PrintingPlugin.h>
#else
@import printing;
#endif
#if __has_include(<share_plus/FPPSharePlusPlugin.h>)
#import <share_plus/FPPSharePlusPlugin.h>
#else
@ -76,6 +88,8 @@
[OpenFilePlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenFilePlugin"]];
[FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
[PrintBluetoothThermalPlugin registerWithRegistrar:[registry registrarForPlugin:@"PrintBluetoothThermalPlugin"]];
[PrintingPlugin registerWithRegistrar:[registry registrarForPlugin:@"PrintingPlugin"]];
[FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]];
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];

View file

@ -0,0 +1,56 @@
import 'package:uuid/uuid.dart';
class ActivityLog {
final String id;
final String action; // : "CREATE", "UPDATE", "DELETE", "GENERATE_PDF"
final String targetType; // : "INVOICE", "CUSTOMER", "PRODUCT"
final String? targetId;
final String? details;
final DateTime timestamp;
ActivityLog({
required this.id,
required this.action,
required this.targetType,
this.targetId,
this.details,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
Map<String, dynamic> toMap() {
return {
'id': id,
'action': action,
'target_type': targetType,
'target_id': targetId,
'details': details,
'timestamp': timestamp.toIso8601String(),
};
}
factory ActivityLog.fromMap(Map<String, dynamic> map) {
return ActivityLog(
id: map['id'],
action: map['action'],
targetType: map['target_type'],
targetId: map['target_id'],
details: map['details'],
timestamp: DateTime.parse(map['timestamp']),
);
}
static ActivityLog create({
required String action,
required String targetType,
String? targetId,
String? details,
}) {
return ActivityLog(
id: const Uuid().v4(),
action: action,
targetType: targetType,
targetId: targetId,
details: details,
);
}
}

View file

@ -5,6 +5,7 @@ class CompanyInfo {
final String? tel;
final double defaultTaxRate;
final String? sealPath; //
final String taxDisplayMode; // 'normal', 'hidden', 'text_only'
CompanyInfo({
required this.name,
@ -13,6 +14,7 @@ class CompanyInfo {
this.tel,
this.defaultTaxRate = 0.10,
this.sealPath,
this.taxDisplayMode = 'normal',
});
Map<String, dynamic> toMap() {
@ -24,6 +26,7 @@ class CompanyInfo {
'tel': tel,
'default_tax_rate': defaultTaxRate,
'seal_path': sealPath,
'tax_display_mode': taxDisplayMode,
};
}
@ -35,6 +38,7 @@ class CompanyInfo {
tel: map['tel'],
defaultTaxRate: map['default_tax_rate'] ?? 0.10,
sealPath: map['seal_path'],
taxDisplayMode: map['tax_display_mode'] ?? 'normal',
);
}
@ -45,6 +49,7 @@ class CompanyInfo {
String? tel,
double? defaultTaxRate,
String? sealPath,
String? taxDisplayMode,
}) {
return CompanyInfo(
name: name ?? this.name,
@ -53,6 +58,7 @@ class CompanyInfo {
tel: tel ?? this.tel,
defaultTaxRate: defaultTaxRate ?? this.defaultTaxRate,
sealPath: sealPath ?? this.sealPath,
taxDisplayMode: taxDisplayMode ?? this.taxDisplayMode,
);
}
}

View file

@ -3,12 +3,14 @@ import 'package:intl/intl.dart';
class InvoiceItem {
final String? id;
final String? productId; //
String description;
int quantity;
int unitPrice;
InvoiceItem({
this.id,
this.productId, //
required this.description,
required this.quantity,
required this.unitPrice,
@ -20,6 +22,7 @@ class InvoiceItem {
return {
'id': id ?? DateTime.now().microsecondsSinceEpoch.toString(),
'invoice_id': invoiceId,
'product_id': productId, //
'description': description,
'quantity': quantity,
'unit_price': unitPrice,
@ -29,6 +32,7 @@ class InvoiceItem {
factory InvoiceItem.fromMap(Map<String, dynamic> map) {
return InvoiceItem(
id: map['id'],
productId: map['product_id'], //
description: map['description'],
quantity: map['quantity'],
unitPrice: map['unit_price'],
@ -36,6 +40,13 @@ class InvoiceItem {
}
}
enum DocumentType {
estimation, //
delivery, //
invoice, //
receipt, //
}
class Invoice {
final String id;
final Customer customer;
@ -44,10 +55,13 @@ class Invoice {
final String? notes;
final String? filePath;
final double taxRate;
final String? customerFormalNameSnapshot; //
final DocumentType documentType; //
final String? customerFormalNameSnapshot;
final String? odooId;
final bool isSynced;
final DateTime updatedAt;
final double? latitude; //
final double? longitude; //
Invoice({
String? id,
@ -56,21 +70,42 @@ class Invoice {
required this.items,
this.notes,
this.filePath,
this.taxRate = 0.10, // 10%
this.customerFormalNameSnapshot, //
this.taxRate = 0.10,
this.documentType = DocumentType.invoice, //
this.customerFormalNameSnapshot,
this.odooId,
this.isSynced = false,
DateTime? updatedAt,
this.latitude, //
this.longitude, //
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
updatedAt = updatedAt ?? DateTime.now();
String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
String get documentTypeName {
switch (documentType) {
case DocumentType.estimation: return "見積書";
case DocumentType.delivery: return "納品書";
case DocumentType.invoice: return "請求書";
case DocumentType.receipt: return "領収書";
}
}
String get invoiceNumberPrefix {
switch (documentType) {
case DocumentType.estimation: return "EST";
case DocumentType.delivery: return "DEL";
case DocumentType.invoice: return "INV";
case DocumentType.receipt: return "REC";
}
}
String get invoiceNumber => "$invoiceNumberPrefix-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
//
String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName;
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
int get tax => (subtotal * taxRate).floor(); // taxRateを使用
int get tax => (subtotal * taxRate).floor();
int get totalAmount => subtotal + tax;
Map<String, dynamic> toMap() {
@ -81,33 +116,17 @@ class Invoice {
'notes': notes,
'file_path': filePath,
'total_amount': totalAmount,
'tax_rate': taxRate, //
'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName, //
'tax_rate': taxRate,
'document_type': documentType.name, //
'customer_formal_name': customerFormalNameSnapshot ?? customer.formalName,
'odoo_id': odooId,
'is_synced': isSynced ? 1 : 0,
'updated_at': updatedAt.toIso8601String(),
'latitude': latitude, //
'longitude': longitude, //
};
}
// : fromMap Customer
// factory
String toCsv() {
final dateFormatter = DateFormat('yyyy/MM/dd');
StringBuffer buffer = StringBuffer();
buffer.writeln("日付,請求番号,取引先,合計金額,備考");
buffer.writeln("${dateFormatter.format(date)},$invoiceNumber,$customerNameForDisplay,$totalAmount,${notes ?? ""}");
buffer.writeln("");
buffer.writeln("品名,数量,単価,小計");
for (var item in items) {
buffer.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}");
}
return buffer.toString();
}
Invoice copyWith({
String? id,
Customer? customer,
@ -116,10 +135,13 @@ class Invoice {
String? notes,
String? filePath,
double? taxRate,
DocumentType? documentType,
String? customerFormalNameSnapshot,
String? odooId,
bool? isSynced,
DateTime? updatedAt,
double? latitude,
double? longitude,
}) {
return Invoice(
id: id ?? this.id,
@ -129,10 +151,25 @@ class Invoice {
notes: notes ?? this.notes,
filePath: filePath ?? this.filePath,
taxRate: taxRate ?? this.taxRate,
documentType: documentType ?? this.documentType,
customerFormalNameSnapshot: customerFormalNameSnapshot ?? this.customerFormalNameSnapshot,
odooId: odooId ?? this.odooId,
isSynced: isSynced ?? this.isSynced,
updatedAt: updatedAt ?? this.updatedAt,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
);
}
String toCsv() {
final buffer = StringBuffer();
buffer.writeln('伝票種別,伝票番号,日付,取引先,合計金額,緯度,経度');
buffer.writeln('$documentTypeName,$invoiceNumber,${DateFormat('yyyy/MM/dd').format(date)},$customerNameForDisplay,$totalAmount,${latitude ?? ""},${longitude ?? ""}');
buffer.writeln('');
buffer.writeln('品名,数量,単価,小計');
for (var item in items) {
buffer.writeln('${item.description},${item.quantity},${item.unitPrice},${item.subtotal}');
}
return buffer.toString();
}
}

View file

@ -3,6 +3,8 @@ class Product {
final String name;
final int defaultUnitPrice;
final String? barcode;
final String? category;
final int stockQuantity; //
final String? odooId;
Product({
@ -10,6 +12,8 @@ class Product {
required this.name,
this.defaultUnitPrice = 0,
this.barcode,
this.category,
this.stockQuantity = 0, //
this.odooId,
});
@ -19,6 +23,8 @@ class Product {
'name': name,
'default_unit_price': defaultUnitPrice,
'barcode': barcode,
'category': category,
'stock_quantity': stockQuantity, //
'odoo_id': odooId,
};
}
@ -29,6 +35,8 @@ class Product {
name: map['name'],
defaultUnitPrice: map['default_unit_price'] ?? 0,
barcode: map['barcode'],
category: map['category'],
stockQuantity: map['stock_quantity'] ?? 0, //
odooId: map['odoo_id'],
);
}

View file

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../models/activity_log_model.dart';
import '../services/activity_log_repository.dart';
class ActivityLogScreen extends StatefulWidget {
const ActivityLogScreen({Key? key}) : super(key: key);
@override
State<ActivityLogScreen> createState() => _ActivityLogScreenState();
}
class _ActivityLogScreenState extends State<ActivityLogScreen> {
final ActivityLogRepository _logRepo = ActivityLogRepository();
List<ActivityLog> _logs = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadLogs();
}
Future<void> _loadLogs() async {
setState(() => _isLoading = true);
final logs = await _logRepo.getAllLogs();
setState(() {
_logs = logs;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
final dateFormat = DateFormat('yyyy/MM/dd HH:mm:ss');
return Scaffold(
appBar: AppBar(
title: const Text("アクティビティ履歴 (Gitログ風)"),
backgroundColor: Colors.blueGrey.shade800,
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadLogs),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _logs.isEmpty
? const Center(child: Text("履歴はありません"))
: ListView.builder(
itemCount: _logs.length,
itemBuilder: (context, index) {
final log = _logs[index];
return _buildLogTile(log, dateFormat);
},
),
);
}
Widget _buildLogTile(ActivityLog log, DateFormat fmt) {
IconData icon;
Color color;
switch (log.action) {
case "SAVE_INVOICE":
case "SAVE_PRODUCT":
case "SAVE_CUSTOMER":
icon = Icons.save;
color = Colors.green;
break;
case "DELETE_INVOICE":
case "DELETE_PRODUCT":
case "DELETE_CUSTOMER":
icon = Icons.delete_forever;
color = Colors.red;
break;
case "GENERATE_PDF":
icon = Icons.picture_as_pdf;
color = Colors.orange;
break;
default:
icon = Icons.info_outline;
color = Colors.blueGrey;
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: Colors.grey.shade200),
borderRadius: BorderRadius.circular(8),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: color.withOpacity(0.1),
child: Icon(icon, color: color, size: 20),
),
title: Text(
"${_getActionJapanese(log.action)} [${log.targetType}]",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (log.details != null)
Text(log.details!, style: const TextStyle(fontSize: 12, color: Colors.black87)),
const SizedBox(height: 4),
Text(fmt.format(log.timestamp), style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
],
),
isThreeLine: log.details != null,
),
);
}
String _getActionJapanese(String action) {
switch (action) {
case "SAVE_INVOICE": return "伝票保存";
case "DELETE_INVOICE": return "伝票削除";
case "SAVE_PRODUCT": return "商品更新";
case "DELETE_PRODUCT": return "商品削除";
case "SAVE_CUSTOMER": return "顧客更新";
case "DELETE_CUSTOMER": return "顧客削除";
case "GENERATE_PDF": return "PDF発行";
default: return action;
}
}
}

View file

@ -21,6 +21,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
final _addressController = TextEditingController();
final _telController = TextEditingController();
double _taxRate = 0.10;
String _taxDisplayMode = 'normal';
@override
void initState() {
@ -35,6 +36,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
_addressController.text = _info.address ?? "";
_telController.text = _info.tel ?? "";
_taxRate = _info.defaultTaxRate;
_taxDisplayMode = _info.taxDisplayMode;
setState(() => _isLoading = false);
}
@ -55,6 +57,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
address: _addressController.text,
tel: _telController.text,
defaultTaxRate: _taxRate,
taxDisplayMode: _taxDisplayMode,
);
await _companyRepo.saveCompanyInfo(updated);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("自社情報を保存しました")));
@ -94,6 +97,29 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
ChoiceChip(label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (_) => setState(() => _taxRate = 0.08)),
],
),
const SizedBox(height: 20),
const Text("消費税の表示設定T番号非取得時など", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text("通常表示"),
selected: _taxDisplayMode == 'normal',
onSelected: (_) => setState(() => _taxDisplayMode = 'normal'),
),
ChoiceChip(
label: const Text("表示しない"),
selected: _taxDisplayMode == 'hidden',
onSelected: (_) => setState(() => _taxDisplayMode = 'hidden'),
),
ChoiceChip(
label: const Text("「税別」と表示"),
selected: _taxDisplayMode == 'text_only',
onSelected: (_) => setState(() => _taxDisplayMode = 'text_only'),
),
],
),
const SizedBox(height: 24),
const Text("印影(角印)撮影", style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),

View file

@ -28,29 +28,18 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
@override
void initState() {
super.initState();
_loadCustomers();
_onSearch(""); //
}
Future<void> _loadCustomers() async {
Future<void> _onSearch(String query) async {
setState(() => _isLoading = true);
final customers = await _repository.getAllCustomers();
final customers = await _repository.searchCustomers(query);
setState(() {
_allCustomers = customers;
_filteredCustomers = customers;
_isLoading = false;
});
}
void _filterCustomers(String query) {
setState(() {
_searchQuery = query.toLowerCase();
_filteredCustomers = _allCustomers.where((customer) {
return customer.formalName.toLowerCase().contains(_searchQuery) ||
customer.displayName.toLowerCase().contains(_searchQuery);
}).toList();
});
}
///
Future<void> _importFromPhoneContacts() async {
setState(() => _isImportingFromContacts = true);
@ -149,7 +138,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
await _repository.saveCustomer(updatedCustomer);
Navigator.pop(context); //
_loadCustomers(); //
_onSearch(""); //
//
// widget.onCustomerSelected(updatedCustomer);
@ -174,7 +163,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
onPressed: () async {
await _repository.deleteCustomer(customer.id);
Navigator.pop(context);
_loadCustomers();
_onSearch("");
},
child: const Text("削除する", style: TextStyle(color: Colors.red)),
),
@ -207,7 +196,7 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
),
onChanged: _filterCustomers,
onChanged: _onSearch,
),
const SizedBox(height: 12),
SizedBox(

View file

@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../services/gps_service.dart';
class GpsHistoryScreen extends StatefulWidget {
const GpsHistoryScreen({Key? key}) : super(key: key);
@override
State<GpsHistoryScreen> createState() => _GpsHistoryScreenState();
}
class _GpsHistoryScreenState extends State<GpsHistoryScreen> {
final _gpsService = GpsService();
List<Map<String, dynamic>> _history = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadHistory();
}
Future<void> _loadHistory() async {
setState(() => _isLoading = true);
final history = await _gpsService.getHistory(limit: 50);
setState(() {
_history = history;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("GPS位置情報履歴"),
backgroundColor: Colors.blueGrey,
actions: [
IconButton(onPressed: _loadHistory, icon: const Icon(Icons.refresh)),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _history.isEmpty
? const Center(child: Text("位置情報の履歴がありません。"))
: ListView.builder(
itemCount: _history.length,
itemBuilder: (context, index) {
final item = _history[index];
final date = DateTime.parse(item['timestamp']);
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.location_on)),
title: Text("${item['latitude'].toStringAsFixed(6)}, ${item['longitude'].toStringAsFixed(6)}"),
subtitle: Text(DateFormat('yyyy/MM/dd HH:mm:ss').format(date)),
trailing: const Icon(Icons.arrow_forward_ios, size: 14),
);
},
),
);
}
}

View file

@ -6,7 +6,11 @@ import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart';
import '../services/customer_repository.dart';
import '../services/company_repository.dart';
import 'product_picker_modal.dart';
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
import '../services/print_service.dart';
import '../models/company_model.dart';
class InvoiceDetailPage extends StatefulWidget {
final Invoice invoice;
@ -27,6 +31,8 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
String? _currentFilePath;
final _invoiceRepo = InvoiceRepository();
final _customerRepo = CustomerRepository();
final _companyRepo = CompanyRepository();
CompanyInfo? _companyInfo;
@override
void initState() {
@ -37,6 +43,12 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
_notesController = TextEditingController(text: _currentInvoice.notes ?? "");
_items = List.from(_currentInvoice.items);
_isEditing = false;
_loadCompanyInfo();
}
Future<void> _loadCompanyInfo() async {
final info = await _companyRepo.getCompanyInfo();
setState(() => _companyInfo = info);
}
@override
@ -127,6 +139,59 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
Share.share(csvData, subject: '請求書データ_CSV');
}
Future<void> _printReceipt() async {
final printService = PrintService();
final isConnected = await printService.isConnected;
if (!isConnected) {
final devices = await printService.getPairedDevices();
if (devices.isEmpty) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ペアリング済みのデバイスが見つかりません。OSの設定を確認してください。")));
return;
}
final selected = await showDialog<BluetoothInfo>(
context: context,
builder: (context) => AlertDialog(
title: const Text("プリンターを選択"),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: devices.length,
itemBuilder: (context, idx) => ListTile(
leading: const Icon(Icons.print),
title: Text(devices[idx].name ?? "Unknown Device"),
subtitle: Text(devices[idx].macAdress ?? ""),
onTap: () => Navigator.pop(context, devices[idx]),
),
),
),
),
);
if (selected != null) {
final ok = await printService.connect(selected.macAdress!);
if (!ok) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("接続に失敗しました。")));
return;
}
} else {
return;
}
}
final success = await printService.printReceipt(_currentInvoice);
if (!mounted) return;
if (success) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("レシートを印刷しました。")));
} else {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("印刷エラーが発生しました。")));
}
}
@override
Widget build(BuildContext context) {
final amountFormatter = NumberFormat("#,###");
@ -214,6 +279,18 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
const SizedBox(height: 4),
Text("請求番号: ${_currentInvoice.invoiceNumber}"),
Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"),
if (_currentInvoice.latitude != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Icon(Icons.location_on, size: 14, color: Colors.blueGrey),
const SizedBox(width: 4),
Text("座標: ${_currentInvoice.latitude!.toStringAsFixed(4)}, ${_currentInvoice.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12, color: Colors.blueGrey)),
],
),
),
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
const SizedBox(height: 8),
Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)),
@ -290,9 +367,12 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
child: Column(
children: [
_SummaryRow("小計 (税抜)", formatter.format(subtotal)),
if (_companyInfo?.taxDisplayMode == 'normal')
_SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)),
if (_companyInfo?.taxDisplayMode == 'text_only')
_SummaryRow("消費税", "(税別)"),
const Divider(),
_SummaryRow("合計 (税込)", "${formatter.format(total)}", isBold: true),
_SummaryRow("合計", "${formatter.format(total)}", isBold: true),
],
),
),
@ -324,6 +404,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: _printReceipt,
icon: const Icon(Icons.print),
label: const Text("レシート"),
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade800, foregroundColor: Colors.white),
),
),
],
);
}

View file

@ -6,8 +6,12 @@ import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart';
import '../services/customer_repository.dart';
import 'package:printing/printing.dart';
import '../services/gps_service.dart';
import 'customer_picker_modal.dart';
import 'product_picker_modal.dart';
import '../models/company_model.dart';
import '../services/company_repository.dart';
class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
@ -27,6 +31,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
final List<InvoiceItem> _items = [];
double _taxRate = 0.10;
bool _includeTax = true;
CompanyInfo? _companyInfo;
DocumentType _documentType = DocumentType.invoice; //
String _status = "取引先と商品を入力してください";
//
@ -45,6 +51,13 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
if (customers.isNotEmpty) {
setState(() => _selectedCustomer = customers.first);
}
final companyRepo = CompanyRepository();
final companyInfo = await companyRepo.getCompanyInfo();
setState(() {
_companyInfo = companyInfo;
_taxRate = companyInfo.defaultTaxRate;
});
}
void _addItem() {
@ -74,13 +87,23 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
return;
}
// GPS情報の取得
final gpsService = GpsService();
final pos = await gpsService.getCurrentLocation();
if (pos != null) {
await gpsService.logLocation(); //
}
final invoice = Invoice(
customer: _selectedCustomer!,
date: DateTime.now(),
items: _items,
taxRate: _includeTax ? _taxRate : 0.0,
documentType: _documentType,
customerFormalNameSnapshot: _selectedCustomer!.formalName,
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
latitude: pos?.latitude,
longitude: pos?.longitude,
);
if (generatePdf) {
@ -101,28 +124,44 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
void _showPreview() {
if (_selectedCustomer == null) return;
final invoice = Invoice(
customer: _selectedCustomer!,
date: DateTime.now(),
items: _items,
taxRate: _includeTax ? _taxRate : 0.0,
documentType: _documentType,
customerFormalNameSnapshot: _selectedCustomer!.formalName,
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("伝票プレビュー(仮)"),
content: SingleChildScrollView(
builder: (context) => Dialog.fullscreen(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("宛名: ${_selectedCustomer!.formalName} ${_selectedCustomer!.title}"),
const Divider(),
..._items.map((it) => Text("${it.description} x ${it.quantity} = ¥${it.subtotal}")),
const Divider(),
Text("小計: ¥${NumberFormat("#,###").format(_subTotal)}"),
Text("消費税: ¥${NumberFormat("#,###").format(_tax)}"),
Text("合計: ¥${NumberFormat("#,###").format(_total)}", style: const TextStyle(fontWeight: FontWeight.bold)),
],
AppBar(
title: Text("${invoice.documentTypeName}プレビュー"),
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
),
Expanded(
child: PdfPreview(
build: (format) async {
// PdfGeneratorを少しリファクタして pw.Document
// generateInvoicePdf
// ( generateInvoicePdf )
// Generatorを修正する
// Generator pw.Document
final pdfDoc = await buildInvoiceDocument(invoice);
return pdfDoc.save();
},
allowPrinting: false,
allowSharing: false,
canChangePageFormat: false,
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("閉じる")),
],
),
),
);
}
@ -138,6 +177,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildDocumentTypeSection(), //
const SizedBox(height: 16),
_buildCustomerSection(),
const SizedBox(height: 20),
_buildItemsSection(fmt),
@ -156,6 +197,51 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
);
}
Widget _buildDocumentTypeSection() {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: DocumentType.values.map((type) {
final isSelected = _documentType == type;
String label = "";
IconData icon = Icons.description;
switch (type) {
case DocumentType.estimation: label = "見積"; icon = Icons.article_outlined; break;
case DocumentType.delivery: label = "納品"; icon = Icons.local_shipping_outlined; break;
case DocumentType.invoice: label = "請求"; icon = Icons.receipt_long_outlined; break;
case DocumentType.receipt: label = "領収"; icon = Icons.payments_outlined; break;
}
return Expanded(
child: InkWell(
onTap: () => setState(() => _documentType = type),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? Colors.indigo : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(icon, color: isSelected ? Colors.white : Colors.grey.shade600, size: 20),
Text(label, style: TextStyle(
color: isSelected ? Colors.white : Colors.grey.shade600,
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
)),
],
),
),
),
);
}).toList(),
),
);
}
Widget _buildCustomerSection() {
return Card(
elevation: 0,
@ -268,8 +354,11 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)),
child: Column(
children: [
_buildSummaryRow("小計", "${fmt.format(_subTotal)}", Colors.white70),
_buildSummaryRow("消費税", "${fmt.format(_tax)}", Colors.white70),
_buildSummaryRow("小計 (税抜)", "${fmt.format(_subTotal)}", Colors.white70),
if (_companyInfo?.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(_taxRate * 100).toInt()}%)", "${fmt.format(_tax)}", Colors.white70),
if (_companyInfo?.taxDisplayMode == 'text_only')
_buildSummaryRow("消費税", "(税別)", Colors.white70),
const Divider(color: Colors.white24),
_buildSummaryRow("合計金額", "${fmt.format(_total)}", Colors.white, fontSize: 24),
],

View file

@ -3,10 +3,14 @@ import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../services/invoice_repository.dart';
import '../services/customer_repository.dart';
import 'product_master_screen.dart';
import 'customer_master_screen.dart';
import 'activity_log_screen.dart';
import 'sales_report_screen.dart';
import 'gps_history_screen.dart';
class ManagementScreen extends StatelessWidget {
const ManagementScreen({Key? key}) : super(key: key);
@ -58,6 +62,20 @@ class ManagementScreen extends StatelessWidget {
"SQLiteファイルを外部へ保存・シェアします",
() => _backupDatabase(context),
),
_buildMenuTile(
context,
Icons.list_alt,
"アクティビティ履歴 (Git風)",
"データの作成・変更・削除の履歴を確認します",
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const ActivityLogScreen())),
),
_buildMenuTile(
context,
Icons.analytics_outlined,
"売上・資金管理レポート",
"売上や資金の流れを分析します", // Added subtitle
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const SalesReportScreen())),
),
_buildMenuTile(
context,
Icons.settings_backup_restore,
@ -65,6 +83,13 @@ class ManagementScreen extends StatelessWidget {
"バックアップから全てのデータを復元します",
() => _showComingSoon(context),
),
_buildMenuTile(
context,
Icons.location_history,
"GPS座標履歴の管理",
"過去に取得した位置情報の履歴を確認します",
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const GpsHistoryScreen())),
),
const Divider(),
_buildSectionHeader("外部同期 (将来のOdoo連携)"),
_buildMenuTile(

View file

@ -13,8 +13,12 @@ class ProductMasterScreen extends StatefulWidget {
class _ProductMasterScreenState extends State<ProductMasterScreen> {
final ProductRepository _productRepo = ProductRepository();
final TextEditingController _searchController = TextEditingController();
List<Product> _products = [];
List<Product> _filteredProducts = [];
bool _isLoading = true;
String _searchQuery = "";
@override
void initState() {
@ -28,40 +32,46 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
setState(() {
_products = products;
_isLoading = false;
_applyFilter();
});
}
Future<void> _addItem({Product? product}) async {
final isEdit = product != null;
void _applyFilter() {
setState(() {
_filteredProducts = _products.where((p) {
final query = _searchQuery.toLowerCase();
return p.name.toLowerCase().contains(query) ||
(p.barcode?.toLowerCase().contains(query) ?? false) ||
(p.category?.toLowerCase().contains(query) ?? false);
}).toList();
});
}
Future<void> _showEditDialog({Product? product}) async {
final nameController = TextEditingController(text: product?.name ?? "");
final priceController = TextEditingController(text: product?.defaultUnitPrice.toString() ?? "0");
final priceController = TextEditingController(text: (product?.defaultUnitPrice ?? 0).toString());
final barcodeController = TextEditingController(text: product?.barcode ?? "");
final categoryController = TextEditingController(text: product?.category ?? "");
final stockController = TextEditingController(text: (product?.stockQuantity ?? 0).toString());
final result = await showDialog<Product>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(isEdit ? "商品を編集" : "商品を新規登録"),
content: Column(
title: Text(product == null ? "商品追加" : "商品編集"),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: "商品名"),
),
TextField(
controller: priceController,
decoration: const InputDecoration(labelText: "初期単価"),
keyboardType: TextInputType.number,
),
TextField(controller: nameController, decoration: const InputDecoration(labelText: "商品名")),
TextField(controller: categoryController, decoration: const InputDecoration(labelText: "カテゴリ")),
TextField(controller: priceController, decoration: const InputDecoration(labelText: "初期単価"), keyboardType: TextInputType.number),
TextField(controller: stockController, decoration: const InputDecoration(labelText: "在庫数"), keyboardType: TextInputType.number),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: barcodeController,
decoration: const InputDecoration(labelText: "バーコード"),
),
child: TextField(controller: barcodeController, decoration: const InputDecoration(labelText: "バーコード")),
),
IconButton(
icon: const Icon(Icons.qr_code_scanner),
@ -71,9 +81,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
MaterialPageRoute(builder: (context) => const BarcodeScannerScreen()),
);
if (code != null) {
setDialogState(() {
barcodeController.text = code;
});
setDialogState(() => barcodeController.text = code);
}
},
),
@ -81,19 +89,24 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(
ElevatedButton(
onPressed: () {
if (nameController.text.isEmpty) return;
final newProduct = Product(
Navigator.pop(
context,
Product(
id: product?.id ?? const Uuid().v4(),
name: nameController.text,
name: nameController.text.trim(),
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
barcode: barcodeController.text.isEmpty ? null : barcodeController.text,
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
stockQuantity: int.tryParse(stockController.text) ?? 0,
odooId: product?.odooId,
),
);
Navigator.pop(context, newProduct);
},
child: const Text("保存"),
),
@ -112,42 +125,67 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("商品マスター管理"),
title: const Text("商品マスター"),
backgroundColor: Colors.blueGrey,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: "商品名・バーコード・カテゴリで検索",
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
contentPadding: EdgeInsets.zero,
),
onChanged: (val) {
_searchQuery = val;
_applyFilter();
},
),
),
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _products.isEmpty
? const Center(child: Text("商品が登録されていません"))
: _filteredProducts.isEmpty
? const Center(child: Text("商品が見つかりません"))
: ListView.builder(
itemCount: _products.length,
itemCount: _filteredProducts.length,
itemBuilder: (context, index) {
final p = _products[index];
final p = _filteredProducts[index];
return ListTile(
leading: const CircleAvatar(child: Icon(Icons.inventory_2)),
title: Text(p.name),
subtitle: Text("初期単価: ¥${p.defaultUnitPrice}"),
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _addItem(product: p)),
IconButton(icon: const Icon(Icons.edit), onPressed: () => _showEditDialog(product: p)),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("削除確認"),
content: Text("${p.name}」を削除しますか?"),
title: const Text("削除確認"),
content: Text("${p.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))),
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(
onPressed: () async {
await _productRepo.deleteProduct(p.id);
Navigator.pop(context);
_loadProducts();
},
child: const Text("削除", style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true) {
await _productRepo.deleteProduct(p.id);
_loadProducts();
}
},
),
],
@ -156,9 +194,10 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
},
),
floatingActionButton: FloatingActionButton(
onPressed: _addItem,
onPressed: () => _showEditDialog(),
child: const Icon(Icons.add),
backgroundColor: Colors.indigo,
backgroundColor: Colors.blueGrey.shade800,
foregroundColor: Colors.white,
),
);
}

View file

@ -16,18 +16,19 @@ class ProductPickerModal extends StatefulWidget {
class _ProductPickerModalState extends State<ProductPickerModal> {
final ProductRepository _productRepo = ProductRepository();
final TextEditingController _searchController = TextEditingController();
List<Product> _products = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadProducts();
_onSearch(""); //
}
Future<void> _loadProducts() async {
Future<void> _onSearch(String val) async {
setState(() => _isLoading = true);
final products = await _productRepo.getAllProducts();
final products = await _productRepo.searchProducts(val);
setState(() {
_products = products;
_isLoading = false;
@ -53,6 +54,25 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
],
),
),
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
@ -62,13 +82,13 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("商品マスターが空です"),
const Text("商品が見つかりません"),
TextButton(
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
_loadProducts();
_onSearch(_searchController.text);
},
child: const Text("商品マスターを編集する"),
child: const Text("マスターに追加する"),
),
],
),
@ -80,9 +100,10 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
return ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text(product.name),
subtitle: Text("初期単価: ${product.defaultUnitPrice}"),
subtitle: Text("${product.defaultUnitPrice} (在庫: ${product.stockQuantity})"),
onTap: () => widget.onItemSelected(
InvoiceItem(
productId: product.id,
description: product.name,
quantity: 1,
unitPrice: product.defaultUnitPrice,
@ -92,18 +113,6 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
},
),
),
if (_products.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: TextButton.icon(
icon: const Icon(Icons.edit),
label: const Text("商品マスターの管理"),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
_loadProducts();
},
),
),
],
),
);

View file

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../services/invoice_repository.dart';
class SalesReportScreen extends StatefulWidget {
const SalesReportScreen({Key? key}) : super(key: key);
@override
State<SalesReportScreen> createState() => _SalesReportScreenState();
}
class _SalesReportScreenState extends State<SalesReportScreen> {
final _invoiceRepo = InvoiceRepository();
int _targetYear = DateTime.now().year;
Map<String, int> _monthlySales = {};
int _yearlyTotal = 0;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() => _isLoading = true);
final monthly = await _invoiceRepo.getMonthlySales(_targetYear);
final yearly = await _invoiceRepo.getYearlyTotal(_targetYear);
setState(() {
_monthlySales = monthly;
_yearlyTotal = yearly;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
final fmt = NumberFormat("#,###");
return Scaffold(
appBar: AppBar(
title: const Text("売上・資金管理レポート"),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_buildYearSelector(),
_buildYearlySummary(fmt),
const Divider(height: 1),
Expanded(child: _buildMonthlyList(fmt)),
],
),
);
}
Widget _buildYearSelector() {
return Container(
color: Colors.indigo.shade50,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
setState(() => _targetYear--);
_loadData();
},
),
Text(
"$_targetYear年度",
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
setState(() => _targetYear++);
_loadData();
},
),
],
),
);
}
Widget _buildYearlySummary(NumberFormat fmt) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.indigo.shade900,
),
child: Column(
children: [
const Text("年間売上合計 (請求確定分)", style: TextStyle(color: Colors.white70)),
const SizedBox(height: 8),
Text(
"${fmt.format(_yearlyTotal)}",
style: const TextStyle(color: Colors.white, fontSize: 32, fontWeight: FontWeight.bold),
),
],
),
);
}
Widget _buildMonthlyList(NumberFormat fmt) {
return ListView.builder(
itemCount: 12,
itemBuilder: (context, index) {
final month = (index + 1).toString().padLeft(2, '0');
final amount = _monthlySales[month] ?? 0;
final percentage = _yearlyTotal > 0 ? (amount / _yearlyTotal * 100).toStringAsFixed(1) : "0.0";
return ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blueGrey.shade100,
child: Text("${index + 1}", style: const TextStyle(color: Colors.indigo)),
),
title: Text("${index + 1}月の売上"),
subtitle: amount > 0 ? Text("シェア: $percentage%") : null,
trailing: Text(
"${fmt.format(amount)}",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: amount > 0 ? Colors.black87 : Colors.grey,
),
),
);
},
);
}
}
// FontWeight.bold in Text widget is TextStyle.fontWeight not pw.FontWeight
// Corrected to FontWeight.bold below in replace or write.

View file

@ -0,0 +1,36 @@
import '../models/activity_log_model.dart';
import 'database_helper.dart';
class ActivityLogRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
Future<void> log(ActivityLog log) async {
final db = await _dbHelper.database;
await db.insert('activity_logs', log.toMap());
}
Future<void> logAction({
required String action,
required String targetType,
String? targetId,
String? details,
}) async {
final activity = ActivityLog.create(
action: action,
targetType: targetType,
targetId: targetId,
details: details,
);
await log(activity);
}
Future<List<ActivityLog>> getAllLogs({int limit = 100}) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'activity_logs',
orderBy: 'timestamp DESC',
limit: limit,
);
return List.generate(maps.length, (i) => ActivityLog.fromMap(maps[i]));
}
}

View file

@ -2,9 +2,11 @@ import 'package:sqflite/sqflite.dart';
import '../models/customer_model.dart';
import 'database_helper.dart';
import 'package:uuid/uuid.dart';
import 'activity_log_repository.dart';
class CustomerRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository();
Future<List<Customer>> getAllCustomers() async {
final db = await _dbHelper.database;
@ -43,11 +45,29 @@ class CustomerRepository {
customer.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _logRepo.logAction(
action: "SAVE_CUSTOMER",
targetType: "CUSTOMER",
targetId: customer.id,
details: "名称: ${customer.formalName}, 敬称: ${customer.title}",
);
}
Future<void> deleteCustomer(String id) async {
final db = await _dbHelper.database;
await db.delete('customers', where: 'id = ?', whereArgs: [id]);
await db.delete(
'customers',
where: 'id = ?',
whereArgs: [id],
);
await _logRepo.logAction(
action: "DELETE_CUSTOMER",
targetType: "CUSTOMER",
targetId: id,
details: "顧客を削除しました",
);
}
// GPS履歴の保存 (10)
@ -86,4 +106,16 @@ class CustomerRepository {
orderBy: 'timestamp DESC',
);
}
Future<List<Customer>> searchCustomers(String query) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'customers',
where: 'display_name LIKE ? OR formal_name LIKE ?',
whereArgs: ['%$query%', '%$query%'],
orderBy: 'display_name ASC',
limit: 50,
);
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
}
}

View file

@ -2,6 +2,7 @@ import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
static const _databaseVersion = 9;
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database;
@ -19,7 +20,7 @@ class DatabaseHelper {
String path = join(await getDatabasesPath(), 'gemi_invoice.db');
return await openDatabase(
path,
version: 5,
version: _databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
);
@ -46,13 +47,46 @@ class DatabaseHelper {
await db.execute('ALTER TABLE products ADD COLUMN barcode TEXT');
}
if (oldVersion < 5) {
//
await db.execute('ALTER TABLE invoices ADD COLUMN customer_formal_name TEXT');
}
if (oldVersion < 6) {
await db.execute('ALTER TABLE products ADD COLUMN category TEXT');
await db.execute('CREATE INDEX idx_products_name ON products(name)');
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
await db.execute('''
CREATE TABLE activity_logs (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
details TEXT,
timestamp TEXT NOT NULL
)
''');
}
if (oldVersion < 7) {
await db.execute('ALTER TABLE products ADD COLUMN stock_quantity INTEGER DEFAULT 0');
await db.execute('ALTER TABLE invoices ADD COLUMN document_type TEXT DEFAULT "invoice"');
await db.execute('ALTER TABLE invoice_items ADD COLUMN product_id TEXT');
}
if (oldVersion < 8) {
await db.execute('ALTER TABLE invoices ADD COLUMN latitude REAL');
await db.execute('ALTER TABLE invoices ADD COLUMN longitude REAL');
await db.execute('''
CREATE TABLE app_gps_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
timestamp TEXT NOT NULL
)
''');
}
if (oldVersion < 9) {
await db.execute('ALTER TABLE company_info ADD COLUMN tax_display_mode TEXT DEFAULT "normal"');
}
}
Future<void> _onCreate(Database db, int version) async {
//
await db.execute('''
CREATE TABLE customers (
id TEXT PRIMARY KEY,
@ -68,7 +102,6 @@ class DatabaseHelper {
)
''');
// GPS履歴 (10DB上は保持)
await db.execute('''
CREATE TABLE customer_gps_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -87,9 +120,13 @@ class DatabaseHelper {
name TEXT NOT NULL,
default_unit_price INTEGER,
barcode TEXT,
category TEXT,
stock_quantity INTEGER DEFAULT 0,
odoo_id TEXT
)
''');
await db.execute('CREATE INDEX idx_products_name ON products(name)');
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
//
await db.execute('''
@ -101,19 +138,32 @@ class DatabaseHelper {
file_path TEXT,
total_amount INTEGER,
tax_rate REAL DEFAULT 0.10,
document_type TEXT DEFAULT "invoice",
customer_formal_name TEXT,
odoo_id TEXT,
is_synced INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,
latitude REAL,
longitude REAL,
FOREIGN KEY (customer_id) REFERENCES customers (id)
)
''');
await db.execute('''
CREATE TABLE app_gps_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
timestamp TEXT NOT NULL
)
''');
//
await db.execute('''
CREATE TABLE invoice_items (
id TEXT PRIMARY KEY,
invoice_id TEXT NOT NULL,
product_id TEXT,
description TEXT NOT NULL,
quantity INTEGER NOT NULL,
unit_price INTEGER NOT NULL,
@ -121,7 +171,6 @@ class DatabaseHelper {
)
''');
//
await db.execute('''
CREATE TABLE company_info (
id INTEGER PRIMARY KEY,
@ -130,7 +179,19 @@ class DatabaseHelper {
address TEXT,
tel TEXT,
default_tax_rate REAL DEFAULT 0.10,
seal_path TEXT
seal_path TEXT,
tax_display_mode TEXT DEFAULT "normal"
)
''');
await db.execute('''
CREATE TABLE activity_logs (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
details TEXT,
timestamp TEXT NOT NULL
)
''');
}

View file

@ -0,0 +1,53 @@
import 'package:geolocator/geolocator.dart';
import 'database_helper.dart';
class GpsService {
final _dbHelper = DatabaseHelper();
///
Future<Position?> getCurrentLocation() async {
bool serviceEnabled;
LocationPermission permission;
serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) return null;
permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) return null;
}
if (permission == LocationPermission.deniedForever) return null;
try {
return await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 5),
),
);
} catch (_) {
return null;
}
}
///
Future<void> logLocation() async {
final pos = await getCurrentLocation();
if (pos == null) return;
final db = await _dbHelper.database;
await db.insert('app_gps_history', {
'latitude': pos.latitude,
'longitude': pos.longitude,
'timestamp': DateTime.now().toIso8601String(),
});
}
/// GPS履歴を取得
Future<List<Map<String, dynamic>>> getHistory({int limit = 10}) async {
final db = await _dbHelper.database;
return await db.query('app_gps_history', orderBy: 'timestamp DESC', limit: limit);
}
}

View file

@ -4,14 +4,33 @@ import 'package:path_provider/path_provider.dart';
import '../models/invoice_models.dart';
import '../models/customer_model.dart';
import 'database_helper.dart';
import 'activity_log_repository.dart';
class InvoiceRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository();
Future<void> saveInvoice(Invoice invoice) async {
final db = await _dbHelper.database;
await db.transaction((txn) async {
// 調
final List<Map<String, dynamic>> oldItems = await txn.query(
'invoice_items',
where: 'invoice_id = ?',
whereArgs: [invoice.id],
);
//
for (var item in oldItems) {
if (item['product_id'] != null) {
await txn.execute(
'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?',
[item['quantity'], item['product_id']],
);
}
}
//
await txn.insert(
'invoices',
@ -19,18 +38,31 @@ class InvoiceRepository {
conflictAlgorithm: ConflictAlgorithm.replace,
);
//
//
await txn.delete(
'invoice_items',
where: 'invoice_id = ?',
whereArgs: [invoice.id],
);
//
//
for (var item in invoice.items) {
await txn.insert('invoice_items', item.toMap(invoice.id));
if (item.productId != null) {
await txn.execute(
'UPDATE products SET stock_quantity = stock_quantity - ? WHERE id = ?',
[item.quantity, item.productId],
);
}
}
});
await _logRepo.logAction(
action: "SAVE_INVOICE",
targetType: "INVOICE",
targetId: invoice.id,
details: "種別: ${invoice.documentTypeName}, 取引先: ${invoice.customerNameForDisplay}, 合計: ¥${invoice.totalAmount}",
);
}
Future<List<Invoice>> getAllInvoices(List<Customer> customers) async {
@ -52,6 +84,14 @@ class InvoiceRepository {
final items = List.generate(itemMaps.length, (i) => InvoiceItem.fromMap(itemMaps[i]));
// document_typeのパース
DocumentType docType = DocumentType.invoice;
if (iMap['document_type'] != null) {
try {
docType = DocumentType.values.firstWhere((e) => e.name == iMap['document_type']);
} catch (_) {}
}
invoices.add(Invoice(
id: iMap['id'],
customer: customer,
@ -60,10 +100,13 @@ class InvoiceRepository {
notes: iMap['notes'],
filePath: iMap['file_path'],
taxRate: iMap['tax_rate'] ?? 0.10,
customerFormalNameSnapshot: iMap['customer_formal_name'], //
documentType: docType,
customerFormalNameSnapshot: iMap['customer_formal_name'],
odooId: iMap['odoo_id'],
isSynced: iMap['is_synced'] == 1,
updatedAt: DateTime.parse(iMap['updated_at']),
latitude: iMap['latitude'],
longitude: iMap['longitude'],
));
}
return invoices;
@ -72,6 +115,22 @@ class InvoiceRepository {
Future<void> deleteInvoice(String id) async {
final db = await _dbHelper.database;
await db.transaction((txn) async {
//
final List<Map<String, dynamic>> items = await txn.query(
'invoice_items',
where: 'invoice_id = ?',
whereArgs: [id],
);
for (var item in items) {
if (item['product_id'] != null) {
await txn.execute(
'UPDATE products SET stock_quantity = stock_quantity + ? WHERE id = ?',
[item['quantity'], item['product_id']],
);
}
}
// PDFパスの取得
final List<Map<String, dynamic>> maps = await txn.query(
'invoices',
@ -122,4 +181,34 @@ class InvoiceRepository {
return 0;
}
}
Future<Map<String, int>> getMonthlySales(int year) async {
final db = await _dbHelper.database;
final String yearStr = year.toString();
final List<Map<String, dynamic>> results = await db.rawQuery('''
SELECT strftime('%m', date) as month, SUM(total_amount) as total
FROM invoices
WHERE strftime('%Y', date) = ? AND document_type = 'invoice'
GROUP BY month
ORDER BY month ASC
''', [yearStr]);
Map<String, int> monthlyTotal = {};
for (var r in results) {
monthlyTotal[r['month']] = (r['total'] as num).toInt();
}
return monthlyTotal;
}
Future<int> getYearlyTotal(int year) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> results = await db.rawQuery('''
SELECT SUM(total_amount) as total
FROM invoices
WHERE strftime('%Y', date) = ? AND document_type = 'invoice'
''', [year.toString()]);
if (results.isEmpty || results.first['total'] == null) return 0;
return (results.first['total'] as num).toInt();
}
}

View file

@ -8,16 +8,16 @@ import 'package:crypto/crypto.dart';
import 'package:intl/intl.dart';
import '../models/invoice_models.dart';
import 'company_repository.dart';
import 'activity_log_repository.dart';
/// A4サイズのプロフェッショナルな請求書PDFを生成し
Future<String?> generateInvoicePdf(Invoice invoice) async {
try {
/// PDFドキュメントの構築使
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
final pdf = pw.Document();
//
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ttf = pw.Font.ttf(fontData);
final boldTtf = pw.Font.ttf(fontData); // IPAexGはウェイトが1つなので同じものを使用
final boldTtf = pw.Font.ttf(fontData);
final dateFormatter = DateFormat('yyyy年MM月dd日');
final amountFormatter = NumberFormat("#,###");
@ -48,11 +48,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text("請求書", style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text("請求番号: ${invoice.invoiceNumber}"),
pw.Text("番号: ${invoice.invoiceNumber}"),
pw.Text("発行日: ${dateFormatter.format(invoice.date)}"),
],
),
@ -77,7 +77,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
style: const pw.TextStyle(fontSize: 18)),
),
pw.SizedBox(height: 10),
pw.Text("下記の通り、ご請求申し上げます。"),
pw.Text(invoice.documentType == DocumentType.receipt
? "上記の金額を正に領収いたしました。"
: (invoice.documentType == DocumentType.estimation
? "下記の通り、お見積り申し上げます。"
: "下記の通り、ご請求申し上げます。")),
],
),
),
@ -117,7 +121,11 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Text("ご請求金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)),
pw.Text(
invoice.documentType == DocumentType.receipt
? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)")
: (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"),
style: const pw.TextStyle(fontSize: 16)),
pw.Text("${amountFormatter.format(invoice.totalAmount)} -",
style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)),
],
@ -161,7 +169,10 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
children: [
pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
if (companyInfo.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
if (companyInfo.taxDisplayMode == 'text_only')
_buildSummaryRow("消費税", "(税別)"),
pw.Divider(),
_buildSummaryRow("合計", "${amountFormatter.format(invoice.totalAmount)}", isBold: true),
],
@ -193,17 +204,35 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
),
);
return pdf;
}
/// A4サイズのプロフェッショナルな伝票PDFを生成し
Future<String?> generateInvoicePdf(Invoice invoice) async {
try {
final pdf = await buildInvoiceDocument(invoice);
//
final Uint8List bytes = await pdf.save();
final String hash = sha256.convert(bytes).toString().substring(0, 8);
final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date);
String fileName = "Invoice_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf";
final String hash = sha256.convert(bytes).toString().substring(0, 4);
final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
String fileName = "${invoice.invoiceNumberPrefix}_${invoice.invoiceNumber}_${timeStr}_$hash.pdf";
final directory = await getExternalStorageDirectory();
if (directory == null) return null;
final file = File("${directory.path}/$fileName");
await file.writeAsBytes(bytes);
//
final logRepo = ActivityLogRepository();
await logRepo.logAction(
action: "GENERATE_PDF",
targetType: "INVOICE",
targetId: invoice.id,
details: "PDF生成: $fileName (${invoice.documentTypeName})",
);
return file.path;
} catch (e) {
debugPrint("PDF Generation Error: $e");

View file

@ -0,0 +1,86 @@
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
import 'package:esc_pos_utils_plus/esc_pos_utils_plus.dart';
import '../models/invoice_models.dart';
import 'package:intl/intl.dart';
import 'company_repository.dart';
class PrintService {
/// Bluetoothデバイス一覧を取得
Future<List<BluetoothInfo>> getPairedDevices() async {
return await PrintBluetoothThermal.pairedBluetooths;
}
///
Future<bool> connect(String macAddress) async {
return await PrintBluetoothThermal.connect(macPrinterAddress: macAddress);
}
///
Future<bool> get isConnected async => await PrintBluetoothThermal.connectionStatus;
///
Future<bool> printReceipt(Invoice invoice) async {
if (!await isConnected) return false;
//
// ESC/POSテキスト出力を実装
final profile = await CapabilityProfile.load();
final generator = Generator(PaperSize.mm58, profile);
List<int> bytes = [];
//
final companyRepo = CompanyRepository();
final companyInfo = await companyRepo.getCompanyInfo();
//
bytes += generator.text(
invoice.documentTypeName,
styles: const PosStyles(align: PosAlign.center, height: PosTextSize.size2, width: PosTextSize.size2, bold: true),
);
bytes += generator.text("--------------------------------", styles: const PosStyles(align: PosAlign.center));
bytes += generator.text("No: ${invoice.invoiceNumber}", styles: const PosStyles(align: PosAlign.center));
bytes += generator.text("Date: ${DateFormat('yyyy/MM/dd HH:mm').format(invoice.date)}", styles: const PosStyles(align: PosAlign.center));
bytes += generator.text("--------------------------------", styles: const PosStyles(align: PosAlign.center));
bytes += generator.text("Customer:", styles: const PosStyles(bold: true));
bytes += generator.text(invoice.customerNameForDisplay);
bytes += generator.feed(1);
bytes += generator.text("Items:", styles: const PosStyles(bold: true));
for (var item in invoice.items) {
bytes += generator.text(item.description);
bytes += generator.row([
PosColumn(text: " ${item.quantity} x ${item.unitPrice}", width: 8),
PosColumn(text: "${item.subtotal}", width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
}
bytes += generator.text("--------------------------------");
bytes += generator.row([
PosColumn(text: "Subtotal", width: 8),
PosColumn(text: "${invoice.subtotal}", width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
if (companyInfo.taxDisplayMode == 'normal') {
bytes += generator.row([
PosColumn(text: "Tax", width: 8),
PosColumn(text: "${invoice.tax}", width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
} else if (companyInfo.taxDisplayMode == 'text_only') {
bytes += generator.row([
PosColumn(text: "Tax", width: 8),
PosColumn(text: "(Tax Excl.)", width: 4, styles: const PosStyles(align: PosAlign.right)),
]);
}
bytes += generator.row([
PosColumn(text: companyInfo.taxDisplayMode == 'hidden' ? "TOTAL" : "TOTAL(Incl)", width: 7, styles: const PosStyles(bold: true, height: PosTextSize.size1)),
PosColumn(text: "${invoice.totalAmount}", width: 5, styles: const PosStyles(align: PosAlign.right, bold: true, height: PosTextSize.size1)),
]);
bytes += generator.feed(3);
bytes += generator.cut();
return await PrintBluetoothThermal.writeBytes(bytes);
}
}

View file

@ -1,10 +1,12 @@
import 'package:sqflite/sqflite.dart';
import '../models/product_model.dart';
import 'database_helper.dart';
import 'activity_log_repository.dart';
import 'package:uuid/uuid.dart';
class ProductRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository();
Future<List<Product>> getAllProducts() async {
final db = await _dbHelper.database;
@ -18,18 +20,30 @@ class ProductRepository {
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
}
Future<List<Product>> searchProducts(String query) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'products',
where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?',
whereArgs: ['%$query%', '%$query%', '%$query%'],
orderBy: 'name ASC',
limit: 50,
);
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
}
Future<void> _generateSampleProducts() async {
final samples = [
Product(id: const Uuid().v4(), name: "基本技術料", defaultUnitPrice: 50000),
Product(id: const Uuid().v4(), name: "出張診断費", defaultUnitPrice: 10000),
Product(id: const Uuid().v4(), name: "交換用ハードディスク (1TB)", defaultUnitPrice: 8500),
Product(id: const Uuid().v4(), name: "メモリ増設 (8GB)", defaultUnitPrice: 6000),
Product(id: const Uuid().v4(), name: "OSインストール作業", defaultUnitPrice: 15000),
Product(id: const Uuid().v4(), name: "データ復旧作業 (軽度)", defaultUnitPrice: 30000),
Product(id: const Uuid().v4(), name: "LANケーブル (5m)", defaultUnitPrice: 1200),
Product(id: const Uuid().v4(), name: "ウイルス除去作業", defaultUnitPrice: 20000),
Product(id: const Uuid().v4(), name: "液晶ディスプレイ (24インチ)", defaultUnitPrice: 25000),
Product(id: const Uuid().v4(), name: "定期保守契約料 (月額)", defaultUnitPrice: 5000),
Product(id: const Uuid().v4(), name: "基本技術料", defaultUnitPrice: 50000, category: "技術料"),
Product(id: const Uuid().v4(), name: "出張診断費", defaultUnitPrice: 10000, category: "諸経費"),
Product(id: const Uuid().v4(), name: "交換用ハードディスク (1TB)", defaultUnitPrice: 8500, category: "パーツ"),
Product(id: const Uuid().v4(), name: "メモリ増設 (8GB)", defaultUnitPrice: 6000, category: "パーツ"),
Product(id: const Uuid().v4(), name: "OSインストール作業", defaultUnitPrice: 15000, category: "技術料"),
Product(id: const Uuid().v4(), name: "データ復旧作業 (軽度)", defaultUnitPrice: 30000, category: "技術料"),
Product(id: const Uuid().v4(), name: "LANケーブル (5m)", defaultUnitPrice: 1200, category: "サプライ"),
Product(id: const Uuid().v4(), name: "ウイルス除去作業", defaultUnitPrice: 20000, category: "技術料"),
Product(id: const Uuid().v4(), name: "液晶ディスプレイ (24インチ)", defaultUnitPrice: 25000, category: "周辺機器"),
Product(id: const Uuid().v4(), name: "定期保守契約料 (月額)", defaultUnitPrice: 5000, category: "保守"),
];
for (var s in samples) {
await saveProduct(s);
@ -43,10 +57,28 @@ class ProductRepository {
product.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _logRepo.logAction(
action: "SAVE_PRODUCT",
targetType: "PRODUCT",
targetId: product.id,
details: "商品名: ${product.name}, 単価: ${product.defaultUnitPrice}, カテゴリ: ${product.category ?? '未設定'}",
);
}
Future<void> deleteProduct(String id) async {
final db = await _dbHelper.database;
await db.delete('products', where: 'id = ?', whereArgs: [id]);
await db.delete(
'products',
where: 'id = ?',
whereArgs: [id],
);
await _logRepo.logAction(
action: "DELETE_PRODUCT",
targetType: "PRODUCT",
targetId: id,
details: "商品を削除しました",
);
}
}

View file

@ -0,0 +1 @@
/home/user/.pub-cache/hosted/pub.dev/printing-5.14.2/

View file

@ -7,12 +7,16 @@
#include "generated_plugin_registrant.h"
#include <file_selector_linux/file_selector_plugin.h>
#include <printing/printing_plugin.h>
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) printing_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin");
printing_plugin_register_with_registrar(printing_registrar);
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);

View file

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
printing
url_launcher_linux
)

View file

@ -9,6 +9,8 @@ import file_selector_macos
import geolocator_apple
import mobile_scanner
import package_info_plus
import print_bluetooth_thermal
import printing
import share_plus
import sqflite_darwin
import url_launcher_macos
@ -18,6 +20,8 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PrintBluetoothThermalPlugin.register(with: registry.registrar(forPlugin: "PrintBluetoothThermalPlugin"))
PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))

View file

@ -89,6 +89,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@ -97,6 +105,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
esc_pos_utils_plus:
dependency: "direct main"
description:
name: esc_pos_utils_plus
sha256: "2a22d281cb6f04600ba3ebd607ad8df03a4b2446d814007d22525bab4d50c2ff"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
fake_async:
dependency: transitive
description:
@ -264,6 +280,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: transitive
description:
@ -552,6 +576,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.11.3"
pdf_widget_wrapper:
dependency: transitive
description:
name: pdf_widget_wrapper
sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5
url: "https://pub.dev"
source: hosted
version: "1.0.4"
permission_handler:
dependency: "direct main"
description:
@ -632,6 +664,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.3"
print_bluetooth_thermal:
dependency: "direct main"
description:
name: print_bluetooth_thermal
sha256: "17b204a5340174c02acf5f6caf7279b5000344f1f1e1bcd01dbdbf912bce6e46"
url: "https://pub.dev"
source: hosted
version: "1.1.9"
printing:
dependency: "direct main"
description:
name: printing
sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93"
url: "https://pub.dev"
source: hosted
version: "5.14.2"
pub_semver:
dependency: transitive
description:
@ -877,6 +925,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win_ble:
dependency: transitive
description:
name: win_ble
sha256: "2a867e13c4b355b101fc2c6e2ac85eeebf965db34eca46856f8b478e93b41e96"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
xdg_directories:
dependency: transitive
description:

View file

@ -50,6 +50,9 @@ dependencies:
image_picker: ^1.2.1
mobile_scanner: ^7.1.4
package_info_plus: ^9.0.0
printing: ^5.14.2
print_bluetooth_thermal: ^1.1.9
esc_pos_utils_plus: ^2.0.3
dev_dependencies:
flutter_test:

View file

@ -35,4 +35,24 @@
ロック機能はご動作対策であって削除と編集機能以外は全部使える様にする
- 顧客マスターの新規・編集・削除機能を実装する
- PDF作成と保存と仮表示は別ボタンで実装
各マスター情報は1万件を越えたりするのでカテゴライズ等工夫して迅速に検索可能にする
- PDF出力したりPDF共有は日時情報を付けて保存する
- データはgit的にアクティビティを残す様にする
- 顧客や商品等一覧から選択する時は必ず数万単位の検索がある前提にする
- 仮表示とはPDFプレビューの事です
- 見積納品請求領収書の切り替えボタンの実装
- 商品マスターには在庫管理機能をこっそり実装する
- 伝票で在庫の増減を自動的に計算する仕組みを作る
- 年次月次の管理を実装する
- 将来的にスタンドアローンでも資金管理を可能にする
- データにはGPS座標の履歴を最低10件保持する
- アマゾンで売っていたプリンタでレシート印刷をするhttps://www.amazon.co.jp/dp/B0G33SSZV6?ref=ppx_yo2ov_dt_b_fed_asin_title&th=1
- 伝票入力画面で消費税表示がシステム通りでないのを修正
日時の自動入力と編集機能の実装
- 管理番号端末UIDと伝票UIDの組み合わせで管理する
- 管理番号の表示を画面とPDFにする
- 伝票のコンテンツ情報に規則性を持たせSHA256をPDFに番号とQRで表示
- PDFのファイル名にSHA256を含める
-