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