smtp実装と寿命実装

This commit is contained in:
joe 2026-03-01 15:59:30 +09:00
parent 2ec25371a6
commit 01f5851ddc
40 changed files with 4495 additions and 874 deletions

View file

@ -28,6 +28,10 @@
<action android:name="android.intent.action.PROCESS_TEXT" /> <action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent> </intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries> </queries>
<application <application

View file

@ -12,6 +12,12 @@
@import flutter_contacts; @import flutter_contacts;
#endif #endif
#if __has_include(<flutter_email_sender/FlutterEmailSenderPlugin.h>)
#import <flutter_email_sender/FlutterEmailSenderPlugin.h>
#else
@import flutter_email_sender;
#endif
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>) #if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
#import <geolocator_apple/GeolocatorPlugin.h> #import <geolocator_apple/GeolocatorPlugin.h>
#else #else
@ -82,6 +88,7 @@
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry { + (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]]; [FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]];
[FlutterEmailSenderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterEmailSenderPlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]]; [MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]];

View file

@ -0,0 +1,35 @@
///
/// - --dart-define
/// -
class AppConfig {
/// --dart-define=APP_VERSION=...
static const String version = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0');
/// --dart-define
static const bool enableBillingDocs = bool.fromEnvironment('ENABLE_BILLING_DOCS', defaultValue: true);
static const bool enableSalesManagement = bool.fromEnvironment('ENABLE_SALES_MANAGEMENT', defaultValue: false);
/// APIエンドポイント dart-define
static const String apiEndpoint = String.fromEnvironment('API_ENDPOINT', defaultValue: '');
/// UIなどで表示する用途向け
static Map<String, bool> get features => {
'enableBillingDocs': enableBillingDocs,
'enableSalesManagement': enableSalesManagement,
};
/// /
static bool isFeatureEnabled(String key) => features[key] ?? false;
///
static Set<String> get enabledRoutes {
final routes = <String>{'settings'};
if (enableBillingDocs) {
routes.addAll({'invoice_history', 'invoice_input', 'master_hub', 'customer_master', 'product_master'});
}
if (enableSalesManagement) {
routes.add('sales_management');
}
return routes;
}
}

View file

@ -0,0 +1,22 @@
const String kCompanyNameKey = 'company_name';
const String kCompanyZipKey = 'company_zip';
const String kCompanyAddressKey = 'company_addr';
const String kCompanyTelKey = 'company_tel';
const String kCompanyFaxKey = 'company_fax';
const String kCompanyEmailKey = 'company_email';
const String kCompanyUrlKey = 'company_url';
const String kCompanyRegKey = 'company_reg';
const String kStaffNameKey = 'staff_name';
const String kStaffEmailKey = 'staff_mail';
const String kStaffMobileKey = 'staff_mobile';
const String kCompanyBankAccountsKey = 'company_bank_accounts';
const String kCompanyTaxRateKey = 'company_tax_rate';
const String kCompanyTaxDisplayModeKey = 'company_tax_display_mode';
const String kCompanySealPathKey = 'company_seal_path';
const int kCompanyBankSlotCount = 4;
const int kCompanyBankActiveLimit = 2;
const List<String> kAccountTypeOptions = ['普通', '当座', '貯蓄'];

View file

@ -0,0 +1,10 @@
const String kMailSendMethodPrefKey = 'mail_send_method';
const String kMailSendMethodSmtp = 'smtp';
const String kMailSendMethodDeviceMailer = 'device_mailer';
String normalizeMailSendMethod(String? value) {
if (value == kMailSendMethodDeviceMailer) {
return kMailSendMethodDeviceMailer;
}
return kMailSendMethodSmtp;
}

View file

@ -0,0 +1,32 @@
const String kMailPlaceholderFilename = '{{FILENAME}}';
const String kMailPlaceholderHash = '{{HASH}}';
const String kMailPlaceholderCompanyName = '{{COMPANY_NAME}}';
const String kMailPlaceholderCompanyEmail = '{{COMPANY_EMAIL}}';
const String kMailPlaceholderCompanyTel = '{{COMPANY_TEL}}';
const String kMailPlaceholderCompanyAddress = '{{COMPANY_ADDRESS}}';
const String kMailPlaceholderCompanyReg = '{{COMPANY_REG}}';
const String kMailPlaceholderStaffName = '{{STAFF_NAME}}';
const String kMailPlaceholderStaffEmail = '{{STAFF_EMAIL}}';
const String kMailPlaceholderStaffMobile = '{{STAFF_MOBILE}}';
const String kMailPlaceholderBankAccounts = '{{BANK_ACCOUNTS}}';
const String kMailPlaceholderAccountsList = '{{ACCOUNTS}}';
const String kMailTemplateIdDefault = 'default';
const String kMailTemplateIdNone = 'none';
const String kMailHeaderTemplateKey = 'mail_header_template';
const String kMailFooterTemplateKey = 'mail_footer_template';
const String kMailHeaderTextKey = 'mail_header_text';
const String kMailFooterTextKey = 'mail_footer_text';
const String kMailHeaderTemplateDefault = '【請求書送付のお知らせ】\nファイル名: $kMailPlaceholderFilename\nHASH: $kMailPlaceholderHash';
const String kMailFooterTemplateDefault =
'---\n$kMailPlaceholderCompanyName\n$kMailPlaceholderCompanyAddress\nTEL: $kMailPlaceholderCompanyTel / MAIL: $kMailPlaceholderCompanyEmail\n担当: $kMailPlaceholderStaffName ($kMailPlaceholderStaffMobile) $kMailPlaceholderStaffEmail\n$kMailPlaceholderBankAccounts\n登録番号: $kMailPlaceholderCompanyReg\nファイル名: $kMailPlaceholderFilename\nHASH: $kMailPlaceholderHash';
String applyMailTemplate(String template, Map<String, String> values) {
var result = template;
values.forEach((placeholder, value) {
result = result.replaceAll(placeholder, value);
});
return result;
}

View file

@ -1,85 +1,295 @@
// lib/main.dart // lib/main.dart
// version: 1.5.02 (Update: Date selection & Tax fix) // version: 1.5.02 (Update: Date selection & Tax fix)
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// --- --- // --- ---
import 'models/invoice_models.dart'; // Invoice, InvoiceItem import 'models/invoice_models.dart'; // Invoice, InvoiceItem
import 'screens/invoice_input_screen.dart'; // import 'screens/invoice_input_screen.dart'; //
import 'screens/invoice_detail_page.dart'; // import 'screens/invoice_detail_page.dart'; //
import 'screens/invoice_history_screen.dart'; // import 'screens/invoice_history_screen.dart'; //
import 'screens/dashboard_screen.dart'; //
import 'services/location_service.dart'; // import 'services/location_service.dart'; //
import 'services/customer_repository.dart'; // import 'services/customer_repository.dart'; //
import 'services/app_settings_repository.dart';
import 'services/theme_controller.dart';
import 'utils/build_expiry_info.dart';
void main() { void main() async {
WidgetsFlutterBinding.ensureInitialized();
await AppThemeController.instance.load();
final expiryInfo = BuildExpiryInfo.fromEnvironment();
if (expiryInfo.isExpired) {
runApp(ExpiredApp(expiryInfo: expiryInfo));
return;
}
runApp(const MyApp()); runApp(const MyApp());
} }
class MyApp extends StatelessWidget { class MyApp extends StatefulWidget {
const MyApp({super.key}); const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final TransformationController _zoomController = TransformationController();
int _activePointers = 0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return ValueListenableBuilder<ThemeMode>(
title: '販売アシスト1号', valueListenable: AppThemeController.instance.notifier,
theme: ThemeData( builder: (context, mode, _) => MaterialApp(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith( title: '販売アシスト1号',
primary: Colors.indigo.shade700, navigatorObservers: [
secondary: Colors.deepOrange.shade400, _ZoomResetObserver(_zoomController),
surface: Colors.grey.shade50, ],
onSurface: Colors.blueGrey.shade900, theme: ThemeData(
), colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
scaffoldBackgroundColor: Colors.grey.shade50, primary: Colors.indigo.shade700,
appBarTheme: AppBarTheme( secondary: Colors.deepOrange.shade400,
backgroundColor: Colors.indigo.shade700, surface: Colors.grey.shade100,
foregroundColor: Colors.white, onSurface: Colors.blueGrey.shade900,
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),
), ),
), scaffoldBackgroundColor: Colors.grey.shade100,
outlinedButtonTheme: OutlinedButtonThemeData( appBarTheme: AppBarTheme(
style: OutlinedButton.styleFrom( backgroundColor: Colors.indigo.shade700,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), foregroundColor: Colors.white,
side: BorderSide(color: Colors.indigo.shade700), elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
), ),
), elevatedButtonTheme: ElevatedButtonThemeData(
inputDecorationTheme: InputDecorationTheme( style: ElevatedButton.styleFrom(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
focusedBorder: OutlineInputBorder( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
borderRadius: BorderRadius.circular(12), textStyle: const TextStyle(fontWeight: FontWeight.bold),
borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4),
),
),
visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true,
fontFamily: 'IPAexGothic',
),
builder: (context, child) {
final mq = MediaQuery.of(context);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: InteractiveViewer(
panEnabled: false,
scaleEnabled: true,
minScale: 0.8,
maxScale: 4.0,
child: child ?? const SizedBox.shrink(),
), ),
), ),
); 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',
),
darkTheme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme(
brightness: Brightness.dark,
primary: const Color(0xFF66D9EF),
onPrimary: const Color(0xFF1E1F1C),
secondary: const Color(0xFFF92672),
onSecondary: const Color(0xFF1E1F1C),
surface: const Color(0xFF272822),
onSurface: const Color(0xFFF8F8F2),
error: Colors.red.shade300,
onError: const Color(0xFF1E1F1C),
),
scaffoldBackgroundColor: const Color(0xFF272822),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF32332A),
foregroundColor: Color(0xFFF8F8F2),
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF66D9EF),
foregroundColor: const Color(0xFF1E1F1C),
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: const BorderSide(color: Color(0xFF66D9EF)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
),
inputDecorationTheme: const InputDecorationTheme(
filled: true,
fillColor: Color(0xFF32332A),
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Color(0xFF66D9EF), width: 1.4),
),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: Color(0xFF32332A),
contentTextStyle: TextStyle(color: Color(0xFFF8F8F2)),
),
visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true,
fontFamily: 'IPAexGothic',
),
themeMode: mode,
builder: (context, child) {
final mq = MediaQuery.of(context);
return Listener(
onPointerDown: (_) => setState(() => _activePointers++),
onPointerUp: (_) => setState(() => _activePointers = (_activePointers - 1).clamp(0, 10)),
onPointerCancel: (_) => setState(() => _activePointers = (_activePointers - 1).clamp(0, 10)),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: InteractiveViewer(
panEnabled: false,
scaleEnabled: true,
minScale: 0.8,
maxScale: 4.0,
transformationController: _zoomController,
child: IgnorePointer(
ignoring: _activePointers > 1,
child: child ?? const SizedBox.shrink(),
),
),
),
),
);
},
home: const _HomeDecider(),
),
);
}
}
class ExpiredApp extends StatelessWidget {
final BuildExpiryInfo expiryInfo;
const ExpiredApp({super.key, required this.expiryInfo});
String _format(DateTime? timestamp) {
if (timestamp == null) return '不明';
final local = timestamp.toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${local.year}/${two(local.month)}/${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
@override
Widget build(BuildContext context) {
final buildText = _format(expiryInfo.buildTimestamp);
final expiryText = _format(expiryInfo.expiryTimestamp);
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.lock_clock, size: 72, color: Colors.white),
const SizedBox(height: 24),
const Text(
'このビルドは有効期限を過ぎています',
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text('ビルド日時: $buildText', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 4),
Text('有効期限: $expiryText', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 24),
const Text(
'最新版を取得してインストールしてください。',
style: TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.black87),
onPressed: () => SystemNavigator.pop(),
icon: const Icon(Icons.exit_to_app),
label: const Text('アプリを終了する'),
),
],
),
),
),
),
);
}
}
class _ZoomResetObserver extends NavigatorObserver {
final TransformationController controller;
_ZoomResetObserver(this.controller);
void _reset() {
controller.value = Matrix4.identity();
}
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
_reset();
}
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
_reset();
}
@override
void didReplace({Route? newRoute, Route? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
_reset();
}
}
class _HomeDecider extends StatefulWidget {
const _HomeDecider();
@override
State<_HomeDecider> createState() => _HomeDeciderState();
}
class _HomeDeciderState extends State<_HomeDecider> {
final _settings = AppSettingsRepository();
late Future<String> _homeFuture;
@override
void initState() {
super.initState();
_homeFuture = _settings.getHomeMode();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<String>(
future: _homeFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
final mode = snapshot.data ?? 'invoice_history';
if (mode == 'dashboard') {
return const DashboardScreen();
}
return const InvoiceHistoryScreen();
}, },
home: const InvoiceHistoryScreen(),
); );
} }
} }

View file

@ -13,6 +13,7 @@ class Customer {
final bool isSynced; // final bool isSynced; //
final DateTime updatedAt; // final DateTime updatedAt; //
final bool isLocked; // final bool isLocked; //
final bool isHidden; //
final String? headChar1; // 1 final String? headChar1; // 1
final String? headChar2; // 2 final String? headChar2; // 2
@ -30,6 +31,7 @@ class Customer {
this.isSynced = false, this.isSynced = false,
DateTime? updatedAt, DateTime? updatedAt,
this.isLocked = false, this.isLocked = false,
this.isHidden = false,
this.headChar1, this.headChar1,
this.headChar2, this.headChar2,
}) : updatedAt = updatedAt ?? DateTime.now(); }) : updatedAt = updatedAt ?? DateTime.now();
@ -57,6 +59,7 @@ class Customer {
'head_char2': headChar2, 'head_char2': headChar2,
'is_locked': isLocked ? 1 : 0, 'is_locked': isLocked ? 1 : 0,
'is_synced': isSynced ? 1 : 0, 'is_synced': isSynced ? 1 : 0,
'is_hidden': isHidden ? 1 : 0,
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
}; };
} }
@ -75,6 +78,7 @@ class Customer {
odooId: map['odoo_id'], odooId: map['odoo_id'],
isLocked: (map['is_locked'] ?? 0) == 1, isLocked: (map['is_locked'] ?? 0) == 1,
isSynced: map['is_synced'] == 1, isSynced: map['is_synced'] == 1,
isHidden: (map['is_hidden'] ?? 0) == 1,
updatedAt: DateTime.parse(map['updated_at']), updatedAt: DateTime.parse(map['updated_at']),
headChar1: map['head_char1'], headChar1: map['head_char1'],
headChar2: map['head_char2'], headChar2: map['head_char2'],
@ -93,6 +97,7 @@ class Customer {
bool? isSynced, bool? isSynced,
DateTime? updatedAt, DateTime? updatedAt,
bool? isLocked, bool? isLocked,
bool? isHidden,
String? email, String? email,
int? contactVersionId, int? contactVersionId,
String? headChar1, String? headChar1,
@ -112,6 +117,7 @@ class Customer {
isSynced: isSynced ?? this.isSynced, isSynced: isSynced ?? this.isSynced,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,
isLocked: isLocked ?? this.isLocked, isLocked: isLocked ?? this.isLocked,
isHidden: isHidden ?? this.isHidden,
headChar1: headChar1 ?? this.headChar1, headChar1: headChar1 ?? this.headChar1,
headChar2: headChar2 ?? this.headChar2, headChar2: headChar2 ?? this.headChar2,
); );

View file

@ -66,6 +66,10 @@ enum DocumentType {
} }
class Invoice { class Invoice {
static const String lockStatement =
'正式発行ボタン押下時にこの伝票はロックされ、以後の編集・削除はできません。ロック状態はハッシュチェーンで保護されます。';
static const String hashDescription =
'metaJson = JSON.stringify({id, invoiceNumber, customer, date, total, documentType, hash, lockStatement, companySnapshot, companySealHash}); metaHash = SHA-256(metaJson).';
final String id; final String id;
final Customer customer; final Customer customer;
final DateTime date; final DateTime date;
@ -88,6 +92,10 @@ class Invoice {
final String? contactEmailSnapshot; final String? contactEmailSnapshot;
final String? contactTelSnapshot; final String? contactTelSnapshot;
final String? contactAddressSnapshot; final String? contactAddressSnapshot;
final String? companySnapshot; // :
final String? companySealHash; // :
final String? metaJson;
final String? metaHash;
Invoice({ Invoice({
String? id, String? id,
@ -112,6 +120,10 @@ class Invoice {
this.contactEmailSnapshot, this.contactEmailSnapshot,
this.contactTelSnapshot, this.contactTelSnapshot,
this.contactAddressSnapshot, this.contactAddressSnapshot,
this.companySnapshot,
this.companySealHash,
this.metaJson,
this.metaHash,
}) : 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();
@ -132,6 +144,13 @@ class Invoice {
} }
} }
static const Map<DocumentType, String> _docTypeShortLabel = {
DocumentType.estimation: '見積',
DocumentType.delivery: '納品',
DocumentType.invoice: '請求',
DocumentType.receipt: '領収',
};
String get invoiceNumberPrefix { String get invoiceNumberPrefix {
switch (documentType) { switch (documentType) {
case DocumentType.estimation: return "EST"; case DocumentType.estimation: return "EST";
@ -143,13 +162,74 @@ class Invoice {
String get invoiceNumber => "$invoiceNumberPrefix-$terminalId-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; String get invoiceNumber => "$invoiceNumberPrefix-$terminalId-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
// //
String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName; String get customerNameForDisplay {
final base = customerFormalNameSnapshot ?? customer.formalName;
final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base);
return hasHonorific ? base : '$base ${customer.title}';
}
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal); int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
int get tax => (subtotal * taxRate).floor(); int get tax => (subtotal * taxRate).floor();
int get totalAmount => subtotal + tax; int get totalAmount => subtotal + tax;
String get _projectLabel {
if (subject != null && subject!.trim().isNotEmpty) {
return subject!.trim();
}
return '案件';
}
String get mailTitleCore {
final dateStr = DateFormat('yyyyMMdd').format(date);
final docLabel = _docTypeShortLabel[documentType] ?? documentTypeName.replaceAll('', '');
final customerCompact = customerNameForDisplay.replaceAll(RegExp(r'\s+'), '');
final amountStr = NumberFormat('#,###').format(totalAmount);
final buffer = StringBuffer()
..write(dateStr)
..write('($docLabel)')
..write(_projectLabel)
..write('@')
..write(customerCompact)
..write('_')
..write(amountStr)
..write('');
final raw = buffer.toString();
return _sanitizeForFile(raw);
}
String get mailAttachmentFileName => '$mailTitleCore.PDF';
String get mailBodyText => '請求書をお送りします。ご確認ください。';
static String _sanitizeForFile(String input) {
var sanitized = input.replaceAll(RegExp(r'[\\/:*?"<>|]'), '-');
sanitized = sanitized.replaceAll(RegExp(r'[\r\n]+'), '');
sanitized = sanitized.replaceAll(' ', '');
sanitized = sanitized.replaceAll(' ', '');
return sanitized;
}
Map<String, dynamic> metaPayload() {
return {
'id': id,
'invoiceNumber': invoiceNumber,
'customer': customerNameForDisplay,
'date': date.toIso8601String(),
'total': totalAmount,
'documentType': documentType.name,
'hash': contentHash,
'lockStatement': lockStatement,
'hashDescription': hashDescription,
'companySnapshot': companySnapshot,
'companySealHash': companySealHash,
};
}
String get metaJsonValue => metaJson ?? jsonEncode(metaPayload());
String get metaHashValue => metaHash ?? sha256.convert(utf8.encode(metaJsonValue)).toString();
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
return { return {
'id': id, 'id': id,
@ -175,6 +255,10 @@ class Invoice {
'contact_email_snapshot': contactEmailSnapshot, 'contact_email_snapshot': contactEmailSnapshot,
'contact_tel_snapshot': contactTelSnapshot, 'contact_tel_snapshot': contactTelSnapshot,
'contact_address_snapshot': contactAddressSnapshot, 'contact_address_snapshot': contactAddressSnapshot,
'company_snapshot': companySnapshot,
'company_seal_hash': companySealHash,
'meta_json': metaJsonValue,
'meta_hash': metaHashValue,
}; };
} }
@ -201,6 +285,10 @@ class Invoice {
String? contactEmailSnapshot, String? contactEmailSnapshot,
String? contactTelSnapshot, String? contactTelSnapshot,
String? contactAddressSnapshot, String? contactAddressSnapshot,
String? companySnapshot,
String? companySealHash,
String? metaJson,
String? metaHash,
}) { }) {
return Invoice( return Invoice(
id: id ?? this.id, id: id ?? this.id,
@ -225,6 +313,10 @@ class Invoice {
contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot, contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot,
contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot, contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot,
contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot, contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot,
companySnapshot: companySnapshot ?? this.companySnapshot,
companySealHash: companySealHash ?? this.companySealHash,
metaJson: metaJson ?? this.metaJson,
metaHash: metaHash ?? this.metaHash,
); );
} }

View file

@ -7,6 +7,7 @@ class Product {
final int stockQuantity; // final int stockQuantity; //
final String? odooId; final String? odooId;
final bool isLocked; // final bool isLocked; //
final bool isHidden; //
Product({ Product({
required this.id, required this.id,
@ -17,6 +18,7 @@ class Product {
this.stockQuantity = 0, // this.stockQuantity = 0, //
this.odooId, this.odooId,
this.isLocked = false, this.isLocked = false,
this.isHidden = false,
}); });
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
@ -29,6 +31,7 @@ class Product {
'stock_quantity': stockQuantity, // 'stock_quantity': stockQuantity, //
'is_locked': isLocked ? 1 : 0, 'is_locked': isLocked ? 1 : 0,
'odoo_id': odooId, 'odoo_id': odooId,
'is_hidden': isHidden ? 1 : 0,
}; };
} }
@ -42,6 +45,7 @@ class Product {
stockQuantity: map['stock_quantity'] ?? 0, // stockQuantity: map['stock_quantity'] ?? 0, //
isLocked: (map['is_locked'] ?? 0) == 1, isLocked: (map['is_locked'] ?? 0) == 1,
odooId: map['odoo_id'], odooId: map['odoo_id'],
isHidden: (map['is_hidden'] ?? 0) == 1,
); );
} }
@ -50,15 +54,22 @@ class Product {
String? name, String? name,
int? defaultUnitPrice, int? defaultUnitPrice,
String? barcode, String? barcode,
String? category,
int? stockQuantity,
String? odooId, String? odooId,
bool? isLocked, bool? isLocked,
bool? isHidden,
}) { }) {
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,
barcode: barcode ?? this.barcode,
category: category ?? this.category,
stockQuantity: stockQuantity ?? this.stockQuantity,
odooId: odooId ?? this.odooId, odooId: odooId ?? this.odooId,
isLocked: isLocked ?? this.isLocked, isLocked: isLocked ?? this.isLocked,
isHidden: isHidden ?? this.isHidden,
); );
} }
} }

