From 01f5851ddc398595166557951a595e7fa3beb02f Mon Sep 17 00:00:00 2001 From: joe Date: Sun, 1 Mar 2026 15:59:30 +0900 Subject: [PATCH] =?UTF-8?q?smtp=E5=AE=9F=E8=A3=85=E3=81=A8=E5=AF=BF?= =?UTF-8?q?=E5=91=BD=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 4 + ios/Runner/GeneratedPluginRegistrant.m | 7 + lib/config/app_config.dart | 35 + lib/constants/company_profile_keys.dart | 22 + lib/constants/mail_send_method.dart | 10 + lib/constants/mail_templates.dart | 32 + lib/main.dart | 326 +++++-- lib/models/customer_model.dart | 6 + lib/models/invoice_models.dart | 96 ++- lib/models/product_model.dart | 11 + lib/screens/business_profile_screen.dart | 471 +++++++++++ lib/screens/company_info_screen.dart | 1 + lib/screens/customer_master_screen.dart | 123 +-- lib/screens/customer_picker_modal.dart | 67 +- lib/screens/dashboard_screen.dart | 281 +++++++ lib/screens/email_settings_screen.dart | 586 +++++++++++++ lib/screens/invoice_detail_page.dart | 205 +++-- .../invoice_history/invoice_history_item.dart | 235 ++++-- .../invoice_history/invoice_history_list.dart | 2 +- lib/screens/invoice_history_screen.dart | 143 +++- lib/screens/invoice_input_screen.dart | 796 ++++++++++++------ lib/screens/master_hub_page.dart | 80 ++ lib/screens/product_master_screen.dart | 37 +- lib/screens/settings_screen.dart | 488 ++++++----- lib/services/app_settings_repository.dart | 144 ++++ lib/services/company_profile_service.dart | 265 ++++++ lib/services/customer_repository.dart | 47 +- lib/services/database_helper.dart | 56 +- lib/services/edit_log_repository.dart | 60 ++ lib/services/email_sender.dart | 238 ++++++ lib/services/invoice_repository.dart | 48 ++ lib/services/pdf_generator.dart | 12 +- lib/services/product_repository.dart | 56 +- lib/services/theme_controller.dart | 31 + lib/utils/build_expiry_info.dart | 39 + lib/widgets/contact_picker_sheet.dart | 127 +++ lib/widgets/invoice_pdf_preview_page.dart | 135 ++- pubspec.lock | 8 + pubspec.yaml | 1 + scripts/build_with_expiry.sh | 38 + 40 files changed, 4495 insertions(+), 874 deletions(-) create mode 100644 lib/config/app_config.dart create mode 100644 lib/constants/company_profile_keys.dart create mode 100644 lib/constants/mail_send_method.dart create mode 100644 lib/constants/mail_templates.dart create mode 100644 lib/screens/business_profile_screen.dart create mode 100644 lib/screens/dashboard_screen.dart create mode 100644 lib/screens/email_settings_screen.dart create mode 100644 lib/screens/master_hub_page.dart create mode 100644 lib/services/app_settings_repository.dart create mode 100644 lib/services/company_profile_service.dart create mode 100644 lib/services/edit_log_repository.dart create mode 100644 lib/services/email_sender.dart create mode 100644 lib/services/theme_controller.dart create mode 100644 lib/utils/build_expiry_info.dart create mode 100644 lib/widgets/contact_picker_sheet.dart create mode 100755 scripts/build_with_expiry.sh diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ff12c06..d9efe17 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -28,6 +28,10 @@ + + + + ) +#import +#else +@import flutter_email_sender; +#endif + #if __has_include() #import #else @@ -82,6 +88,7 @@ + (void)registerWithRegistry:(NSObject*)registry { [FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]]; + [FlutterEmailSenderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterEmailSenderPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]]; diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart new file mode 100644 index 0000000..718700e --- /dev/null +++ b/lib/config/app_config.dart @@ -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 get features => { + 'enableBillingDocs': enableBillingDocs, + 'enableSalesManagement': enableSalesManagement, + }; + + /// 機能キーで有効/無効を判定するヘルパー。 + static bool isFeatureEnabled(String key) => features[key] ?? false; + + /// 有効なダッシュボードルート一覧(動的に増える場合はここで管理)。 + static Set get enabledRoutes { + final routes = {'settings'}; + if (enableBillingDocs) { + routes.addAll({'invoice_history', 'invoice_input', 'master_hub', 'customer_master', 'product_master'}); + } + if (enableSalesManagement) { + routes.add('sales_management'); + } + return routes; + } +} diff --git a/lib/constants/company_profile_keys.dart b/lib/constants/company_profile_keys.dart new file mode 100644 index 0000000..e2631f6 --- /dev/null +++ b/lib/constants/company_profile_keys.dart @@ -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 kAccountTypeOptions = ['普通', '当座', '貯蓄']; diff --git a/lib/constants/mail_send_method.dart b/lib/constants/mail_send_method.dart new file mode 100644 index 0000000..74e7d5e --- /dev/null +++ b/lib/constants/mail_send_method.dart @@ -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; +} diff --git a/lib/constants/mail_templates.dart b/lib/constants/mail_templates.dart new file mode 100644 index 0000000..14763f5 --- /dev/null +++ b/lib/constants/mail_templates.dart @@ -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 values) { + var result = template; + values.forEach((placeholder, value) { + result = result.replaceAll(placeholder, value); + }); + return result; +} diff --git a/lib/main.dart b/lib/main.dart index e98aaff..3b97cc5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,85 +1,295 @@ // lib/main.dart // version: 1.5.02 (Update: Date selection & Tax fix) import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; // --- 独自モジュールのインポート --- import 'models/invoice_models.dart'; // Invoice, InvoiceItem モデル import 'screens/invoice_input_screen.dart'; // 入力フォーム画面 import 'screens/invoice_detail_page.dart'; // 詳細表示・編集画面 import 'screens/invoice_history_screen.dart'; // 履歴画面 +import 'screens/dashboard_screen.dart'; // ダッシュボード import 'services/location_service.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()); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + final TransformationController _zoomController = TransformationController(); + int _activePointers = 0; + @override Widget build(BuildContext context) { - return MaterialApp( - title: '販売アシスト1号', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith( - primary: Colors.indigo.shade700, - secondary: Colors.deepOrange.shade400, - surface: Colors.grey.shade50, - onSurface: Colors.blueGrey.shade900, - ), - scaffoldBackgroundColor: Colors.grey.shade50, - appBarTheme: AppBarTheme( - backgroundColor: Colors.indigo.shade700, - foregroundColor: Colors.white, - elevation: 0, - ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - textStyle: const TextStyle(fontWeight: FontWeight.bold), + return ValueListenableBuilder( + valueListenable: AppThemeController.instance.notifier, + builder: (context, mode, _) => MaterialApp( + title: '販売アシスト1号', + navigatorObservers: [ + _ZoomResetObserver(_zoomController), + ], + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith( + primary: Colors.indigo.shade700, + secondary: Colors.deepOrange.shade400, + surface: Colors.grey.shade100, + onSurface: Colors.blueGrey.shade900, ), - ), - 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), + scaffoldBackgroundColor: Colors.grey.shade100, + appBarTheme: AppBarTheme( + backgroundColor: Colors.indigo.shade700, + foregroundColor: Colors.white, + elevation: 0, ), - ), - 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', - ), - 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(), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + textStyle: const TextStyle(fontWeight: FontWeight.bold), ), ), - ); + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + side: BorderSide(color: Colors.indigo.shade700), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + textStyle: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4), + ), + ), + visualDensity: VisualDensity.adaptivePlatformDensity, + useMaterial3: true, + fontFamily: 'IPAexGothic', + ), + 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 _homeFuture; + + @override + void initState() { + super.initState(); + _homeFuture = _settings.getHomeMode(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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(), ); } } diff --git a/lib/models/customer_model.dart b/lib/models/customer_model.dart index 479a381..9fcf7dc 100644 --- a/lib/models/customer_model.dart +++ b/lib/models/customer_model.dart @@ -13,6 +13,7 @@ class Customer { final bool isSynced; // 同期フラグ final DateTime updatedAt; // 最終更新日時 final bool isLocked; // ロック + final bool isHidden; // 非表示 final String? headChar1; // インデックス1 final String? headChar2; // インデックス2 @@ -30,6 +31,7 @@ class Customer { this.isSynced = false, DateTime? updatedAt, this.isLocked = false, + this.isHidden = false, this.headChar1, this.headChar2, }) : updatedAt = updatedAt ?? DateTime.now(); @@ -57,6 +59,7 @@ class Customer { 'head_char2': headChar2, 'is_locked': isLocked ? 1 : 0, 'is_synced': isSynced ? 1 : 0, + 'is_hidden': isHidden ? 1 : 0, 'updated_at': updatedAt.toIso8601String(), }; } @@ -75,6 +78,7 @@ class Customer { odooId: map['odoo_id'], isLocked: (map['is_locked'] ?? 0) == 1, isSynced: map['is_synced'] == 1, + isHidden: (map['is_hidden'] ?? 0) == 1, updatedAt: DateTime.parse(map['updated_at']), headChar1: map['head_char1'], headChar2: map['head_char2'], @@ -93,6 +97,7 @@ class Customer { bool? isSynced, DateTime? updatedAt, bool? isLocked, + bool? isHidden, String? email, int? contactVersionId, String? headChar1, @@ -112,6 +117,7 @@ class Customer { isSynced: isSynced ?? this.isSynced, updatedAt: updatedAt ?? this.updatedAt, isLocked: isLocked ?? this.isLocked, + isHidden: isHidden ?? this.isHidden, headChar1: headChar1 ?? this.headChar1, headChar2: headChar2 ?? this.headChar2, ); diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index 6db28c9..25be41d 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -66,6 +66,10 @@ enum DocumentType { } 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 Customer customer; final DateTime date; @@ -88,6 +92,10 @@ class Invoice { final String? contactEmailSnapshot; final String? contactTelSnapshot; final String? contactAddressSnapshot; + final String? companySnapshot; // 追加: 発行時会社情報スナップショット + final String? companySealHash; // 追加: 角印画像ハッシュ + final String? metaJson; + final String? metaHash; Invoice({ String? id, @@ -112,6 +120,10 @@ class Invoice { this.contactEmailSnapshot, this.contactTelSnapshot, this.contactAddressSnapshot, + this.companySnapshot, + this.companySealHash, + this.metaJson, + this.metaHash, }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), terminalId = terminalId ?? "T1", // デフォルト端末ID updatedAt = updatedAt ?? DateTime.now(); @@ -132,6 +144,13 @@ class Invoice { } } + static const Map _docTypeShortLabel = { + DocumentType.estimation: '見積', + DocumentType.delivery: '納品', + DocumentType.invoice: '請求', + DocumentType.receipt: '領収', + }; + String get invoiceNumberPrefix { switch (documentType) { 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 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 tax => (subtotal * taxRate).floor(); 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 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 toMap() { return { 'id': id, @@ -175,6 +255,10 @@ class Invoice { 'contact_email_snapshot': contactEmailSnapshot, 'contact_tel_snapshot': contactTelSnapshot, '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? contactTelSnapshot, String? contactAddressSnapshot, + String? companySnapshot, + String? companySealHash, + String? metaJson, + String? metaHash, }) { return Invoice( id: id ?? this.id, @@ -225,6 +313,10 @@ class Invoice { contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot, contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot, contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot, + companySnapshot: companySnapshot ?? this.companySnapshot, + companySealHash: companySealHash ?? this.companySealHash, + metaJson: metaJson ?? this.metaJson, + metaHash: metaHash ?? this.metaHash, ); } diff --git a/lib/models/product_model.dart b/lib/models/product_model.dart index 6ba76c9..229f32c 100644 --- a/lib/models/product_model.dart +++ b/lib/models/product_model.dart @@ -7,6 +7,7 @@ class Product { final int stockQuantity; // 追加 final String? odooId; final bool isLocked; // ロック + final bool isHidden; // 非表示 Product({ required this.id, @@ -17,6 +18,7 @@ class Product { this.stockQuantity = 0, // 追加 this.odooId, this.isLocked = false, + this.isHidden = false, }); Map toMap() { @@ -29,6 +31,7 @@ class Product { 'stock_quantity': stockQuantity, // 追加 'is_locked': isLocked ? 1 : 0, 'odoo_id': odooId, + 'is_hidden': isHidden ? 1 : 0, }; } @@ -42,6 +45,7 @@ class Product { stockQuantity: map['stock_quantity'] ?? 0, // 追加 isLocked: (map['is_locked'] ?? 0) == 1, odooId: map['odoo_id'], + isHidden: (map['is_hidden'] ?? 0) == 1, ); } @@ -50,15 +54,22 @@ class Product { String? name, int? defaultUnitPrice, String? barcode, + String? category, + int? stockQuantity, String? odooId, bool? isLocked, + bool? isHidden, }) { return Product( id: id ?? this.id, name: name ?? this.name, defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice, + barcode: barcode ?? this.barcode, + category: category ?? this.category, + stockQuantity: stockQuantity ?? this.stockQuantity, odooId: odooId ?? this.odooId, isLocked: isLocked ?? this.isLocked, + isHidden: isHidden ?? this.isHidden, ); } } diff --git a/lib/screens/business_profile_screen.dart b/lib/screens/business_profile_screen.dart new file mode 100644 index 0000000..d8f9927 --- /dev/null +++ b/lib/screens/business_profile_screen.dart @@ -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 createState() => _BusinessProfileScreenState(); +} + +class _BusinessProfileScreenState extends State { + 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 _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 _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 _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 _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( + 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( + 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(); + } +} + diff --git a/lib/screens/company_info_screen.dart b/lib/screens/company_info_screen.dart index 0f7bb6b..ff5e60f 100644 --- a/lib/screens/company_info_screen.dart +++ b/lib/screens/company_info_screen.dart @@ -71,6 +71,7 @@ class _CompanyInfoScreenState extends State { if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator())); return Scaffold( + resizeToAvoidBottomInset: false, appBar: AppBar( title: const Text("F1:自社情報"), backgroundColor: Colors.indigo, diff --git a/lib/screens/customer_master_screen.dart b/lib/screens/customer_master_screen.dart index 76a01da..7ab8cfd 100644 --- a/lib/screens/customer_master_screen.dart +++ b/lib/screens/customer_master_screen.dart @@ -6,11 +6,13 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'dart:convert'; import '../models/customer_model.dart'; import '../services/customer_repository.dart'; +import '../widgets/contact_picker_sheet.dart'; class CustomerMasterScreen extends StatefulWidget { final bool selectionMode; + final bool showHidden; - const CustomerMasterScreen({super.key, this.selectionMode = false}); + const CustomerMasterScreen({super.key, this.selectionMode = false, this.showHidden = false}); @override State createState() => _CustomerMasterScreenState(); @@ -87,6 +89,12 @@ class _CustomerMasterScreenState extends State { } Future _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 telController = TextEditingController(text: customer.tel ?? ""); final addressController = TextEditingController(text: customer.address ?? ""); @@ -130,7 +138,7 @@ class _CustomerMasterScreenState extends State { Future _loadCustomers() async { setState(() => _isLoading = true); try { - final customers = await _customerRepo.getAllCustomers(); + final customers = await _customerRepo.getAllCustomers(includeHidden: widget.showHidden); if (!mounted) return; setState(() { _customers = customers; @@ -149,13 +157,20 @@ class _CustomerMasterScreenState extends State { List list = _customers.where((c) { return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query); }).toList(); + if (!widget.showHidden) { + list = list.where((c) => !c.isHidden).toList(); + } // Kana filtering disabled temporarily for stability switch (_sortKey) { 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; 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; } @@ -205,16 +220,6 @@ class _CustomerMasterScreenState extends State { late final Map _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 _addOrEditCustomer({Customer? customer}) async { final isEdit = customer != null; @@ -244,26 +249,8 @@ class _CustomerMasterScreenState extends State { final Contact? picked = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (ctx) => SafeArea( - child: SizedBox( - 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), - ); - }, - ), - ), - ), + backgroundColor: Colors.transparent, + builder: (ctx) => ContactPickerSheet(contacts: contacts, title: isEdit ? '電話帳から上書き' : '電話帳から新規入力'), ); if (!mounted) return; if (picked != null) { @@ -404,22 +391,25 @@ class _CustomerMasterScreenState extends State { TextButton( onPressed: () { if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("表示名と正式名称は必須です"))); return; } - final head1 = _normalizeIndexChar(head1Controller.text); - final head2 = _normalizeIndexChar(head2Controller.text); + final head1 = head1Controller.text.trim(); + final head2 = head2Controller.text.trim(); + final locked = customer?.isLocked ?? false; + final newId = locked ? const Uuid().v4() : (customer?.id ?? const Uuid().v4()); final newCustomer = Customer( - id: customer?.id ?? const Uuid().v4(), - displayName: displayNameController.text, - formalName: formalNameController.text, + id: newId, + displayName: displayNameController.text.trim(), + formalName: formalNameController.text.trim(), title: selectedTitle, - department: departmentController.text.isEmpty ? null : departmentController.text, - address: addressController.text.isEmpty ? null : addressController.text, - tel: telController.text.isEmpty ? null : telController.text, - email: emailController.text.isEmpty ? null : emailController.text, - headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1, + department: departmentController.text.trim().isEmpty ? null : departmentController.text.trim(), + address: addressController.text.trim().isEmpty ? null : addressController.text.trim(), + tel: telController.text.trim().isEmpty ? null : telController.text.trim(), + email: emailController.text.trim().isEmpty ? null : emailController.text.trim(), + headChar1: head1.isEmpty ? null : head1, headChar2: head2.isEmpty ? null : head2, - isLocked: customer?.isLocked ?? false, + isLocked: false, ); Navigator.pop(context, newCustomer); }, @@ -783,7 +773,12 @@ class _CustomerMasterScreenState extends State { ), title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)), 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 ? null : IconButton( @@ -861,23 +856,33 @@ class _CustomerMasterScreenState extends State { leading: const Icon(Icons.edit), title: const Text('編集'), enabled: !c.isLocked, - onTap: c.isLocked - ? null - : () { - Navigator.pop(context); - _addOrEditCustomer(customer: c); - }, + onTap: () { + Navigator.pop(context); + _addOrEditCustomer(customer: c); + }, ), ListTile( leading: const Icon(Icons.contact_mail), title: const Text('連絡先を更新'), + enabled: !c.isLocked, onTap: () { + if (c.isLocked) return; Navigator.pop(context); _showContactUpdateDialog(c); }, ), 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)), enabled: !c.isLocked, onTap: c.isLocked @@ -957,10 +962,12 @@ class _CustomerMasterScreenState extends State { ), const SizedBox(width: 8), OutlinedButton.icon( - onPressed: () { - Navigator.pop(context); - _showContactUpdateSheet(c); - }, + onPressed: c.isLocked + ? null + : () { + Navigator.pop(context); + _showContactUpdateSheet(c); + }, icon: const Icon(Icons.contact_mail), label: const Text("連絡先を更新"), ), @@ -1018,7 +1025,9 @@ class _CustomerMasterScreenState extends State { ListTile( leading: const Icon(Icons.contact_mail), title: const Text('連絡先を更新'), + enabled: !c.isLocked, onTap: () { + if (c.isLocked) return; Navigator.pop(context); _showContactUpdateDialog(c); }, diff --git a/lib/screens/customer_picker_modal.dart b/lib/screens/customer_picker_modal.dart index ce670c9..462d7c6 100644 --- a/lib/screens/customer_picker_modal.dart +++ b/lib/screens/customer_picker_modal.dart @@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart'; import '../models/customer_model.dart'; import '../services/customer_repository.dart'; import '../widgets/keyboard_inset_wrapper.dart'; +import '../widgets/contact_picker_sheet.dart'; /// 顧客マスターからの選択、登録、編集、削除を行うモーダル class CustomerPickerModal extends StatefulWidget { @@ -55,7 +56,8 @@ class _CustomerPickerModalState extends State { final Contact? selectedContact = await showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => _PhoneContactListSelector(contacts: contacts), + backgroundColor: Colors.transparent, + builder: (context) => ContactPickerSheet(contacts: contacts, title: '電話帳から顧客候補を選択'), ); if (!context.mounted) return; @@ -316,66 +318,3 @@ class _CustomerPickerModalState extends State { } } -/// 電話帳から一人選ぶための内部ウィジェット -class _PhoneContactListSelector extends StatefulWidget { - final List contacts; - const _PhoneContactListSelector({required this.contacts}); - - @override - State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState(); -} - -class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> { - List _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]), - ), - ), - ), - ], - ), - ); - } -} diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart new file mode 100644 index 0000000..5b0b14d --- /dev/null +++ b/lib/screens/dashboard_screen.dart @@ -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 createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends State { + final _repo = AppSettingsRepository(); + bool _loading = true; + bool _statusEnabled = true; + String _statusText = '工事中'; + List _menu = []; + bool _historyUnlocked = false; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 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, +}; diff --git a/lib/screens/email_settings_screen.dart b/lib/screens/email_settings_screen.dart new file mode 100644 index 0000000..0b7b56e --- /dev/null +++ b/lib/screens/email_settings_screen.dart @@ -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 createState() => _EmailSettingsScreenState(); +} + +class _EmailSettingsScreenState extends State { + 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 _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 _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 _loadSmtpLogs() async { + setState(() => _loadingLogs = true); + final logs = await EmailSender.loadLogs(); + if (!mounted) return; + setState(() { + _smtpLogs = logs; + _loadingLogs = false; + }); + } + + Future _clearSmtpLogs() async { + await EmailSender.clearLogs(); + await _loadSmtpLogs(); + } + + Future _copySmtpLogs() async { + if (_smtpLogs.isEmpty) return; + await Clipboard.setData(ClipboardData(text: _smtpLogs.join('\n'))); + _showSnackbar('ログをクリップボードにコピーしました'); + } + + Future _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 _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 _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 _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.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.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( + 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( + 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( + 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, + ], + ), + ), + ); + } +} diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index a608d59..589db5d 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -12,6 +12,7 @@ import '../services/company_repository.dart'; import 'product_picker_modal.dart'; import '../models/company_model.dart'; import '../widgets/keyboard_inset_wrapper.dart'; +import '../services/app_settings_repository.dart'; class _DetailSnapshot { 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 _cloneItemsDetail(List source) { return source .map((e) => InvoiceItem( @@ -63,14 +83,16 @@ class _InvoiceDetailPageState extends State { late double _taxRate; // 追加 late bool _includeTax; // 追加 String? _currentFilePath; - final _invoiceRepo = InvoiceRepository(); - final _customerRepo = CustomerRepository(); - final _companyRepo = CompanyRepository(); + final InvoiceRepository _invoiceRepo = InvoiceRepository(); + final CustomerRepository _customerRepo = CustomerRepository(); + final CompanyRepository _companyRepo = CompanyRepository(); + final AppSettingsRepository _settingsRepo = AppSettingsRepository(); // 追加 CompanyInfo? _companyInfo; bool _showFormalWarning = true; final List<_DetailSnapshot> _undoStack = []; final List<_DetailSnapshot> _redoStack = []; bool _isApplyingSnapshot = false; + bool _summaryIsBlue = false; // デフォルトは白 @override void initState() { @@ -84,6 +106,13 @@ class _InvoiceDetailPageState extends State { _includeTax = _currentInvoice.taxRate > 0; // 初期化 _isEditing = false; _loadCompanyInfo(); + _loadSummaryTheme(); + } + + Future _loadSummaryTheme() async { + final saved = await _settingsRepo.getSummaryTheme(); + if (!mounted) return; + setState(() => _summaryIsBlue = saved == 'blue'); } Future _loadCompanyInfo() async { @@ -186,12 +215,39 @@ class _InvoiceDetailPageState extends State { SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV')); } + Future _pickSummaryColor() async { + final selected = await showModalBottomSheet( + 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 Widget build(BuildContext context) { final fmt = NumberFormat("#,###"); final isDraft = _currentInvoice.isDraft; final docTypeName = _currentInvoice.documentTypeName; - final themeColor = Colors.white; // 常に明色 + final themeColor = Theme.of(context).scaffoldBackgroundColor; final textColor = Colors.black87; final locked = _currentInvoice.isLocked; @@ -292,18 +348,18 @@ class _InvoiceDetailPageState extends State { padding: const EdgeInsets.all(10), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( - color: Colors.indigo.shade800, + color: Colors.indigo, // 合計金額と同じカラー borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.indigo.shade900), + border: Border.all(color: Colors.indigo.shade700), ), child: Row( children: [ const Icon(Icons.edit_note, color: Colors.white70), const SizedBox(width: 8), - Expanded( + const Expanded( child: Text( - "下書き: 未確定・PDFは正式発行で確定", - style: const TextStyle(color: Colors.white70), + "未確定・PDFは正式発行で確定", + style: TextStyle(color: Colors.white70), ), ), const SizedBox(width: 8), @@ -314,7 +370,7 @@ class _InvoiceDetailPageState extends State { borderRadius: BorderRadius.circular(16), ), child: Text( - "下書${docTypeName}", + "下書$docTypeName", style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12), ), ), @@ -406,8 +462,15 @@ class _InvoiceDetailPageState extends State { width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.grey.shade100, + color: Colors.white, borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -417,25 +480,34 @@ class _InvoiceDetailPageState extends State { Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)), if (_currentInvoice.subject?.isNotEmpty ?? false) ...[ - const SizedBox(height: 6), - Text("件名: ${_currentInvoice.subject}", - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo)), - ], - 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), + const SizedBox(height: 8), + const Text("件名", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black54)), + const SizedBox(height: 2), Text( - "備考: ${_currentInvoice.notes}", - style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())), + _currentInvoice.subject!, + 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 { final int tax = (subtotal * currentTaxRate).floor(); final int total = subtotal + tax; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.indigo, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70), - if (currentTaxRate > 0) ...[ - const Divider(color: Colors.white24), - if (_companyInfo?.taxDisplayMode == 'normal') - _buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "¥${formatter.format(tax)}", Colors.white70), - if (_companyInfo?.taxDisplayMode == 'text_only') - _buildSummaryRow("消費税", "(税別)", Colors.white70), + final bool useBlue = _summaryIsBlue; + final Color bgColor = useBlue ? Colors.indigo : Colors.white; + final Color borderColor = useBlue ? Colors.transparent : Colors.grey.shade300; + final Color labelColor = useBlue ? Colors.white70 : Colors.black87; + final Color totalColor = useBlue ? Colors.white : Colors.black87; + final Color dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300; + + return GestureDetector( + onLongPress: _pickSummaryColor, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + 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 { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text("この下書き伝票を「確定」として正式に発行しますか?"), + Row( + children: const [ + _DraftBadge(), + SizedBox(width: 8), + Expanded(child: Text("この伝票を「確定」として正式に発行しますか?")), + ], + ), const SizedBox(height: 8), if (showWarning) Container( @@ -785,7 +874,15 @@ class _InvoiceDetailPageState extends State { children: [ const Icon(Icons.drafts, color: Colors.orange), 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( value: _currentInvoice.isDraft, onChanged: (val) { diff --git a/lib/screens/invoice_history/invoice_history_item.dart b/lib/screens/invoice_history/invoice_history_item.dart index 489970d..c5afda5 100644 --- a/lib/screens/invoice_history/invoice_history_item.dart +++ b/lib/screens/invoice_history/invoice_history_item.dart @@ -25,86 +25,171 @@ class InvoiceHistoryItem extends StatelessWidget { @override Widget build(BuildContext context) { - return ListTile( - tileColor: invoice.isDraft ? Colors.orange.shade50 : null, - leading: CircleAvatar( - backgroundColor: invoice.isDraft - ? Colors.orange.shade100 - : (isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200), - child: Stack( - children: [ - Align( - alignment: Alignment.center, - child: Icon( - invoice.isDraft ? Icons.edit_note : Icons.description_outlined, - color: invoice.isDraft - ? Colors.orange - : (isUnlocked ? Colors.indigo : Colors.grey), + final cardColor = invoice.isDraft ? Colors.orange.shade50 : Colors.white; + final iconBg = isUnlocked + ? _docTypeColor(invoice.documentType).withValues(alpha: 0.18) + : Colors.grey.shade200; + final iconColor = isUnlocked ? _docTypeColor(invoice.documentType) : Colors.grey; + + final hasSubject = invoice.subject?.isNotEmpty ?? false; + final firstItemDesc = invoice.items.isNotEmpty ? invoice.items.first.description : ''; + final othersCount = invoice.items.length > 1 ? invoice.items.length - 1 : 0; + final subjectLine = hasSubject ? invoice.subject! : firstItemDesc; + final subjectDisplay = hasSubject + ? subjectLine + : (othersCount > 0 ? "$subjectLine 他$othersCount件" : subjectLine); + final customerName = invoice.customerNameForDisplay.endsWith('様') + ? 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), + ), + ], + ), ), - ), - if (invoice.isLocked) - const Align( - alignment: Alignment.bottomRight, - child: Icon(Icons.lock, size: 14, color: Colors.redAccent), + const SizedBox(width: 12), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + 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; + } + } } diff --git a/lib/screens/invoice_history/invoice_history_list.dart b/lib/screens/invoice_history/invoice_history_list.dart index b56e673..394709e 100644 --- a/lib/screens/invoice_history/invoice_history_list.dart +++ b/lib/screens/invoice_history/invoice_history_list.dart @@ -41,7 +41,7 @@ class InvoiceHistoryList extends StatelessWidget { return ListView.builder( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - padding: const EdgeInsets.only(bottom: 120), // FAB分の固定余白 + padding: const EdgeInsets.fromLTRB(12, 0, 12, 120), // 横揃えとFAB余白 itemCount: invoices.length, itemBuilder: (context, index) { final invoice = invoices[index]; diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index eb12863..0ca2b20 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -10,6 +10,8 @@ import 'customer_master_screen.dart'; import 'invoice_input_screen.dart'; import 'settings_screen.dart'; import 'company_info_screen.dart'; +import 'dashboard_screen.dart'; +import '../services/app_settings_repository.dart'; import '../widgets/slide_to_unlock.dart'; // InvoiceFlowScreen import removed; using inline type picker 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'; class InvoiceHistoryScreen extends StatefulWidget { - const InvoiceHistoryScreen({super.key}); + final bool initialUnlocked; + const InvoiceHistoryScreen({super.key, this.initialUnlocked = false}); @override State createState() => _InvoiceHistoryScreenState(); @@ -26,6 +29,7 @@ class InvoiceHistoryScreen extends StatefulWidget { class _InvoiceHistoryScreenState extends State { final InvoiceRepository _invoiceRepo = InvoiceRepository(); final CustomerRepository _customerRepo = CustomerRepository(); + final AppSettingsRepository _settingsRepo = AppSettingsRepository(); List _invoices = []; List _filteredInvoices = []; bool _isLoading = true; @@ -35,12 +39,26 @@ class _InvoiceHistoryScreenState extends State { DateTime? _startDate; DateTime? _endDate; String _appVersion = "1.0.0"; + bool _useDashboardHome = false; @override void initState() { super.initState(); + _isUnlocked = widget.initialUnlocked; _loadData(); _loadVersion(); + _loadHomeMode(); + } + + Future _loadHomeMode() async { + final mode = await _settingsRepo.getHomeMode(); + if (!mounted) return; + setState(() { + _useDashboardHome = mode == 'dashboard'; + if (_useDashboardHome && widget.initialUnlocked) { + _isUnlocked = true; + } + }); } Future _showInvoiceActions(Invoice invoice) async { @@ -198,20 +216,20 @@ class _InvoiceHistoryScreenState extends State { final dateFormatter = DateFormat('yyyy/MM/dd'); return Scaffold( resizeToAvoidBottomInset: false, - drawer: _isUnlocked - ? Drawer( + drawer: (_useDashboardHome || !_isUnlocked) + ? null + : Drawer( child: ListView( padding: EdgeInsets.zero, children: [ DrawerHeader( - decoration: BoxDecoration(color: Colors.indigo.shade700), + decoration: const BoxDecoration(color: Colors.indigo), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Text("v$_appVersion", style: const TextStyle(color: Colors.white70)), + children: const [ + Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)), + SizedBox(height: 8), + Text("メニュー", style: TextStyle(color: Colors.white70)), ], ), ), @@ -257,10 +275,27 @@ class _InvoiceHistoryScreenState extends State { ), ], ), - ) - : null, + ), 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( onLongPress: () { Navigator.push( @@ -307,19 +342,41 @@ class _InvoiceHistoryScreenState extends State { preferredSize: const Size.fromHeight(60), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: TextField( - decoration: InputDecoration( - hintText: "検索 (顧客名、伝票番号...)", - prefixIcon: const Icon(Icons.search), - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), - isDense: true, + child: Container( + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + // outer shadow + BoxShadow( + 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 { body: SafeArea( child: Column( children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: SlideToUnlock( - isLocked: !_isUnlocked, - onUnlocked: _toggleUnlock, - text: "スライドでロック解除", + if (!_useDashboardHome) + Padding( + padding: const EdgeInsets.all(16.0), + child: SlideToUnlock( + isLocked: !_isUnlocked, + onUnlocked: _toggleUnlock, + text: "スライドでロック解除", + ), ), - ), Expanded( child: _isLoading ? const Center(child: CircularProgressIndicator()) @@ -347,8 +405,9 @@ class _InvoiceHistoryScreenState extends State { await Navigator.push( context, MaterialPageRoute( - builder: (context) => InvoiceDetailPage( - invoice: invoice, + builder: (context) => InvoiceInputForm( + existingInvoice: invoice, + onInvoiceGenerated: (inv, path) {}, ), ), ); @@ -393,23 +452,23 @@ class _InvoiceHistoryScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ ListTile( - leading: const Icon(Icons.insert_drive_file_outlined), - title: const Text('下書き: 見積書', style: TextStyle(fontSize: 24)), + leading: CircleAvatar(backgroundColor: Colors.blue.withValues(alpha: 0.12), child: const Icon(Icons.request_quote, color: Colors.blue)), + title: const Text('見積書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), onTap: () => _startNew(DocumentType.estimation), ), ListTile( - leading: const Icon(Icons.local_shipping_outlined), - title: const Text('下書き: 納品書', style: TextStyle(fontSize: 24)), + leading: CircleAvatar(backgroundColor: Colors.teal.withValues(alpha: 0.12), child: const Icon(Icons.local_shipping, color: Colors.teal)), + title: const Text('納品書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), onTap: () => _startNew(DocumentType.delivery), ), ListTile( - leading: const Icon(Icons.request_quote_outlined), - title: const Text('下書き: 請求書', style: TextStyle(fontSize: 24)), + leading: CircleAvatar(backgroundColor: Colors.indigo.withValues(alpha: 0.12), child: const Icon(Icons.receipt_long, color: Colors.indigo)), + title: const Text('請求書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), onTap: () => _startNew(DocumentType.invoice), ), ListTile( - leading: const Icon(Icons.receipt_long_outlined), - title: const Text('下書き: 領収書', style: TextStyle(fontSize: 24)), + leading: CircleAvatar(backgroundColor: Colors.green.withValues(alpha: 0.12), child: const Icon(Icons.task_alt, color: Colors.green)), + title: const Text('領収書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)), onTap: () => _startNew(DocumentType.receipt), ), ], @@ -426,6 +485,8 @@ class _InvoiceHistoryScreenState extends State { builder: (_) => InvoiceInputForm( onInvoiceGenerated: (inv, path) {}, initialDocumentType: type, + startViewMode: false, + showNewBadge: true, ), ), ); diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index bd018e6..185702a 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -6,32 +6,39 @@ import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; import '../widgets/invoice_pdf_preview_page.dart'; -import 'invoice_detail_page.dart'; import '../services/gps_service.dart'; import 'customer_master_screen.dart'; import 'product_master_screen.dart'; import '../models/product_model.dart'; +import '../services/app_settings_repository.dart'; +import '../services/edit_log_repository.dart'; class InvoiceInputForm extends StatefulWidget { final Function(Invoice invoice, String filePath) onInvoiceGenerated; final Invoice? existingInvoice; // 追加: 編集時の既存伝票 final DocumentType initialDocumentType; + final bool startViewMode; + final bool showNewBadge; + final bool showCopyBadge; const InvoiceInputForm({ super.key, required this.onInvoiceGenerated, this.existingInvoice, // 追加 this.initialDocumentType = DocumentType.invoice, + this.startViewMode = true, + this.showNewBadge = false, + this.showCopyBadge = false, }); @override State createState() => _InvoiceInputFormState(); } -List _cloneItems(List source) { +List _cloneItems(List source, {bool resetIds = false}) { return source .map((e) => InvoiceItem( - id: e.id, + id: resetIds ? null : e.id, productId: e.productId, description: e.description, quantity: e.quantity, @@ -52,34 +59,106 @@ class _InvoiceInputFormState extends State { bool _isDraft = true; // デフォルトは下書き final TextEditingController _subjectController = TextEditingController(); // 追加 bool _isSaving = false; // 保存中フラグ + String? _currentId; // 保存対象のID(コピー時に新規になる) + bool _isLocked = false; final List<_InvoiceSnapshot> _undoStack = []; final List<_InvoiceSnapshot> _redoStack = []; bool _isApplyingSnapshot = false; bool get _canUndo => _undoStack.length > 1; bool get _canRedo => _redoStack.isNotEmpty; + bool _isViewMode = true; // デフォルトでビューワ + bool _summaryIsBlue = false; // デフォルトは白 + final AppSettingsRepository _settingsRepo = AppSettingsRepository(); + bool _showNewBadge = false; + bool _showCopyBadge = false; + final EditLogRepository _editLogRepo = EditLogRepository(); + List _editLogs = []; + final FocusNode _subjectFocusNode = FocusNode(); + String _lastLoggedSubject = ""; - // 署名用の実験的パス - final List _signaturePath = []; + String _documentTypeLabel(DocumentType type) { + switch (type) { + case DocumentType.estimation: + return "見積書"; + case DocumentType.delivery: + return "納品書"; + case DocumentType.invoice: + return "請求書"; + case DocumentType.receipt: + return "領収書"; + } + } + + Color _documentTypeColor(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; + } + } + + String _customerNameWithHonorific(Customer customer) { + final base = customer.formalName; + final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base); + return hasHonorific ? base : "$base ${customer.title}"; + } + + String _ensureCurrentId() { + _currentId ??= DateTime.now().millisecondsSinceEpoch.toString(); + return _currentId!; + } + + void _copyAsNew() { + if (widget.existingInvoice == null && _currentId == null) return; + final clonedItems = _cloneItems(_items, resetIds: true); + setState(() { + _currentId = DateTime.now().millisecondsSinceEpoch.toString(); + _isDraft = true; + _isLocked = false; + _selectedDate = DateTime.now(); + _items + ..clear() + ..addAll(clonedItems); + _isViewMode = false; + _showCopyBadge = true; + _showNewBadge = false; + _pushHistory(clearRedo: true); + _editLogs.clear(); + }); + } @override void initState() { super.initState(); _subjectController.addListener(_onSubjectChanged); + _subjectFocusNode.addListener(() { + if (!_subjectFocusNode.hasFocus) { + final current = _subjectController.text; + if (current != _lastLoggedSubject) { + final id = _ensureCurrentId(); + final msg = "件名を『$current』に更新しました"; + _editLogRepo.addLog(id, msg).then((_) => _loadEditLogs()); + _lastLoggedSubject = current; + } + } + }); + _subjectController.addListener(_onSubjectChanged); _loadInitialData(); } - @override - void dispose() { - _subjectController.removeListener(_onSubjectChanged); - _subjectController.dispose(); - super.dispose(); - } - Future _loadInitialData() async { _repository.cleanupOrphanedPdfs(); final customerRepo = CustomerRepository(); await customerRepo.getAllCustomers(); + final savedSummary = await _settingsRepo.getSummaryTheme(); + _summaryIsBlue = savedSummary == 'blue'; + setState(() { // 既存伝票がある場合は初期値を上書き if (widget.existingInvoice != null) { @@ -91,15 +170,39 @@ class _InvoiceInputFormState extends State { _documentType = inv.documentType; _selectedDate = inv.date; _isDraft = inv.isDraft; + _currentId = inv.id; + _isLocked = inv.isLocked; if (inv.subject != null) _subjectController.text = inv.subject!; } else { _taxRate = 0; _includeTax = false; _isDraft = true; _documentType = widget.initialDocumentType; + _currentId = null; + _isLocked = false; } }); + _isViewMode = widget.startViewMode; // 指定に従う + _showNewBadge = widget.showNewBadge; + _showCopyBadge = widget.showCopyBadge; _pushHistory(clearRedo: true); + _lastLoggedSubject = _subjectController.text; + if (_currentId != null) { + _loadEditLogs(); + } + } + + @override + void dispose() { + _subjectFocusNode.dispose(); + super.dispose(); + } + + Future _loadEditLogs() async { + if (_currentId == null) return; + final logs = await _editLogRepo.getLogs(_currentId!); + if (!mounted) return; + setState(() => _editLogs = logs); } void _onSubjectChanged() { @@ -122,6 +225,9 @@ class _InvoiceInputFormState extends State { )); }); _pushHistory(); + final id = _ensureCurrentId(); + final msg = "商品「${product.name}」を追加しました"; + _editLogRepo.addLog(id, msg).then((_) => _loadEditLogs()); }); } @@ -143,8 +249,10 @@ class _InvoiceInputFormState extends State { await gpsService.logLocation(); // 履歴テーブルにも保存 } + final invoiceId = _ensureCurrentId(); + final invoice = Invoice( - id: widget.existingInvoice?.id, // 既存IDがあれば引き継ぐ + id: invoiceId, customer: _selectedCustomer!, date: _selectedDate, items: _items, @@ -166,6 +274,7 @@ class _InvoiceInputFormState extends State { if (path != null) { final updatedInvoice = invoice.copyWith(filePath: path); await _repository.saveInvoice(updatedInvoice); + _currentId = updatedInvoice.id; if (mounted) widget.onInvoiceGenerated(updatedInvoice, path); if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました"))); } else { @@ -173,9 +282,12 @@ class _InvoiceInputFormState extends State { } } else { await _repository.saveInvoice(invoice); + _currentId = invoice.id; if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しました(PDF未生成)"))); - if (mounted) Navigator.pop(context); } + await _editLogRepo.addLog(_currentId!, "伝票を保存しました"); + await _loadEditLogs(); + if (mounted) setState(() => _isViewMode = true); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e'))); @@ -187,7 +299,10 @@ class _InvoiceInputFormState extends State { void _showPreview() { if (_selectedCustomer == null) return; + final id = _ensureCurrentId(); + _editLogRepo.addLog(id, "PDFプレビューを開きました").then((_) => _loadEditLogs()); final invoice = Invoice( + id: id, customer: _selectedCustomer!, date: _selectedDate, // 修正 items: _items, @@ -195,6 +310,8 @@ class _InvoiceInputFormState extends State { documentType: _documentType, customerFormalNameSnapshot: _selectedCustomer!.formalName, notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", + isDraft: _isDraft, + isLocked: _isLocked, ); Navigator.push( @@ -203,34 +320,27 @@ class _InvoiceInputFormState extends State { builder: (context) => InvoicePdfPreviewPage( invoice: invoice, isUnlocked: true, - isLocked: false, - allowFormalIssue: widget.existingInvoice != null && !(widget.existingInvoice?.isLocked ?? false), - onFormalIssue: (widget.existingInvoice != null) + isLocked: _isLocked, + allowFormalIssue: invoice.isDraft && !_isLocked, + onFormalIssue: invoice.isDraft ? () async { - final promoted = invoice.copyWith(isDraft: false); + final promoted = invoice.copyWith(id: id, isDraft: false, isLocked: true); await _invoiceRepo.saveInvoice(promoted); final newPath = await generateInvoicePdf(promoted); final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted; await _invoiceRepo.saveInvoice(saved); + await _editLogRepo.addLog(_ensureCurrentId(), "正式発行しました"); if (!context.mounted) return false; - Navigator.pop(context); // close preview - Navigator.pop(context); // exit edit screen - if (!context.mounted) return false; - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => InvoiceDetailPage( - invoice: saved, - isUnlocked: true, - ), - ), - ); + setState(() { + _isDraft = false; + _isLocked = true; + }); return true; } : null, - showShare: false, - showEmail: false, - showPrint: false, + showShare: true, + showEmail: true, + showPrint: true, ), ), ); @@ -317,26 +427,58 @@ class _InvoiceInputFormState extends State { @override Widget build(BuildContext context) { final fmt = NumberFormat("#,###"); - final themeColor = Colors.white; + final themeColor = Theme.of(context).scaffoldBackgroundColor; final textColor = Colors.black87; + final docColor = _documentTypeColor(_documentType); + return Scaffold( backgroundColor: themeColor, resizeToAvoidBottomInset: false, appBar: AppBar( + backgroundColor: docColor, leading: const BackButton(), - title: const Text("A1:伝票入力"), + title: Text("A1:${_documentTypeLabel(_documentType)}"), actions: [ + if (_isDraft) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: _DraftBadge(), + ), IconButton( - icon: const Icon(Icons.undo), - onPressed: _canUndo ? _undo : null, - tooltip: "元に戻す", - ), - IconButton( - icon: const Icon(Icons.redo), - onPressed: _canRedo ? _redo : null, - tooltip: "やり直す", + icon: const Icon(Icons.copy), + tooltip: "コピーして新規", + onPressed: _copyAsNew, ), + if (_isLocked) + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Icon(Icons.lock, color: Colors.white), + ) + else if (_isViewMode) + IconButton( + icon: const Icon(Icons.edit), + tooltip: "編集モードにする", + onPressed: () => setState(() => _isViewMode = false), + ) + else ...[ + IconButton( + icon: const Icon(Icons.undo), + onPressed: _canUndo ? _undo : null, + tooltip: "元に戻す", + ), + IconButton( + icon: const Icon(Icons.redo), + onPressed: _canRedo ? _redo : null, + tooltip: "やり直す", + ), + if (!_isLocked) + IconButton( + icon: const Icon(Icons.save), + tooltip: "保存", + onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false), + ), + ], ], ), body: Stack( @@ -359,9 +501,9 @@ class _InvoiceInputFormState extends State { _buildItemsSection(fmt), const SizedBox(height: 20), _buildSummarySection(fmt), - const SizedBox(height: 20), - _buildSignatureSection(), const SizedBox(height: 12), + _buildEditLogsSection(), + const SizedBox(height: 20), ], ), ), @@ -391,62 +533,95 @@ class _InvoiceInputFormState extends State { Widget _buildDateSection() { final fmt = DateFormat('yyyy/MM/dd'); return GestureDetector( - onTap: () async { - final picked = await showDatePicker( - context: context, - initialDate: _selectedDate, - firstDate: DateTime(2000), - lastDate: DateTime(2100), - ); - if (picked != null) { - setState(() => _selectedDate = picked); - _pushHistory(); - } - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - child: Row( - children: [ - const Icon(Icons.calendar_today, size: 18, color: Colors.indigo), - const SizedBox(width: 8), - Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)), - const Spacer(), - const Icon(Icons.chevron_right, size: 18, color: Colors.indigo), - ], + onTap: _isViewMode + ? null + : () async { + final picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (picked != null) { + setState(() => _selectedDate = picked); + _pushHistory(); + } + }, + child: Align( + alignment: Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.calendar_today, size: 18, color: Colors.indigo), + const SizedBox(width: 8), + Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)), + if (_showNewBadge) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: const Text("新規", style: TextStyle(color: Colors.deepOrange, fontSize: 11, fontWeight: FontWeight.bold)), + ), + if (_showCopyBadge) + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: const Text("複写", style: TextStyle(color: Colors.blue, fontSize: 11, fontWeight: FontWeight.bold)), + ), + if (!_isViewMode && !_isLocked) ...[ + const SizedBox(width: 8), + const Icon(Icons.chevron_right, size: 18, color: Colors.indigo), + ], + ], + ), ), ), ); } Widget _buildCustomerSection() { - return Card( - elevation: 0, - color: Colors.blueGrey.shade50, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))], + ), child: ListTile( leading: const Icon(Icons.business, color: Colors.blueGrey), - title: Text(_selectedCustomer?.formalName ?? "取引先を選択してください", + title: Text( + _selectedCustomer != null ? _customerNameWithHonorific(_selectedCustomer!) : "取引先を選択してください", style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)), - subtitle: const Text("顧客マスターから選択"), // 修正 - trailing: const Icon(Icons.chevron_right), - onTap: () async { - final Customer? picked = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CustomerMasterScreen(selectionMode: true), - fullscreenDialog: true, - ), - ); - if (picked != null) { - setState(() => _selectedCustomer = picked); - _pushHistory(); - } - }, + subtitle: _isViewMode ? null : const Text("顧客マスターから選択"), + trailing: (_isViewMode || _isLocked) ? null : const Icon(Icons.chevron_right), + onTap: (_isViewMode || _isLocked) + ? null + : () async { + final Customer? picked = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CustomerMasterScreen(selectionMode: true), + fullscreenDialog: true, + ), + ); + if (picked != null) { + setState(() => _selectedCustomer = picked); + _pushHistory(); + } + }, ), ); } @@ -459,7 +634,8 @@ class _InvoiceInputFormState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")), + if (!_isViewMode && !_isLocked) + TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")), ], ), if (_items.isEmpty) @@ -467,18 +643,43 @@ class _InvoiceInputFormState extends State { padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))), ) + else if (_isViewMode) + Column( + children: _items + .map((item) => Card( + margin: const EdgeInsets.only(bottom: 6), + elevation: 0.5, + child: ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + title: Text(item.description, style: const TextStyle(fontSize: 13.5)), + subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)), + trailing: Text( + "¥${fmt.format(item.unitPrice * item.quantity)}", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5), + ), + ), + )) + .toList(), + ) else ReorderableListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _items.length, - onReorder: (oldIndex, newIndex) { + onReorder: (oldIndex, newIndex) async { + int targetIndex = newIndex; setState(() { - if (newIndex > oldIndex) newIndex -= 1; + if (targetIndex > oldIndex) targetIndex -= 1; final item = _items.removeAt(oldIndex); - _items.insert(newIndex, item); + _items.insert(targetIndex, item); }); _pushHistory(); + final id = _ensureCurrentId(); + final item = _items[targetIndex]; + final msg = "明細を並べ替えました: ${item.description} を ${oldIndex + 1} → ${targetIndex + 1}"; + await _editLogRepo.addLog(id, msg); + await _loadEditLogs(); }, buildDefaultDragHandles: false, itemBuilder: (context, idx) { @@ -487,84 +688,90 @@ class _InvoiceInputFormState extends State { key: ValueKey('item_${idx}_${item.description}'), index: idx, child: Card( - margin: const EdgeInsets.only(bottom: 8), + margin: const EdgeInsets.only(bottom: 6), + elevation: 0.5, child: ListTile( - title: Text(item.description), - subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}"), + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + title: Text(item.description, style: const TextStyle(fontSize: 13.5)), + subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}", style: const TextStyle(fontSize: 12.5)), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(width: 8), + if (!_isViewMode && !_isLocked) ...[ + IconButton( + icon: const Icon(Icons.remove, size: 18), + onPressed: () async { + if (item.quantity <= 1) return; + setState(() => _items[idx] = item.copyWith(quantity: item.quantity - 1)); + _pushHistory(); + final id = _ensureCurrentId(); + final msg = "${item.description} の数量を ${item.quantity - 1} に変更しました"; + await _editLogRepo.addLog(id, msg); + await _loadEditLogs(); + }, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + padding: EdgeInsets.zero, + ), + Text('${item.quantity}', style: const TextStyle(fontSize: 12.5)), + IconButton( + icon: const Icon(Icons.add, size: 18), + onPressed: () async { + setState(() => _items[idx] = item.copyWith(quantity: item.quantity + 1)); + _pushHistory(); + final id = _ensureCurrentId(); + final msg = "${item.description} の数量を ${item.quantity + 1} に変更しました"; + await _editLogRepo.addLog(id, msg); + await _loadEditLogs(); + }, + constraints: const BoxConstraints.tightFor(width: 28, height: 28), + padding: EdgeInsets.zero, + ), + ], + Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13.5)), + const SizedBox(width: 6), IconButton( - icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent), - onPressed: () { + icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent, size: 18), + onPressed: () async { + final removed = _items[idx]; setState(() => _items.removeAt(idx)); _pushHistory(); + final id = _ensureCurrentId(); + final msg = "商品「${removed.description}」を削除しました"; + await _editLogRepo.addLog(id, msg); + await _loadEditLogs(); }, tooltip: "削除", + constraints: const BoxConstraints.tightFor(width: 32, height: 32), + padding: EdgeInsets.zero, ), ], ), - onTap: () { - // 簡易編集ダイアログ(キーボードでせり上げない) - final descCtrl = TextEditingController(text: item.description); - final qtyCtrl = TextEditingController(text: item.quantity.toString()); - final priceCtrl = TextEditingController(text: item.unitPrice.toString()); - showDialog( - context: context, - builder: (context) { - final inset = MediaQuery.of(context).viewInsets.bottom; - return MediaQuery.removeViewInsets( - removeBottom: true, - context: context, - child: AlertDialog( - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - title: const Text("明細の編集"), - content: SingleChildScrollView( - padding: EdgeInsets.only(bottom: inset + 12), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField(controller: descCtrl, decoration: const InputDecoration(labelText: "品名 / 項目")), - TextField(controller: qtyCtrl, decoration: const InputDecoration(labelText: "数量"), keyboardType: TextInputType.number), - TextField(controller: priceCtrl, decoration: const InputDecoration(labelText: "単価"), keyboardType: TextInputType.number), - ], - ), - ), - actions: [ - TextButton.icon( - icon: const Icon(Icons.search, size: 18), - label: const Text("マスター参照"), - onPressed: () async { - Navigator.pop(context); // close edit dialog before jumping - await Navigator.push( - this.context, - MaterialPageRoute(builder: (_) => const ProductMasterScreen()), - ); - }, - ), - TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), - ElevatedButton( - onPressed: () { - setState(() { - _items[idx] = item.copyWith( - description: descCtrl.text, - quantity: int.tryParse(qtyCtrl.text) ?? item.quantity, - unitPrice: int.tryParse(priceCtrl.text) ?? item.unitPrice, - ); - }); - _pushHistory(); - Navigator.pop(context); - }, - child: const Text("更新"), - ), - ], - ), - ); - }, + onTap: () async { + if (_isViewMode || _isLocked) return; + final messenger = ScaffoldMessenger.of(context); + final product = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)), ); + if (product != null) { + if (!mounted) return; + final prevDesc = item.description; + setState(() { + _items[idx] = item.copyWith( + productId: product.id, + description: product.name, + unitPrice: product.defaultUnitPrice, + ); + }); + _pushHistory(); + final id = _ensureCurrentId(); + final msg = "商品を $prevDesc から ${product.name} に変更しました"; + await _editLogRepo.addLog(id, msg); + await _loadEditLogs(); + if (!mounted) return; + messenger.showSnackBar(SnackBar(content: Text(msg))); + } }, ), ), @@ -580,29 +787,65 @@ class _InvoiceInputFormState extends State { final int tax = _includeTax ? (subtotal * _taxRate).floor() : 0; final int total = subtotal + tax; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.indigo, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70), - if (tax > 0) ...[ - const Divider(color: Colors.white24), - _buildSummaryRow("消費税", "¥${formatter.format(tax)}", Colors.white70), - ], - const Divider(color: Colors.white24), - _buildSummaryRow( - tax > 0 ? "合計金額 (税込)" : "合計金額", - "¥${formatter.format(total)}", - Colors.white, - isTotal: true, + final useBlue = _summaryIsBlue; + final bgColor = useBlue ? Colors.indigo : Colors.white; + final borderColor = Colors.transparent; + final labelColor = useBlue ? Colors.white70 : Colors.black87; + final totalColor = useBlue ? Colors.white : Colors.black87; + final dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300; + + return GestureDetector( + onLongPress: () async { + final selected = await showModalBottomSheet( + 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); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: borderColor), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor), + if (tax > 0) ...[ + Divider(color: dividerColor), + _buildSummaryRow("消費税", "¥${formatter.format(tax)}", labelColor), + ], + Divider(color: dividerColor), + _buildSummaryRow( + tax > 0 ? "合計金額 (税込)" : "合計金額", + "¥${formatter.format(total)}", + totalColor, + isTotal: true, + ), + ], + ), ), ); } @@ -634,43 +877,6 @@ class _InvoiceInputFormState extends State { ); } - Widget _buildSignatureSection() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text("手書き署名 (実験的)", style: TextStyle(fontWeight: FontWeight.bold)), - TextButton(onPressed: () => setState(() => _signaturePath.clear()), child: const Text("クリア")), - ], - ), - Container( - height: 150, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.white, - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8), - ), - child: GestureDetector( - onPanUpdate: (details) { - setState(() { - RenderBox renderBox = context.findRenderObject() as RenderBox; - _signaturePath.add(renderBox.globalToLocal(details.globalPosition)); - }); - }, - onPanEnd: (details) => _signaturePath.add(null), - child: CustomPaint( - painter: SignaturePainter(_signaturePath), - size: Size.infinite, - ), - ), - ), - ], - ); - } - Widget _buildBottomActionBar() { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), @@ -697,16 +903,33 @@ class _InvoiceInputFormState extends State { ), const SizedBox(width: 8), Expanded( - child: ElevatedButton.icon( - onPressed: () => _saveInvoice(generatePdf: false), - icon: const Icon(Icons.save), - label: const Text("保存"), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - ), - ), + child: _isLocked + ? ElevatedButton.icon( + onPressed: null, + icon: const Icon(Icons.lock), + label: const Text("ロック中"), + ) + : (_isViewMode + ? ElevatedButton.icon( + onPressed: () => setState(() => _isViewMode = false), + icon: const Icon(Icons.edit), + label: const Text("編集"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ) + : ElevatedButton.icon( + onPressed: () => _saveInvoice(generatePdf: false), + icon: const Icon(Icons.save), + label: const Text("保存"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + ), + )), ), ], ), @@ -726,12 +949,16 @@ class _InvoiceInputFormState extends State { width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.grey.shade100, + color: Colors.white, borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))], ), child: TextField( + focusNode: _subjectFocusNode, controller: _subjectController, style: TextStyle(color: textColor), + readOnly: _isViewMode || _isLocked, + enableInteractiveSelection: !(_isViewMode || _isLocked), decoration: InputDecoration( hintText: "例:事務所改修工事 / 〇〇月分リース料", hintStyle: TextStyle(color: textColor.withAlpha((0.5 * 255).round())), @@ -744,6 +971,91 @@ class _InvoiceInputFormState extends State { ], ); } + + Widget _buildEditLogsSection() { + if (_currentId == null) { + return const SizedBox.shrink(); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 0.5, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: const [ + BoxShadow( + color: Color(0x22000000), + blurRadius: 10, + spreadRadius: -4, + offset: Offset(0, 2), + blurStyle: BlurStyle.inner, + ), + ], + ), + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("編集ログ (直近1週間)", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 8), + if (_editLogs.isEmpty) + const Text("編集ログはありません", style: TextStyle(color: Colors.grey, fontSize: 12)) + else + ..._editLogs.map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.circle, size: 6, color: Colors.grey), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + DateFormat('yyyy/MM/dd HH:mm').format(e.createdAt), + style: const TextStyle(fontSize: 11, color: Colors.black54), + ), + Text( + e.message, + style: const TextStyle(fontSize: 13), + ), + ], + ), + ), + ], + ), + )), + ], + ), + ), + ), + ], + ); + } +} + +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), + ), + ); + } } class _InvoiceSnapshot { @@ -767,25 +1079,3 @@ class _InvoiceSnapshot { required this.subject, }); } - -class SignaturePainter extends CustomPainter { - final List points; - SignaturePainter(this.points); - - @override - void paint(Canvas canvas, Size size) { - Paint paint = Paint() - ..color = Colors.black - ..strokeCap = StrokeCap.round - ..strokeWidth = 3.0; - - for (int i = 0; i < points.length - 1; i++) { - if (points[i] != null && points[i + 1] != null) { - canvas.drawLine(points[i]!, points[i + 1]!, paint); - } - } - } - - @override - bool shouldRepaint(SignaturePainter oldDelegate) => true; -} diff --git a/lib/screens/master_hub_page.dart b/lib/screens/master_hub_page.dart new file mode 100644 index 0000000..85b50eb --- /dev/null +++ b/lib/screens/master_hub_page.dart @@ -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( + 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, + }); +} diff --git a/lib/screens/product_master_screen.dart b/lib/screens/product_master_screen.dart index 4a83348..7055d3f 100644 --- a/lib/screens/product_master_screen.dart +++ b/lib/screens/product_master_screen.dart @@ -6,8 +6,9 @@ import 'barcode_scanner_screen.dart'; class ProductMasterScreen extends StatefulWidget { final bool selectionMode; + final bool showHidden; - const ProductMasterScreen({super.key, this.selectionMode = false}); + const ProductMasterScreen({super.key, this.selectionMode = false, this.showHidden = false}); @override State createState() => _ProductMasterScreenState(); @@ -30,7 +31,7 @@ class _ProductMasterScreenState extends State { Future _loadProducts() async { setState(() => _isLoading = true); - final products = await _productRepo.getAllProducts(); + final products = await _productRepo.getAllProducts(includeHidden: widget.showHidden); if (!mounted) return; setState(() { _products = products; @@ -47,6 +48,12 @@ class _ProductMasterScreenState extends State { (p.barcode?.toLowerCase().contains(query) ?? false) || (p.category?.toLowerCase().contains(query) ?? false); }).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 { ElevatedButton( onPressed: () { if (nameController.text.isEmpty) return; + final locked = product?.isLocked ?? false; + final newId = locked ? const Uuid().v4() : (product?.id ?? const Uuid().v4()); Navigator.pop( context, Product( - id: product?.id ?? const Uuid().v4(), + id: newId, name: nameController.text.trim(), defaultUnitPrice: int.tryParse(priceController.text) ?? 0, barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(), category: categoryController.text.isEmpty ? null : categoryController.text.trim(), stockQuantity: int.tryParse(stockController.text) ?? 0, odooId: product?.odooId, + isLocked: false, ), ); }, @@ -188,10 +198,19 @@ class _ProductMasterScreenState extends State { ], ), ), - 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})"), onTap: () { if (widget.selectionMode) { + if (p.isHidden) return; // safety: do not return hidden in selection Navigator.pop(context, p); } else { _showDetailPane(p); @@ -212,6 +231,16 @@ class _ProductMasterScreenState extends State { _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) ListTile( leading: const Icon(Icons.delete_outline, color: Colors.redAccent), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 652fbe1..8e88c29 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,7 +1,12 @@ +import 'dart:io'; import 'package:flutter/material.dart'; 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 'email_settings_screen.dart'; +import 'business_profile_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -10,28 +15,22 @@ class SettingsScreen extends StatefulWidget { State createState() => _SettingsScreenState(); } +// シンプルなアイコンマップ(拡張可) +const Map 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 { - // Company - 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; + final _appSettingsRepo = AppSettingsRepository(); // External sync (母艦システム「お局様」連携) final _externalHostCtrl = TextEditingController(); @@ -47,85 +46,55 @@ class _SettingsScreenState extends State { final _kanaKeyCtrl = TextEditingController(); final _kanaValCtrl = TextEditingController(); - // SharedPreferences keys - static const _kCompanyName = 'company_name'; - static const _kCompanyZip = 'company_zip'; - static const _kCompanyAddr = 'company_addr'; - static const _kCompanyTel = 'company_tel'; - static const _kCompanyReg = 'company_reg'; - 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'; + // Dashboard / Home + bool _homeDashboard = false; + bool _statusEnabled = true; + final _statusTextCtrl = TextEditingController(text: '工事中'); + List _menuItems = []; + bool _loadingAppSettings = true; static const _kExternalHost = 'external_host'; static const _kExternalPass = 'external_pass'; - static const _kCryptKey = 'test'; - static const _kBackupPath = 'backup_path'; @override 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(); _externalPassCtrl.dispose(); _backupPathCtrl.dispose(); _kanaKeyCtrl.dispose(); _kanaValCtrl.dispose(); + _statusTextCtrl.dispose(); super.dispose(); } Future _loadAll() async { 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(() { - _companyNameCtrl.text = prefs.getString(_kCompanyName) ?? ''; - _companyZipCtrl.text = prefs.getString(_kCompanyZip) ?? ''; - _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) ?? ''; + _externalHostCtrl.text = externalHost; + _externalPassCtrl.text = externalPass; - _staffNameCtrl.text = prefs.getString(_kStaffName) ?? ''; - _staffMailCtrl.text = prefs.getString(_kStaffMail) ?? ''; + _backupPathCtrl.text = backupPath; + _theme = theme; + }); - _smtpHostCtrl.text = prefs.getString(_kSmtpHost) ?? ''; - _smtpPortCtrl.text = prefs.getString(_kSmtpPort) ?? '587'; - _smtpUserCtrl.text = prefs.getString(_kSmtpUser) ?? ''; - _smtpPassCtrl.text = _decryptWithFallback(prefs.getString(_kSmtpPass) ?? ''); - _smtpTls = prefs.getBool(_kSmtpTls) ?? true; - _smtpBccCtrl.text = prefs.getString(_kSmtpBcc) ?? ''; - - _externalHostCtrl.text = prefs.getString(_kExternalHost) ?? ''; - _externalPassCtrl.text = prefs.getString(_kExternalPass) ?? ''; - - _backupPathCtrl.text = prefs.getString(_kBackupPath) ?? ''; + final homeMode = await _appSettingsRepo.getHomeMode(); + final statusEnabled = await _appSettingsRepo.getDashboardStatusEnabled(); + final statusText = await _appSettingsRepo.getDashboardStatusText(); + final menu = await _appSettingsRepo.getDashboardMenu(); + setState(() { + _homeDashboard = homeMode == 'dashboard'; + _statusEnabled = statusEnabled; + _statusTextCtrl.text = statusText; + _menuItems = menu; + _loadingAppSettings = false; }); } @@ -139,55 +108,156 @@ class _SettingsScreenState extends State { ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); } - Future _saveCompany() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kCompanyName, _companyNameCtrl.text); - await prefs.setString(_kCompanyZip, _companyZipCtrl.text); - await prefs.setString(_kCompanyAddr, _companyAddrCtrl.text); - await prefs.setString(_kCompanyTel, _companyTelCtrl.text); - 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 _saveAppSettings() async { + await _appSettingsRepo.setHomeMode(_homeDashboard ? 'dashboard' : 'invoice_history'); + await _appSettingsRepo.setDashboardStatusEnabled(_statusEnabled); + await _appSettingsRepo.setDashboardStatusText(_statusTextCtrl.text.trim().isEmpty ? '工事中' : _statusTextCtrl.text.trim()); + await _appSettingsRepo.setDashboardMenu(_menuItems); + _showSnackbar('ホーム/ダッシュボード設定を保存しました'); } - Future _saveStaff() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kStaffName, _staffNameCtrl.text); - await prefs.setString(_kStaffMail, _staffMailCtrl.text); - _showSnackbar('担当者情報を保存しました'); + Future _persistMenu() async { + await _appSettingsRepo.setDashboardMenu(_menuItems); } - Future _saveSmtp() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kSmtpHost, _smtpHostCtrl.text); - await prefs.setString(_kSmtpPort, _smtpPortCtrl.text); - await prefs.setString(_kSmtpUser, _smtpUserCtrl.text); - await prefs.setString(_kSmtpPass, _encrypt(_smtpPassCtrl.text)); - await prefs.setBool(_kSmtpTls, _smtpTls); - await prefs.setString(_kSmtpBcc, _smtpBccCtrl.text); - _showSnackbar('SMTP設定を保存しました'); + void _addMenuItem() async { + final titleCtrl = TextEditingController(); + String route = 'invoice_history'; + final iconCtrl = TextEditingController(text: 'list_alt'); + String? customIconPath; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('メニューを追加'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: 'タイトル')), + DropdownButtonFormField( + 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 _saveExternalSync() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kExternalHost, _externalHostCtrl.text); - await prefs.setString(_kExternalPass, _externalPassCtrl.text); + await _appSettingsRepo.setString(_kExternalHost, _externalHostCtrl.text); + await _appSettingsRepo.setString(_kExternalPass, _externalPassCtrl.text); _showSnackbar('外部同期設定を保存しました'); } Future _saveBackup() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_kBackupPath, _backupPathCtrl.text); + await _appSettingsRepo.setString(_kBackupPath, _backupPathCtrl.text); _showSnackbar('バックアップ設定を保存しました'); } void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装'); Future _loadKanaMap() async { - final prefs = await SharedPreferences.getInstance(); - final json = prefs.getString('customKanaMap'); + final json = await _appSettingsRepo.getString('customKanaMap'); if (json != null && json.isNotEmpty) { try { final Map decoded = jsonDecode(json); @@ -199,31 +269,10 @@ class _SettingsScreenState extends State { } Future _saveKanaMap() async { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString('customKanaMap', jsonEncode(_customKanaMap)); + await _appSettingsRepo.setString('customKanaMap', jsonEncode(_customKanaMap)); _showSnackbar('かなインデックスを保存しました'); } - String _encrypt(String plain) { - if (plain.isEmpty) return ''; - final pb = utf8.encode(plain); - final kb = utf8.encode(_kCryptKey); - final ob = List.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.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]); - return utf8.decode(pb); - } catch (_) { - return cipher; // 旧プレーンテキストも許容 - } - } - @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).viewInsets.bottom; @@ -247,33 +296,68 @@ class _SettingsScreenState extends State { physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.only(bottom: listBottomPadding), children: [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - margin: const EdgeInsets.only(bottom: 16), - decoration: BoxDecoration( - color: Colors.indigo.shade50, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.indigo.shade100), - ), - child: Row( + _section( + title: 'ホームモード / ダッシュボード', + subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.business, color: Colors.indigo, size: 28), - const SizedBox(width: 12), - const Expanded( - child: Text( - "自社情報を開く", - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo), - ), + SwitchListTile( + title: const Text('ホームをダッシュボードにする'), + value: _homeDashboard, + onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v), ), - ElevatedButton.icon( - onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CompanyInfoScreen())), - icon: const Icon(Icons.chevron_right), - label: const Text("詳細"), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + SwitchListTile( + title: const Text('ステータスを表示する'), + value: _statusEnabled, + onChanged: _loadingAppSettings ? null : (v) => setState(() => _statusEnabled = v), + ), + TextField( + controller: _statusTextCtrl, + 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 { ), _section( title: '自社情報', - subtitle: '会社名・住所・登録番号など', + subtitle: '会社・担当者・振込口座・電話帳取り込み', child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')), - TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')), - 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), + const Text('自社/担当者情報、振込口座設定、メールフッタをまとめて編集できます。'), + const SizedBox(height: 12), Row( children: [ OutlinedButton.icon( - icon: const Icon(Icons.upload_file), - label: const Text('画面で編集'), + icon: const Icon(Icons.info_outline), + label: const Text('旧画面 (税率/印影)'), onPressed: () async { await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen())); }, ), const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveCompany, + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.business), + label: const Text('自社情報ページを開く'), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (context) => const BusinessProfileScreen())); + }, + ), ), ], ), @@ -314,40 +396,25 @@ class _SettingsScreenState extends State { ), ), _section( - title: '担当者情報', - subtitle: '署名や連絡先(送信者情報)', + title: 'メール設定(SM画面へ)', + subtitle: 'SMTP・端末メーラー・BCC必須・ログ閲覧など', child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')), - TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')), - const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveStaff, - ), - ], - ), - ), - _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, + const Text('メール送信に関する設定は専用画面でまとめて編集できます。'), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: ElevatedButton.icon( + icon: const Icon(Icons.mail_outline), + label: const Text('メール設定を開く'), + onPressed: () async { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => const EmailSettingsScreen()), + ); + }, + ), ), ], ), @@ -413,7 +480,12 @@ class _SettingsScreenState extends State { ElevatedButton.icon( icon: const Icon(Icons.save), label: const Text('保存'), - onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'), + onPressed: () async { + await _appSettingsRepo.setTheme(_theme); + await AppThemeController.instance.setTheme(_theme); + if (!mounted) return; + _showSnackbar('テーマ設定を保存しました'); + }, ), ], ), diff --git a/lib/services/app_settings_repository.dart b/lib/services/app_settings_repository.dart new file mode 100644 index 0000000..3ecbe42 --- /dev/null +++ b/lib/services/app_settings_repository.dart @@ -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 _ensureTable() async { + final db = await _dbHelper.database; + await db.execute(''' + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + '''); + } + + Future getHomeMode() async { + final v = await _getValue(_kHomeMode); + return v ?? 'invoice_history'; + } + + Future setHomeMode(String mode) async { + await _setValue(_kHomeMode, mode); + } + + Future getDashboardStatusEnabled() async { + final v = await _getValue(_kDashboardStatusEnabled); + if (v == null) return true; // デフォルト表示ON + return v == '1' || v.toLowerCase() == 'true'; + } + + Future setDashboardStatusEnabled(bool enabled) async { + await _setValue(_kDashboardStatusEnabled, enabled ? '1' : '0'); + } + + Future getDashboardStatusText() async { + return await _getValue(_kDashboardStatusText) ?? '工事中'; + } + + Future setDashboardStatusText(String text) async { + await _setValue(_kDashboardStatusText, text); + } + + Future> 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)).toList(); + } + } catch (_) {} + return [DashboardMenuItem(id: 'a2', title: '伝票一覧', route: 'invoice_history', iconName: 'list_alt')]; + } + + Future setDashboardMenu(List items) async { + final raw = jsonEncode(items.map((e) => e.toJson()).toList()); + await _setValue(_kDashboardMenu, raw); + } + + Future getDashboardHistoryUnlocked() async => getBool(_kDashboardHistoryUnlocked, defaultValue: false); + Future setDashboardHistoryUnlocked(bool unlocked) async => setBool(_kDashboardHistoryUnlocked, unlocked); + + Future getTheme() async => await getString(_kTheme) ?? 'system'; + Future setTheme(String theme) async => setString(_kTheme, theme); + + Future getSummaryTheme() async => await getString(_kSummaryTheme) ?? 'white'; + Future setSummaryTheme(String theme) async => setString(_kSummaryTheme, theme); + + // Generic helpers + Future getString(String key) async => _getValue(key); + Future setString(String key, String value) async => _setValue(key, value); + + Future getBool(String key, {bool defaultValue = false}) async { + final v = await _getValue(key); + if (v == null) return defaultValue; + return v == '1' || v.toLowerCase() == 'true'; + } + + Future setBool(String key, bool value) async => _setValue(key, value ? '1' : '0'); + + Future _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 _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 toJson() => { + 'id': id, + 'title': title, + 'route': route, + 'iconName': iconName, + 'customIconPath': customIconPath, + }; + + factory DashboardMenuItem.fromJson(Map 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?, + ); + } +} diff --git a/lib/services/company_profile_service.dart b/lib/services/company_profile_service.dart new file mode 100644 index 0000000..9af7554 --- /dev/null +++ b/lib/services/company_profile_service.dart @@ -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 toJson() => { + 'bankName': bankName, + 'branchName': branchName, + 'accountType': accountType, + 'accountNumber': accountNumber, + 'holderName': holderName, + 'isActive': isActive, + }; + + factory CompanyBankAccount.fromJson(Map 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 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? 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? 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 loadProfile() async { + final prefs = await SharedPreferences.getInstance(); + + Future 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 saveProfile(CompanyProfile profile) async { + final prefs = await SharedPreferences.getInstance(); + + Future 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> 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 _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.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 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(); + } +} diff --git a/lib/services/customer_repository.dart b/lib/services/customer_repository.dart index 5b1d67b..c268c3c 100644 --- a/lib/services/customer_repository.dart +++ b/lib/services/customer_repository.dart @@ -9,21 +9,28 @@ class CustomerRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); final ActivityLogRepository _logRepo = ActivityLogRepository(); - Future> getAllCustomers() async { + Future> getAllCustomers({bool includeHidden = false}) async { final db = await _dbHelper.database; + final filter = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, c.is_hidden, 0) = 0'; List> 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 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) { await _generateSampleCustomers(limit: 3); 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 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])); @@ -128,14 +135,17 @@ class CustomerRepository { ); } - Future> searchCustomers(String query) async { + Future> searchCustomers(String query, {bool includeHidden = false}) async { final db = await _dbHelper.database; + final where = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, c.is_hidden, 0) = 0'; final List> 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 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 ? - ORDER BY c.display_name ASC + LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id + WHERE (c.display_name LIKE ? OR c.formal_name LIKE ?) $where + ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'} LIMIT 50 ''', ['%$query%', '%$query%']); return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); @@ -173,6 +183,25 @@ class CustomerRepository { return CustomerContact.fromMap(rows.first); } + Future 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 _nextContactVersion(DatabaseExecutor txn, String customerId) async { final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]); final current = res.first['v'] as int?; diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 7b03721..974bc23 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; class DatabaseHelper { - static const _databaseVersion = 20; + static const _databaseVersion = 25; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -164,6 +164,37 @@ class DatabaseHelper { if (oldVersion < 20) { 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 _onCreate(Database db, int version) async { @@ -182,6 +213,7 @@ class DatabaseHelper { head_char1 TEXT, head_char2 TEXT, is_locked INTEGER DEFAULT 0, + is_hidden INTEGER DEFAULT 0, is_synced INTEGER DEFAULT 0, updated_at TEXT NOT NULL ) @@ -223,12 +255,23 @@ class DatabaseHelper { category TEXT, stock_quantity INTEGER DEFAULT 0, is_locked INTEGER DEFAULT 0, + is_hidden INTEGER DEFAULT 0, odoo_id TEXT ) '''); await db.execute('CREATE INDEX idx_products_name ON products(name)'); await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)'); + await db.execute(''' + 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(''' CREATE TABLE invoices ( @@ -255,6 +298,10 @@ class DatabaseHelper { contact_email_snapshot TEXT, contact_tel_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) ) '''); @@ -305,6 +352,13 @@ class DatabaseHelper { timestamp TEXT NOT NULL ) '''); + + await db.execute(''' + CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ) + '''); } Future _safeAddColumn(Database db, String table, String columnDef) async { diff --git a/lib/services/edit_log_repository.dart b/lib/services/edit_log_repository.dart new file mode 100644 index 0000000..4ce45ae --- /dev/null +++ b/lib/services/edit_log_repository.dart @@ -0,0 +1,60 @@ +import 'database_helper.dart'; + +class EditLogRepository { + final DatabaseHelper _dbHelper = DatabaseHelper(); + + Future _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 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> 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}); +} diff --git a/lib/services/email_sender.dart b/lib/services/email_sender.dart new file mode 100644 index 0000000..776b50f --- /dev/null +++ b/lib/services/email_sender.dart @@ -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 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 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.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]); + return utf8.decode(pb); + } catch (_) { + return cipher; + } + } + + static Future _appendLog(String line) async { + final prefs = await SharedPreferences.getInstance(); + final now = DateTime.now().toIso8601String(); + final entry = '[$now] $line'; + final existing = List.from(prefs.getStringList(_kLogsKey) ?? const []); + existing.add(entry); + if (existing.length > _kMaxLogLines) { + final dropCount = existing.length - _kMaxLogLines; + existing.removeRange(0, dropCount); + } + await prefs.setStringList(_kLogsKey, existing); + } + + static Future> loadLogs() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getStringList(_kLogsKey) ?? []; + } + + static Future clearLogs() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_kLogsKey); + } + + static Future _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 _checkAndLogConfig({required EmailSenderConfig config, required String channel}) async { + final checks = { + '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 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 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 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 logDeviceMailer({ + required bool success, + required String toEmail, + required List 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()); + } +} diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index da4168f..c3c8bcd 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -1,4 +1,6 @@ import 'dart:io'; +import 'dart:convert'; +import 'package:crypto/crypto.dart'; import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; import '../models/invoice_models.dart'; @@ -6,15 +8,38 @@ import '../models/customer_model.dart'; import '../models/customer_contact.dart'; import 'database_helper.dart'; import 'activity_log_repository.dart'; +import 'company_repository.dart'; class InvoiceRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); final ActivityLogRepository _logRepo = ActivityLogRepository(); + final CompanyRepository _companyRepo = CompanyRepository(); Future saveInvoice(Invoice invoice) async { 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); await db.transaction((txn) async { @@ -29,6 +54,10 @@ class InvoiceRepository { contactEmailSnapshot: activeContact?.email, contactTelSnapshot: activeContact?.tel, contactAddressSnapshot: activeContact?.address, + companySnapshot: companySnapshot, + companySealHash: sealHash, + metaJson: null, + metaHash: null, ); // 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く) @@ -150,6 +179,10 @@ class InvoiceRepository { contactEmailSnapshot: iMap['contact_email_snapshot'], contactTelSnapshot: iMap['contact_tel_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; @@ -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 verifyInvoiceMetaById(String id, List 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> getMonthlySales(int year) async { final db = await _dbHelper.database; final String yearStr = year.toString(); diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index 136c3c0..4ce4220 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -11,7 +11,15 @@ import 'activity_log_repository.dart'; /// PDFドキュメントの構築(プレビューと実保存の両方で使用) Future 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 ipaex = pw.Font.ttf(fontData); @@ -221,7 +229,7 @@ Future buildInvoiceDocument(Invoice invoice) async { pw.Container( width: 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), ), ], ), diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart index dfb6cf5..53c55ab 100644 --- a/lib/services/product_repository.dart +++ b/lib/services/product_repository.dart @@ -8,27 +8,38 @@ class ProductRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); final ActivityLogRepository _logRepo = ActivityLogRepository(); - Future> getAllProducts() async { + Future> getAllProducts({bool includeHidden = false}) async { final db = await _dbHelper.database; - final List> maps = await db.query('products', orderBy: 'name ASC'); - + final String where = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, p.is_hidden, 0) = 0'; + final List> 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) { await _generateSampleProducts(); - return getAllProducts(); + return getAllProducts(includeHidden: includeHidden); } - + return List.generate(maps.length, (i) => Product.fromMap(maps[i])); } - Future> searchProducts(String query) async { + Future> searchProducts(String query, {bool includeHidden = false}) async { final db = await _dbHelper.database; - final List> maps = await db.query( - 'products', - where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?', - whereArgs: ['%$query%', '%$query%', '%$query%'], - orderBy: 'name ASC', - limit: 50, - ); + final args = ['%$query%', '%$query%', '%$query%']; + final String whereHidden = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, p.is_hidden, 0) = 0'; + final List> 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 (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])); } @@ -81,4 +92,23 @@ class ProductRepository { details: "商品を削除しました", ); } + + Future 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 ? "商品を非表示にしました" : "商品を再表示しました", + ); + } } diff --git a/lib/services/theme_controller.dart b/lib/services/theme_controller.dart new file mode 100644 index 0000000..da0b97d --- /dev/null +++ b/lib/services/theme_controller.dart @@ -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 notifier = ValueNotifier(ThemeMode.system); + + Future load() async { + final theme = await _repo.getTheme(); + notifier.value = _toMode(theme); + } + + Future 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; + } + } +} diff --git a/lib/utils/build_expiry_info.dart b/lib/utils/build_expiry_info.dart new file mode 100644 index 0000000..1525f3f --- /dev/null +++ b/lib/utils/build_expiry_info.dart @@ -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()); + } +} diff --git a/lib/widgets/contact_picker_sheet.dart b/lib/widgets/contact_picker_sheet.dart new file mode 100644 index 0000000..80ccbb8 --- /dev/null +++ b/lib/widgets/contact_picker_sheet.dart @@ -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 contacts; + final String title; + + @override + State createState() => _ContactPickerSheetState(); +} + +class _ContactPickerSheetState extends State { + late List _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), + ); + }, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/invoice_pdf_preview_page.dart b/lib/widgets/invoice_pdf_preview_page.dart index 3e53565..b66a79a 100644 --- a/lib/widgets/invoice_pdf_preview_page.dart +++ b/lib/widgets/invoice_pdf_preview_page.dart @@ -1,13 +1,19 @@ -import 'dart:typed_data'; import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; import 'package:flutter/material.dart'; -import 'package:printing/printing.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:flutter_email_sender/flutter_email_sender.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 { final Invoice invoice; @@ -39,24 +45,17 @@ class InvoicePdfPreviewPage extends StatelessWidget { Future _sendEmail(BuildContext context) async { try { final prefs = await SharedPreferences.getInstance(); - final host = prefs.getString('smtp_host') ?? ''; - 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 mailMethod = normalizeMailSendMethod(prefs.getString(kMailSendMethodPrefKey)); 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) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください'))); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('BCCは必須項目です(設定画面で登録してください)'))); } 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; if (toEmail == null || toEmail.isEmpty) { if (context.mounted) { @@ -66,18 +65,71 @@ class InvoicePdfPreviewPage extends StatelessWidget { } final bytes = await _buildPdfBytes(); + final fileName = invoice.mailAttachmentFileName; final tempDir = await getTemporaryDirectory(); - final file = File('${tempDir.path}/invoice.pdf'); + final file = File('${tempDir.path}/$fileName'); await file.writeAsBytes(bytes, flush: true); - final message = Message() - ..from = Address(user) - ..recipients = [toEmail] - ..bccRecipients = bccList - ..subject = '請求書送付' - ..text = '請求書をお送りします。ご確認ください。' - ..attachments = [FileAttachment(file)..fileName = 'invoice.pdf'..contentType = 'application/pdf']; + final hash = sha256.convert(bytes).toString(); + final headerTemplate = prefs.getString(kMailHeaderTextKey) ?? kMailHeaderTemplateDefault; + final footerTemplate = prefs.getString(kMailFooterTextKey) ?? kMailFooterTemplateDefault; + final placeholderMap = await CompanyProfileService().buildMailPlaceholderMap(filename: fileName, hash: hash); + final header = applyMailTemplate(headerTemplate, placeholderMap); + final footer = applyMailTemplate(footerTemplate, placeholderMap); + 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) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました'))); } @@ -92,7 +144,15 @@ class InvoicePdfPreviewPage extends StatelessWidget { Widget build(BuildContext context) { final isDraft = invoice.isDraft; 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( children: [ Expanded( @@ -121,17 +181,28 @@ class InvoicePdfPreviewPage extends StatelessWidget { } : null, 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), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton.icon( - onPressed: showShare + onPressed: (showShare && (!isDraft || isLocked)) ? () async { final bytes = await _buildPdfBytes(); - await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf'); + final fileName = invoice.mailAttachmentFileName; + await Printing.sharePdf(bytes: bytes, filename: fileName); } : null, icon: const Icon(Icons.share), @@ -141,7 +212,7 @@ class InvoicePdfPreviewPage extends StatelessWidget { const SizedBox(width: 8), Expanded( child: ElevatedButton.icon( - onPressed: showEmail + onPressed: (showEmail && (!isDraft || isLocked)) ? () async { await _sendEmail(context); } diff --git a/pubspec.lock b/pubspec.lock index e717202..20bc2ce 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -190,6 +190,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 08da273..918e55c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -53,6 +53,7 @@ dependencies: printing: ^5.14.2 shared_preferences: ^2.2.2 mailer: ^6.0.1 + flutter_email_sender: ^6.0.3 dev_dependencies: flutter_test: diff --git a/scripts/build_with_expiry.sh b/scripts/build_with_expiry.sh new file mode 100755 index 0000000..7250ca1 --- /dev/null +++ b/scripts/build_with_expiry.sh @@ -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."