View file

@ -0,0 +1,471 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:image_picker/image_picker.dart';
import '../constants/company_profile_keys.dart';
// NOTE: mail template placeholders may rely on fields edited here.
import '../models/company_model.dart';
import '../services/company_profile_service.dart';
import '../services/company_repository.dart';
import '../widgets/contact_picker_sheet.dart';
import '../widgets/keyboard_inset_wrapper.dart';
class BusinessProfileScreen extends StatefulWidget {
const BusinessProfileScreen({super.key});
@override
State<BusinessProfileScreen> createState() => _BusinessProfileScreenState();
}
class _BusinessProfileScreenState extends State<BusinessProfileScreen> {
final _service = CompanyProfileService();
final _companyRepo = CompanyRepository();
final _companyNameCtrl = TextEditingController();
final _companyZipCtrl = TextEditingController();
final _companyAddrCtrl = TextEditingController();
final _companyTelCtrl = TextEditingController();
final _companyFaxCtrl = TextEditingController();
final _companyEmailCtrl = TextEditingController();
final _companyUrlCtrl = TextEditingController();
final _companyRegCtrl = TextEditingController();
final _staffNameCtrl = TextEditingController();
final _staffEmailCtrl = TextEditingController();
final _staffMobileCtrl = TextEditingController();
final List<_BankControllers> _bankCtrls = List.generate(
kCompanyBankSlotCount,
(_) => _BankControllers(),
);
bool _loading = true;
double _taxRate = 0.10;
String _taxDisplayMode = 'normal';
String? _sealPath;
CompanyInfo? _legacyInfo;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final profile = await _service.loadProfile();
final legacyInfo = await _companyRepo.getCompanyInfo();
if (!mounted) return;
setState(() {
_companyNameCtrl.text = profile.companyName.isNotEmpty ? profile.companyName : legacyInfo.name;
_companyZipCtrl.text = profile.companyZip.isNotEmpty ? profile.companyZip : (legacyInfo.zipCode ?? '');
_companyAddrCtrl.text = profile.companyAddress.isNotEmpty ? profile.companyAddress : (legacyInfo.address ?? '');
_companyTelCtrl.text = profile.companyTel.isNotEmpty ? profile.companyTel : (legacyInfo.tel ?? '');
_companyFaxCtrl.text = profile.companyFax.isNotEmpty ? profile.companyFax : (legacyInfo.fax ?? '');
_companyEmailCtrl.text = profile.companyEmail.isNotEmpty ? profile.companyEmail : (legacyInfo.email ?? '');
_companyUrlCtrl.text = profile.companyUrl.isNotEmpty ? profile.companyUrl : (legacyInfo.url ?? '');
_companyRegCtrl.text = profile.companyReg.isNotEmpty ? profile.companyReg : (legacyInfo.registrationNumber ?? '');
_staffNameCtrl.text = profile.staffName;
_staffEmailCtrl.text = profile.staffEmail;
_staffMobileCtrl.text = profile.staffMobile;
for (var i = 0; i < _bankCtrls.length; i++) {
final ctrl = _bankCtrls[i];
if (i < profile.bankAccounts.length) {
final acc = profile.bankAccounts[i];
ctrl.bankName.text = acc.bankName;
ctrl.branchName.text = acc.branchName;
ctrl.accountType = acc.accountType;
ctrl.accountNumber.text = acc.accountNumber;
ctrl.holderName.text = acc.holderName;
ctrl.isActive = acc.isActive;
}
}
_taxRate = legacyInfo.defaultTaxRate;
_taxDisplayMode = legacyInfo.taxDisplayMode;
_sealPath = legacyInfo.sealPath;
_legacyInfo = legacyInfo;
_loading = false;
});
}
Future<void> _save() async {
final accounts = _bankCtrls
.map(
(c) => CompanyBankAccount(
bankName: c.bankName.text,
branchName: c.branchName.text,
accountType: c.accountType,
accountNumber: c.accountNumber.text,
holderName: c.holderName.text,
isActive: c.isActive,
),
)
.toList();
final activeCount = accounts.where((a) => a.isActive && a.bankName.trim().isNotEmpty).length;
if (activeCount > kCompanyBankActiveLimit) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('振込口座は最大$kCompanyBankActiveLimit件まで有効化できます')),
);
return;
}
final profile = CompanyProfile(
companyName: _companyNameCtrl.text.trim(),
companyZip: _companyZipCtrl.text.trim(),
companyAddress: _companyAddrCtrl.text.trim(),
companyTel: _companyTelCtrl.text.trim(),
companyFax: _companyFaxCtrl.text.trim(),
companyEmail: _companyEmailCtrl.text.trim(),
companyUrl: _companyUrlCtrl.text.trim(),
companyReg: _companyRegCtrl.text.trim(),
staffName: _staffNameCtrl.text.trim(),
staffEmail: _staffEmailCtrl.text.trim(),
staffMobile: _staffMobileCtrl.text.trim(),
bankAccounts: accounts,
);
await _service.saveProfile(profile);
await _companyRepo.saveCompanyInfo(
(_legacyInfo ?? CompanyInfo(name: _companyNameCtrl.text.trim().isEmpty ? '未設定' : _companyNameCtrl.text.trim())).copyWith(
name: _companyNameCtrl.text.trim(),
zipCode: _companyZipCtrl.text.trim(),
address: _companyAddrCtrl.text.trim(),
tel: _companyTelCtrl.text.trim(),
fax: _companyFaxCtrl.text.trim(),
email: _companyEmailCtrl.text.trim(),
url: _companyUrlCtrl.text.trim(),
registrationNumber: _companyRegCtrl.text.trim().isEmpty ? null : _companyRegCtrl.text.trim(),
defaultTaxRate: _taxRate,
taxDisplayMode: _taxDisplayMode,
sealPath: _sealPath,
),
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('自社情報を保存しました')));
}
Future<void> _pickSeal(ImageSource source) async {
final picker = ImagePicker();
final image = await picker.pickImage(source: source, imageQuality: 85);
if (image == null) return;
setState(() {
_sealPath = image.path;
});
}
Future<void> _pickContacts(bool forCompany) async {
final granted = await FlutterContacts.requestPermission(readonly: true);
if (!granted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('連絡先へのアクセス権限が必要です')));
}
return;
}
final contacts = await FlutterContacts.getContacts(withProperties: true, withAccounts: true);
if (!mounted) return;
final selected = await showModalBottomSheet<Contact?>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => ContactPickerSheet(contacts: contacts, title: forCompany ? '会社情報を電話帳から' : '担当者を電話帳から'),
);
if (selected == null) return;
if (forCompany) {
if (selected.organizations.isNotEmpty) {
_companyNameCtrl.text = selected.organizations.first.company;
} else {
_companyNameCtrl.text = selected.displayName;
}
if (selected.addresses.isNotEmpty) {
final addr = selected.addresses.first;
_companyAddrCtrl.text = [addr.postalCode, addr.state, addr.city, addr.street].where((e) => e.trim().isNotEmpty).join(' ');
}
if (selected.phones.isNotEmpty) {
_companyTelCtrl.text = selected.phones.first.number;
}
if (selected.emails.isNotEmpty) {
_companyEmailCtrl.text = selected.emails.first.address;
}
} else {
_staffNameCtrl.text = selected.displayName;
if (selected.phones.isNotEmpty) {
_staffMobileCtrl.text = selected.phones.first.number;
}
if (selected.emails.isNotEmpty) {
_staffEmailCtrl.text = selected.emails.first.address;
}
}
}
@override
void dispose() {
_companyNameCtrl.dispose();
_companyZipCtrl.dispose();
_companyAddrCtrl.dispose();
_companyTelCtrl.dispose();
_companyFaxCtrl.dispose();
_companyEmailCtrl.dispose();
_companyUrlCtrl.dispose();
_companyRegCtrl.dispose();
_staffNameCtrl.dispose();
_staffEmailCtrl.dispose();
_staffMobileCtrl.dispose();
for (final ctrl in _bankCtrls) {
ctrl.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('F2:自社情報'),
backgroundColor: Colors.indigo,
actions: [
IconButton(onPressed: _save, icon: const Icon(Icons.save)),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: KeyboardInsetWrapper(
basePadding: const EdgeInsets.all(16),
extraBottom: 24,
child: ListView(
children: [
_section('会社情報', _buildCompanySection()),
_section('担当者情報', _buildStaffSection()),
_section('消費税設定', _buildTaxSection()),
_section('印影(角印)', _buildSealSection()),
_section('振込先口座 (最大2件まで有効)', _buildBankSection()),
],
),
),
);
}
Widget _section(String title, Widget child) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
child,
],
),
),
);
}
Widget _buildCompanySection() {
return Column(
children: [
TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '自社名')),
const SizedBox(height: 8),
TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')),
const SizedBox(height: 8),
TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')),
const SizedBox(height: 8),
TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')),
const SizedBox(height: 8),
TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')),
const SizedBox(height: 8),
TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: '代表メールアドレス')),
const SizedBox(height: 8),
TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')),
const SizedBox(height: 8),
TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号(T番号)')),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: () => _pickContacts(true),
icon: const Icon(Icons.import_contacts),
label: const Text('電話帳から取り込む'),
),
),
],
);
}
Widget _buildStaffSection() {
return Column(
children: [
TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')),
const SizedBox(height: 8),
TextField(controller: _staffEmailCtrl, decoration: const InputDecoration(labelText: '担当者メール')),
const SizedBox(height: 8),
TextField(controller: _staffMobileCtrl, decoration: const InputDecoration(labelText: '担当者携帯番号')),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton.icon(
onPressed: () => _pickContacts(false),
icon: const Icon(Icons.smartphone),
label: const Text('電話帳から取り込む'),
),
),
],
);
}
Widget _buildBankSection() {
return Column(
children: List.generate(_bankCtrls.length, (index) {
final ctrl = _bankCtrls[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
color: ctrl.isActive ? Colors.green.shade50 : null,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text('口座 ${index + 1}', style: const TextStyle(fontWeight: FontWeight.bold)),
const Spacer(),
Switch(
value: ctrl.isActive,
onChanged: (v) {
setState(() => ctrl.isActive = v);
},
),
],
),
TextField(controller: ctrl.bankName, decoration: const InputDecoration(labelText: '銀行名')),
const SizedBox(height: 8),
TextField(controller: ctrl.branchName, decoration: const InputDecoration(labelText: '支店名')),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: ctrl.accountType,
decoration: const InputDecoration(labelText: '種別'),
items: kAccountTypeOptions.map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
onChanged: (v) => setState(() => ctrl.accountType = v ?? '普通'),
),
const SizedBox(height: 8),
TextField(controller: ctrl.accountNumber, decoration: const InputDecoration(labelText: '口座番号')),
const SizedBox(height: 8),
TextField(controller: ctrl.holderName, decoration: const InputDecoration(labelText: '名義人')),
],
),
),
);
}),
);
}
Widget _buildTaxSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('デフォルト消費税率', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ChoiceChip(
label: const Text('10%'),
selected: _taxRate == 0.10,
onSelected: (_) => setState(() => _taxRate = 0.10),
),
ChoiceChip(
label: const Text('8%'),
selected: _taxRate == 0.08,
onSelected: (_) => setState(() => _taxRate = 0.08),
),
ChoiceChip(
label: const Text('0%'),
selected: _taxRate == 0.0,
onSelected: (_) => setState(() => _taxRate = 0.0),
),
],
),
const SizedBox(height: 16),
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'),
),
],
),
],
);
}
Widget _buildSealSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(12),
color: Colors.grey.shade50,
),
child: _sealPath == null
? const Center(child: Icon(Icons.crop_original, size: 48, color: Colors.grey))
: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(File(_sealPath!), fit: BoxFit.contain),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: () => _pickSeal(ImageSource.camera),
icon: const Icon(Icons.camera_alt),
label: const Text('カメラで取り込む'),
),
OutlinedButton.icon(
onPressed: () => _pickSeal(ImageSource.gallery),
icon: const Icon(Icons.photo_library),
label: const Text('アルバムから選択'),
),
],
),
const SizedBox(height: 6),
const Text('白い紙に押した判子を真上から撮影してください', style: TextStyle(fontSize: 12, color: Colors.grey)),
],
);
}
}
class _BankControllers {
final bankName = TextEditingController();
final branchName = TextEditingController();
final accountNumber = TextEditingController();
final holderName = TextEditingController();
String accountType = '普通';
bool isActive = false;
void dispose() {
bankName.dispose();
branchName.dispose();
accountNumber.dispose();
holderName.dispose();
}
}

View file

@ -71,6 +71,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
title: const Text("F1:自社情報"), title: const Text("F1:自社情報"),
backgroundColor: Colors.indigo, backgroundColor: Colors.indigo,

View file

@ -6,11 +6,13 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert'; import 'dart:convert';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import '../widgets/contact_picker_sheet.dart';
class CustomerMasterScreen extends StatefulWidget { class CustomerMasterScreen extends StatefulWidget {
final bool selectionMode; final bool selectionMode;
final bool showHidden;
const CustomerMasterScreen({super.key, this.selectionMode = false}); const CustomerMasterScreen({super.key, this.selectionMode = false, this.showHidden = false});
@override @override
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState(); State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
@ -87,6 +89,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
} }
Future<void> _showContactUpdateDialog(Customer customer) async { Future<void> _showContactUpdateDialog(Customer customer) async {
if (customer.isLocked) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロック中の顧客は連絡先を更新できません')));
}
return;
}
final emailController = TextEditingController(text: customer.email ?? ""); final emailController = TextEditingController(text: customer.email ?? "");
final telController = TextEditingController(text: customer.tel ?? ""); final telController = TextEditingController(text: customer.tel ?? "");
final addressController = TextEditingController(text: customer.address ?? ""); final addressController = TextEditingController(text: customer.address ?? "");
@ -130,7 +138,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
Future<void> _loadCustomers() async { Future<void> _loadCustomers() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final customers = await _customerRepo.getAllCustomers(); final customers = await _customerRepo.getAllCustomers(includeHidden: widget.showHidden);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_customers = customers; _customers = customers;
@ -149,13 +157,20 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
List<Customer> list = _customers.where((c) { List<Customer> list = _customers.where((c) {
return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query); return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query);
}).toList(); }).toList();
if (!widget.showHidden) {
list = list.where((c) => !c.isHidden).toList();
}
// Kana filtering disabled temporarily for stability // Kana filtering disabled temporarily for stability
switch (_sortKey) { switch (_sortKey) {
case 'name_desc': case 'name_desc':
list.sort((a, b) => _normalizedName(b.displayName).compareTo(_normalizedName(a.displayName))); list.sort((a, b) => widget.showHidden
? b.id.compareTo(a.id)
: _normalizedName(b.displayName).compareTo(_normalizedName(a.displayName)));
break; break;
default: default:
list.sort((a, b) => _normalizedName(a.displayName).compareTo(_normalizedName(b.displayName))); list.sort((a, b) => widget.showHidden
? b.id.compareTo(a.id)
: _normalizedName(a.displayName).compareTo(_normalizedName(b.displayName)));
} }
_filtered = list; _filtered = list;
} }
@ -205,16 +220,6 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
late final Map<String, String> _defaultKanaMap = _buildDefaultKanaMap(); late final Map<String, String> _defaultKanaMap = _buildDefaultKanaMap();
String _normalizeIndexChar(String input) {
var s = input.replaceAll(RegExp(r"\s+|\u3000"), "");
if (s.isEmpty) return '';
String ch = s.characters.first;
final code = ch.codeUnitAt(0);
if (code >= 0x30A1 && code <= 0x30F6) {
ch = String.fromCharCode(code - 0x60); // katakana -> hiragana
}
return ch;
}
Future<void> _addOrEditCustomer({Customer? customer}) async { Future<void> _addOrEditCustomer({Customer? customer}) async {
final isEdit = customer != null; final isEdit = customer != null;
@ -244,26 +249,8 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final Contact? picked = await showModalBottomSheet<Contact>( final Contact? picked = await showModalBottomSheet<Contact>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (ctx) => SafeArea( backgroundColor: Colors.transparent,
child: SizedBox( builder: (ctx) => ContactPickerSheet(contacts: contacts, title: isEdit ? '電話帳から上書き' : '電話帳から新規入力'),
height: MediaQuery.of(ctx).size.height * 0.6,
child: ListView.builder(
itemCount: contacts.length,
itemBuilder: (_, i) {
final c = contacts[i];
final orgCompany = c.organizations.isNotEmpty ? c.organizations.first.company : '';
final personParts = [c.name.last, c.name.first].where((v) => v.isNotEmpty).toList();
final person = personParts.isNotEmpty ? personParts.join(' ').trim() : c.displayName;
final label = orgCompany.isNotEmpty ? orgCompany : person;
return ListTile(
title: Text(label),
subtitle: person.isNotEmpty ? Text(person) : null,
onTap: () => Navigator.pop(ctx, c),
);
},
),
),
),
); );
if (!mounted) return; if (!mounted) return;
if (picked != null) { if (picked != null) {
@ -404,22 +391,25 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
TextButton( TextButton(
onPressed: () { onPressed: () {
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) { if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("表示名と正式名称は必須です")));
return; return;
} }
final head1 = _normalizeIndexChar(head1Controller.text); final head1 = head1Controller.text.trim();
final head2 = _normalizeIndexChar(head2Controller.text); final head2 = head2Controller.text.trim();
final locked = customer?.isLocked ?? false;
final newId = locked ? const Uuid().v4() : (customer?.id ?? const Uuid().v4());
final newCustomer = Customer( final newCustomer = Customer(
id: customer?.id ?? const Uuid().v4(), id: newId,
displayName: displayNameController.text, displayName: displayNameController.text.trim(),
formalName: formalNameController.text, formalName: formalNameController.text.trim(),
title: selectedTitle, title: selectedTitle,
department: departmentController.text.isEmpty ? null : departmentController.text, department: departmentController.text.trim().isEmpty ? null : departmentController.text.trim(),
address: addressController.text.isEmpty ? null : addressController.text, address: addressController.text.trim().isEmpty ? null : addressController.text.trim(),
tel: telController.text.isEmpty ? null : telController.text, tel: telController.text.trim().isEmpty ? null : telController.text.trim(),
email: emailController.text.isEmpty ? null : emailController.text, email: emailController.text.trim().isEmpty ? null : emailController.text.trim(),
headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1, headChar1: head1.isEmpty ? null : head1,
headChar2: head2.isEmpty ? null : head2, headChar2: head2.isEmpty ? null : head2,
isLocked: customer?.isLocked ?? false, isLocked: false,
); );
Navigator.pop(context, newCustomer); Navigator.pop(context, newCustomer);
}, },
@ -783,7 +773,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
), ),
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)), title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)),
subtitle: Text("${c.formalName} ${c.title}"), subtitle: Text("${c.formalName} ${c.title}"),
onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c), onTap: widget.selectionMode
? () {
if (c.isHidden) return; // do not select hidden
Navigator.pop(context, c);
}
: () => _showDetailPane(c),
trailing: widget.selectionMode trailing: widget.selectionMode
? null ? null
: IconButton( : IconButton(
@ -861,23 +856,33 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),
title: const Text('編集'), title: const Text('編集'),
enabled: !c.isLocked, enabled: !c.isLocked,
onTap: c.isLocked onTap: () {
? null Navigator.pop(context);
: () { _addOrEditCustomer(customer: c);
Navigator.pop(context); },
_addOrEditCustomer(customer: c);
},
), ),
ListTile( ListTile(
leading: const Icon(Icons.contact_mail), leading: const Icon(Icons.contact_mail),
title: const Text('連絡先を更新'), title: const Text('連絡先を更新'),
enabled: !c.isLocked,
onTap: () { onTap: () {
if (c.isLocked) return;
Navigator.pop(context); Navigator.pop(context);
_showContactUpdateDialog(c); _showContactUpdateDialog(c);
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.delete, color: Colors.redAccent), leading: const Icon(Icons.visibility_off),
title: const Text('非表示にする'),
onTap: () async {
Navigator.pop(context);
await _customerRepo.setHidden(c.id, true);
if (!mounted) return;
_loadCustomers();
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
title: const Text('削除', style: TextStyle(color: Colors.redAccent)), title: const Text('削除', style: TextStyle(color: Colors.redAccent)),
enabled: !c.isLocked, enabled: !c.isLocked,
onTap: c.isLocked onTap: c.isLocked
@ -957,10 +962,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () { onPressed: c.isLocked
Navigator.pop(context); ? null
_showContactUpdateSheet(c); : () {
}, Navigator.pop(context);
_showContactUpdateSheet(c);
},
icon: const Icon(Icons.contact_mail), icon: const Icon(Icons.contact_mail),
label: const Text("連絡先を更新"), label: const Text("連絡先を更新"),
), ),
@ -1018,7 +1025,9 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
ListTile( ListTile(
leading: const Icon(Icons.contact_mail), leading: const Icon(Icons.contact_mail),
title: const Text('連絡先を更新'), title: const Text('連絡先を更新'),
enabled: !c.isLocked,
onTap: () { onTap: () {
if (c.isLocked) return;
Navigator.pop(context); Navigator.pop(context);
_showContactUpdateDialog(c); _showContactUpdateDialog(c);
}, },

View file

@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart'; import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/contact_picker_sheet.dart';
/// ///
class CustomerPickerModal extends StatefulWidget { class CustomerPickerModal extends StatefulWidget {
@ -55,7 +56,8 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
final Contact? selectedContact = await showModalBottomSheet<Contact?>( final Contact? selectedContact = await showModalBottomSheet<Contact?>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (context) => _PhoneContactListSelector(contacts: contacts), backgroundColor: Colors.transparent,
builder: (context) => ContactPickerSheet(contacts: contacts, title: '電話帳から顧客候補を選択'),
); );
if (!context.mounted) return; if (!context.mounted) return;
@ -316,66 +318,3 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
} }
} }
///
class _PhoneContactListSelector extends StatefulWidget {
final List<Contact> contacts;
const _PhoneContactListSelector({required this.contacts});
@override
State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState();
}
class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> {
List<Contact> _filtered = [];
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_filtered = widget.contacts;
}
void _onSearch(String q) {
setState(() {
_filtered = widget.contacts
.where((c) {
final org = c.organizations.isNotEmpty ? c.organizations.first.company : '';
final label = org.isNotEmpty ? org : c.displayName;
return label.toLowerCase().contains(q.toLowerCase());
})
.toList();
});
}
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
heightFactor: 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)),
onChanged: _onSearch,
),
),
Expanded(
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (context, index) => ListTile(
title: Text(
_filtered[index].organizations.isNotEmpty && _filtered[index].organizations.first.company.isNotEmpty
? _filtered[index].organizations.first.company
: _filtered[index].displayName,
),
onTap: () => Navigator.pop(context, _filtered[index]),
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,281 @@
import 'dart:io';
import 'package:flutter/material.dart';
import '../services/app_settings_repository.dart';
import 'invoice_history_screen.dart';
import 'invoice_input_screen.dart';
import 'invoice_detail_page.dart';
import 'customer_master_screen.dart';
import 'product_master_screen.dart';
import 'settings_screen.dart';
import 'master_hub_page.dart';
import '../models/invoice_models.dart';
import '../services/location_service.dart';
import '../services/customer_repository.dart';
import '../widgets/slide_to_unlock.dart';
import '../config/app_config.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
final _repo = AppSettingsRepository();
bool _loading = true;
bool _statusEnabled = true;
String _statusText = '工事中';
List<DashboardMenuItem> _menu = [];
bool _historyUnlocked = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final statusEnabled = await _repo.getDashboardStatusEnabled();
final statusText = await _repo.getDashboardStatusText();
final rawMenu = await _repo.getDashboardMenu();
final enabledRoutes = AppConfig.enabledRoutes;
final menu = rawMenu.where((m) => enabledRoutes.contains(m.route)).toList();
final unlocked = await _repo.getDashboardHistoryUnlocked();
setState(() {
_statusEnabled = statusEnabled;
_statusText = statusText;
_menu = menu;
_loading = false;
_historyUnlocked = unlocked;
});
}
void _navigate(DashboardMenuItem item) async {
Widget? target;
final enabledRoutes = AppConfig.enabledRoutes;
if (!enabledRoutes.contains(item.route)) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('この機能は現在ご利用いただけません')));
return;
}
switch (item.route) {
case 'invoice_history':
if (!_historyUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロックを解除してください')));
return;
}
target = const InvoiceHistoryScreen(initialUnlocked: true);
break;
case 'invoice_input':
target = InvoiceInputForm(
onInvoiceGenerated: (invoice, path) async {
final locationService = LocationService();
final pos = await locationService.getCurrentLocation();
if (pos != null) {
final customerRepo = CustomerRepository();
await customerRepo.addGpsHistory(invoice.customer.id, pos.latitude, pos.longitude);
}
if (!mounted) return;
Navigator.push(
context,
MaterialPageRoute(builder: (_) => InvoiceDetailPage(invoice: invoice)),
);
},
initialDocumentType: DocumentType.invoice,
);
break;
case 'customer_master':
target = const CustomerMasterScreen();
break;
case 'product_master':
target = const ProductMasterScreen();
break;
case 'master_hub':
target = const MasterHubPage();
break;
case 'settings':
target = const SettingsScreen();
break;
default:
target = const InvoiceHistoryScreen();
break;
}
await Navigator.push(context, MaterialPageRoute(builder: (_) => target!));
if (item.route == 'settings') {
await _load();
}
}
Widget _tile(DashboardMenuItem item) {
return GestureDetector(
onTap: () => _navigate(item),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(color: Colors.black12, blurRadius: 6, offset: const Offset(0, 2)),
],
),
padding: const EdgeInsets.all(16),
child: Row(
children: [
_leading(item),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 4),
Text(_routeLabel(item.route), style: const TextStyle(color: Colors.grey)),
],
),
),
const Icon(Icons.chevron_right, color: Colors.grey),
],
),
),
);
}
Widget _leading(DashboardMenuItem item) {
if (item.customIconPath != null && File(item.customIconPath!).existsSync()) {
return CircleAvatar(backgroundImage: FileImage(File(item.customIconPath!)), radius: 22);
}
return CircleAvatar(
radius: 22,
backgroundColor: Colors.indigo.shade50,
foregroundColor: Colors.indigo.shade700,
child: Icon(_iconForName(item.iconName ?? 'list_alt')),
);
}
IconData _iconForName(String name) {
return kIconsMap[name] ?? Icons.apps;
}
String _routeLabel(String route) {
switch (route) {
case 'invoice_history':
return 'A2:伝票一覧';
case 'invoice_input':
return 'A1:伝票入力';
case 'customer_master':
return 'C1:顧客マスター';
case 'product_master':
return 'P1:商品マスター';
case 'master_hub':
return 'M1:マスター管理';
case 'settings':
return 'S1:設定';
default:
return route;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
title: const Text('D1:ダッシュボード'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
await _load();
},
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: _historyUnlocked
? Row(
children: [
const Icon(Icons.lock_open, color: Colors.green),
const SizedBox(width: 8),
const Expanded(child: Text('A2ロック解除済')),
OutlinedButton.icon(
onPressed: () async {
setState(() => _historyUnlocked = false);
await _repo.setDashboardHistoryUnlocked(false);
},
icon: const Icon(Icons.lock),
label: const Text('再ロック'),
),
],
)
: SlideToUnlock(
isLocked: !_historyUnlocked,
onUnlocked: () async {
setState(() => _historyUnlocked = true);
await _repo.setDashboardHistoryUnlocked(true);
},
text: 'スライドでロック解除 (A2)',
),
),
if (_statusEnabled)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.orange.shade200),
),
child: Row(
children: [
const Icon(Icons.info_outline, color: Colors.orange),
const SizedBox(width: 12),
Expanded(child: Text(_statusText, style: const TextStyle(fontWeight: FontWeight.bold))),
],
),
),
..._menu.map((e) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _tile(e),
)),
if (_menu.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text('メニューが未設定です。設定画面から追加してください。'),
),
)
],
),
),
);
}
}
// fallback icon map for dashboard
const Map<String, IconData> kIconsMap = {
'list_alt': Icons.list_alt,
'edit_note': Icons.edit_note,
'history': Icons.history,
'settings': Icons.settings,
'invoice': Icons.receipt_long,
'customer': Icons.people,
'product': Icons.inventory_2,
'menu': Icons.menu,
'analytics': Icons.analytics,
'map': Icons.map,
'master': Icons.storage,
'qr': Icons.qr_code,
'camera': Icons.camera_alt,
'contact': Icons.contact_mail,
};

View file

@ -0,0 +1,586 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../constants/mail_send_method.dart';
import '../constants/mail_templates.dart';
import '../services/app_settings_repository.dart';
import '../services/email_sender.dart';
class EmailSettingsScreen extends StatefulWidget {
const EmailSettingsScreen({super.key});
@override
State<EmailSettingsScreen> createState() => _EmailSettingsScreenState();
}
class _EmailSettingsScreenState extends State<EmailSettingsScreen> {
final _appSettingsRepo = AppSettingsRepository();
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController(text: '587');
final _smtpUserCtrl = TextEditingController();
final _smtpPassCtrl = TextEditingController();
final _smtpBccCtrl = TextEditingController();
final _mailHeaderCtrl = TextEditingController();
final _mailFooterCtrl = TextEditingController();
bool _smtpTls = true;
bool _smtpIgnoreBadCert = false;
bool _loadingLogs = false;
String _mailSendMethod = kMailSendMethodSmtp;
List<String> _smtpLogs = [];
String _mailHeaderTemplateId = kMailTemplateIdDefault;
String _mailFooterTemplateId = kMailTemplateIdDefault;
static const _kSmtpHost = 'smtp_host';
static const _kSmtpPort = 'smtp_port';
static const _kSmtpUser = 'smtp_user';
static const _kSmtpPass = 'smtp_pass';
static const _kSmtpTls = 'smtp_tls';
static const _kSmtpBcc = 'smtp_bcc';
static const _kSmtpIgnoreBadCert = 'smtp_ignore_bad_cert';
static const _kMailSendMethod = kMailSendMethodPrefKey;
static const _kMailHeaderTemplate = kMailHeaderTemplateKey;
static const _kMailFooterTemplate = kMailFooterTemplateKey;
static const _kMailHeaderText = kMailHeaderTextKey;
static const _kMailFooterText = kMailFooterTextKey;
static const _kCryptKey = 'test';
@override
void initState() {
super.initState();
_loadAll();
}
@override
void dispose() {
_smtpHostCtrl.dispose();
_smtpPortCtrl.dispose();
_smtpUserCtrl.dispose();
_smtpPassCtrl.dispose();
_smtpBccCtrl.dispose();
_mailHeaderCtrl.dispose();
_mailFooterCtrl.dispose();
super.dispose();
}
Future<void> _loadAll() async {
final prefs = await SharedPreferences.getInstance();
final hostPref = prefs.getString(_kSmtpHost);
final smtpHost = hostPref ?? await _appSettingsRepo.getString(_kSmtpHost) ?? '';
final portPref = prefs.getString(_kSmtpPort);
final smtpPort = (portPref ?? await _appSettingsRepo.getString(_kSmtpPort) ?? '587').trim().isEmpty
? '587'
: (portPref ?? await _appSettingsRepo.getString(_kSmtpPort) ?? '587');
final userPref = prefs.getString(_kSmtpUser);
final smtpUser = userPref ?? await _appSettingsRepo.getString(_kSmtpUser) ?? '';
final passPref = prefs.getString(_kSmtpPass);
final smtpPassEncrypted = passPref ?? await _appSettingsRepo.getString(_kSmtpPass) ?? '';
final smtpPass = _decryptWithFallback(smtpPassEncrypted);
final tlsPrefExists = prefs.containsKey(_kSmtpTls);
final smtpTls = tlsPrefExists ? (prefs.getBool(_kSmtpTls) ?? true) : await _appSettingsRepo.getBool(_kSmtpTls, defaultValue: true);
final bccPref = prefs.getString(_kSmtpBcc);
final smtpBcc = bccPref ?? await _appSettingsRepo.getString(_kSmtpBcc) ?? '';
final ignorePrefExists = prefs.containsKey(_kSmtpIgnoreBadCert);
final smtpIgnoreBadCert = ignorePrefExists
? (prefs.getBool(_kSmtpIgnoreBadCert) ?? false)
: await _appSettingsRepo.getBool(_kSmtpIgnoreBadCert, defaultValue: false);
final mailSendMethodPref = prefs.getString(_kMailSendMethod);
final mailSendMethodDb = await _appSettingsRepo.getString(_kMailSendMethod) ?? kMailSendMethodSmtp;
final resolvedMailSendMethod = normalizeMailSendMethod(mailSendMethodPref ?? mailSendMethodDb);
final headerTemplatePref = prefs.getString(_kMailHeaderTemplate);
final headerTemplateDb = await _appSettingsRepo.getString(_kMailHeaderTemplate) ?? kMailTemplateIdDefault;
final resolvedHeaderTemplate = headerTemplatePref ?? headerTemplateDb;
final headerTextPref = prefs.getString(_kMailHeaderText);
final headerTextDb = await _appSettingsRepo.getString(_kMailHeaderText) ?? kMailHeaderTemplateDefault;
final resolvedHeaderText = headerTextPref ?? headerTextDb;
final footerTemplatePref = prefs.getString(_kMailFooterTemplate);
final footerTemplateDb = await _appSettingsRepo.getString(_kMailFooterTemplate) ?? kMailTemplateIdDefault;
final resolvedFooterTemplate = footerTemplatePref ?? footerTemplateDb;
final footerTextPref = prefs.getString(_kMailFooterText);
final footerTextDb = await _appSettingsRepo.getString(_kMailFooterText) ?? kMailFooterTemplateDefault;
final resolvedFooterText = footerTextPref ?? footerTextDb;
final needsPrefSync =
hostPref == null ||
portPref == null ||
userPref == null ||
passPref == null ||
bccPref == null ||
!tlsPrefExists ||
!ignorePrefExists ||
mailSendMethodPref == null ||
headerTemplatePref == null ||
headerTextPref == null ||
footerTemplatePref == null ||
footerTextPref == null;
if (needsPrefSync) {
await _saveSmtpPrefs(
host: smtpHost,
port: smtpPort,
user: smtpUser,
encryptedPass: smtpPassEncrypted,
tls: smtpTls,
bcc: smtpBcc,
ignoreBadCert: smtpIgnoreBadCert,
mailSendMethod: resolvedMailSendMethod,
headerTemplate: resolvedHeaderTemplate,
headerText: resolvedHeaderText,
footerTemplate: resolvedFooterTemplate,
footerText: resolvedFooterText,
);
}
setState(() {
_smtpHostCtrl.text = smtpHost;
_smtpPortCtrl.text = smtpPort;
_smtpUserCtrl.text = smtpUser;
_smtpPassCtrl.text = smtpPass;
_smtpBccCtrl.text = smtpBcc;
_smtpTls = smtpTls;
_smtpIgnoreBadCert = smtpIgnoreBadCert;
_mailSendMethod = resolvedMailSendMethod;
_mailHeaderTemplateId = resolvedHeaderTemplate;
_mailFooterTemplateId = resolvedFooterTemplate;
_mailHeaderCtrl.text = resolvedHeaderText;
_mailFooterCtrl.text = resolvedFooterText;
});
await _loadSmtpLogs();
}
Future<void> _loadSmtpLogs() async {
setState(() => _loadingLogs = true);
final logs = await EmailSender.loadLogs();
if (!mounted) return;
setState(() {
_smtpLogs = logs;
_loadingLogs = false;
});
}
Future<void> _clearSmtpLogs() async {
await EmailSender.clearLogs();
await _loadSmtpLogs();
}
Future<void> _copySmtpLogs() async {
if (_smtpLogs.isEmpty) return;
await Clipboard.setData(ClipboardData(text: _smtpLogs.join('\n')));
_showSnackbar('ログをクリップボードにコピーしました');
}
Future<void> _saveSmtp() async {
final host = _smtpHostCtrl.text.trim();
final port = _smtpPortCtrl.text.trim().isEmpty ? '587' : _smtpPortCtrl.text.trim();
final user = _smtpUserCtrl.text.trim();
final passPlain = _smtpPassCtrl.text;
final passEncrypted = _encrypt(passPlain);
final bcc = _smtpBccCtrl.text.trim();
if (bcc.isEmpty) {
_showSnackbar('BCCは必須項目です');
return;
}
await _appSettingsRepo.setString(_kSmtpHost, host);
await _appSettingsRepo.setString(_kSmtpPort, port);
await _appSettingsRepo.setString(_kSmtpUser, user);
await _appSettingsRepo.setString(_kSmtpPass, passEncrypted);
await _appSettingsRepo.setBool(_kSmtpTls, _smtpTls);
await _appSettingsRepo.setString(_kSmtpBcc, bcc);
await _appSettingsRepo.setBool(_kSmtpIgnoreBadCert, _smtpIgnoreBadCert);
await _appSettingsRepo.setString(_kMailSendMethod, _mailSendMethod);
await _appSettingsRepo.setString(_kMailHeaderTemplate, _mailHeaderTemplateId);
await _appSettingsRepo.setString(_kMailFooterTemplate, _mailFooterTemplateId);
await _appSettingsRepo.setString(_kMailHeaderText, _mailHeaderCtrl.text);
await _appSettingsRepo.setString(_kMailFooterText, _mailFooterCtrl.text);
await _saveSmtpPrefs(
host: host,
port: port,
user: user,
encryptedPass: passEncrypted,
tls: _smtpTls,
bcc: bcc,
ignoreBadCert: _smtpIgnoreBadCert,
mailSendMethod: _mailSendMethod,
headerTemplate: _mailHeaderTemplateId,
headerText: _mailHeaderCtrl.text,
footerTemplate: _mailFooterTemplateId,
footerText: _mailFooterCtrl.text,
);
_showSnackbar('メール設定を保存しました');
}
Future<void> _saveSmtpPrefs({
required String host,
required String port,
required String user,
required String encryptedPass,
required bool tls,
required String bcc,
required bool ignoreBadCert,
required String mailSendMethod,
required String headerTemplate,
required String headerText,
required String footerTemplate,
required String footerText,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kSmtpHost, host);
await prefs.setString(_kSmtpPort, port);
await prefs.setString(_kSmtpUser, user);
await prefs.setString(_kSmtpPass, encryptedPass);
await prefs.setBool(_kSmtpTls, tls);
await prefs.setString(_kSmtpBcc, bcc);
await prefs.setBool(_kSmtpIgnoreBadCert, ignoreBadCert);
await prefs.setString(_kMailSendMethod, mailSendMethod);
await prefs.setString(_kMailHeaderTemplate, headerTemplate);
await prefs.setString(_kMailHeaderText, headerText);
await prefs.setString(_kMailFooterTemplate, footerTemplate);
await prefs.setString(_kMailFooterText, footerText);
}
Future<void> _testSmtp() async {
try {
if (_mailSendMethod != kMailSendMethodSmtp) {
_showSnackbar('SMTPテストは送信方法を「SMTP」に設定した時のみ利用できます');
return;
}
await _saveSmtp();
final config = await EmailSender.loadConfigFromPrefs();
if (config == null || config.bcc.isEmpty) {
_showSnackbar('ホスト/ユーザー/パスワード/BCCを入力してください');
return;
}
await EmailSender.sendTest(config: config);
_showSnackbar('テスト送信に成功しました');
} catch (e) {
_showSnackbar('テスト送信に失敗しました: $e');
}
await _loadSmtpLogs();
}
Future<void> _updateMailSendMethod(String method) async {
final normalized = normalizeMailSendMethod(method);
setState(() => _mailSendMethod = normalized);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kMailSendMethod, normalized);
await _appSettingsRepo.setString(_kMailSendMethod, normalized);
}
void _applyHeaderTemplate(String templateId) {
setState(() => _mailHeaderTemplateId = templateId);
if (templateId == kMailTemplateIdDefault) {
_mailHeaderCtrl.text = kMailHeaderTemplateDefault;
} else if (templateId == kMailTemplateIdNone) {
_mailHeaderCtrl.clear();
}
}
void _applyFooterTemplate(String templateId) {
setState(() => _mailFooterTemplateId = templateId);
if (templateId == kMailTemplateIdDefault) {
_mailFooterCtrl.text = kMailFooterTemplateDefault;
} else if (templateId == kMailTemplateIdNone) {
_mailFooterCtrl.clear();
}
}
String _encrypt(String plain) {
if (plain.isEmpty) return '';
final pb = utf8.encode(plain);
final kb = utf8.encode(_kCryptKey);
final ob = List<int>.generate(pb.length, (i) => pb[i] ^ kb[i % kb.length]);
return base64Encode(ob);
}
String _decryptWithFallback(String cipher) {
if (cipher.isEmpty) return '';
try {
final ob = base64Decode(cipher);
final kb = utf8.encode(_kCryptKey);
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
return utf8.decode(pb);
} catch (_) {
return cipher;
}
}
void _showSnackbar(String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
final listBottomPadding = 24 + bottomInset;
return Scaffold(
appBar: AppBar(
title: const Text('SM:メール設定'),
backgroundColor: Colors.indigo,
),
body: Padding(
padding: const EdgeInsets.all(16),
child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.only(bottom: listBottomPadding),
children: [
_section(
title: '送信設定',
subtitle: 'SMTP / 端末メーラー切り替えやBCC必須設定',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
decoration: const InputDecoration(labelText: '送信方法'),
initialValue: _mailSendMethod,
items: const [
DropdownMenuItem(value: kMailSendMethodSmtp, child: Text('SMTPサーバー経由')),
DropdownMenuItem(value: kMailSendMethodDeviceMailer, child: Text('端末メーラーで送信')),
],
onChanged: (value) {
if (value != null) {
_updateMailSendMethod(value);
}
},
),
const SizedBox(height: 8),
if (_mailSendMethod == kMailSendMethodDeviceMailer)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border.all(color: Colors.orange.shade200),
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'端末メーラーで送信する場合もBCCは必須です。SMTP設定は保持されますが、送信時は端末のメールアプリが起動します。',
style: TextStyle(fontSize: 12, color: Colors.black87),
),
),
const SizedBox(height: 12),
TextField(
controller: _smtpHostCtrl,
decoration: const InputDecoration(labelText: 'SMTPホスト名'),
enabled: _mailSendMethod == kMailSendMethodSmtp,
),
TextField(
controller: _smtpPortCtrl,
decoration: const InputDecoration(labelText: 'SMTPポート番号'),
keyboardType: TextInputType.number,
enabled: _mailSendMethod == kMailSendMethodSmtp,
),
TextField(
controller: _smtpUserCtrl,
decoration: const InputDecoration(labelText: 'SMTPユーザー名'),
enabled: _mailSendMethod == kMailSendMethodSmtp,
),
TextField(
controller: _smtpPassCtrl,
decoration: const InputDecoration(labelText: 'SMTPパスワード'),
obscureText: true,
enabled: _mailSendMethod == kMailSendMethodSmtp,
),
TextField(
controller: _smtpBccCtrl,
decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可) *必須'),
),
SwitchListTile(
title: const Text('STARTTLS を使用'),
value: _smtpTls,
onChanged: _mailSendMethod == kMailSendMethodSmtp ? (v) => setState(() => _smtpTls = v) : null,
),
SwitchListTile(
title: const Text('証明書検証をスキップ(開発用)'),
subtitle: const Text('自己署名/ホスト名不一致を許可します'),
value: _smtpIgnoreBadCert,
onChanged: _mailSendMethod == kMailSendMethodSmtp ? (v) => setState(() => _smtpIgnoreBadCert = v) : null,
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveSmtp,
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.send),
label: const Text('BCC宛にテスト送信'),
onPressed: _mailSendMethod == kMailSendMethodSmtp ? _testSmtp : null,
),
),
],
),
],
),
),
_section(
title: '通信ログ',
subtitle: '最大1000行まで保持されますSMTP/端末メーラー共通)',
child: Column(
children: [
Row(
children: [
const Expanded(child: Text('ログ一覧', style: TextStyle(fontWeight: FontWeight.bold))),
IconButton(
tooltip: '再読込',
icon: const Icon(Icons.refresh),
onPressed: _loadingLogs ? null : _loadSmtpLogs,
),
IconButton(
tooltip: 'コピー',
icon: const Icon(Icons.copy),
onPressed: _smtpLogs.isEmpty ? null : _copySmtpLogs,
),
IconButton(
tooltip: 'クリア',
icon: const Icon(Icons.delete_outline),
onPressed: _smtpLogs.isEmpty ? null : _clearSmtpLogs,
),
],
),
const SizedBox(height: 8),
Container(
height: 220,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: _loadingLogs
? const Center(child: CircularProgressIndicator())
: _smtpLogs.isEmpty
? const Center(child: Text('ログなし'))
: Scrollbar(
child: SelectionArea(
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _smtpLogs.length,
itemBuilder: (context, index) => Text(
_smtpLogs[index],
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
),
),
),
),
],
),
),
_section(
title: 'メール本文ヘッダ/フッタ',
subtitle: 'テンプレを選択して編集するか、自由にテキストを入力できます({{FILENAME}}, {{HASH}} が利用可)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('ヘッダテンプレ', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _mailHeaderTemplateId,
items: const [
DropdownMenuItem(value: kMailTemplateIdDefault, child: Text('デフォルト')),
DropdownMenuItem(value: kMailTemplateIdNone, child: Text('なし / 空テンプレ')),
],
onChanged: (v) {
if (v != null) {
_applyHeaderTemplate(v);
}
},
),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => _applyHeaderTemplate(_mailHeaderTemplateId),
child: const Text('テンプレ適用'),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _mailHeaderCtrl,
keyboardType: TextInputType.multiline,
maxLines: null,
decoration: const InputDecoration(border: OutlineInputBorder(), hintText: 'メールヘッダ文…'),
),
const SizedBox(height: 16),
Text('フッタテンプレ', style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _mailFooterTemplateId,
items: const [
DropdownMenuItem(value: kMailTemplateIdDefault, child: Text('デフォルト')),
DropdownMenuItem(value: kMailTemplateIdNone, child: Text('なし / 空テンプレ')),
],
onChanged: (v) {
if (v != null) {
_applyFooterTemplate(v);
}
},
),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: () => _applyFooterTemplate(_mailFooterTemplateId),
child: const Text('テンプレ適用'),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _mailFooterCtrl,
keyboardType: TextInputType.multiline,
maxLines: null,
decoration: const InputDecoration(border: OutlineInputBorder(), hintText: 'メールフッタ文…'),
),
const SizedBox(height: 8),
const Text('※ {{FILENAME}} と {{HASH}} は送信時に自動置換されます。'),
],
),
),
],
),
),
);
}
Widget _section({required String title, required String subtitle, required Widget child}) {
return Card(
margin: const EdgeInsets.only(bottom: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(subtitle, style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 12),
child,
],
),
),
);
}
}

View file

@ -12,6 +12,7 @@ import '../services/company_repository.dart';
import 'product_picker_modal.dart'; import 'product_picker_modal.dart';
import '../models/company_model.dart'; import '../models/company_model.dart';
import '../widgets/keyboard_inset_wrapper.dart'; import '../widgets/keyboard_inset_wrapper.dart';
import '../services/app_settings_repository.dart';
class _DetailSnapshot { class _DetailSnapshot {
final String formalName; final String formalName;
@ -31,6 +32,25 @@ class _DetailSnapshot {
}); });
} }
class _DraftBadge extends StatelessWidget {
const _DraftBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'下書き',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
),
);
}
}
List<InvoiceItem> _cloneItemsDetail(List<InvoiceItem> source) { List<InvoiceItem> _cloneItemsDetail(List<InvoiceItem> source) {
return source return source
.map((e) => InvoiceItem( .map((e) => InvoiceItem(
@ -63,14 +83,16 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
late double _taxRate; // late double _taxRate; //
late bool _includeTax; // late bool _includeTax; //
String? _currentFilePath; String? _currentFilePath;
final _invoiceRepo = InvoiceRepository(); final InvoiceRepository _invoiceRepo = InvoiceRepository();
final _customerRepo = CustomerRepository(); final CustomerRepository _customerRepo = CustomerRepository();
final _companyRepo = CompanyRepository(); final CompanyRepository _companyRepo = CompanyRepository();
final AppSettingsRepository _settingsRepo = AppSettingsRepository(); //
CompanyInfo? _companyInfo; CompanyInfo? _companyInfo;
bool _showFormalWarning = true; bool _showFormalWarning = true;
final List<_DetailSnapshot> _undoStack = []; final List<_DetailSnapshot> _undoStack = [];
final List<_DetailSnapshot> _redoStack = []; final List<_DetailSnapshot> _redoStack = [];
bool _isApplyingSnapshot = false; bool _isApplyingSnapshot = false;
bool _summaryIsBlue = false; //
@override @override
void initState() { void initState() {
@ -84,6 +106,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
_includeTax = _currentInvoice.taxRate > 0; // _includeTax = _currentInvoice.taxRate > 0; //
_isEditing = false; _isEditing = false;
_loadCompanyInfo(); _loadCompanyInfo();
_loadSummaryTheme();
}
Future<void> _loadSummaryTheme() async {
final saved = await _settingsRepo.getSummaryTheme();
if (!mounted) return;
setState(() => _summaryIsBlue = saved == 'blue');
} }
Future<void> _loadCompanyInfo() async { Future<void> _loadCompanyInfo() async {
@ -186,12 +215,39 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV')); SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV'));
} }
Future<void> _pickSummaryColor() async {
final selected = await showModalBottomSheet<String>(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.palette, color: Colors.indigo),
title: const Text('インディゴ'),
onTap: () => Navigator.pop(context, 'blue'),
),
ListTile(
leading: const Icon(Icons.palette, color: Colors.grey),
title: const Text(''),
onTap: () => Navigator.pop(context, 'white'),
),
],
),
),
);
if (selected == null) return;
setState(() => _summaryIsBlue = selected == 'blue');
await _settingsRepo.setSummaryTheme(selected);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fmt = NumberFormat("#,###"); final fmt = NumberFormat("#,###");
final isDraft = _currentInvoice.isDraft; final isDraft = _currentInvoice.isDraft;
final docTypeName = _currentInvoice.documentTypeName; final docTypeName = _currentInvoice.documentTypeName;
final themeColor = Colors.white; // final themeColor = Theme.of(context).scaffoldBackgroundColor;
final textColor = Colors.black87; final textColor = Colors.black87;
final locked = _currentInvoice.isLocked; final locked = _currentInvoice.isLocked;
@ -292,18 +348,18 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.indigo.shade800, color: Colors.indigo, //
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.indigo.shade900), border: Border.all(color: Colors.indigo.shade700),
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.edit_note, color: Colors.white70), const Icon(Icons.edit_note, color: Colors.white70),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( const Expanded(
child: Text( child: Text(
"下書き: 未確定・PDFは正式発行で確定", "未確定・PDFは正式発行で確定",
style: const TextStyle(color: Colors.white70), style: TextStyle(color: Colors.white70),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -314,7 +370,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Text( child: Text(
"下書${docTypeName}", "下書$docTypeName",
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
), ),
), ),
@ -406,8 +462,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade100, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -417,25 +480,34 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}", Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)), style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[ if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
const SizedBox(height: 6), const SizedBox(height: 8),
Text("件名: ${_currentInvoice.subject}", const Text("件名", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black54)),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo)), const SizedBox(height: 2),
],
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
if ((_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address) != null)
Text("住所: ${_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address}", style: TextStyle(color: textColor)),
if ((_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel) != null)
Text("TEL: ${_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel}", style: TextStyle(color: textColor)),
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
const SizedBox(height: 6),
Text( Text(
"備考: ${_currentInvoice.notes}", _currentInvoice.subject!,
style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo),
), ),
], ],
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
const SizedBox(height: 6),
Text(
"備考: ${_currentInvoice.notes}",
style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())),
),
],
],
),
),
], ],
), ),
), ),
@ -520,32 +592,43 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
final int tax = (subtotal * currentTaxRate).floor(); final int tax = (subtotal * currentTaxRate).floor();
final int total = subtotal + tax; final int total = subtotal + tax;
return Container( final bool useBlue = _summaryIsBlue;
width: double.infinity, final Color bgColor = useBlue ? Colors.indigo : Colors.white;
padding: const EdgeInsets.all(16), final Color borderColor = useBlue ? Colors.transparent : Colors.grey.shade300;
decoration: BoxDecoration( final Color labelColor = useBlue ? Colors.white70 : Colors.black87;
color: Colors.indigo, final Color totalColor = useBlue ? Colors.white : Colors.black87;
borderRadius: BorderRadius.circular(12), final Color dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300;
),
child: Column( return GestureDetector(
crossAxisAlignment: CrossAxisAlignment.start, onLongPress: _pickSummaryColor,
children: [ child: Container(
_buildSummaryRow("小計", "${formatter.format(subtotal)}", Colors.white70), width: double.infinity,
if (currentTaxRate > 0) ...[ padding: const EdgeInsets.all(16),
const Divider(color: Colors.white24), decoration: BoxDecoration(
if (_companyInfo?.taxDisplayMode == 'normal') color: bgColor,
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "${formatter.format(tax)}", Colors.white70), borderRadius: BorderRadius.circular(12),
if (_companyInfo?.taxDisplayMode == 'text_only') border: Border.all(color: borderColor),
_buildSummaryRow("消費税", "(税別)", Colors.white70), ),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummaryRow("小計", "${formatter.format(subtotal)}", labelColor),
if (currentTaxRate > 0) ...[
Divider(color: dividerColor),
if (_companyInfo?.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "${formatter.format(tax)}", labelColor),
if (_companyInfo?.taxDisplayMode == 'text_only')
_buildSummaryRow("消費税", "(税別)", labelColor),
],
Divider(color: dividerColor),
_buildSummaryRow(
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
"${formatter.format(total)}",
totalColor,
isTotal: true,
),
], ],
const Divider(color: Colors.white24), ),
_buildSummaryRow(
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
"${formatter.format(total)}",
Colors.white,
isTotal: true,
),
],
), ),
); );
} }
@ -721,7 +804,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text("この下書き伝票を「確定」として正式に発行しますか?"), Row(
children: const [
_DraftBadge(),
SizedBox(width: 8),
Expanded(child: Text("この伝票を「確定」として正式に発行しますか?")),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
if (showWarning) if (showWarning)
Container( Container(
@ -785,7 +874,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
children: [ children: [
const Icon(Icons.drafts, color: Colors.orange), const Icon(Icons.drafts, color: Colors.orange),
const SizedBox(width: 12), const SizedBox(width: 12),
const Expanded(child: Text("下書き状態として保持", style: TextStyle(fontWeight: FontWeight.bold))), const Expanded(
child: Row(
children: [
_DraftBadge(),
SizedBox(width: 8),
Text("状態として保持", style: TextStyle(fontWeight: FontWeight.bold)),
],
),
),
Switch( Switch(
value: _currentInvoice.isDraft, value: _currentInvoice.isDraft,
onChanged: (val) { onChanged: (val) {

View file

@ -25,86 +25,171 @@ class InvoiceHistoryItem extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( final cardColor = invoice.isDraft ? Colors.orange.shade50 : Colors.white;
tileColor: invoice.isDraft ? Colors.orange.shade50 : null, final iconBg = isUnlocked
leading: CircleAvatar( ? _docTypeColor(invoice.documentType).withValues(alpha: 0.18)
backgroundColor: invoice.isDraft : Colors.grey.shade200;
? Colors.orange.shade100 final iconColor = isUnlocked ? _docTypeColor(invoice.documentType) : Colors.grey;
: (isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
child: Stack( final hasSubject = invoice.subject?.isNotEmpty ?? false;
children: [ final firstItemDesc = invoice.items.isNotEmpty ? invoice.items.first.description : '';
Align( final othersCount = invoice.items.length > 1 ? invoice.items.length - 1 : 0;
alignment: Alignment.center, final subjectLine = hasSubject ? invoice.subject! : firstItemDesc;
child: Icon( final subjectDisplay = hasSubject
invoice.isDraft ? Icons.edit_note : Icons.description_outlined, ? subjectLine
color: invoice.isDraft : (othersCount > 0 ? "$subjectLine$othersCount件" : subjectLine);
? Colors.orange final customerName = invoice.customerNameForDisplay.endsWith('')
: (isUnlocked ? Colors.indigo : Colors.grey), ? invoice.customerNameForDisplay
: '${invoice.customerNameForDisplay}';
final subjectColor = invoice.isLocked ? Colors.grey.shade500 : Colors.indigo.shade700;
final amountColor = invoice.isLocked ? Colors.grey.shade500 : Colors.black87;
final dateColor = Colors.black87;
return Card(
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: invoice.isDraft ? 1.5 : 0.5,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: iconBg,
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: Icon(
_docTypeIcon(invoice.documentType),
color: iconColor,
),
),
if (invoice.isLocked)
const Align(
alignment: Alignment.bottomRight,
child: Icon(Icons.lock, size: 14, color: Colors.redAccent),
),
],
),
), ),
), const SizedBox(width: 12),
if (invoice.isLocked) Expanded(
const Align( child: Row(
alignment: Alignment.bottomRight, crossAxisAlignment: CrossAxisAlignment.start,
child: Icon(Icons.lock, size: 14, color: Colors.redAccent), children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customerName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: invoice.isLocked ? Colors.grey : Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
subjectDisplay,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: subjectColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (invoice.isDraft)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'下書き',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
),
),
Text(
dateFormatter.format(invoice.date),
style: TextStyle(fontSize: 12, color: dateColor),
),
],
),
const SizedBox(height: 2),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
invoice.invoiceNumber,
style: const TextStyle(fontSize: 10.5, color: Colors.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
const SizedBox(height: 8),
Text(
"${amountFormatter.format(invoice.totalAmount)}",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: amountColor),
textAlign: TextAlign.right,
),
],
),
],
),
), ),
], ],
),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invoice.customerNameForDisplay,
style: TextStyle(
fontWeight: FontWeight.bold,
color: invoice.isLocked ? Colors.grey : Colors.black87,
),
), ),
if (invoice.subject?.isNotEmpty ?? false)
Text(
invoice.subject!,
style: TextStyle(
fontSize: 13,
color: Colors.indigo.shade700,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
trailing: SizedBox(
height: 48,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"${amountFormatter.format(invoice.totalAmount)}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
if (invoice.isSynced)
const Icon(Icons.sync, size: 14, color: Colors.green)
else
const Icon(Icons.sync_disabled, size: 14, color: Colors.orange),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 28, height: 24),
icon: const Icon(Icons.edit, size: 16),
tooltip: invoice.isLocked
? "ロック中"
: (isUnlocked ? "編集" : "アンロックして編集"),
onPressed: (invoice.isLocked || !isUnlocked)
? null
: onEdit,
),
],
), ),
), ),
onTap: onTap,
onLongPress: onLongPress,
); );
} }
IconData _docTypeIcon(DocumentType type) {
switch (type) {
case DocumentType.estimation:
return Icons.request_quote;
case DocumentType.delivery:
return Icons.local_shipping;
case DocumentType.invoice:
return Icons.receipt_long;
case DocumentType.receipt:
return Icons.task_alt;
}
}
Color _docTypeColor(DocumentType type) {
switch (type) {
case DocumentType.estimation:
return Colors.blue;
case DocumentType.delivery:
return Colors.teal;
case DocumentType.invoice:
return Colors.indigo;
case DocumentType.receipt:
return Colors.green;
}
}
} }

View file

@ -41,7 +41,7 @@ class InvoiceHistoryList extends StatelessWidget {
return ListView.builder( return ListView.builder(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 120), // FAB分の固定余白 padding: const EdgeInsets.fromLTRB(12, 0, 12, 120), // FAB余白
itemCount: invoices.length, itemCount: invoices.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final invoice = invoices[index]; final invoice = invoices[index];

View file

@ -10,6 +10,8 @@ import 'customer_master_screen.dart';
import 'invoice_input_screen.dart'; import 'invoice_input_screen.dart';
import 'settings_screen.dart'; import 'settings_screen.dart';
import 'company_info_screen.dart'; import 'company_info_screen.dart';
import 'dashboard_screen.dart';
import '../services/app_settings_repository.dart';
import '../widgets/slide_to_unlock.dart'; import '../widgets/slide_to_unlock.dart';
// InvoiceFlowScreen import removed; using inline type picker // InvoiceFlowScreen import removed; using inline type picker
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
@ -17,7 +19,8 @@ import '../widgets/invoice_pdf_preview_page.dart';
import 'invoice_history/invoice_history_list.dart'; import 'invoice_history/invoice_history_list.dart';
class InvoiceHistoryScreen extends StatefulWidget { class InvoiceHistoryScreen extends StatefulWidget {
const InvoiceHistoryScreen({super.key}); final bool initialUnlocked;
const InvoiceHistoryScreen({super.key, this.initialUnlocked = false});
@override @override
State<InvoiceHistoryScreen> createState() => _InvoiceHistoryScreenState(); State<InvoiceHistoryScreen> createState() => _InvoiceHistoryScreenState();
@ -26,6 +29,7 @@ class InvoiceHistoryScreen extends StatefulWidget {
class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> { class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
final InvoiceRepository _invoiceRepo = InvoiceRepository(); final InvoiceRepository _invoiceRepo = InvoiceRepository();
final CustomerRepository _customerRepo = CustomerRepository(); final CustomerRepository _customerRepo = CustomerRepository();
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
List<Invoice> _invoices = []; List<Invoice> _invoices = [];
List<Invoice> _filteredInvoices = []; List<Invoice> _filteredInvoices = [];
bool _isLoading = true; bool _isLoading = true;
@ -35,12 +39,26 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
DateTime? _startDate; DateTime? _startDate;
DateTime? _endDate; DateTime? _endDate;
String _appVersion = "1.0.0"; String _appVersion = "1.0.0";
bool _useDashboardHome = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_isUnlocked = widget.initialUnlocked;
_loadData(); _loadData();
_loadVersion(); _loadVersion();
_loadHomeMode();
}
Future<void> _loadHomeMode() async {
final mode = await _settingsRepo.getHomeMode();
if (!mounted) return;
setState(() {
_useDashboardHome = mode == 'dashboard';
if (_useDashboardHome && widget.initialUnlocked) {
_isUnlocked = true;
}
});
} }
Future<void> _showInvoiceActions(Invoice invoice) async { Future<void> _showInvoiceActions(Invoice invoice) async {
@ -198,20 +216,20 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
final dateFormatter = DateFormat('yyyy/MM/dd'); final dateFormatter = DateFormat('yyyy/MM/dd');
return Scaffold( return Scaffold(
resizeToAvoidBottomInset: false, resizeToAvoidBottomInset: false,
drawer: _isUnlocked drawer: (_useDashboardHome || !_isUnlocked)
? Drawer( ? null
: Drawer(
child: ListView( child: ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
children: [ children: [
DrawerHeader( DrawerHeader(
decoration: BoxDecoration(color: Colors.indigo.shade700), decoration: const BoxDecoration(color: Colors.indigo),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end, children: const [
children: [ Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)),
const Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), SizedBox(height: 8),
const SizedBox(height: 8), Text("メニュー", style: TextStyle(color: Colors.white70)),
Text("v$_appVersion", style: const TextStyle(color: Colors.white70)),
], ],
), ),
), ),
@ -257,10 +275,27 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
), ),
], ],
), ),
) ),
: null,
appBar: AppBar( appBar: AppBar(
// leading removed automaticallyImplyLeading: false,
leading: _useDashboardHome
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
);
},
)
: (_isUnlocked
? Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
)
: null),
title: GestureDetector( title: GestureDetector(
onLongPress: () { onLongPress: () {
Navigator.push( Navigator.push(
@ -307,19 +342,41 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
preferredSize: const Size.fromHeight(60), preferredSize: const Size.fromHeight(60),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: TextField( child: Container(
decoration: InputDecoration( decoration: BoxDecoration(
hintText: "検索 (顧客名、伝票番号...)", color: Colors.grey.shade50,
prefixIcon: const Icon(Icons.search), borderRadius: BorderRadius.circular(16),
filled: true, boxShadow: [
fillColor: Colors.white, // outer shadow
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), BoxShadow(
isDense: true, color: Colors.black.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
// faux inset highlight
BoxShadow(
color: Colors.white.withValues(alpha: 0.9),
blurRadius: 4,
spreadRadius: -4,
offset: const Offset(-1, -1),
),
],
),
child: TextField(
decoration: InputDecoration(
hintText: "検索 (顧客名、伝票番号...)",
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
onChanged: (val) {
_searchQuery = val;
_applyFilterAndSort();
},
), ),
onChanged: (val) {
_searchQuery = val;
_applyFilterAndSort();
},
), ),
), ),
), ),
@ -327,14 +384,15 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
Padding( if (!_useDashboardHome)
padding: const EdgeInsets.all(16.0), Padding(
child: SlideToUnlock( padding: const EdgeInsets.all(16.0),
isLocked: !_isUnlocked, child: SlideToUnlock(
onUnlocked: _toggleUnlock, isLocked: !_isUnlocked,
text: "スライドでロック解除", onUnlocked: _toggleUnlock,
text: "スライドでロック解除",
),
), ),
),
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
@ -347,8 +405,9 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
await Navigator.push( await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => InvoiceDetailPage( builder: (context) => InvoiceInputForm(
invoice: invoice, existingInvoice: invoice,
onInvoiceGenerated: (inv, path) {},
), ),
), ),
); );
@ -393,23 +452,23 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
leading: const Icon(Icons.insert_drive_file_outlined), leading: CircleAvatar(backgroundColor: Colors.blue.withValues(alpha: 0.12), child: const Icon(Icons.request_quote, color: Colors.blue)),
title: const Text('下書き: 見積書', style: TextStyle(fontSize: 24)), title: const Text('見積書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.estimation), onTap: () => _startNew(DocumentType.estimation),
), ),
ListTile( ListTile(
leading: const Icon(Icons.local_shipping_outlined), leading: CircleAvatar(backgroundColor: Colors.teal.withValues(alpha: 0.12), child: const Icon(Icons.local_shipping, color: Colors.teal)),
title: const Text('下書き: 納品書', style: TextStyle(fontSize: 24)), title: const Text('納品書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.delivery), onTap: () => _startNew(DocumentType.delivery),
), ),
ListTile( ListTile(
leading: const Icon(Icons.request_quote_outlined), leading: CircleAvatar(backgroundColor: Colors.indigo.withValues(alpha: 0.12), child: const Icon(Icons.receipt_long, color: Colors.indigo)),
title: const Text('下書き: 請求書', style: TextStyle(fontSize: 24)), title: const Text('請求書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.invoice), onTap: () => _startNew(DocumentType.invoice),
), ),
ListTile( ListTile(
leading: const Icon(Icons.receipt_long_outlined), leading: CircleAvatar(backgroundColor: Colors.green.withValues(alpha: 0.12), child: const Icon(Icons.task_alt, color: Colors.green)),
title: const Text('下書き: 領収書', style: TextStyle(fontSize: 24)), title: const Text('領収書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.receipt), onTap: () => _startNew(DocumentType.receipt),
), ),
], ],
@ -426,6 +485,8 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
builder: (_) => InvoiceInputForm( builder: (_) => InvoiceInputForm(
onInvoiceGenerated: (inv, path) {}, onInvoiceGenerated: (inv, path) {},
initialDocumentType: type, initialDocumentType: type,
startViewMode: false,
showNewBadge: true,
), ),
), ),
); );

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'customer_master_screen.dart';
import 'product_master_screen.dart';
import 'settings_screen.dart';
class MasterHubPage extends StatelessWidget {
const MasterHubPage({super.key});
@override
Widget build(BuildContext context) {
final items = <MasterEntry>[
MasterEntry(
title: '顧客マスター',
description: '顧客情報の管理・編集',
icon: Icons.people,
builder: (_) => const CustomerMasterScreen(),
),
MasterEntry(
title: '商品マスター',
description: '商品情報の管理・編集',
icon: Icons.inventory_2,
builder: (_) => const ProductMasterScreen(),
),
MasterEntry(
title: '設定',
description: 'アプリ設定・メニュー管理',
icon: Icons.settings,
builder: (_) => const SettingsScreen(),
),
];
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('マスター管理'),
Text('ScreenID: 03', style: TextStyle(fontSize: 11, color: Colors.white70)),
],
),
),
body: ListView.separated(
padding: const EdgeInsets.all(16),
itemBuilder: (context, index) {
final item = items[index];
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: 1,
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.indigo.shade50,
foregroundColor: Colors.indigo.shade700,
child: Icon(item.icon),
),
title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(item.description),
trailing: const Icon(Icons.chevron_right),
onTap: () => Navigator.push(context, MaterialPageRoute(builder: item.builder)),
),
);
},
separatorBuilder: (context, _) => const SizedBox(height: 12),
itemCount: items.length,
),
);
}
}
class MasterEntry {
final String title;
final String description;
final IconData icon;
final WidgetBuilder builder;
const MasterEntry({
required this.title,
required this.description,
required this.icon,
required this.builder,
});
}

View file

@ -6,8 +6,9 @@ import 'barcode_scanner_screen.dart';
class ProductMasterScreen extends StatefulWidget { class ProductMasterScreen extends StatefulWidget {
final bool selectionMode; final bool selectionMode;
final bool showHidden;
const ProductMasterScreen({super.key, this.selectionMode = false}); const ProductMasterScreen({super.key, this.selectionMode = false, this.showHidden = false});
@override @override
State<ProductMasterScreen> createState() => _ProductMasterScreenState(); State<ProductMasterScreen> createState() => _ProductMasterScreenState();
@ -30,7 +31,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
Future<void> _loadProducts() async { Future<void> _loadProducts() async {
setState(() => _isLoading = true); setState(() => _isLoading = true);
final products = await _productRepo.getAllProducts(); final products = await _productRepo.getAllProducts(includeHidden: widget.showHidden);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_products = products; _products = products;
@ -47,6 +48,12 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
(p.barcode?.toLowerCase().contains(query) ?? false) || (p.barcode?.toLowerCase().contains(query) ?? false) ||
(p.category?.toLowerCase().contains(query) ?? false); (p.category?.toLowerCase().contains(query) ?? false);
}).toList(); }).toList();
if (!widget.showHidden) {
_filteredProducts = _filteredProducts.where((p) => !p.isHidden).toList();
}
if (widget.showHidden) {
_filteredProducts.sort((a, b) => b.id.compareTo(a.id));
}
}); });
} }
@ -106,16 +113,19 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
if (nameController.text.isEmpty) return; if (nameController.text.isEmpty) return;
final locked = product?.isLocked ?? false;
final newId = locked ? const Uuid().v4() : (product?.id ?? const Uuid().v4());
Navigator.pop( Navigator.pop(
context, context,
Product( Product(
id: product?.id ?? const Uuid().v4(), id: newId,
name: nameController.text.trim(), name: nameController.text.trim(),
defaultUnitPrice: int.tryParse(priceController.text) ?? 0, defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(), barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
category: categoryController.text.isEmpty ? null : categoryController.text.trim(), category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
stockQuantity: int.tryParse(stockController.text) ?? 0, stockQuantity: int.tryParse(stockController.text) ?? 0,
odooId: product?.odooId, odooId: product?.odooId,
isLocked: false,
), ),
); );
}, },
@ -188,10 +198,19 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
], ],
), ),
), ),
title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)), title: Text(
p.name + (p.isHidden ? " (非表示)" : ""),
style: TextStyle(
fontWeight: FontWeight.bold,
color: p.isHidden
? Colors.grey
: (p.isLocked ? Colors.grey : Colors.black87),
),
),
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"), subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
onTap: () { onTap: () {
if (widget.selectionMode) { if (widget.selectionMode) {
if (p.isHidden) return; // safety: do not return hidden in selection
Navigator.pop(context, p); Navigator.pop(context, p);
} else { } else {
_showDetailPane(p); _showDetailPane(p);
@ -212,6 +231,16 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
_showEditDialog(product: p); _showEditDialog(product: p);
}, },
), ),
if (!p.isHidden)
ListTile(
leading: const Icon(Icons.visibility_off),
title: const Text("非表示にする"),
onTap: () async {
Navigator.pop(ctx);
await _productRepo.setHidden(p.id, true);
if (mounted) _loadProducts();
},
),
if (!p.isLocked) if (!p.isLocked)
ListTile( ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent), leading: const Icon(Icons.delete_outline, color: Colors.redAccent),

View file

@ -1,7 +1,12 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:image_picker/image_picker.dart';
import '../services/app_settings_repository.dart';
import '../services/theme_controller.dart';
import 'company_info_screen.dart'; import 'company_info_screen.dart';
import 'email_settings_screen.dart';
import 'business_profile_screen.dart';
class SettingsScreen extends StatefulWidget { class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@ -10,28 +15,22 @@ class SettingsScreen extends StatefulWidget {
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
} }
//
const Map<String, IconData> kIconsMap = {
'list_alt': Icons.list_alt,
'edit_note': Icons.edit_note,
'history': Icons.history,
'settings': Icons.settings,
'invoice': Icons.receipt_long,
'dashboard': Icons.dashboard,
'home': Icons.home,
'info': Icons.info,
'mail': Icons.mail,
'shopping_cart': Icons.shopping_cart,
};
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
// Company final _appSettingsRepo = AppSettingsRepository();
final _companyNameCtrl = TextEditingController();
final _companyZipCtrl = TextEditingController();
final _companyAddrCtrl = TextEditingController();
final _companyTelCtrl = TextEditingController();
final _companyRegCtrl = TextEditingController();
final _companyFaxCtrl = TextEditingController();
final _companyEmailCtrl = TextEditingController();
final _companyUrlCtrl = TextEditingController();
// Staff
final _staffNameCtrl = TextEditingController();
final _staffMailCtrl = TextEditingController();
// SMTP
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController(text: '587');
final _smtpUserCtrl = TextEditingController();
final _smtpPassCtrl = TextEditingController();
final _smtpBccCtrl = TextEditingController();
bool _smtpTls = true;
// External sync () // External sync ()
final _externalHostCtrl = TextEditingController(); final _externalHostCtrl = TextEditingController();
@ -47,85 +46,55 @@ class _SettingsScreenState extends State<SettingsScreen> {
final _kanaKeyCtrl = TextEditingController(); final _kanaKeyCtrl = TextEditingController();
final _kanaValCtrl = TextEditingController(); final _kanaValCtrl = TextEditingController();
// SharedPreferences keys // Dashboard / Home
static const _kCompanyName = 'company_name'; bool _homeDashboard = false;
static const _kCompanyZip = 'company_zip'; bool _statusEnabled = true;
static const _kCompanyAddr = 'company_addr'; final _statusTextCtrl = TextEditingController(text: '工事中');
static const _kCompanyTel = 'company_tel'; List<DashboardMenuItem> _menuItems = [];
static const _kCompanyReg = 'company_reg'; bool _loadingAppSettings = true;
static const _kCompanyFax = 'company_fax';
static const _kCompanyEmail = 'company_email';
static const _kCompanyUrl = 'company_url';
static const _kStaffName = 'staff_name';
static const _kStaffMail = 'staff_mail';
static const _kSmtpHost = 'smtp_host';
static const _kSmtpPort = 'smtp_port';
static const _kSmtpUser = 'smtp_user';
static const _kSmtpPass = 'smtp_pass';
static const _kSmtpTls = 'smtp_tls';
static const _kSmtpBcc = 'smtp_bcc';
static const _kExternalHost = 'external_host'; static const _kExternalHost = 'external_host';
static const _kExternalPass = 'external_pass'; static const _kExternalPass = 'external_pass';
static const _kCryptKey = 'test';
static const _kBackupPath = 'backup_path'; static const _kBackupPath = 'backup_path';
@override @override
void dispose() { void dispose() {
_companyNameCtrl.dispose();
_companyZipCtrl.dispose();
_companyAddrCtrl.dispose();
_companyTelCtrl.dispose();
_companyRegCtrl.dispose();
_companyFaxCtrl.dispose();
_companyEmailCtrl.dispose();
_companyUrlCtrl.dispose();
_staffNameCtrl.dispose();
_staffMailCtrl.dispose();
_smtpHostCtrl.dispose();
_smtpPortCtrl.dispose();
_smtpUserCtrl.dispose();
_smtpPassCtrl.dispose();
_smtpBccCtrl.dispose();
_externalHostCtrl.dispose(); _externalHostCtrl.dispose();
_externalPassCtrl.dispose(); _externalPassCtrl.dispose();
_backupPathCtrl.dispose(); _backupPathCtrl.dispose();
_kanaKeyCtrl.dispose(); _kanaKeyCtrl.dispose();
_kanaValCtrl.dispose(); _kanaValCtrl.dispose();
_statusTextCtrl.dispose();
super.dispose(); super.dispose();
} }
Future<void> _loadAll() async { Future<void> _loadAll() async {
await _loadKanaMap(); await _loadKanaMap();
final prefs = await SharedPreferences.getInstance(); final externalHost = await _appSettingsRepo.getString(_kExternalHost) ?? '';
final externalPass = await _appSettingsRepo.getString(_kExternalPass) ?? '';
final backupPath = await _appSettingsRepo.getString(_kBackupPath) ?? '';
final theme = await _appSettingsRepo.getTheme();
setState(() { setState(() {
_companyNameCtrl.text = prefs.getString(_kCompanyName) ?? ''; _externalHostCtrl.text = externalHost;
_companyZipCtrl.text = prefs.getString(_kCompanyZip) ?? ''; _externalPassCtrl.text = externalPass;
_companyAddrCtrl.text = prefs.getString(_kCompanyAddr) ?? '';
_companyTelCtrl.text = prefs.getString(_kCompanyTel) ?? '';
_companyRegCtrl.text = prefs.getString(_kCompanyReg) ?? '';
_companyFaxCtrl.text = prefs.getString(_kCompanyFax) ?? '';
_companyEmailCtrl.text = prefs.getString(_kCompanyEmail) ?? '';
_companyUrlCtrl.text = prefs.getString(_kCompanyUrl) ?? '';
_staffNameCtrl.text = prefs.getString(_kStaffName) ?? ''; _backupPathCtrl.text = backupPath;
_staffMailCtrl.text = prefs.getString(_kStaffMail) ?? ''; _theme = theme;
});
_smtpHostCtrl.text = prefs.getString(_kSmtpHost) ?? ''; final homeMode = await _appSettingsRepo.getHomeMode();
_smtpPortCtrl.text = prefs.getString(_kSmtpPort) ?? '587'; final statusEnabled = await _appSettingsRepo.getDashboardStatusEnabled();
_smtpUserCtrl.text = prefs.getString(_kSmtpUser) ?? ''; final statusText = await _appSettingsRepo.getDashboardStatusText();
_smtpPassCtrl.text = _decryptWithFallback(prefs.getString(_kSmtpPass) ?? ''); final menu = await _appSettingsRepo.getDashboardMenu();
_smtpTls = prefs.getBool(_kSmtpTls) ?? true; setState(() {
_smtpBccCtrl.text = prefs.getString(_kSmtpBcc) ?? ''; _homeDashboard = homeMode == 'dashboard';
_statusEnabled = statusEnabled;
_externalHostCtrl.text = prefs.getString(_kExternalHost) ?? ''; _statusTextCtrl.text = statusText;
_externalPassCtrl.text = prefs.getString(_kExternalPass) ?? ''; _menuItems = menu;
_loadingAppSettings = false;
_backupPathCtrl.text = prefs.getString(_kBackupPath) ?? '';
}); });
} }
@ -139,55 +108,156 @@ class _SettingsScreenState extends State<SettingsScreen> {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
} }
Future<void> _saveCompany() async { Future<void> _saveAppSettings() async {
final prefs = await SharedPreferences.getInstance(); await _appSettingsRepo.setHomeMode(_homeDashboard ? 'dashboard' : 'invoice_history');
await prefs.setString(_kCompanyName, _companyNameCtrl.text); await _appSettingsRepo.setDashboardStatusEnabled(_statusEnabled);
await prefs.setString(_kCompanyZip, _companyZipCtrl.text); await _appSettingsRepo.setDashboardStatusText(_statusTextCtrl.text.trim().isEmpty ? '工事中' : _statusTextCtrl.text.trim());
await prefs.setString(_kCompanyAddr, _companyAddrCtrl.text); await _appSettingsRepo.setDashboardMenu(_menuItems);
await prefs.setString(_kCompanyTel, _companyTelCtrl.text); _showSnackbar('ホーム/ダッシュボード設定を保存しました');
await prefs.setString(_kCompanyReg, _companyRegCtrl.text);
await prefs.setString(_kCompanyFax, _companyFaxCtrl.text);
await prefs.setString(_kCompanyEmail, _companyEmailCtrl.text);
await prefs.setString(_kCompanyUrl, _companyUrlCtrl.text);
_showSnackbar('自社情報を保存しました');
} }
Future<void> _saveStaff() async { Future<void> _persistMenu() async {
final prefs = await SharedPreferences.getInstance(); await _appSettingsRepo.setDashboardMenu(_menuItems);
await prefs.setString(_kStaffName, _staffNameCtrl.text);
await prefs.setString(_kStaffMail, _staffMailCtrl.text);
_showSnackbar('担当者情報を保存しました');
} }
Future<void> _saveSmtp() async { void _addMenuItem() async {
final prefs = await SharedPreferences.getInstance(); final titleCtrl = TextEditingController();
await prefs.setString(_kSmtpHost, _smtpHostCtrl.text); String route = 'invoice_history';
await prefs.setString(_kSmtpPort, _smtpPortCtrl.text); final iconCtrl = TextEditingController(text: 'list_alt');
await prefs.setString(_kSmtpUser, _smtpUserCtrl.text); String? customIconPath;
await prefs.setString(_kSmtpPass, _encrypt(_smtpPassCtrl.text)); await showDialog(
await prefs.setBool(_kSmtpTls, _smtpTls); context: context,
await prefs.setString(_kSmtpBcc, _smtpBccCtrl.text); builder: (ctx) => AlertDialog(
_showSnackbar('SMTP設定を保存しました'); title: const Text('メニューを追加'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: 'タイトル')),
DropdownButtonFormField<String>(
initialValue: route,
decoration: const InputDecoration(labelText: '遷移先'),
items: const [
DropdownMenuItem(value: 'invoice_history', child: Text('A2:伝票一覧')),
DropdownMenuItem(value: 'invoice_input', child: Text('A1:伝票入力')),
DropdownMenuItem(value: 'customer_master', child: Text('C1:顧客マスター')),
DropdownMenuItem(value: 'product_master', child: Text('P1:商品マスター')),
DropdownMenuItem(value: 'master_hub', child: Text('M1:マスター管理')),
DropdownMenuItem(value: 'settings', child: Text('S1:設定')),
],
onChanged: (v) => route = v ?? 'invoice_history',
),
TextField(controller: iconCtrl, decoration: const InputDecoration(labelText: 'Materialアイコン名 (例: list_alt)')),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: Text(customIconPath ?? 'カスタムアイコン: 未選択', style: const TextStyle(fontSize: 12))),
IconButton(
icon: const Icon(Icons.image_search),
tooltip: 'ギャラリーから選択',
onPressed: () async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery);
if (picked != null) {
setState(() {
customIconPath = picked.path;
});
}
},
),
],
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
if (titleCtrl.text.trim().isEmpty) return;
setState(() {
_menuItems = [
..._menuItems,
DashboardMenuItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: titleCtrl.text.trim(),
route: route,
iconName: iconCtrl.text.trim().isEmpty ? 'list_alt' : iconCtrl.text.trim(),
customIconPath: customIconPath,
),
];
});
_persistMenu();
Navigator.pop(ctx);
},
child: const Text('追加'),
),
],
),
);
}
void _removeMenuItem(String id) {
setState(() {
_menuItems = _menuItems.where((e) => e.id != id).toList();
});
_persistMenu();
}
void _reorderMenu(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final item = _menuItems.removeAt(oldIndex);
_menuItems.insert(newIndex, item);
});
_persistMenu();
}
String _routeLabel(String route) {
switch (route) {
case 'invoice_history':
return 'A2:伝票一覧';
case 'invoice_input':
return 'A1:伝票入力';
case 'customer_master':
return 'C1:顧客マスター';
case 'product_master':
return 'P1:商品マスター';
case 'master_hub':
return 'M1:マスター管理';
case 'settings':
return 'S1:設定';
default:
return route;
}
}
IconData _iconForName(String name) {
return kIconsMap[name] ?? Icons.apps;
}
Widget _menuLeading(DashboardMenuItem item) {
if (item.customIconPath != null && File(item.customIconPath!).existsSync()) {
return CircleAvatar(backgroundImage: FileImage(File(item.customIconPath!)));
}
return Icon(item.iconName != null ? _iconForName(item.iconName!) : Icons.apps);
} }
Future<void> _saveExternalSync() async { Future<void> _saveExternalSync() async {
final prefs = await SharedPreferences.getInstance(); await _appSettingsRepo.setString(_kExternalHost, _externalHostCtrl.text);
await prefs.setString(_kExternalHost, _externalHostCtrl.text); await _appSettingsRepo.setString(_kExternalPass, _externalPassCtrl.text);
await prefs.setString(_kExternalPass, _externalPassCtrl.text);
_showSnackbar('外部同期設定を保存しました'); _showSnackbar('外部同期設定を保存しました');
} }
Future<void> _saveBackup() async { Future<void> _saveBackup() async {
final prefs = await SharedPreferences.getInstance(); await _appSettingsRepo.setString(_kBackupPath, _backupPathCtrl.text);
await prefs.setString(_kBackupPath, _backupPathCtrl.text);
_showSnackbar('バックアップ設定を保存しました'); _showSnackbar('バックアップ設定を保存しました');
} }
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装'); void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
Future<void> _loadKanaMap() async { Future<void> _loadKanaMap() async {
final prefs = await SharedPreferences.getInstance(); final json = await _appSettingsRepo.getString('customKanaMap');
final json = prefs.getString('customKanaMap');
if (json != null && json.isNotEmpty) { if (json != null && json.isNotEmpty) {
try { try {
final Map<String, dynamic> decoded = jsonDecode(json); final Map<String, dynamic> decoded = jsonDecode(json);
@ -199,31 +269,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
} }
Future<void> _saveKanaMap() async { Future<void> _saveKanaMap() async {
final prefs = await SharedPreferences.getInstance(); await _appSettingsRepo.setString('customKanaMap', jsonEncode(_customKanaMap));
await prefs.setString('customKanaMap', jsonEncode(_customKanaMap));
_showSnackbar('かなインデックスを保存しました'); _showSnackbar('かなインデックスを保存しました');
} }
String _encrypt(String plain) {
if (plain.isEmpty) return '';
final pb = utf8.encode(plain);
final kb = utf8.encode(_kCryptKey);
final ob = List<int>.generate(pb.length, (i) => pb[i] ^ kb[i % kb.length]);
return base64Encode(ob);
}
String _decryptWithFallback(String cipher) {
if (cipher.isEmpty) return '';
try {
final ob = base64Decode(cipher);
final kb = utf8.encode(_kCryptKey);
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
return utf8.decode(pb);
} catch (_) {
return cipher; //
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom; final bottomInset = MediaQuery.of(context).viewInsets.bottom;
@ -247,33 +296,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: listBottomPadding), padding: EdgeInsets.only(bottom: listBottomPadding),
children: [ children: [
Container( _section(
width: double.infinity, title: 'ホームモード / ダッシュボード',
padding: const EdgeInsets.all(14), subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)',
margin: const EdgeInsets.only(bottom: 16), child: Column(
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.start,
color: Colors.indigo.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.indigo.shade100),
),
child: Row(
children: [ children: [
const Icon(Icons.business, color: Colors.indigo, size: 28), SwitchListTile(
const SizedBox(width: 12), title: const Text('ホームをダッシュボードにする'),
const Expanded( value: _homeDashboard,
child: Text( onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v),
"自社情報を開く",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo),
),
), ),
ElevatedButton.icon( SwitchListTile(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CompanyInfoScreen())), title: const Text('ステータスを表示する'),
icon: const Icon(Icons.chevron_right), value: _statusEnabled,
label: const Text("詳細"), onChanged: _loadingAppSettings ? null : (v) => setState(() => _statusEnabled = v),
style: ElevatedButton.styleFrom( ),
backgroundColor: Colors.indigo, TextField(
foregroundColor: Colors.white, controller: _statusTextCtrl,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), enabled: !_loadingAppSettings && _statusEnabled,
decoration: const InputDecoration(labelText: 'ステータス文言', hintText: '例: 工事中'),
),
const SizedBox(height: 8),
Row(
children: [
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('メニューを追加'),
onPressed: _loadingAppSettings ? null : _addMenuItem,
),
const SizedBox(width: 12),
Text('ドラッグで並べ替え / ゴミ箱で削除', style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: 8),
_loadingAppSettings
? const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator()))
: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _menuItems.length,
onReorder: _reorderMenu,
itemBuilder: (ctx, index) {
final item = _menuItems[index];
return ListTile(
key: ValueKey(item.id),
leading: _menuLeading(item),
title: Text(item.title),
subtitle: Text(_routeLabel(item.route)),
trailing: IconButton(
icon: const Icon(Icons.delete_forever, color: Colors.redAccent),
onPressed: () => _removeMenuItem(item.id),
),
);
},
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('ホーム設定を保存'),
onPressed: _loadingAppSettings ? null : _saveAppSettings,
), ),
), ),
], ],
@ -281,32 +365,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
_section( _section(
title: '自社情報', title: '自社情報',
subtitle: '会社名・住所・登録番号など', subtitle: '会社・担当者・振込口座・電話帳取り込み',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')), const Text('自社/担当者情報、振込口座設定、メールフッタをまとめて編集できます。'),
TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')), const SizedBox(height: 12),
TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')),
TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')),
TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')),
TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')),
TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')),
TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')),
const SizedBox(height: 8),
Row( Row(
children: [ children: [
OutlinedButton.icon( OutlinedButton.icon(
icon: const Icon(Icons.upload_file), icon: const Icon(Icons.info_outline),
label: const Text('画面で編集'), label: const Text('旧画面 (税率/印影)'),
onPressed: () async { onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
}, },
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
ElevatedButton.icon( Expanded(
icon: const Icon(Icons.save), child: ElevatedButton.icon(
label: const Text('保存'), icon: const Icon(Icons.business),
onPressed: _saveCompany, label: const Text('自社情報ページを開く'),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const BusinessProfileScreen()));
},
),
), ),
], ],
), ),
@ -314,40 +396,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
_section( _section(
title: '担当者情報', title: 'メール設定SM画面へ',
subtitle: '署名や連絡先(送信者情報)', subtitle: 'SMTP・端末メーラー・BCC必須・ログ閲覧など',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')), const Text('メール送信に関する設定は専用画面でまとめて編集できます。'),
TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), const SizedBox(height: 12),
const SizedBox(height: 8), Align(
ElevatedButton.icon( alignment: Alignment.centerRight,
icon: const Icon(Icons.save), child: ElevatedButton.icon(
label: const Text('保存'), icon: const Icon(Icons.mail_outline),
onPressed: _saveStaff, label: const Text('メール設定を開く'),
), onPressed: () async {
], await Navigator.push(
), context,
), MaterialPageRoute(builder: (context) => const EmailSettingsScreen()),
_section( );
title: 'SMTP情報', },
subtitle: 'メール送信サーバ設定(テンプレ)', ),
child: Column(
children: [
TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')),
TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number),
TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')),
TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')),
SwitchListTile(
title: const Text('STARTTLS を使用'),
value: _smtpTls,
onChanged: (v) => setState(() => _smtpTls = v),
),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveSmtp,
), ),
], ],
), ),
@ -413,7 +480,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.save), icon: const Icon(Icons.save),
label: const Text('保存'), label: const Text('保存'),
onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'), onPressed: () async {
await _appSettingsRepo.setTheme(_theme);
await AppThemeController.instance.setTheme(_theme);
if (!mounted) return;
_showSnackbar('テーマ設定を保存しました');
},
), ),
], ],
), ),

View file

@ -0,0 +1,144 @@
import 'dart:convert';
import 'package:sqflite/sqflite.dart';
import 'database_helper.dart';
class AppSettingsRepository {
static const _kHomeMode = 'home_mode'; // 'invoice_history' or 'dashboard'
static const _kDashboardStatusEnabled = 'dashboard_status_enabled';
static const _kDashboardStatusText = 'dashboard_status_text';
static const _kDashboardMenu = 'dashboard_menu';
static const _kDashboardHistoryUnlocked = 'dashboard_history_unlocked';
static const _kTheme = 'app_theme'; // light / dark / system
static const _kSummaryTheme = 'summary_theme'; // 'white' or 'blue'
final DatabaseHelper _dbHelper = DatabaseHelper();
Future<void> _ensureTable() async {
final db = await _dbHelper.database;
await db.execute('''
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
)
''');
}
Future<String> getHomeMode() async {
final v = await _getValue(_kHomeMode);
return v ?? 'invoice_history';
}
Future<void> setHomeMode(String mode) async {
await _setValue(_kHomeMode, mode);
}
Future<bool> getDashboardStatusEnabled() async {
final v = await _getValue(_kDashboardStatusEnabled);
if (v == null) return true; // ON
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setDashboardStatusEnabled(bool enabled) async {
await _setValue(_kDashboardStatusEnabled, enabled ? '1' : '0');
}
Future<String> getDashboardStatusText() async {
return await _getValue(_kDashboardStatusText) ?? '工事中';
}
Future<void> setDashboardStatusText(String text) async {
await _setValue(_kDashboardStatusText, text);
}
Future<List<DashboardMenuItem>> getDashboardMenu() async {
final raw = await _getValue(_kDashboardMenu);
if (raw == null || raw.isEmpty) {
return [DashboardMenuItem(id: 'a2', title: '伝票一覧', route: 'invoice_history', iconName: 'list_alt')];
}
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded.map((e) => DashboardMenuItem.fromJson(e as Map<String, dynamic>)).toList();
}
} catch (_) {}
return [DashboardMenuItem(id: 'a2', title: '伝票一覧', route: 'invoice_history', iconName: 'list_alt')];
}
Future<void> setDashboardMenu(List<DashboardMenuItem> items) async {
final raw = jsonEncode(items.map((e) => e.toJson()).toList());
await _setValue(_kDashboardMenu, raw);
}
Future<bool> getDashboardHistoryUnlocked() async => getBool(_kDashboardHistoryUnlocked, defaultValue: false);
Future<void> setDashboardHistoryUnlocked(bool unlocked) async => setBool(_kDashboardHistoryUnlocked, unlocked);
Future<String> getTheme() async => await getString(_kTheme) ?? 'system';
Future<void> setTheme(String theme) async => setString(_kTheme, theme);
Future<String> getSummaryTheme() async => await getString(_kSummaryTheme) ?? 'white';
Future<void> setSummaryTheme(String theme) async => setString(_kSummaryTheme, theme);
// Generic helpers
Future<String?> getString(String key) async => _getValue(key);
Future<void> setString(String key, String value) async => _setValue(key, value);
Future<bool> getBool(String key, {bool defaultValue = false}) async {
final v = await _getValue(key);
if (v == null) return defaultValue;
return v == '1' || v.toLowerCase() == 'true';
}
Future<void> setBool(String key, bool value) async => _setValue(key, value ? '1' : '0');
Future<String?> _getValue(String key) async {
await _ensureTable();
final db = await _dbHelper.database;
final res = await db.query('app_settings', where: 'key = ?', whereArgs: [key], limit: 1);
if (res.isEmpty) return null;
return res.first['value'] as String?;
}
Future<void> _setValue(String key, String value) async {
await _ensureTable();
final db = await _dbHelper.database;
await db.insert('app_settings', {'key': key, 'value': value}, conflictAlgorithm: ConflictAlgorithm.replace);
}
}
class DashboardMenuItem {
final String id;
final String title;
final String route;
final String? iconName; // Material icon name
final String? customIconPath; // optional local file path
DashboardMenuItem({required this.id, required this.title, required this.route, this.iconName, this.customIconPath});
DashboardMenuItem copyWith({String? id, String? title, String? route, String? iconName, String? customIconPath}) {
return DashboardMenuItem(
id: id ?? this.id,
title: title ?? this.title,
route: route ?? this.route,
iconName: iconName ?? this.iconName,
customIconPath: customIconPath ?? this.customIconPath,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'route': route,
'iconName': iconName,
'customIconPath': customIconPath,
};
factory DashboardMenuItem.fromJson(Map<String, dynamic> json) {
return DashboardMenuItem(
id: json['id'] as String,
title: json['title'] as String,
route: json['route'] as String,
iconName: json['iconName'] as String?,
customIconPath: json['customIconPath'] as String?,
);
}
}

View file

@ -0,0 +1,265 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../constants/company_profile_keys.dart';
import '../constants/mail_templates.dart';
import 'app_settings_repository.dart';
class CompanyBankAccount {
final String bankName;
final String branchName;
final String accountType;
final String accountNumber;
final String holderName;
final bool isActive;
const CompanyBankAccount({
this.bankName = '',
this.branchName = '',
this.accountType = '普通',
this.accountNumber = '',
this.holderName = '',
this.isActive = false,
});
CompanyBankAccount copyWith({
String? bankName,
String? branchName,
String? accountType,
String? accountNumber,
String? holderName,
bool? isActive,
}) {
return CompanyBankAccount(
bankName: bankName ?? this.bankName,
branchName: branchName ?? this.branchName,
accountType: accountType ?? this.accountType,
accountNumber: accountNumber ?? this.accountNumber,
holderName: holderName ?? this.holderName,
isActive: isActive ?? this.isActive,
);
}
Map<String, dynamic> toJson() => {
'bankName': bankName,
'branchName': branchName,
'accountType': accountType,
'accountNumber': accountNumber,
'holderName': holderName,
'isActive': isActive,
};
factory CompanyBankAccount.fromJson(Map<String, dynamic> json) {
return CompanyBankAccount(
bankName: json['bankName'] as String? ?? '',
branchName: json['branchName'] as String? ?? '',
accountType: json['accountType'] as String? ?? '普通',
accountNumber: json['accountNumber'] as String? ?? '',
holderName: json['holderName'] as String? ?? '',
isActive: (json['isActive'] as bool?) ?? false,
);
}
}
class CompanyProfile {
final String companyName;
final String companyZip;
final String companyAddress;
final String companyTel;
final String companyFax;
final String companyEmail;
final String companyUrl;
final String companyReg;
final String staffName;
final String staffEmail;
final String staffMobile;
final List<CompanyBankAccount> bankAccounts;
final double taxRate;
final String taxDisplayMode;
final String? sealPath;
const CompanyProfile({
this.companyName = '',
this.companyZip = '',
this.companyAddress = '',
this.companyTel = '',
this.companyFax = '',
this.companyEmail = '',
this.companyUrl = '',
this.companyReg = '',
this.staffName = '',
this.staffEmail = '',
this.staffMobile = '',
List<CompanyBankAccount>? bankAccounts,
this.taxRate = 0.10,
this.taxDisplayMode = 'normal',
this.sealPath,
}) : bankAccounts = bankAccounts ?? const [
CompanyBankAccount(),
CompanyBankAccount(),
CompanyBankAccount(),
CompanyBankAccount(),
];
CompanyProfile copyWith({
String? companyName,
String? companyZip,
String? companyAddress,
String? companyTel,
String? companyFax,
String? companyEmail,
String? companyUrl,
String? companyReg,
String? staffName,
String? staffEmail,
String? staffMobile,
List<CompanyBankAccount>? bankAccounts,
double? taxRate,
String? taxDisplayMode,
String? sealPath,
}) {
return CompanyProfile(
companyName: companyName ?? this.companyName,
companyZip: companyZip ?? this.companyZip,
companyAddress: companyAddress ?? this.companyAddress,
companyTel: companyTel ?? this.companyTel,
companyFax: companyFax ?? this.companyFax,
companyEmail: companyEmail ?? this.companyEmail,
companyUrl: companyUrl ?? this.companyUrl,
companyReg: companyReg ?? this.companyReg,
staffName: staffName ?? this.staffName,
staffEmail: staffEmail ?? this.staffEmail,
staffMobile: staffMobile ?? this.staffMobile,
bankAccounts: bankAccounts ?? this.bankAccounts,
taxRate: taxRate ?? this.taxRate,
taxDisplayMode: taxDisplayMode ?? this.taxDisplayMode,
sealPath: sealPath ?? this.sealPath,
);
}
}
class CompanyProfileService {
CompanyProfileService({AppSettingsRepository? repo}) : _repo = repo ?? AppSettingsRepository();
final AppSettingsRepository _repo;
Future<CompanyProfile> loadProfile() async {
final prefs = await SharedPreferences.getInstance();
Future<String> loadString(String key) async {
final prefValue = prefs.getString(key);
if (prefValue != null) return prefValue;
return await _repo.getString(key) ?? '';
}
final accountsRaw = prefs.getString(kCompanyBankAccountsKey) ?? await _repo.getString(kCompanyBankAccountsKey);
final accounts = _decodeAccounts(accountsRaw);
final taxRateStr = prefs.getString(kCompanyTaxRateKey) ?? await _repo.getString(kCompanyTaxRateKey);
final taxMode = prefs.getString(kCompanyTaxDisplayModeKey) ?? await _repo.getString(kCompanyTaxDisplayModeKey);
final sealPath = prefs.getString(kCompanySealPathKey) ?? await _repo.getString(kCompanySealPathKey);
return CompanyProfile(
companyName: await loadString(kCompanyNameKey),
companyZip: await loadString(kCompanyZipKey),
companyAddress: await loadString(kCompanyAddressKey),
companyTel: await loadString(kCompanyTelKey),
companyFax: await loadString(kCompanyFaxKey),
companyEmail: await loadString(kCompanyEmailKey),
companyUrl: await loadString(kCompanyUrlKey),
companyReg: await loadString(kCompanyRegKey),
staffName: await loadString(kStaffNameKey),
staffEmail: await loadString(kStaffEmailKey),
staffMobile: await loadString(kStaffMobileKey),
bankAccounts: accounts,
taxRate: double.tryParse(taxRateStr ?? '') ?? 0.10,
taxDisplayMode: taxMode ?? 'normal',
sealPath: sealPath?.isNotEmpty == true ? sealPath : null,
);
}
Future<void> saveProfile(CompanyProfile profile) async {
final prefs = await SharedPreferences.getInstance();
Future<void> persist(String key, String value) async {
await prefs.setString(key, value);
await _repo.setString(key, value);
}
await persist(kCompanyNameKey, profile.companyName);
await persist(kCompanyZipKey, profile.companyZip);
await persist(kCompanyAddressKey, profile.companyAddress);
await persist(kCompanyTelKey, profile.companyTel);
await persist(kCompanyFaxKey, profile.companyFax);
await persist(kCompanyEmailKey, profile.companyEmail);
await persist(kCompanyUrlKey, profile.companyUrl);
await persist(kCompanyRegKey, profile.companyReg);
await persist(kStaffNameKey, profile.staffName);
await persist(kStaffEmailKey, profile.staffEmail);
await persist(kStaffMobileKey, profile.staffMobile);
final accountsJson = jsonEncode(profile.bankAccounts.map((e) => e.toJson()).toList());
await persist(kCompanyBankAccountsKey, accountsJson);
await persist(kCompanyTaxRateKey, profile.taxRate.toString());
await persist(kCompanyTaxDisplayModeKey, profile.taxDisplayMode);
await persist(kCompanySealPathKey, profile.sealPath ?? '');
}
Future<Map<String, String>> buildMailPlaceholderMap({
required String filename,
required String hash,
}) async {
final profile = await loadProfile();
final activeAccounts = profile.bankAccounts.where((e) => e.isActive && e.bankName.trim().isNotEmpty).toList();
final bankText = _composeBankText(activeAccounts);
return {
kMailPlaceholderFilename: filename,
kMailPlaceholderHash: hash,
kMailPlaceholderCompanyName: profile.companyName.isNotEmpty ? profile.companyName : '弊社',
kMailPlaceholderCompanyEmail: profile.companyEmail.isNotEmpty ? profile.companyEmail : profile.staffEmail,
kMailPlaceholderCompanyTel: profile.companyTel,
kMailPlaceholderCompanyAddress: profile.companyAddress,
kMailPlaceholderCompanyReg: profile.companyReg,
kMailPlaceholderStaffName: profile.staffName.isNotEmpty ? profile.staffName : '担当者',
kMailPlaceholderStaffEmail: profile.staffEmail,
kMailPlaceholderStaffMobile: profile.staffMobile.isNotEmpty ? profile.staffMobile : '---',
kMailPlaceholderBankAccounts: bankText,
};
}
List<CompanyBankAccount> _decodeAccounts(String? raw) {
if (raw == null || raw.isEmpty) {
return List.generate(kCompanyBankSlotCount, (_) => const CompanyBankAccount());
}
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
final list = decoded
.map((e) => CompanyBankAccount.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
while (list.length < kCompanyBankSlotCount) {
list.add(const CompanyBankAccount());
}
return list.take(kCompanyBankSlotCount).toList();
}
} catch (_) {
// ignore malformed data
}
return List.generate(kCompanyBankSlotCount, (_) => const CompanyBankAccount());
}
String _composeBankText(List<CompanyBankAccount> accounts) {
if (accounts.isEmpty) {
return '振込先: ご入金方法は別途ご案内いたします。';
}
final buffer = StringBuffer('振込先:\n');
for (var i = 0; i < accounts.length && i < kCompanyBankActiveLimit; i++) {
final acc = accounts[i];
buffer.writeln(
'(${i + 1}) ${acc.bankName} ${acc.branchName} ${acc.accountType} ${acc.accountNumber} ${acc.holderName}',
);
}
return buffer.toString().trim();
}
}

View file

@ -9,21 +9,28 @@ class CustomerRepository {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository(); final ActivityLogRepository _logRepo = ActivityLogRepository();
Future<List<Customer>> getAllCustomers() async { Future<List<Customer>> getAllCustomers({bool includeHidden = false}) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final filter = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
List<Map<String, dynamic>> maps = await db.rawQuery(''' List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
FROM customers c FROM customers c
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1 LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
ORDER BY c.display_name ASC LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
$filter
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
'''); ''');
if (maps.isEmpty) { if (maps.isEmpty) {
await _generateSampleCustomers(limit: 3); await _generateSampleCustomers(limit: 3);
maps = await db.rawQuery(''' maps = await db.rawQuery('''
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
FROM customers c FROM customers c
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1 LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
ORDER BY c.display_name ASC LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
$filter
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
'''); ''');
} }
return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
@ -128,14 +135,17 @@ class CustomerRepository {
); );
} }
Future<List<Customer>> searchCustomers(String query) async { Future<List<Customer>> searchCustomers(String query, {bool includeHidden = false}) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final where = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
final List<Map<String, dynamic>> maps = await db.rawQuery(''' final List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
FROM customers c FROM customers c
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1 LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
WHERE c.display_name LIKE ? OR c.formal_name LIKE ? LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
ORDER BY c.display_name ASC WHERE (c.display_name LIKE ? OR c.formal_name LIKE ?) $where
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
LIMIT 50 LIMIT 50
''', ['%$query%', '%$query%']); ''', ['%$query%', '%$query%']);
return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
@ -173,6 +183,25 @@ class CustomerRepository {
return CustomerContact.fromMap(rows.first); return CustomerContact.fromMap(rows.first);
} }
Future<void> setHidden(String id, bool hidden) async {
final db = await _dbHelper.database;
await db.insert(
'master_hidden',
{
'master_type': 'customer',
'master_id': id,
'is_hidden': hidden ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _logRepo.logAction(
action: hidden ? "HIDE_CUSTOMER" : "UNHIDE_CUSTOMER",
targetType: "CUSTOMER",
targetId: id,
details: hidden ? "顧客を非表示にしました" : "顧客を再表示しました",
);
}
Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async { Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async {
final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]); final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]);
final current = res.first['v'] as int?; final current = res.first['v'] as int?;

View file

@ -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 = 20; static const _databaseVersion = 25;
static final DatabaseHelper _instance = DatabaseHelper._internal(); static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database; static Database? _database;
@ -164,6 +164,37 @@ class DatabaseHelper {
if (oldVersion < 20) { if (oldVersion < 20) {
await _safeAddColumn(db, 'customers', 'email TEXT'); await _safeAddColumn(db, 'customers', 'email TEXT');
} }
if (oldVersion < 22) {
await db.execute('''
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
)
''');
}
if (oldVersion < 23) {
await _safeAddColumn(db, 'customers', 'is_hidden INTEGER DEFAULT 0');
await _safeAddColumn(db, 'products', 'is_hidden INTEGER DEFAULT 0');
await db.execute('CREATE INDEX IF NOT EXISTS idx_customers_hidden ON customers(is_hidden)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_products_hidden ON products(is_hidden)');
}
if (oldVersion < 24) {
await db.execute('''
CREATE TABLE IF NOT EXISTS master_hidden (
master_type TEXT NOT NULL,
master_id TEXT NOT NULL,
is_hidden INTEGER DEFAULT 0,
PRIMARY KEY(master_type, master_id)
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_master_hidden_type ON master_hidden(master_type)');
}
if (oldVersion < 25) {
await _safeAddColumn(db, 'invoices', 'company_snapshot TEXT');
await _safeAddColumn(db, 'invoices', 'company_seal_hash TEXT');
await _safeAddColumn(db, 'invoices', 'meta_json TEXT');
await _safeAddColumn(db, 'invoices', 'meta_hash TEXT');
}
} }
Future<void> _onCreate(Database db, int version) async { Future<void> _onCreate(Database db, int version) async {
@ -182,6 +213,7 @@ class DatabaseHelper {
head_char1 TEXT, head_char1 TEXT,
head_char2 TEXT, head_char2 TEXT,
is_locked INTEGER DEFAULT 0, is_locked INTEGER DEFAULT 0,
is_hidden INTEGER DEFAULT 0,
is_synced INTEGER DEFAULT 0, is_synced INTEGER DEFAULT 0,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
@ -223,12 +255,23 @@ class DatabaseHelper {
category TEXT, category TEXT,
stock_quantity INTEGER DEFAULT 0, stock_quantity INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0, is_locked INTEGER DEFAULT 0,
is_hidden INTEGER DEFAULT 0,
odoo_id TEXT odoo_id TEXT
) )
'''); ''');
await db.execute('CREATE INDEX idx_products_name ON products(name)'); 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 INDEX idx_products_barcode ON products(barcode)');
await db.execute('''
CREATE TABLE master_hidden (
master_type TEXT NOT NULL,
master_id TEXT NOT NULL,
is_hidden INTEGER DEFAULT 0,
PRIMARY KEY(master_type, master_id)
)
''');
await db.execute('CREATE INDEX idx_master_hidden_type ON master_hidden(master_type)');
// //
await db.execute(''' await db.execute('''
CREATE TABLE invoices ( CREATE TABLE invoices (
@ -255,6 +298,10 @@ class DatabaseHelper {
contact_email_snapshot TEXT, contact_email_snapshot TEXT,
contact_tel_snapshot TEXT, contact_tel_snapshot TEXT,
contact_address_snapshot TEXT, contact_address_snapshot TEXT,
company_snapshot TEXT,
company_seal_hash TEXT,
meta_json TEXT,
meta_hash TEXT,
FOREIGN KEY (customer_id) REFERENCES customers (id) FOREIGN KEY (customer_id) REFERENCES customers (id)
) )
'''); ''');
@ -305,6 +352,13 @@ class DatabaseHelper {
timestamp TEXT NOT NULL timestamp TEXT NOT NULL
) )
'''); ''');
await db.execute('''
CREATE TABLE app_settings (
key TEXT PRIMARY KEY,
value TEXT
)
''');
} }
Future<void> _safeAddColumn(Database db, String table, String columnDef) async { Future<void> _safeAddColumn(Database db, String table, String columnDef) async {

View file

@ -0,0 +1,60 @@
import 'database_helper.dart';
class EditLogRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
Future<void> _ensureTable() async {
final db = await _dbHelper.database;
await db.execute('''
CREATE TABLE IF NOT EXISTS edit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id TEXT,
message TEXT,
created_at INTEGER
)
''');
}
Future<void> addLog(String invoiceId, String message) async {
await _ensureTable();
final db = await _dbHelper.database;
final now = DateTime.now().millisecondsSinceEpoch;
// cleanup older than 30 days
final cutoff = DateTime.now().subtract(const Duration(days: 30)).millisecondsSinceEpoch;
await db.delete('edit_logs', where: 'created_at < ?', whereArgs: [cutoff]);
await db.insert('edit_logs', {
'invoice_id': invoiceId,
'message': message,
'created_at': now,
});
}
Future<List<EditLogEntry>> getLogs(String invoiceId) async {
await _ensureTable();
final db = await _dbHelper.database;
final cutoff = DateTime.now().subtract(const Duration(days: 14)).millisecondsSinceEpoch;
final res = await db.query(
'edit_logs',
where: 'invoice_id = ? AND created_at >= ?',
whereArgs: [invoiceId, cutoff],
orderBy: 'created_at DESC',
);
return res
.map((e) => EditLogEntry(
id: e['id'] as int,
invoiceId: e['invoice_id'] as String,
message: e['message'] as String,
createdAt: DateTime.fromMillisecondsSinceEpoch(e['created_at'] as int),
))
.toList();
}
}
class EditLogEntry {
final int id;
final String invoiceId;
final String message;
final DateTime createdAt;
EditLogEntry({required this.id, required this.invoiceId, required this.message, required this.createdAt});
}

View file

@ -0,0 +1,238 @@
import 'dart:convert';
import 'dart:io';
import 'package:mailer/mailer.dart';
import 'package:mailer/smtp_server.dart';
import 'package:shared_preferences/shared_preferences.dart';
class EmailSenderConfig {
final String host;
final int port;
final String username;
final String password;
final bool useTls;
final bool ignoreBadCert;
final List<String> bcc;
const EmailSenderConfig({
required this.host,
required this.port,
required this.username,
required this.password,
this.useTls = true,
this.ignoreBadCert = false,
this.bcc = const [],
});
bool get isValid => host.isNotEmpty && username.isNotEmpty && password.isNotEmpty;
}
class EmailSender {
static const _kCryptKey = 'test';
static const _kLogsKey = 'smtp_logs';
static const int _kMaxLogLines = 1000;
static List<String> parseBcc(String raw) {
return raw
.split(RegExp('[,\n]'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
}
static String decrypt(String cipher) {
if (cipher.isEmpty) return '';
try {
final ob = base64Decode(cipher);
final kb = utf8.encode(_kCryptKey);
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
return utf8.decode(pb);
} catch (_) {
return cipher;
}
}
static Future<void> _appendLog(String line) async {
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now().toIso8601String();
final entry = '[$now] $line';
final existing = List<String>.from(prefs.getStringList(_kLogsKey) ?? const <String>[]);
existing.add(entry);
if (existing.length > _kMaxLogLines) {
final dropCount = existing.length - _kMaxLogLines;
existing.removeRange(0, dropCount);
}
await prefs.setStringList(_kLogsKey, existing);
}
static Future<List<String>> loadLogs() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getStringList(_kLogsKey) ?? <String>[];
}
static Future<void> clearLogs() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kLogsKey);
}
static Future<bool> _checkPortOpen(String host, int port, {Duration timeout = const Duration(seconds: 5)}) async {
try {
final socket = await Socket.connect(host, port, timeout: timeout);
await socket.close();
await _appendLog('[TEST][PORT][OK] $host:$port reachable');
return true;
} catch (e) {
await _appendLog('[TEST][PORT][NG] $host:$port err=$e');
return false;
}
}
static Future<bool> _checkAndLogConfig({required EmailSenderConfig config, required String channel}) async {
final checks = <String, bool>{
'host': config.host.isNotEmpty,
'port': config.port > 0,
'user': config.username.isNotEmpty,
'pass': config.password.isNotEmpty,
'bcc': config.bcc.isNotEmpty,
};
String valMask(String key) {
switch (key) {
case 'host':
return config.host;
case 'port':
return config.port.toString();
case 'user':
return config.username;
case 'pass':
return config.password.isNotEmpty ? '***' : '';
case 'bcc':
return config.bcc.join(',');
default:
return '';
}
}
final summary = checks.entries
.map((e) => '${e.key}=${valMask(e.key)} (${e.value ? 'OK' : 'NG'})')
.join(' | ');
final tail = 'tls=${config.useTls} ignoreBadCert=${config.ignoreBadCert}';
await _appendLog('[$channel][CFG] $summary | $tail');
return checks.values.every((v) => v);
}
static SmtpServer _serverFromConfig(EmailSenderConfig config) {
return SmtpServer(
config.host,
port: config.port,
username: config.username,
password: config.password,
ssl: !config.useTls,
allowInsecure: config.ignoreBadCert || !config.useTls,
ignoreBadCertificate: config.ignoreBadCert,
);
}
static Future<EmailSenderConfig?> loadConfigFromPrefs() async {
final prefs = await SharedPreferences.getInstance();
final host = (prefs.getString('smtp_host') ?? '').trim();
final portStr = (prefs.getString('smtp_port') ?? '587').trim();
final user = (prefs.getString('smtp_user') ?? '').trim();
final passEncrypted = prefs.getString('smtp_pass') ?? '';
final pass = decrypt(passEncrypted).trim();
final useTls = prefs.getBool('smtp_tls') ?? true;
final ignoreBadCert = prefs.getBool('smtp_ignore_bad_cert') ?? false;
final bccRaw = prefs.getString('smtp_bcc') ?? '';
final bccList = parseBcc(bccRaw);
final port = int.tryParse(portStr) ?? 587;
final config = EmailSenderConfig(
host: host,
port: port,
username: user,
password: pass,
useTls: useTls,
ignoreBadCert: ignoreBadCert,
bcc: bccList,
);
if (!config.isValid) {
await _appendLog('[CFG][NG] host/user/pass が未入力の可能性があります');
return null;
}
return config;
}
static Future<void> sendTest({required EmailSenderConfig config}) async {
final server = _serverFromConfig(config);
final message = Message()
..from = Address(config.username)
..bccRecipients = config.bcc
..subject = 'SMTPテスト送信'
..text = 'これはテストメールですBCC送信';
final configOk = await _checkAndLogConfig(config: config, channel: 'TEST');
if (!configOk) {
throw StateError('SMTP設定が不足しています');
}
await _checkPortOpen(config.host, config.port);
try {
await send(message, server);
await _appendLog('[TEST][OK] bcc: ${config.bcc.join(',')}');
} catch (e) {
await _appendLog('[TEST][NG] err=$e (認証/暗号化設定を確認してください)');
rethrow;
}
}
static Future<void> sendInvoiceEmail({
required EmailSenderConfig config,
required String toEmail,
required File pdfFile,
String? subject,
String? attachmentFileName,
String? body,
}) async {
final server = _serverFromConfig(config);
final message = Message()
..from = Address(config.username)
..recipients = [toEmail]
..bccRecipients = config.bcc
..subject = subject ?? '請求書送付'
..text = body ?? '請求書をお送りします。ご確認ください。'
..attachments = [
FileAttachment(pdfFile)
..fileName = attachmentFileName ?? 'invoice.pdf'
..contentType = 'application/pdf'
];
final configOk = await _checkAndLogConfig(config: config, channel: 'INVOICE');
if (!configOk) {
throw StateError('SMTP設定が不足しています');
}
try {
await send(message, server);
await _appendLog('[INVOICE][OK] to: $toEmail bcc: ${config.bcc.join(',')}');
} catch (e) {
await _appendLog('[INVOICE][NG] to: $toEmail err: $e');
rethrow;
}
}
static Future<void> logDeviceMailer({
required bool success,
required String toEmail,
required List<String> bcc,
String? error,
}) async {
final status = success ? 'OK' : 'NG';
final buffer = StringBuffer('[DEVICE][$status] to: $toEmail bcc: ${bcc.join(',')}');
if (error != null && error.isNotEmpty) {
buffer.write(' err: $error');
}
await _appendLog(buffer.toString());
}
}

View file

@ -1,4 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:sqflite/sqflite.dart'; import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
@ -6,15 +8,38 @@ import '../models/customer_model.dart';
import '../models/customer_contact.dart'; import '../models/customer_contact.dart';
import 'database_helper.dart'; import 'database_helper.dart';
import 'activity_log_repository.dart'; import 'activity_log_repository.dart';
import 'company_repository.dart';
class InvoiceRepository { class InvoiceRepository {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository(); final ActivityLogRepository _logRepo = ActivityLogRepository();
final CompanyRepository _companyRepo = CompanyRepository();
Future<void> saveInvoice(Invoice invoice) async { Future<void> saveInvoice(Invoice invoice) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
// //
final companyInfo = await _companyRepo.getCompanyInfo();
String? sealHash;
if (companyInfo.sealPath != null) {
final file = File(companyInfo.sealPath!);
if (await file.exists()) {
sealHash = sha256.convert(await file.readAsBytes()).toString();
}
}
final companySnapshot = jsonEncode({
'name': companyInfo.name,
'zipCode': companyInfo.zipCode,
'address': companyInfo.address,
'tel': companyInfo.tel,
'fax': companyInfo.fax,
'email': companyInfo.email,
'url': companyInfo.url,
'defaultTaxRate': companyInfo.defaultTaxRate,
'taxDisplayMode': companyInfo.taxDisplayMode,
'registrationNumber': companyInfo.registrationNumber,
});
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true); final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
await db.transaction((txn) async { await db.transaction((txn) async {
@ -29,6 +54,10 @@ class InvoiceRepository {
contactEmailSnapshot: activeContact?.email, contactEmailSnapshot: activeContact?.email,
contactTelSnapshot: activeContact?.tel, contactTelSnapshot: activeContact?.tel,
contactAddressSnapshot: activeContact?.address, contactAddressSnapshot: activeContact?.address,
companySnapshot: companySnapshot,
companySealHash: sealHash,
metaJson: null,
metaHash: null,
); );
// 調 // 調
@ -150,6 +179,10 @@ class InvoiceRepository {
contactEmailSnapshot: iMap['contact_email_snapshot'], contactEmailSnapshot: iMap['contact_email_snapshot'],
contactTelSnapshot: iMap['contact_tel_snapshot'], contactTelSnapshot: iMap['contact_tel_snapshot'],
contactAddressSnapshot: iMap['contact_address_snapshot'], contactAddressSnapshot: iMap['contact_address_snapshot'],
companySnapshot: iMap['company_snapshot'],
companySealHash: iMap['company_seal_hash'],
metaJson: iMap['meta_json'],
metaHash: iMap['meta_hash'],
)); ));
} }
return invoices; return invoices;
@ -248,6 +281,21 @@ class InvoiceRepository {
} }
} }
/// meta_json meta_hash trueなら一致
bool verifyInvoiceMeta(Invoice invoice) {
final metaJson = invoice.metaJson ?? invoice.metaJsonValue;
final expected = sha256.convert(utf8.encode(metaJson)).toString();
final stored = invoice.metaHash ?? expected;
return expected == stored;
}
/// IDを指定してDBから取得し
Future<bool> verifyInvoiceMetaById(String id, List<Customer> customers) async {
final invoices = await getAllInvoices(customers);
final target = invoices.firstWhere((i) => i.id == id, orElse: () => throw Exception('invoice not found'));
return verifyInvoiceMeta(target);
}
Future<Map<String, int>> getMonthlySales(int year) async { Future<Map<String, int>> getMonthlySales(int year) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final String yearStr = year.toString(); final String yearStr = year.toString();

View file

@ -11,7 +11,15 @@ import 'activity_log_repository.dart';
/// PDFドキュメントの構築使 /// PDFドキュメントの構築使
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async { Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
final pdf = pw.Document(); final metaJson = invoice.metaJsonValue;
final metaHash = invoice.metaHashValue;
final pdf = pw.Document(
title: '${invoice.documentTypeName} ${invoice.invoiceNumber}',
author: 'h1-app',
subject: 'metaHash:$metaHash',
keywords: metaJson,
);
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf"); final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ipaex = pw.Font.ttf(fontData); final ipaex = pw.Font.ttf(fontData);
@ -221,7 +229,7 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
pw.Container( pw.Container(
width: 50, width: 50,
height: 50, height: 50,
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: invoice.contentHash, drawText: false), child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: metaHash, drawText: false),
), ),
], ],
), ),

View file

@ -8,27 +8,38 @@ class ProductRepository {
final DatabaseHelper _dbHelper = DatabaseHelper(); final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository(); final ActivityLogRepository _logRepo = ActivityLogRepository();
Future<List<Product>> getAllProducts() async { Future<List<Product>> getAllProducts({bool includeHidden = false}) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'name ASC'); final String where = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
final List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
FROM products p
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
$where
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
''');
if (maps.isEmpty) { if (maps.isEmpty) {
await _generateSampleProducts(); await _generateSampleProducts();
return getAllProducts(); return getAllProducts(includeHidden: includeHidden);
} }
return List.generate(maps.length, (i) => Product.fromMap(maps[i])); return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
} }
Future<List<Product>> searchProducts(String query) async { Future<List<Product>> searchProducts(String query, {bool includeHidden = false}) async {
final db = await _dbHelper.database; final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query( final args = ['%$query%', '%$query%', '%$query%'];
'products', final String whereHidden = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?', final List<Map<String, dynamic>> maps = await db.rawQuery('''
whereArgs: ['%$query%', '%$query%', '%$query%'], SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
orderBy: 'name ASC', FROM products p
limit: 50, LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
); WHERE (p.name LIKE ? OR p.barcode LIKE ? OR p.category LIKE ?)
$whereHidden
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
LIMIT 50
''', args);
return List.generate(maps.length, (i) => Product.fromMap(maps[i])); return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
} }
@ -81,4 +92,23 @@ class ProductRepository {
details: "商品を削除しました", details: "商品を削除しました",
); );
} }
Future<void> setHidden(String id, bool hidden) async {
final db = await _dbHelper.database;
await db.insert(
'master_hidden',
{
'master_type': 'product',
'master_id': id,
'is_hidden': hidden ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _logRepo.logAction(
action: hidden ? "HIDE_PRODUCT" : "UNHIDE_PRODUCT",
targetType: "PRODUCT",
targetId: id,
details: hidden ? "商品を非表示にしました" : "商品を再表示しました",
);
}
} }

View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'app_settings_repository.dart';
class AppThemeController {
AppThemeController._internal();
static final AppThemeController instance = AppThemeController._internal();
final AppSettingsRepository _repo = AppSettingsRepository();
final ValueNotifier<ThemeMode> notifier = ValueNotifier<ThemeMode>(ThemeMode.system);
Future<void> load() async {
final theme = await _repo.getTheme();
notifier.value = _toMode(theme);
}
Future<void> setTheme(String theme) async {
await _repo.setTheme(theme);
notifier.value = _toMode(theme);
}
ThemeMode _toMode(String v) {
switch (v) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
}
}
}

View file

@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
class BuildExpiryInfo {
BuildExpiryInfo._(this.buildTimestamp, this.lifespan, this._hasValidTimestamp);
factory BuildExpiryInfo.fromEnvironment({Duration lifespan = const Duration(days: 90)}) {
const rawTimestamp = String.fromEnvironment('APP_BUILD_TIMESTAMP');
if (rawTimestamp.isEmpty) {
debugPrint('[BuildExpiry] APP_BUILD_TIMESTAMP is missing; expiry guard disabled.');
return BuildExpiryInfo._(null, lifespan, false);
}
final parsed = DateTime.tryParse(rawTimestamp);
if (parsed == null) {
debugPrint('[BuildExpiry] Invalid APP_BUILD_TIMESTAMP: $rawTimestamp. Expiry guard disabled.');
return BuildExpiryInfo._(null, lifespan, false);
}
return BuildExpiryInfo._(parsed.toUtc(), lifespan, true);
}
final DateTime? buildTimestamp;
final Duration lifespan;
final bool _hasValidTimestamp;
bool get isEnforced => _hasValidTimestamp && buildTimestamp != null;
DateTime? get expiryTimestamp => buildTimestamp?.add(lifespan);
bool get isExpired {
if (!isEnforced || expiryTimestamp == null) return false;
return DateTime.now().toUtc().isAfter(expiryTimestamp!);
}
Duration? get remaining {
if (!isEnforced || expiryTimestamp == null) return null;
return expiryTimestamp!.difference(DateTime.now().toUtc());
}
}

View file

@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
class ContactPickerSheet extends StatefulWidget {
const ContactPickerSheet({super.key, required this.contacts, this.title = '電話帳から選択'});
final List<Contact> contacts;
final String title;
@override
State<ContactPickerSheet> createState() => _ContactPickerSheetState();
}
class _ContactPickerSheetState extends State<ContactPickerSheet> {
late List<Contact> _filtered;
final TextEditingController _searchCtrl = TextEditingController();
@override
void initState() {
super.initState();
_filtered = widget.contacts;
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
void _applyFilter(String query) {
final lower = query.toLowerCase();
setState(() {
_filtered = widget.contacts
.where((contact) {
final org = contact.organizations.isNotEmpty ? contact.organizations.first.company : '';
final label = org.isNotEmpty ? org : contact.displayName;
return label.toLowerCase().contains(lower);
})
.toList();
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SafeArea(
top: true,
child: DraggableScrollableSheet(
expand: false,
initialChildSize: 0.9,
minChildSize: 0.6,
maxChildSize: 0.95,
builder: (context, controller) {
return Material(
color: theme.scaffoldBackgroundColor,
elevation: 8,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: Column(
children: [
const SizedBox(height: 12),
Container(
width: 48,
height: 4,
decoration: BoxDecoration(color: Colors.grey.shade400, borderRadius: BorderRadius.circular(999)),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
Expanded(
child: Text(widget.title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
IconButton(
tooltip: '閉じる',
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.close),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: '会社名・氏名で検索',
filled: true,
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
),
onChanged: _applyFilter,
),
),
Expanded(
child: _filtered.isEmpty
? const Center(child: Text('一致する連絡先が見つかりません'))
: ListView.builder(
controller: controller,
itemCount: _filtered.length,
itemBuilder: (context, index) {
final contact = _filtered[index];
final org = contact.organizations.isNotEmpty ? contact.organizations.first.company : '';
final title = org.isNotEmpty ? org : contact.displayName;
final tel = contact.phones.isNotEmpty ? contact.phones.first.number : null;
final email = contact.emails.isNotEmpty ? contact.emails.first.address : null;
final subtitle = [tel, email].where((v) => v != null && v.trim().isNotEmpty).join(' / ');
return ListTile(
title: Text(title),
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
leading: CircleAvatar(
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
child: Text(title.isNotEmpty ? title.characters.first : '?'),
),
onTap: () => Navigator.pop(context, contact),
);
},
),
),
],
),
);
},
),
);
}
}

View file

@ -1,13 +1,19 @@
import 'dart:typed_data';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:printing/printing.dart'; import 'package:flutter_email_sender/flutter_email_sender.dart';
import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import 'package:mailer/mailer.dart';
import 'package:mailer/smtp_server.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:printing/printing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../constants/mail_send_method.dart';
import '../constants/mail_templates.dart';
import '../models/invoice_models.dart';
import '../services/company_profile_service.dart';
import '../services/email_sender.dart';
import '../services/pdf_generator.dart';
class InvoicePdfPreviewPage extends StatelessWidget { class InvoicePdfPreviewPage extends StatelessWidget {
final Invoice invoice; final Invoice invoice;
@ -39,24 +45,17 @@ class InvoicePdfPreviewPage extends StatelessWidget {
Future<void> _sendEmail(BuildContext context) async { Future<void> _sendEmail(BuildContext context) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final host = prefs.getString('smtp_host') ?? ''; final mailMethod = normalizeMailSendMethod(prefs.getString(kMailSendMethodPrefKey));
final portStr = prefs.getString('smtp_port') ?? '587';
final user = prefs.getString('smtp_user') ?? '';
final pass = prefs.getString('smtp_pass') ?? '';
final useTls = prefs.getBool('smtp_tls') ?? true;
final bccRaw = prefs.getString('smtp_bcc') ?? ''; final bccRaw = prefs.getString('smtp_bcc') ?? '';
final bccList = bccRaw.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); final bccList = EmailSender.parseBcc(bccRaw);
if (host.isEmpty || user.isEmpty || pass.isEmpty) { if (bccList.isEmpty) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください'))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('BCCは必須項目です設定画面で登録してください')));
} }
return; return;
} }
final port = int.tryParse(portStr) ?? 587;
final smtpServer = SmtpServer(host, port: port, username: user, password: pass, ignoreBadCertificate: false, ssl: !useTls, allowInsecure: !useTls);
final toEmail = invoice.contactEmailSnapshot ?? invoice.customer.email; final toEmail = invoice.contactEmailSnapshot ?? invoice.customer.email;
if (toEmail == null || toEmail.isEmpty) { if (toEmail == null || toEmail.isEmpty) {
if (context.mounted) { if (context.mounted) {
@ -66,18 +65,71 @@ class InvoicePdfPreviewPage extends StatelessWidget {
} }
final bytes = await _buildPdfBytes(); final bytes = await _buildPdfBytes();
final fileName = invoice.mailAttachmentFileName;
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/invoice.pdf'); final file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(bytes, flush: true); await file.writeAsBytes(bytes, flush: true);
final message = Message() final hash = sha256.convert(bytes).toString();
..from = Address(user) final headerTemplate = prefs.getString(kMailHeaderTextKey) ?? kMailHeaderTemplateDefault;
..recipients = [toEmail] final footerTemplate = prefs.getString(kMailFooterTextKey) ?? kMailFooterTemplateDefault;
..bccRecipients = bccList final placeholderMap = await CompanyProfileService().buildMailPlaceholderMap(filename: fileName, hash: hash);
..subject = '請求書送付' final header = applyMailTemplate(headerTemplate, placeholderMap);
..text = '請求書をお送りします。ご確認ください。' final footer = applyMailTemplate(footerTemplate, placeholderMap);
..attachments = [FileAttachment(file)..fileName = 'invoice.pdf'..contentType = 'application/pdf']; final bodyCore = invoice.mailBodyText;
final body = [header, bodyCore, footer].where((section) => section.trim().isNotEmpty).join('\n\n');
await send(message, smtpServer); if (mailMethod == kMailSendMethodDeviceMailer) {
final email = Email(
body: body,
subject: fileName,
recipients: [toEmail],
bcc: bccList,
attachmentPaths: [file.path],
isHTML: false,
);
try {
await FlutterEmailSender.send(email);
await EmailSender.logDeviceMailer(success: true, toEmail: toEmail, bcc: bccList);
} catch (e) {
await EmailSender.logDeviceMailer(success: false, toEmail: toEmail, bcc: bccList, error: '$e');
rethrow;
}
} else {
final host = prefs.getString('smtp_host') ?? '';
final portStr = prefs.getString('smtp_port') ?? '587';
final user = prefs.getString('smtp_user') ?? '';
final passEncrypted = prefs.getString('smtp_pass') ?? '';
final pass = EmailSender.decrypt(passEncrypted);
final useTls = prefs.getBool('smtp_tls') ?? true;
final ignoreBadCert = prefs.getBool('smtp_ignore_bad_cert') ?? false;
if (host.isEmpty || user.isEmpty || pass.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください')));
}
return;
}
final port = int.tryParse(portStr) ?? 587;
final smtpConfig = EmailSenderConfig(
host: host,
port: port,
username: user,
password: pass,
useTls: useTls,
ignoreBadCert: ignoreBadCert,
bcc: bccList,
);
await EmailSender.sendInvoiceEmail(
config: smtpConfig,
toEmail: toEmail,
pdfFile: file,
subject: fileName,
attachmentFileName: fileName,
body: body,
);
}
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました'))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました')));
} }
@ -92,7 +144,15 @@ class InvoicePdfPreviewPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDraft = invoice.isDraft; final isDraft = invoice.isDraft;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text("PDFプレビュー")), appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text("PDFプレビュー"),
Text("ScreenID: 02", style: TextStyle(fontSize: 11, color: Colors.white70)),
],
),
),
body: Column( body: Column(
children: [ children: [
Expanded( Expanded(
@ -121,17 +181,28 @@ class InvoicePdfPreviewPage extends StatelessWidget {
} }
: null, : null,
icon: const Icon(Icons.check_circle_outline), icon: const Icon(Icons.check_circle_outline),
label: const Text("正式発行"), label: Stack(
alignment: Alignment.center,
children: [
const Text("正式発行"),
if (!isDraft || isLocked)
const Positioned(
right: 0,
child: Icon(Icons.lock, size: 16, color: Colors.white70),
),
],
),
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: showShare onPressed: (showShare && (!isDraft || isLocked))
? () async { ? () async {
final bytes = await _buildPdfBytes(); final bytes = await _buildPdfBytes();
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf'); final fileName = invoice.mailAttachmentFileName;
await Printing.sharePdf(bytes: bytes, filename: fileName);
} }
: null, : null,
icon: const Icon(Icons.share), icon: const Icon(Icons.share),
@ -141,7 +212,7 @@ class InvoicePdfPreviewPage extends StatelessWidget {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: ElevatedButton.icon( child: ElevatedButton.icon(
onPressed: showEmail onPressed: (showEmail && (!isDraft || isLocked))
? () async { ? () async {
await _sendEmail(context); await _sendEmail(context);
} }

View file

@ -190,6 +190,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.9+2" version: "1.1.9+2"
flutter_email_sender:
dependency: "direct main"
description:
name: flutter_email_sender
sha256: fb515d4e073d238d0daf1d765e5318487b6396d46b96e0ae9745dbc9a133f97a
url: "https://pub.dev"
source: hosted
version: "6.0.3"
flutter_lints: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:

View file

@ -53,6 +53,7 @@ dependencies:
printing: ^5.14.2 printing: ^5.14.2
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
mailer: ^6.0.1 mailer: ^6.0.1
flutter_email_sender: ^6.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

38
scripts/build_with_expiry.sh Executable file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_MODE="${1:-debug}"
case "${BUILD_MODE}" in
debug|profile|release)
;;
*)
echo "Usage: $0 [debug|profile|release]" >&2
exit 1
;;
esac
cd "${PROJECT_ROOT}"
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
DART_DEFINE="APP_BUILD_TIMESTAMP=${timestamp}"
echo "[build_with_expiry] Using timestamp: ${timestamp} (UTC)"
echo "[build_with_expiry] Running flutter analyze..."
flutter analyze
echo "[build_with_expiry] Building APK (${BUILD_MODE})..."
case "${BUILD_MODE}" in
debug)
flutter build apk --debug --dart-define="${DART_DEFINE}"
;;
profile)
flutter build apk --profile --dart-define="${DART_DEFINE}"
;;
release)
flutter build apk --release --dart-define="${DART_DEFINE}"
;;
esac
echo "[build_with_expiry] Done. APK with 90-day lifespan generated."