smtp実装と寿命実装
This commit is contained in:
parent
2ec25371a6
commit
01f5851ddc
40 changed files with 4495 additions and 874 deletions
|
|
@ -28,6 +28,10 @@
|
|||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@
|
|||
@import flutter_contacts;
|
||||
#endif
|
||||
|
||||
#if __has_include(<flutter_email_sender/FlutterEmailSenderPlugin.h>)
|
||||
#import <flutter_email_sender/FlutterEmailSenderPlugin.h>
|
||||
#else
|
||||
@import flutter_email_sender;
|
||||
#endif
|
||||
|
||||
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
|
||||
#import <geolocator_apple/GeolocatorPlugin.h>
|
||||
#else
|
||||
|
|
@ -82,6 +88,7 @@
|
|||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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"]];
|
||||
|
|
|
|||
35
lib/config/app_config.dart
Normal file
35
lib/config/app_config.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/// アプリ全体のバージョンと機能フラグを集中管理する設定クラス。
|
||||
/// - バージョンや機能フラグは --dart-define で上書き可能。
|
||||
/// - プレイストア公開やベータ配信時の切り替えを容易にする。
|
||||
class AppConfig {
|
||||
/// アプリのバージョン(ビルド時に --dart-define=APP_VERSION=... で上書き可能)。
|
||||
static const String version = String.fromEnvironment('APP_VERSION', defaultValue: '1.0.0');
|
||||
|
||||
/// 機能フラグ(ビルド時に --dart-define で上書き可能)。
|
||||
static const bool enableBillingDocs = bool.fromEnvironment('ENABLE_BILLING_DOCS', defaultValue: true);
|
||||
static const bool enableSalesManagement = bool.fromEnvironment('ENABLE_SALES_MANAGEMENT', defaultValue: false);
|
||||
|
||||
/// APIエンドポイント(必要に応じて dart-define で注入)。
|
||||
static const String apiEndpoint = String.fromEnvironment('API_ENDPOINT', defaultValue: '');
|
||||
|
||||
/// 機能フラグの一覧(UIなどで表示する用途向け)。
|
||||
static Map<String, bool> get features => {
|
||||
'enableBillingDocs': enableBillingDocs,
|
||||
'enableSalesManagement': enableSalesManagement,
|
||||
};
|
||||
|
||||
/// 機能キーで有効/無効を判定するヘルパー。
|
||||
static bool isFeatureEnabled(String key) => features[key] ?? false;
|
||||
|
||||
/// 有効なダッシュボードルート一覧(動的に増える場合はここで管理)。
|
||||
static Set<String> get enabledRoutes {
|
||||
final routes = <String>{'settings'};
|
||||
if (enableBillingDocs) {
|
||||
routes.addAll({'invoice_history', 'invoice_input', 'master_hub', 'customer_master', 'product_master'});
|
||||
}
|
||||
if (enableSalesManagement) {
|
||||
routes.add('sales_management');
|
||||
}
|
||||
return routes;
|
||||
}
|
||||
}
|
||||
22
lib/constants/company_profile_keys.dart
Normal file
22
lib/constants/company_profile_keys.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const String kCompanyNameKey = 'company_name';
|
||||
const String kCompanyZipKey = 'company_zip';
|
||||
const String kCompanyAddressKey = 'company_addr';
|
||||
const String kCompanyTelKey = 'company_tel';
|
||||
const String kCompanyFaxKey = 'company_fax';
|
||||
const String kCompanyEmailKey = 'company_email';
|
||||
const String kCompanyUrlKey = 'company_url';
|
||||
const String kCompanyRegKey = 'company_reg';
|
||||
|
||||
const String kStaffNameKey = 'staff_name';
|
||||
const String kStaffEmailKey = 'staff_mail';
|
||||
const String kStaffMobileKey = 'staff_mobile';
|
||||
|
||||
const String kCompanyBankAccountsKey = 'company_bank_accounts';
|
||||
const String kCompanyTaxRateKey = 'company_tax_rate';
|
||||
const String kCompanyTaxDisplayModeKey = 'company_tax_display_mode';
|
||||
const String kCompanySealPathKey = 'company_seal_path';
|
||||
|
||||
const int kCompanyBankSlotCount = 4;
|
||||
const int kCompanyBankActiveLimit = 2;
|
||||
|
||||
const List<String> kAccountTypeOptions = ['普通', '当座', '貯蓄'];
|
||||
10
lib/constants/mail_send_method.dart
Normal file
10
lib/constants/mail_send_method.dart
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
const String kMailSendMethodPrefKey = 'mail_send_method';
|
||||
const String kMailSendMethodSmtp = 'smtp';
|
||||
const String kMailSendMethodDeviceMailer = 'device_mailer';
|
||||
|
||||
String normalizeMailSendMethod(String? value) {
|
||||
if (value == kMailSendMethodDeviceMailer) {
|
||||
return kMailSendMethodDeviceMailer;
|
||||
}
|
||||
return kMailSendMethodSmtp;
|
||||
}
|
||||
32
lib/constants/mail_templates.dart
Normal file
32
lib/constants/mail_templates.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
const String kMailPlaceholderFilename = '{{FILENAME}}';
|
||||
const String kMailPlaceholderHash = '{{HASH}}';
|
||||
const String kMailPlaceholderCompanyName = '{{COMPANY_NAME}}';
|
||||
const String kMailPlaceholderCompanyEmail = '{{COMPANY_EMAIL}}';
|
||||
const String kMailPlaceholderCompanyTel = '{{COMPANY_TEL}}';
|
||||
const String kMailPlaceholderCompanyAddress = '{{COMPANY_ADDRESS}}';
|
||||
const String kMailPlaceholderCompanyReg = '{{COMPANY_REG}}';
|
||||
const String kMailPlaceholderStaffName = '{{STAFF_NAME}}';
|
||||
const String kMailPlaceholderStaffEmail = '{{STAFF_EMAIL}}';
|
||||
const String kMailPlaceholderStaffMobile = '{{STAFF_MOBILE}}';
|
||||
const String kMailPlaceholderBankAccounts = '{{BANK_ACCOUNTS}}';
|
||||
const String kMailPlaceholderAccountsList = '{{ACCOUNTS}}';
|
||||
|
||||
const String kMailTemplateIdDefault = 'default';
|
||||
const String kMailTemplateIdNone = 'none';
|
||||
|
||||
const String kMailHeaderTemplateKey = 'mail_header_template';
|
||||
const String kMailFooterTemplateKey = 'mail_footer_template';
|
||||
const String kMailHeaderTextKey = 'mail_header_text';
|
||||
const String kMailFooterTextKey = 'mail_footer_text';
|
||||
|
||||
const String kMailHeaderTemplateDefault = '【請求書送付のお知らせ】\nファイル名: $kMailPlaceholderFilename\nHASH: $kMailPlaceholderHash';
|
||||
const String kMailFooterTemplateDefault =
|
||||
'---\n$kMailPlaceholderCompanyName\n$kMailPlaceholderCompanyAddress\nTEL: $kMailPlaceholderCompanyTel / MAIL: $kMailPlaceholderCompanyEmail\n担当: $kMailPlaceholderStaffName ($kMailPlaceholderStaffMobile) $kMailPlaceholderStaffEmail\n$kMailPlaceholderBankAccounts\n登録番号: $kMailPlaceholderCompanyReg\nファイル名: $kMailPlaceholderFilename\nHASH: $kMailPlaceholderHash';
|
||||
|
||||
String applyMailTemplate(String template, Map<String, String> values) {
|
||||
var result = template;
|
||||
values.forEach((placeholder, value) {
|
||||
result = result.replaceAll(placeholder, value);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
224
lib/main.dart
224
lib/main.dart
|
|
@ -1,34 +1,59 @@
|
|||
// 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<MyApp> createState() => _MyAppState();
|
||||
}
|
||||
|
||||
class _MyAppState extends State<MyApp> {
|
||||
final TransformationController _zoomController = TransformationController();
|
||||
int _activePointers = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
return ValueListenableBuilder<ThemeMode>(
|
||||
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.shade50,
|
||||
surface: Colors.grey.shade100,
|
||||
onSurface: Colors.blueGrey.shade900,
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.grey.shade50,
|
||||
scaffoldBackgroundColor: Colors.grey.shade100,
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: Colors.indigo.shade700,
|
||||
foregroundColor: Colors.white,
|
||||
|
|
@ -60,9 +85,67 @@ class MyApp extends StatelessWidget {
|
|||
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 GestureDetector(
|
||||
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(
|
||||
|
|
@ -74,12 +157,139 @@ class MyApp extends StatelessWidget {
|
|||
scaleEnabled: true,
|
||||
minScale: 0.8,
|
||||
maxScale: 4.0,
|
||||
transformationController: _zoomController,
|
||||
child: IgnorePointer(
|
||||
ignoring: _activePointers > 1,
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
home: const InvoiceHistoryScreen(),
|
||||
home: const _HomeDecider(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExpiredApp extends StatelessWidget {
|
||||
final BuildExpiryInfo expiryInfo;
|
||||
const ExpiredApp({super.key, required this.expiryInfo});
|
||||
|
||||
String _format(DateTime? timestamp) {
|
||||
if (timestamp == null) return '不明';
|
||||
final local = timestamp.toLocal();
|
||||
String two(int v) => v.toString().padLeft(2, '0');
|
||||
return '${local.year}/${two(local.month)}/${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final buildText = _format(expiryInfo.buildTimestamp);
|
||||
final expiryText = _format(expiryInfo.expiryTimestamp);
|
||||
return MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.lock_clock, size: 72, color: Colors.white),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'このビルドは有効期限を過ぎています',
|
||||
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('ビルド日時: $buildText', style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 4),
|
||||
Text('有効期限: $expiryText', style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'最新版を取得してインストールしてください。',
|
||||
style: TextStyle(color: Colors.white70),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.black87),
|
||||
onPressed: () => SystemNavigator.pop(),
|
||||
icon: const Icon(Icons.exit_to_app),
|
||||
label: const Text('アプリを終了する'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomResetObserver extends NavigatorObserver {
|
||||
final TransformationController controller;
|
||||
_ZoomResetObserver(this.controller);
|
||||
|
||||
void _reset() {
|
||||
controller.value = Matrix4.identity();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previousRoute) {
|
||||
super.didPush(route, previousRoute);
|
||||
_reset();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPop(Route route, Route? previousRoute) {
|
||||
super.didPop(route, previousRoute);
|
||||
_reset();
|
||||
}
|
||||
|
||||
@override
|
||||
void didReplace({Route? newRoute, Route? oldRoute}) {
|
||||
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||
_reset();
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeDecider extends StatefulWidget {
|
||||
const _HomeDecider();
|
||||
|
||||
@override
|
||||
State<_HomeDecider> createState() => _HomeDeciderState();
|
||||
}
|
||||
|
||||
class _HomeDeciderState extends State<_HomeDecider> {
|
||||
final _settings = AppSettingsRepository();
|
||||
late Future<String> _homeFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_homeFuture = _settings.getHomeMode();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<String>(
|
||||
future: _homeFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
final mode = snapshot.data ?? 'invoice_history';
|
||||
if (mode == 'dashboard') {
|
||||
return const DashboardScreen();
|
||||
}
|
||||
return const InvoiceHistoryScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<DocumentType, String> _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<String, dynamic> metaPayload() {
|
||||
return {
|
||||
'id': id,
|
||||
'invoiceNumber': invoiceNumber,
|
||||
'customer': customerNameForDisplay,
|
||||
'date': date.toIso8601String(),
|
||||
'total': totalAmount,
|
||||
'documentType': documentType.name,
|
||||
'hash': contentHash,
|
||||
'lockStatement': lockStatement,
|
||||
'hashDescription': hashDescription,
|
||||
'companySnapshot': companySnapshot,
|
||||
'companySealHash': companySealHash,
|
||||
};
|
||||
}
|
||||
|
||||
String get metaJsonValue => metaJson ?? jsonEncode(metaPayload());
|
||||
|
||||
String get metaHashValue => metaHash ?? sha256.convert(utf8.encode(metaJsonValue)).toString();
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, dynamic> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
471
lib/screens/business_profile_screen.dart
Normal file
471
lib/screens/business_profile_screen.dart
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../constants/company_profile_keys.dart';
|
||||
// NOTE: mail template placeholders may rely on fields edited here.
|
||||
import '../models/company_model.dart';
|
||||
import '../services/company_profile_service.dart';
|
||||
import '../services/company_repository.dart';
|
||||
import '../widgets/contact_picker_sheet.dart';
|
||||
import '../widgets/keyboard_inset_wrapper.dart';
|
||||
|
||||
class BusinessProfileScreen extends StatefulWidget {
|
||||
const BusinessProfileScreen({super.key});
|
||||
|
||||
@override
|
||||
State<BusinessProfileScreen> createState() => _BusinessProfileScreenState();
|
||||
}
|
||||
|
||||
class _BusinessProfileScreenState extends State<BusinessProfileScreen> {
|
||||
final _service = CompanyProfileService();
|
||||
final _companyRepo = CompanyRepository();
|
||||
final _companyNameCtrl = TextEditingController();
|
||||
final _companyZipCtrl = TextEditingController();
|
||||
final _companyAddrCtrl = TextEditingController();
|
||||
final _companyTelCtrl = TextEditingController();
|
||||
final _companyFaxCtrl = TextEditingController();
|
||||
final _companyEmailCtrl = TextEditingController();
|
||||
final _companyUrlCtrl = TextEditingController();
|
||||
final _companyRegCtrl = TextEditingController();
|
||||
final _staffNameCtrl = TextEditingController();
|
||||
final _staffEmailCtrl = TextEditingController();
|
||||
final _staffMobileCtrl = TextEditingController();
|
||||
|
||||
final List<_BankControllers> _bankCtrls = List.generate(
|
||||
kCompanyBankSlotCount,
|
||||
(_) => _BankControllers(),
|
||||
);
|
||||
|
||||
bool _loading = true;
|
||||
double _taxRate = 0.10;
|
||||
String _taxDisplayMode = 'normal';
|
||||
String? _sealPath;
|
||||
CompanyInfo? _legacyInfo;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final profile = await _service.loadProfile();
|
||||
final legacyInfo = await _companyRepo.getCompanyInfo();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_companyNameCtrl.text = profile.companyName.isNotEmpty ? profile.companyName : legacyInfo.name;
|
||||
_companyZipCtrl.text = profile.companyZip.isNotEmpty ? profile.companyZip : (legacyInfo.zipCode ?? '');
|
||||
_companyAddrCtrl.text = profile.companyAddress.isNotEmpty ? profile.companyAddress : (legacyInfo.address ?? '');
|
||||
_companyTelCtrl.text = profile.companyTel.isNotEmpty ? profile.companyTel : (legacyInfo.tel ?? '');
|
||||
_companyFaxCtrl.text = profile.companyFax.isNotEmpty ? profile.companyFax : (legacyInfo.fax ?? '');
|
||||
_companyEmailCtrl.text = profile.companyEmail.isNotEmpty ? profile.companyEmail : (legacyInfo.email ?? '');
|
||||
_companyUrlCtrl.text = profile.companyUrl.isNotEmpty ? profile.companyUrl : (legacyInfo.url ?? '');
|
||||
_companyRegCtrl.text = profile.companyReg.isNotEmpty ? profile.companyReg : (legacyInfo.registrationNumber ?? '');
|
||||
_staffNameCtrl.text = profile.staffName;
|
||||
_staffEmailCtrl.text = profile.staffEmail;
|
||||
_staffMobileCtrl.text = profile.staffMobile;
|
||||
for (var i = 0; i < _bankCtrls.length; i++) {
|
||||
final ctrl = _bankCtrls[i];
|
||||
if (i < profile.bankAccounts.length) {
|
||||
final acc = profile.bankAccounts[i];
|
||||
ctrl.bankName.text = acc.bankName;
|
||||
ctrl.branchName.text = acc.branchName;
|
||||
ctrl.accountType = acc.accountType;
|
||||
ctrl.accountNumber.text = acc.accountNumber;
|
||||
ctrl.holderName.text = acc.holderName;
|
||||
ctrl.isActive = acc.isActive;
|
||||
}
|
||||
}
|
||||
_taxRate = legacyInfo.defaultTaxRate;
|
||||
_taxDisplayMode = legacyInfo.taxDisplayMode;
|
||||
_sealPath = legacyInfo.sealPath;
|
||||
_legacyInfo = legacyInfo;
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final accounts = _bankCtrls
|
||||
.map(
|
||||
(c) => CompanyBankAccount(
|
||||
bankName: c.bankName.text,
|
||||
branchName: c.branchName.text,
|
||||
accountType: c.accountType,
|
||||
accountNumber: c.accountNumber.text,
|
||||
holderName: c.holderName.text,
|
||||
isActive: c.isActive,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
final activeCount = accounts.where((a) => a.isActive && a.bankName.trim().isNotEmpty).length;
|
||||
if (activeCount > kCompanyBankActiveLimit) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('振込口座は最大$kCompanyBankActiveLimit件まで有効化できます')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final profile = CompanyProfile(
|
||||
companyName: _companyNameCtrl.text.trim(),
|
||||
companyZip: _companyZipCtrl.text.trim(),
|
||||
companyAddress: _companyAddrCtrl.text.trim(),
|
||||
companyTel: _companyTelCtrl.text.trim(),
|
||||
companyFax: _companyFaxCtrl.text.trim(),
|
||||
companyEmail: _companyEmailCtrl.text.trim(),
|
||||
companyUrl: _companyUrlCtrl.text.trim(),
|
||||
companyReg: _companyRegCtrl.text.trim(),
|
||||
staffName: _staffNameCtrl.text.trim(),
|
||||
staffEmail: _staffEmailCtrl.text.trim(),
|
||||
staffMobile: _staffMobileCtrl.text.trim(),
|
||||
bankAccounts: accounts,
|
||||
);
|
||||
await _service.saveProfile(profile);
|
||||
await _companyRepo.saveCompanyInfo(
|
||||
(_legacyInfo ?? CompanyInfo(name: _companyNameCtrl.text.trim().isEmpty ? '未設定' : _companyNameCtrl.text.trim())).copyWith(
|
||||
name: _companyNameCtrl.text.trim(),
|
||||
zipCode: _companyZipCtrl.text.trim(),
|
||||
address: _companyAddrCtrl.text.trim(),
|
||||
tel: _companyTelCtrl.text.trim(),
|
||||
fax: _companyFaxCtrl.text.trim(),
|
||||
email: _companyEmailCtrl.text.trim(),
|
||||
url: _companyUrlCtrl.text.trim(),
|
||||
registrationNumber: _companyRegCtrl.text.trim().isEmpty ? null : _companyRegCtrl.text.trim(),
|
||||
defaultTaxRate: _taxRate,
|
||||
taxDisplayMode: _taxDisplayMode,
|
||||
sealPath: _sealPath,
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('自社情報を保存しました')));
|
||||
}
|
||||
|
||||
Future<void> _pickSeal(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final image = await picker.pickImage(source: source, imageQuality: 85);
|
||||
if (image == null) return;
|
||||
setState(() {
|
||||
_sealPath = image.path;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickContacts(bool forCompany) async {
|
||||
final granted = await FlutterContacts.requestPermission(readonly: true);
|
||||
if (!granted) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('連絡先へのアクセス権限が必要です')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final contacts = await FlutterContacts.getContacts(withProperties: true, withAccounts: true);
|
||||
if (!mounted) return;
|
||||
final selected = await showModalBottomSheet<Contact?>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) => ContactPickerSheet(contacts: contacts, title: forCompany ? '会社情報を電話帳から' : '担当者を電話帳から'),
|
||||
);
|
||||
if (selected == null) return;
|
||||
if (forCompany) {
|
||||
if (selected.organizations.isNotEmpty) {
|
||||
_companyNameCtrl.text = selected.organizations.first.company;
|
||||
} else {
|
||||
_companyNameCtrl.text = selected.displayName;
|
||||
}
|
||||
if (selected.addresses.isNotEmpty) {
|
||||
final addr = selected.addresses.first;
|
||||
_companyAddrCtrl.text = [addr.postalCode, addr.state, addr.city, addr.street].where((e) => e.trim().isNotEmpty).join(' ');
|
||||
}
|
||||
if (selected.phones.isNotEmpty) {
|
||||
_companyTelCtrl.text = selected.phones.first.number;
|
||||
}
|
||||
if (selected.emails.isNotEmpty) {
|
||||
_companyEmailCtrl.text = selected.emails.first.address;
|
||||
}
|
||||
} else {
|
||||
_staffNameCtrl.text = selected.displayName;
|
||||
if (selected.phones.isNotEmpty) {
|
||||
_staffMobileCtrl.text = selected.phones.first.number;
|
||||
}
|
||||
if (selected.emails.isNotEmpty) {
|
||||
_staffEmailCtrl.text = selected.emails.first.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_companyNameCtrl.dispose();
|
||||
_companyZipCtrl.dispose();
|
||||
_companyAddrCtrl.dispose();
|
||||
_companyTelCtrl.dispose();
|
||||
_companyFaxCtrl.dispose();
|
||||
_companyEmailCtrl.dispose();
|
||||
_companyUrlCtrl.dispose();
|
||||
_companyRegCtrl.dispose();
|
||||
_staffNameCtrl.dispose();
|
||||
_staffEmailCtrl.dispose();
|
||||
_staffMobileCtrl.dispose();
|
||||
for (final ctrl in _bankCtrls) {
|
||||
ctrl.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('F2:自社情報'),
|
||||
backgroundColor: Colors.indigo,
|
||||
actions: [
|
||||
IconButton(onPressed: _save, icon: const Icon(Icons.save)),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: KeyboardInsetWrapper(
|
||||
basePadding: const EdgeInsets.all(16),
|
||||
extraBottom: 24,
|
||||
child: ListView(
|
||||
children: [
|
||||
_section('会社情報', _buildCompanySection()),
|
||||
_section('担当者情報', _buildStaffSection()),
|
||||
_section('消費税設定', _buildTaxSection()),
|
||||
_section('印影(角印)', _buildSealSection()),
|
||||
_section('振込先口座 (最大2件まで有効)', _buildBankSection()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section(String title, Widget child) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompanySection() {
|
||||
return Column(
|
||||
children: [
|
||||
TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '自社名')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: '代表メールアドレス')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号(T番号)')),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _pickContacts(true),
|
||||
icon: const Icon(Icons.import_contacts),
|
||||
label: const Text('電話帳から取り込む'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStaffSection() {
|
||||
return Column(
|
||||
children: [
|
||||
TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _staffEmailCtrl, decoration: const InputDecoration(labelText: '担当者メール')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: _staffMobileCtrl, decoration: const InputDecoration(labelText: '担当者携帯番号')),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _pickContacts(false),
|
||||
icon: const Icon(Icons.smartphone),
|
||||
label: const Text('電話帳から取り込む'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBankSection() {
|
||||
return Column(
|
||||
children: List.generate(_bankCtrls.length, (index) {
|
||||
final ctrl = _bankCtrls[index];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: ctrl.isActive ? Colors.green.shade50 : null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('口座 ${index + 1}', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
Switch(
|
||||
value: ctrl.isActive,
|
||||
onChanged: (v) {
|
||||
setState(() => ctrl.isActive = v);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
TextField(controller: ctrl.bankName, decoration: const InputDecoration(labelText: '銀行名')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: ctrl.branchName, decoration: const InputDecoration(labelText: '支店名')),
|
||||
const SizedBox(height: 8),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: ctrl.accountType,
|
||||
decoration: const InputDecoration(labelText: '種別'),
|
||||
items: kAccountTypeOptions.map((e) => DropdownMenuItem(value: e, child: Text(e))).toList(),
|
||||
onChanged: (v) => setState(() => ctrl.accountType = v ?? '普通'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: ctrl.accountNumber, decoration: const InputDecoration(labelText: '口座番号')),
|
||||
const SizedBox(height: 8),
|
||||
TextField(controller: ctrl.holderName, decoration: const InputDecoration(labelText: '名義人')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaxSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('デフォルト消費税率', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('10%'),
|
||||
selected: _taxRate == 0.10,
|
||||
onSelected: (_) => setState(() => _taxRate = 0.10),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('8%'),
|
||||
selected: _taxRate == 0.08,
|
||||
onSelected: (_) => setState(() => _taxRate = 0.08),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('0%'),
|
||||
selected: _taxRate == 0.0,
|
||||
onSelected: (_) => setState(() => _taxRate = 0.0),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('消費税の表示設定 (T番号未取得時など)', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [
|
||||
ChoiceChip(
|
||||
label: const Text('通常表示'),
|
||||
selected: _taxDisplayMode == 'normal',
|
||||
onSelected: (_) => setState(() => _taxDisplayMode = 'normal'),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('表示しない'),
|
||||
selected: _taxDisplayMode == 'hidden',
|
||||
onSelected: (_) => setState(() => _taxDisplayMode = 'hidden'),
|
||||
),
|
||||
ChoiceChip(
|
||||
label: const Text('「税別」と表示'),
|
||||
selected: _taxDisplayMode == 'text_only',
|
||||
onSelected: (_) => setState(() => _taxDisplayMode = 'text_only'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSealSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 180,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.grey.shade50,
|
||||
),
|
||||
child: _sealPath == null
|
||||
? const Center(child: Icon(Icons.crop_original, size: 48, color: Colors.grey))
|
||||
: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.file(File(_sealPath!), fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _pickSeal(ImageSource.camera),
|
||||
icon: const Icon(Icons.camera_alt),
|
||||
label: const Text('カメラで取り込む'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _pickSeal(ImageSource.gallery),
|
||||
icon: const Icon(Icons.photo_library),
|
||||
label: const Text('アルバムから選択'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text('白い紙に押した判子を真上から撮影してください', style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BankControllers {
|
||||
final bankName = TextEditingController();
|
||||
final branchName = TextEditingController();
|
||||
final accountNumber = TextEditingController();
|
||||
final holderName = TextEditingController();
|
||||
String accountType = '普通';
|
||||
bool isActive = false;
|
||||
|
||||
void dispose() {
|
||||
bankName.dispose();
|
||||
branchName.dispose();
|
||||
accountNumber.dispose();
|
||||
holderName.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -71,6 +71,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
|
|||
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
|
||||
return Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
title: const Text("F1:自社情報"),
|
||||
backgroundColor: Colors.indigo,
|
||||
|
|
|
|||
|
|
@ -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<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
|
||||
|
|
@ -87,6 +89,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
}
|
||||
|
||||
Future<void> _showContactUpdateDialog(Customer customer) async {
|
||||
if (customer.isLocked) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロック中の顧客は連絡先を更新できません')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final emailController = TextEditingController(text: customer.email ?? "");
|
||||
final telController = TextEditingController(text: customer.tel ?? "");
|
||||
final addressController = TextEditingController(text: customer.address ?? "");
|
||||
|
|
@ -130,7 +138,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
Future<void> _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<CustomerMasterScreen> {
|
|||
List<Customer> 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<CustomerMasterScreen> {
|
|||
|
||||
late final Map<String, String> _defaultKanaMap = _buildDefaultKanaMap();
|
||||
|
||||
String _normalizeIndexChar(String input) {
|
||||
var s = input.replaceAll(RegExp(r"\s+|\u3000"), "");
|
||||
if (s.isEmpty) return '';
|
||||
String ch = s.characters.first;
|
||||
final code = ch.codeUnitAt(0);
|
||||
if (code >= 0x30A1 && code <= 0x30F6) {
|
||||
ch = String.fromCharCode(code - 0x60); // katakana -> hiragana
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
|
||||
Future<void> _addOrEditCustomer({Customer? customer}) async {
|
||||
final isEdit = customer != null;
|
||||
|
|
@ -244,26 +249,8 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
final Contact? picked = await showModalBottomSheet<Contact>(
|
||||
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<CustomerMasterScreen> {
|
|||
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<CustomerMasterScreen> {
|
|||
),
|
||||
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,9 +856,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
leading: const Icon(Icons.edit),
|
||||
title: const Text('編集'),
|
||||
enabled: !c.isLocked,
|
||||
onTap: c.isLocked
|
||||
? null
|
||||
: () {
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_addOrEditCustomer(customer: c);
|
||||
},
|
||||
|
|
@ -871,13 +864,25 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
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,7 +962,9 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onPressed: c.isLocked
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
_showContactUpdateSheet(c);
|
||||
},
|
||||
|
|
@ -1018,7 +1025,9 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
|||
ListTile(
|
||||
leading: const Icon(Icons.contact_mail),
|
||||
title: const Text('連絡先を更新'),
|
||||
enabled: !c.isLocked,
|
||||
onTap: () {
|
||||
if (c.isLocked) return;
|
||||
Navigator.pop(context);
|
||||
_showContactUpdateDialog(c);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<CustomerPickerModal> {
|
|||
final Contact? selectedContact = await showModalBottomSheet<Contact?>(
|
||||
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<CustomerPickerModal> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 電話帳から一人選ぶための内部ウィジェット
|
||||
class _PhoneContactListSelector extends StatefulWidget {
|
||||
final List<Contact> contacts;
|
||||
const _PhoneContactListSelector({required this.contacts});
|
||||
|
||||
@override
|
||||
State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState();
|
||||
}
|
||||
|
||||
class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> {
|
||||
List<Contact> _filtered = [];
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filtered = widget.contacts;
|
||||
}
|
||||
|
||||
void _onSearch(String q) {
|
||||
setState(() {
|
||||
_filtered = widget.contacts
|
||||
.where((c) {
|
||||
final org = c.organizations.isNotEmpty ? c.organizations.first.company : '';
|
||||
final label = org.isNotEmpty ? org : c.displayName;
|
||||
return label.toLowerCase().contains(q.toLowerCase());
|
||||
})
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.8,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)),
|
||||
onChanged: _onSearch,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (context, index) => ListTile(
|
||||
title: Text(
|
||||
_filtered[index].organizations.isNotEmpty && _filtered[index].organizations.first.company.isNotEmpty
|
||||
? _filtered[index].organizations.first.company
|
||||
: _filtered[index].displayName,
|
||||
),
|
||||
onTap: () => Navigator.pop(context, _filtered[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
281
lib/screens/dashboard_screen.dart
Normal file
281
lib/screens/dashboard_screen.dart
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/app_settings_repository.dart';
|
||||
import 'invoice_history_screen.dart';
|
||||
import 'invoice_input_screen.dart';
|
||||
import 'invoice_detail_page.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'product_master_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import 'master_hub_page.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../services/location_service.dart';
|
||||
import '../services/customer_repository.dart';
|
||||
import '../widgets/slide_to_unlock.dart';
|
||||
import '../config/app_config.dart';
|
||||
|
||||
class DashboardScreen extends StatefulWidget {
|
||||
const DashboardScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardScreen> createState() => _DashboardScreenState();
|
||||
}
|
||||
|
||||
class _DashboardScreenState extends State<DashboardScreen> {
|
||||
final _repo = AppSettingsRepository();
|
||||
bool _loading = true;
|
||||
bool _statusEnabled = true;
|
||||
String _statusText = '工事中';
|
||||
List<DashboardMenuItem> _menu = [];
|
||||
bool _historyUnlocked = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final statusEnabled = await _repo.getDashboardStatusEnabled();
|
||||
final statusText = await _repo.getDashboardStatusText();
|
||||
final rawMenu = await _repo.getDashboardMenu();
|
||||
final enabledRoutes = AppConfig.enabledRoutes;
|
||||
final menu = rawMenu.where((m) => enabledRoutes.contains(m.route)).toList();
|
||||
final unlocked = await _repo.getDashboardHistoryUnlocked();
|
||||
setState(() {
|
||||
_statusEnabled = statusEnabled;
|
||||
_statusText = statusText;
|
||||
_menu = menu;
|
||||
_loading = false;
|
||||
_historyUnlocked = unlocked;
|
||||
});
|
||||
}
|
||||
|
||||
void _navigate(DashboardMenuItem item) async {
|
||||
Widget? target;
|
||||
final enabledRoutes = AppConfig.enabledRoutes;
|
||||
if (!enabledRoutes.contains(item.route)) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('この機能は現在ご利用いただけません')));
|
||||
return;
|
||||
}
|
||||
switch (item.route) {
|
||||
case 'invoice_history':
|
||||
if (!_historyUnlocked) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロックを解除してください')));
|
||||
return;
|
||||
}
|
||||
target = const InvoiceHistoryScreen(initialUnlocked: true);
|
||||
break;
|
||||
case 'invoice_input':
|
||||
target = InvoiceInputForm(
|
||||
onInvoiceGenerated: (invoice, path) async {
|
||||
final locationService = LocationService();
|
||||
final pos = await locationService.getCurrentLocation();
|
||||
if (pos != null) {
|
||||
final customerRepo = CustomerRepository();
|
||||
await customerRepo.addGpsHistory(invoice.customer.id, pos.latitude, pos.longitude);
|
||||
}
|
||||
if (!mounted) return;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => InvoiceDetailPage(invoice: invoice)),
|
||||
);
|
||||
},
|
||||
initialDocumentType: DocumentType.invoice,
|
||||
);
|
||||
break;
|
||||
case 'customer_master':
|
||||
target = const CustomerMasterScreen();
|
||||
break;
|
||||
case 'product_master':
|
||||
target = const ProductMasterScreen();
|
||||
break;
|
||||
case 'master_hub':
|
||||
target = const MasterHubPage();
|
||||
break;
|
||||
case 'settings':
|
||||
target = const SettingsScreen();
|
||||
break;
|
||||
default:
|
||||
target = const InvoiceHistoryScreen();
|
||||
break;
|
||||
}
|
||||
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => target!));
|
||||
if (item.route == 'settings') {
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _tile(DashboardMenuItem item) {
|
||||
return GestureDetector(
|
||||
onTap: () => _navigate(item),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.black12, blurRadius: 6, offset: const Offset(0, 2)),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
_leading(item),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.title, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_routeLabel(item.route), style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _leading(DashboardMenuItem item) {
|
||||
if (item.customIconPath != null && File(item.customIconPath!).existsSync()) {
|
||||
return CircleAvatar(backgroundImage: FileImage(File(item.customIconPath!)), radius: 22);
|
||||
}
|
||||
return CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: Colors.indigo.shade50,
|
||||
foregroundColor: Colors.indigo.shade700,
|
||||
child: Icon(_iconForName(item.iconName ?? 'list_alt')),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _iconForName(String name) {
|
||||
return kIconsMap[name] ?? Icons.apps;
|
||||
}
|
||||
|
||||
String _routeLabel(String route) {
|
||||
switch (route) {
|
||||
case 'invoice_history':
|
||||
return 'A2:伝票一覧';
|
||||
case 'invoice_input':
|
||||
return 'A1:伝票入力';
|
||||
case 'customer_master':
|
||||
return 'C1:顧客マスター';
|
||||
case 'product_master':
|
||||
return 'P1:商品マスター';
|
||||
case 'master_hub':
|
||||
return 'M1:マスター管理';
|
||||
case 'settings':
|
||||
return 'S1:設定';
|
||||
default:
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text('D1:ダッシュボード'),
|
||||
actions: [
|
||||
IconButton(icon: const Icon(Icons.refresh), onPressed: _load),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () async {
|
||||
await Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||||
await _load();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _historyUnlocked
|
||||
? Row(
|
||||
children: [
|
||||
const Icon(Icons.lock_open, color: Colors.green),
|
||||
const SizedBox(width: 8),
|
||||
const Expanded(child: Text('A2ロック解除済')),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
setState(() => _historyUnlocked = false);
|
||||
await _repo.setDashboardHistoryUnlocked(false);
|
||||
},
|
||||
icon: const Icon(Icons.lock),
|
||||
label: const Text('再ロック'),
|
||||
),
|
||||
],
|
||||
)
|
||||
: SlideToUnlock(
|
||||
isLocked: !_historyUnlocked,
|
||||
onUnlocked: () async {
|
||||
setState(() => _historyUnlocked = true);
|
||||
await _repo.setDashboardHistoryUnlocked(true);
|
||||
},
|
||||
text: 'スライドでロック解除 (A2)',
|
||||
),
|
||||
),
|
||||
if (_statusEnabled)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.orange),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(_statusText, style: const TextStyle(fontWeight: FontWeight.bold))),
|
||||
],
|
||||
),
|
||||
),
|
||||
..._menu.map((e) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _tile(e),
|
||||
)),
|
||||
if (_menu.isEmpty)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('メニューが未設定です。設定画面から追加してください。'),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// fallback icon map for dashboard
|
||||
const Map<String, IconData> kIconsMap = {
|
||||
'list_alt': Icons.list_alt,
|
||||
'edit_note': Icons.edit_note,
|
||||
'history': Icons.history,
|
||||
'settings': Icons.settings,
|
||||
'invoice': Icons.receipt_long,
|
||||
'customer': Icons.people,
|
||||
'product': Icons.inventory_2,
|
||||
'menu': Icons.menu,
|
||||
'analytics': Icons.analytics,
|
||||
'map': Icons.map,
|
||||
'master': Icons.storage,
|
||||
'qr': Icons.qr_code,
|
||||
'camera': Icons.camera_alt,
|
||||
'contact': Icons.contact_mail,
|
||||
};
|
||||
586
lib/screens/email_settings_screen.dart
Normal file
586
lib/screens/email_settings_screen.dart
Normal file
|
|
@ -0,0 +1,586 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../constants/mail_send_method.dart';
|
||||
import '../constants/mail_templates.dart';
|
||||
import '../services/app_settings_repository.dart';
|
||||
import '../services/email_sender.dart';
|
||||
|
||||
class EmailSettingsScreen extends StatefulWidget {
|
||||
const EmailSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<EmailSettingsScreen> createState() => _EmailSettingsScreenState();
|
||||
}
|
||||
|
||||
class _EmailSettingsScreenState extends State<EmailSettingsScreen> {
|
||||
final _appSettingsRepo = AppSettingsRepository();
|
||||
|
||||
final _smtpHostCtrl = TextEditingController();
|
||||
final _smtpPortCtrl = TextEditingController(text: '587');
|
||||
final _smtpUserCtrl = TextEditingController();
|
||||
final _smtpPassCtrl = TextEditingController();
|
||||
final _smtpBccCtrl = TextEditingController();
|
||||
final _mailHeaderCtrl = TextEditingController();
|
||||
final _mailFooterCtrl = TextEditingController();
|
||||
|
||||
bool _smtpTls = true;
|
||||
bool _smtpIgnoreBadCert = false;
|
||||
bool _loadingLogs = false;
|
||||
String _mailSendMethod = kMailSendMethodSmtp;
|
||||
List<String> _smtpLogs = [];
|
||||
String _mailHeaderTemplateId = kMailTemplateIdDefault;
|
||||
String _mailFooterTemplateId = kMailTemplateIdDefault;
|
||||
|
||||
static const _kSmtpHost = 'smtp_host';
|
||||
static const _kSmtpPort = 'smtp_port';
|
||||
static const _kSmtpUser = 'smtp_user';
|
||||
static const _kSmtpPass = 'smtp_pass';
|
||||
static const _kSmtpTls = 'smtp_tls';
|
||||
static const _kSmtpBcc = 'smtp_bcc';
|
||||
static const _kSmtpIgnoreBadCert = 'smtp_ignore_bad_cert';
|
||||
static const _kMailSendMethod = kMailSendMethodPrefKey;
|
||||
static const _kMailHeaderTemplate = kMailHeaderTemplateKey;
|
||||
static const _kMailFooterTemplate = kMailFooterTemplateKey;
|
||||
static const _kMailHeaderText = kMailHeaderTextKey;
|
||||
static const _kMailFooterText = kMailFooterTextKey;
|
||||
static const _kCryptKey = 'test';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAll();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_smtpHostCtrl.dispose();
|
||||
_smtpPortCtrl.dispose();
|
||||
_smtpUserCtrl.dispose();
|
||||
_smtpPassCtrl.dispose();
|
||||
_smtpBccCtrl.dispose();
|
||||
_mailHeaderCtrl.dispose();
|
||||
_mailFooterCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadAll() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final hostPref = prefs.getString(_kSmtpHost);
|
||||
final smtpHost = hostPref ?? await _appSettingsRepo.getString(_kSmtpHost) ?? '';
|
||||
final portPref = prefs.getString(_kSmtpPort);
|
||||
final smtpPort = (portPref ?? await _appSettingsRepo.getString(_kSmtpPort) ?? '587').trim().isEmpty
|
||||
? '587'
|
||||
: (portPref ?? await _appSettingsRepo.getString(_kSmtpPort) ?? '587');
|
||||
final userPref = prefs.getString(_kSmtpUser);
|
||||
final smtpUser = userPref ?? await _appSettingsRepo.getString(_kSmtpUser) ?? '';
|
||||
final passPref = prefs.getString(_kSmtpPass);
|
||||
final smtpPassEncrypted = passPref ?? await _appSettingsRepo.getString(_kSmtpPass) ?? '';
|
||||
final smtpPass = _decryptWithFallback(smtpPassEncrypted);
|
||||
final tlsPrefExists = prefs.containsKey(_kSmtpTls);
|
||||
final smtpTls = tlsPrefExists ? (prefs.getBool(_kSmtpTls) ?? true) : await _appSettingsRepo.getBool(_kSmtpTls, defaultValue: true);
|
||||
final bccPref = prefs.getString(_kSmtpBcc);
|
||||
final smtpBcc = bccPref ?? await _appSettingsRepo.getString(_kSmtpBcc) ?? '';
|
||||
final ignorePrefExists = prefs.containsKey(_kSmtpIgnoreBadCert);
|
||||
final smtpIgnoreBadCert = ignorePrefExists
|
||||
? (prefs.getBool(_kSmtpIgnoreBadCert) ?? false)
|
||||
: await _appSettingsRepo.getBool(_kSmtpIgnoreBadCert, defaultValue: false);
|
||||
|
||||
final mailSendMethodPref = prefs.getString(_kMailSendMethod);
|
||||
final mailSendMethodDb = await _appSettingsRepo.getString(_kMailSendMethod) ?? kMailSendMethodSmtp;
|
||||
final resolvedMailSendMethod = normalizeMailSendMethod(mailSendMethodPref ?? mailSendMethodDb);
|
||||
|
||||
final headerTemplatePref = prefs.getString(_kMailHeaderTemplate);
|
||||
final headerTemplateDb = await _appSettingsRepo.getString(_kMailHeaderTemplate) ?? kMailTemplateIdDefault;
|
||||
final resolvedHeaderTemplate = headerTemplatePref ?? headerTemplateDb;
|
||||
final headerTextPref = prefs.getString(_kMailHeaderText);
|
||||
final headerTextDb = await _appSettingsRepo.getString(_kMailHeaderText) ?? kMailHeaderTemplateDefault;
|
||||
final resolvedHeaderText = headerTextPref ?? headerTextDb;
|
||||
|
||||
final footerTemplatePref = prefs.getString(_kMailFooterTemplate);
|
||||
final footerTemplateDb = await _appSettingsRepo.getString(_kMailFooterTemplate) ?? kMailTemplateIdDefault;
|
||||
final resolvedFooterTemplate = footerTemplatePref ?? footerTemplateDb;
|
||||
final footerTextPref = prefs.getString(_kMailFooterText);
|
||||
final footerTextDb = await _appSettingsRepo.getString(_kMailFooterText) ?? kMailFooterTemplateDefault;
|
||||
final resolvedFooterText = footerTextPref ?? footerTextDb;
|
||||
|
||||
final needsPrefSync =
|
||||
hostPref == null ||
|
||||
portPref == null ||
|
||||
userPref == null ||
|
||||
passPref == null ||
|
||||
bccPref == null ||
|
||||
!tlsPrefExists ||
|
||||
!ignorePrefExists ||
|
||||
mailSendMethodPref == null ||
|
||||
headerTemplatePref == null ||
|
||||
headerTextPref == null ||
|
||||
footerTemplatePref == null ||
|
||||
footerTextPref == null;
|
||||
if (needsPrefSync) {
|
||||
await _saveSmtpPrefs(
|
||||
host: smtpHost,
|
||||
port: smtpPort,
|
||||
user: smtpUser,
|
||||
encryptedPass: smtpPassEncrypted,
|
||||
tls: smtpTls,
|
||||
bcc: smtpBcc,
|
||||
ignoreBadCert: smtpIgnoreBadCert,
|
||||
mailSendMethod: resolvedMailSendMethod,
|
||||
headerTemplate: resolvedHeaderTemplate,
|
||||
headerText: resolvedHeaderText,
|
||||
footerTemplate: resolvedFooterTemplate,
|
||||
footerText: resolvedFooterText,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_smtpHostCtrl.text = smtpHost;
|
||||
_smtpPortCtrl.text = smtpPort;
|
||||
_smtpUserCtrl.text = smtpUser;
|
||||
_smtpPassCtrl.text = smtpPass;
|
||||
_smtpBccCtrl.text = smtpBcc;
|
||||
_smtpTls = smtpTls;
|
||||
_smtpIgnoreBadCert = smtpIgnoreBadCert;
|
||||
_mailSendMethod = resolvedMailSendMethod;
|
||||
_mailHeaderTemplateId = resolvedHeaderTemplate;
|
||||
_mailFooterTemplateId = resolvedFooterTemplate;
|
||||
_mailHeaderCtrl.text = resolvedHeaderText;
|
||||
_mailFooterCtrl.text = resolvedFooterText;
|
||||
});
|
||||
|
||||
await _loadSmtpLogs();
|
||||
}
|
||||
|
||||
Future<void> _loadSmtpLogs() async {
|
||||
setState(() => _loadingLogs = true);
|
||||
final logs = await EmailSender.loadLogs();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_smtpLogs = logs;
|
||||
_loadingLogs = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _clearSmtpLogs() async {
|
||||
await EmailSender.clearLogs();
|
||||
await _loadSmtpLogs();
|
||||
}
|
||||
|
||||
Future<void> _copySmtpLogs() async {
|
||||
if (_smtpLogs.isEmpty) return;
|
||||
await Clipboard.setData(ClipboardData(text: _smtpLogs.join('\n')));
|
||||
_showSnackbar('ログをクリップボードにコピーしました');
|
||||
}
|
||||
|
||||
Future<void> _saveSmtp() async {
|
||||
final host = _smtpHostCtrl.text.trim();
|
||||
final port = _smtpPortCtrl.text.trim().isEmpty ? '587' : _smtpPortCtrl.text.trim();
|
||||
final user = _smtpUserCtrl.text.trim();
|
||||
final passPlain = _smtpPassCtrl.text;
|
||||
final passEncrypted = _encrypt(passPlain);
|
||||
final bcc = _smtpBccCtrl.text.trim();
|
||||
|
||||
if (bcc.isEmpty) {
|
||||
_showSnackbar('BCCは必須項目です');
|
||||
return;
|
||||
}
|
||||
|
||||
await _appSettingsRepo.setString(_kSmtpHost, host);
|
||||
await _appSettingsRepo.setString(_kSmtpPort, port);
|
||||
await _appSettingsRepo.setString(_kSmtpUser, user);
|
||||
await _appSettingsRepo.setString(_kSmtpPass, passEncrypted);
|
||||
await _appSettingsRepo.setBool(_kSmtpTls, _smtpTls);
|
||||
await _appSettingsRepo.setString(_kSmtpBcc, bcc);
|
||||
await _appSettingsRepo.setBool(_kSmtpIgnoreBadCert, _smtpIgnoreBadCert);
|
||||
await _appSettingsRepo.setString(_kMailSendMethod, _mailSendMethod);
|
||||
await _appSettingsRepo.setString(_kMailHeaderTemplate, _mailHeaderTemplateId);
|
||||
await _appSettingsRepo.setString(_kMailFooterTemplate, _mailFooterTemplateId);
|
||||
await _appSettingsRepo.setString(_kMailHeaderText, _mailHeaderCtrl.text);
|
||||
await _appSettingsRepo.setString(_kMailFooterText, _mailFooterCtrl.text);
|
||||
|
||||
await _saveSmtpPrefs(
|
||||
host: host,
|
||||
port: port,
|
||||
user: user,
|
||||
encryptedPass: passEncrypted,
|
||||
tls: _smtpTls,
|
||||
bcc: bcc,
|
||||
ignoreBadCert: _smtpIgnoreBadCert,
|
||||
mailSendMethod: _mailSendMethod,
|
||||
headerTemplate: _mailHeaderTemplateId,
|
||||
headerText: _mailHeaderCtrl.text,
|
||||
footerTemplate: _mailFooterTemplateId,
|
||||
footerText: _mailFooterCtrl.text,
|
||||
);
|
||||
_showSnackbar('メール設定を保存しました');
|
||||
}
|
||||
|
||||
Future<void> _saveSmtpPrefs({
|
||||
required String host,
|
||||
required String port,
|
||||
required String user,
|
||||
required String encryptedPass,
|
||||
required bool tls,
|
||||
required String bcc,
|
||||
required bool ignoreBadCert,
|
||||
required String mailSendMethod,
|
||||
required String headerTemplate,
|
||||
required String headerText,
|
||||
required String footerTemplate,
|
||||
required String footerText,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kSmtpHost, host);
|
||||
await prefs.setString(_kSmtpPort, port);
|
||||
await prefs.setString(_kSmtpUser, user);
|
||||
await prefs.setString(_kSmtpPass, encryptedPass);
|
||||
await prefs.setBool(_kSmtpTls, tls);
|
||||
await prefs.setString(_kSmtpBcc, bcc);
|
||||
await prefs.setBool(_kSmtpIgnoreBadCert, ignoreBadCert);
|
||||
await prefs.setString(_kMailSendMethod, mailSendMethod);
|
||||
await prefs.setString(_kMailHeaderTemplate, headerTemplate);
|
||||
await prefs.setString(_kMailHeaderText, headerText);
|
||||
await prefs.setString(_kMailFooterTemplate, footerTemplate);
|
||||
await prefs.setString(_kMailFooterText, footerText);
|
||||
}
|
||||
|
||||
Future<void> _testSmtp() async {
|
||||
try {
|
||||
if (_mailSendMethod != kMailSendMethodSmtp) {
|
||||
_showSnackbar('SMTPテストは送信方法を「SMTP」に設定した時のみ利用できます');
|
||||
return;
|
||||
}
|
||||
await _saveSmtp();
|
||||
final config = await EmailSender.loadConfigFromPrefs();
|
||||
if (config == null || config.bcc.isEmpty) {
|
||||
_showSnackbar('ホスト/ユーザー/パスワード/BCCを入力してください');
|
||||
return;
|
||||
}
|
||||
|
||||
await EmailSender.sendTest(config: config);
|
||||
_showSnackbar('テスト送信に成功しました');
|
||||
} catch (e) {
|
||||
_showSnackbar('テスト送信に失敗しました: $e');
|
||||
}
|
||||
await _loadSmtpLogs();
|
||||
}
|
||||
|
||||
Future<void> _updateMailSendMethod(String method) async {
|
||||
final normalized = normalizeMailSendMethod(method);
|
||||
setState(() => _mailSendMethod = normalized);
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kMailSendMethod, normalized);
|
||||
await _appSettingsRepo.setString(_kMailSendMethod, normalized);
|
||||
}
|
||||
|
||||
void _applyHeaderTemplate(String templateId) {
|
||||
setState(() => _mailHeaderTemplateId = templateId);
|
||||
if (templateId == kMailTemplateIdDefault) {
|
||||
_mailHeaderCtrl.text = kMailHeaderTemplateDefault;
|
||||
} else if (templateId == kMailTemplateIdNone) {
|
||||
_mailHeaderCtrl.clear();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyFooterTemplate(String templateId) {
|
||||
setState(() => _mailFooterTemplateId = templateId);
|
||||
if (templateId == kMailTemplateIdDefault) {
|
||||
_mailFooterCtrl.text = kMailFooterTemplateDefault;
|
||||
} else if (templateId == kMailTemplateIdNone) {
|
||||
_mailFooterCtrl.clear();
|
||||
}
|
||||
}
|
||||
|
||||
String _encrypt(String plain) {
|
||||
if (plain.isEmpty) return '';
|
||||
final pb = utf8.encode(plain);
|
||||
final kb = utf8.encode(_kCryptKey);
|
||||
final ob = List<int>.generate(pb.length, (i) => pb[i] ^ kb[i % kb.length]);
|
||||
return base64Encode(ob);
|
||||
}
|
||||
|
||||
String _decryptWithFallback(String cipher) {
|
||||
if (cipher.isEmpty) return '';
|
||||
try {
|
||||
final ob = base64Decode(cipher);
|
||||
final kb = utf8.encode(_kCryptKey);
|
||||
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
|
||||
return utf8.decode(pb);
|
||||
} catch (_) {
|
||||
return cipher;
|
||||
}
|
||||
}
|
||||
|
||||
void _showSnackbar(String msg) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
final listBottomPadding = 24 + bottomInset;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('SM:メール設定'),
|
||||
backgroundColor: Colors.indigo,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ListView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: EdgeInsets.only(bottom: listBottomPadding),
|
||||
children: [
|
||||
_section(
|
||||
title: '送信設定',
|
||||
subtitle: 'SMTP / 端末メーラー切り替えやBCC必須設定',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DropdownButtonFormField<String>(
|
||||
decoration: const InputDecoration(labelText: '送信方法'),
|
||||
initialValue: _mailSendMethod,
|
||||
items: const [
|
||||
DropdownMenuItem(value: kMailSendMethodSmtp, child: Text('SMTPサーバー経由')),
|
||||
DropdownMenuItem(value: kMailSendMethodDeviceMailer, child: Text('端末メーラーで送信')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
_updateMailSendMethod(value);
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (_mailSendMethod == kMailSendMethodDeviceMailer)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
border: Border.all(color: Colors.orange.shade200),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'端末メーラーで送信する場合もBCCは必須です。SMTP設定は保持されますが、送信時は端末のメールアプリが起動します。',
|
||||
style: TextStyle(fontSize: 12, color: Colors.black87),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _smtpHostCtrl,
|
||||
decoration: const InputDecoration(labelText: 'SMTPホスト名'),
|
||||
enabled: _mailSendMethod == kMailSendMethodSmtp,
|
||||
),
|
||||
TextField(
|
||||
controller: _smtpPortCtrl,
|
||||
decoration: const InputDecoration(labelText: 'SMTPポート番号'),
|
||||
keyboardType: TextInputType.number,
|
||||
enabled: _mailSendMethod == kMailSendMethodSmtp,
|
||||
),
|
||||
TextField(
|
||||
controller: _smtpUserCtrl,
|
||||
decoration: const InputDecoration(labelText: 'SMTPユーザー名'),
|
||||
enabled: _mailSendMethod == kMailSendMethodSmtp,
|
||||
),
|
||||
TextField(
|
||||
controller: _smtpPassCtrl,
|
||||
decoration: const InputDecoration(labelText: 'SMTPパスワード'),
|
||||
obscureText: true,
|
||||
enabled: _mailSendMethod == kMailSendMethodSmtp,
|
||||
),
|
||||
TextField(
|
||||
controller: _smtpBccCtrl,
|
||||
decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可) *必須'),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('STARTTLS を使用'),
|
||||
value: _smtpTls,
|
||||
onChanged: _mailSendMethod == kMailSendMethodSmtp ? (v) => setState(() => _smtpTls = v) : null,
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('証明書検証をスキップ(開発用)'),
|
||||
subtitle: const Text('自己署名/ホスト名不一致を許可します'),
|
||||
value: _smtpIgnoreBadCert,
|
||||
onChanged: _mailSendMethod == kMailSendMethodSmtp ? (v) => setState(() => _smtpIgnoreBadCert = v) : null,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('保存'),
|
||||
onPressed: _saveSmtp,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.send),
|
||||
label: const Text('BCC宛にテスト送信'),
|
||||
onPressed: _mailSendMethod == kMailSendMethodSmtp ? _testSmtp : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_section(
|
||||
title: '通信ログ',
|
||||
subtitle: '最大1000行まで保持されます(SMTP/端末メーラー共通)',
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Text('ログ一覧', style: TextStyle(fontWeight: FontWeight.bold))),
|
||||
IconButton(
|
||||
tooltip: '再読込',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadingLogs ? null : _loadSmtpLogs,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'コピー',
|
||||
icon: const Icon(Icons.copy),
|
||||
onPressed: _smtpLogs.isEmpty ? null : _copySmtpLogs,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'クリア',
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
onPressed: _smtpLogs.isEmpty ? null : _clearSmtpLogs,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: _loadingLogs
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _smtpLogs.isEmpty
|
||||
? const Center(child: Text('ログなし'))
|
||||
: Scrollbar(
|
||||
child: SelectionArea(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(8),
|
||||
itemCount: _smtpLogs.length,
|
||||
itemBuilder: (context, index) => Text(
|
||||
_smtpLogs[index],
|
||||
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_section(
|
||||
title: 'メール本文ヘッダ/フッタ',
|
||||
subtitle: 'テンプレを選択して編集するか、自由にテキストを入力できます({{FILENAME}}, {{HASH}} が利用可)',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('ヘッダテンプレ', style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _mailHeaderTemplateId,
|
||||
items: const [
|
||||
DropdownMenuItem(value: kMailTemplateIdDefault, child: Text('デフォルト')),
|
||||
DropdownMenuItem(value: kMailTemplateIdNone, child: Text('なし / 空テンプレ')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
_applyHeaderTemplate(v);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _applyHeaderTemplate(_mailHeaderTemplateId),
|
||||
child: const Text('テンプレ適用'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _mailHeaderCtrl,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), hintText: 'メールヘッダ文…'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('フッタテンプレ', style: Theme.of(context).textTheme.labelLarge),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _mailFooterTemplateId,
|
||||
items: const [
|
||||
DropdownMenuItem(value: kMailTemplateIdDefault, child: Text('デフォルト')),
|
||||
DropdownMenuItem(value: kMailTemplateIdNone, child: Text('なし / 空テンプレ')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
if (v != null) {
|
||||
_applyFooterTemplate(v);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _applyFooterTemplate(_mailFooterTemplateId),
|
||||
child: const Text('テンプレ適用'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _mailFooterCtrl,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
decoration: const InputDecoration(border: OutlineInputBorder(), hintText: 'メールフッタ文…'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('※ {{FILENAME}} と {{HASH}} は送信時に自動置換されます。'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _section({required String title, required String subtitle, required Widget child}) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle, style: const TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<InvoiceItem> _cloneItemsDetail(List<InvoiceItem> source) {
|
||||
return source
|
||||
.map((e) => InvoiceItem(
|
||||
|
|
@ -63,14 +83,16 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
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<InvoiceDetailPage> {
|
|||
_includeTax = _currentInvoice.taxRate > 0; // 初期化
|
||||
_isEditing = false;
|
||||
_loadCompanyInfo();
|
||||
_loadSummaryTheme();
|
||||
}
|
||||
|
||||
Future<void> _loadSummaryTheme() async {
|
||||
final saved = await _settingsRepo.getSummaryTheme();
|
||||
if (!mounted) return;
|
||||
setState(() => _summaryIsBlue = saved == 'blue');
|
||||
}
|
||||
|
||||
Future<void> _loadCompanyInfo() async {
|
||||
|
|
@ -186,12 +215,39 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV'));
|
||||
}
|
||||
|
||||
Future<void> _pickSummaryColor() async {
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette, color: Colors.indigo),
|
||||
title: const Text('インディゴ'),
|
||||
onTap: () => Navigator.pop(context, 'blue'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette, color: Colors.grey),
|
||||
title: const Text('白'),
|
||||
onTap: () => Navigator.pop(context, 'white'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected == null) return;
|
||||
setState(() => _summaryIsBlue = selected == 'blue');
|
||||
await _settingsRepo.setSummaryTheme(selected);
|
||||
}
|
||||
|
||||
@override
|
||||
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<InvoiceDetailPage> {
|
|||
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<InvoiceDetailPage> {
|
|||
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<InvoiceDetailPage> {
|
|||
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,16 +480,22 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
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)),
|
||||
const SizedBox(height: 8),
|
||||
const Text("件名", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black54)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_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.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) ...[
|
||||
|
|
@ -440,6 +509,9 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -520,33 +592,44 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
final int tax = (subtotal * currentTaxRate).floor();
|
||||
final int total = subtotal + tax;
|
||||
|
||||
return Container(
|
||||
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: Colors.indigo,
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70),
|
||||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor),
|
||||
if (currentTaxRate > 0) ...[
|
||||
const Divider(color: Colors.white24),
|
||||
Divider(color: dividerColor),
|
||||
if (_companyInfo?.taxDisplayMode == 'normal')
|
||||
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "¥${formatter.format(tax)}", Colors.white70),
|
||||
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "¥${formatter.format(tax)}", labelColor),
|
||||
if (_companyInfo?.taxDisplayMode == 'text_only')
|
||||
_buildSummaryRow("消費税", "(税別)", Colors.white70),
|
||||
_buildSummaryRow("消費税", "(税別)", labelColor),
|
||||
],
|
||||
const Divider(color: Colors.white24),
|
||||
Divider(color: dividerColor),
|
||||
_buildSummaryRow(
|
||||
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
|
||||
"¥${formatter.format(total)}",
|
||||
Colors.white,
|
||||
totalColor,
|
||||
isTotal: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -721,7 +804,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
|||
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<InvoiceDetailPage> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -25,21 +25,49 @@ 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),
|
||||
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(
|
||||
invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
|
||||
color: invoice.isDraft
|
||||
? Colors.orange
|
||||
: (isUnlocked ? Colors.indigo : Colors.grey),
|
||||
_docTypeIcon(invoice.documentType),
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
if (invoice.isLocked)
|
||||
|
|
@ -50,61 +78,118 @@ class InvoiceHistoryItem extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
),
|
||||
title: Column(
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
invoice.customerNameForDisplay,
|
||||
customerName,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: invoice.isLocked ? Colors.grey : Colors.black87,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (invoice.subject?.isNotEmpty ?? false)
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
invoice.subject!,
|
||||
subjectDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.indigo.shade700,
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: subjectColor,
|
||||
),
|
||||
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,
|
||||
),
|
||||
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: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: amountColor),
|
||||
textAlign: TextAlign.right,
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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<InvoiceHistoryScreen> createState() => _InvoiceHistoryScreenState();
|
||||
|
|
@ -26,6 +29,7 @@ class InvoiceHistoryScreen extends StatefulWidget {
|
|||
class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||
final InvoiceRepository _invoiceRepo = InvoiceRepository();
|
||||
final CustomerRepository _customerRepo = CustomerRepository();
|
||||
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
|
||||
List<Invoice> _invoices = [];
|
||||
List<Invoice> _filteredInvoices = [];
|
||||
bool _isLoading = true;
|
||||
|
|
@ -35,12 +39,26 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
DateTime? _startDate;
|
||||
DateTime? _endDate;
|
||||
String _appVersion = "1.0.0";
|
||||
bool _useDashboardHome = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isUnlocked = widget.initialUnlocked;
|
||||
_loadData();
|
||||
_loadVersion();
|
||||
_loadHomeMode();
|
||||
}
|
||||
|
||||
Future<void> _loadHomeMode() async {
|
||||
final mode = await _settingsRepo.getHomeMode();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_useDashboardHome = mode == 'dashboard';
|
||||
if (_useDashboardHome && widget.initialUnlocked) {
|
||||
_isUnlocked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showInvoiceActions(Invoice invoice) async {
|
||||
|
|
@ -198,20 +216,20 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
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<InvoiceHistoryScreen> {
|
|||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: 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,14 +342,35 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
preferredSize: const Size.fromHeight(60),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||
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.white,
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
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;
|
||||
|
|
@ -324,9 +380,11 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
if (!_useDashboardHome)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: SlideToUnlock(
|
||||
|
|
@ -347,8 +405,9 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
|||
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<InvoiceHistoryScreen> {
|
|||
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<InvoiceHistoryScreen> {
|
|||
builder: (_) => InvoiceInputForm(
|
||||
onInvoiceGenerated: (inv, path) {},
|
||||
initialDocumentType: type,
|
||||
startViewMode: false,
|
||||
showNewBadge: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<InvoiceInputForm> createState() => _InvoiceInputFormState();
|
||||
}
|
||||
|
||||
List<InvoiceItem> _cloneItems(List<InvoiceItem> source) {
|
||||
List<InvoiceItem> _cloneItems(List<InvoiceItem> 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,27 +59,96 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
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<EditLogEntry> _editLogs = [];
|
||||
final FocusNode _subjectFocusNode = FocusNode();
|
||||
String _lastLoggedSubject = "";
|
||||
|
||||
// 署名用の実験的パス
|
||||
final List<Offset?> _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);
|
||||
_loadInitialData();
|
||||
_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;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subjectController.removeListener(_onSubjectChanged);
|
||||
_subjectController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
});
|
||||
_subjectController.addListener(_onSubjectChanged);
|
||||
_loadInitialData();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialData() async {
|
||||
|
|
@ -80,6 +156,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
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<InvoiceInputForm> {
|
|||
_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<void> _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<InvoiceInputForm> {
|
|||
));
|
||||
});
|
||||
_pushHistory();
|
||||
final id = _ensureCurrentId();
|
||||
final msg = "商品「${product.name}」を追加しました";
|
||||
_editLogRepo.addLog(id, msg).then((_) => _loadEditLogs());
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -143,8 +249,10 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
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<InvoiceInputForm> {
|
|||
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<InvoiceInputForm> {
|
|||
}
|
||||
} 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<InvoiceInputForm> {
|
|||
|
||||
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<InvoiceInputForm> {
|
|||
documentType: _documentType,
|
||||
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
||||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||||
isDraft: _isDraft,
|
||||
isLocked: _isLocked,
|
||||
);
|
||||
|
||||
Navigator.push(
|
||||
|
|
@ -203,34 +320,27 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
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,16 +427,41 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
@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.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,
|
||||
|
|
@ -337,6 +472,13 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
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<InvoiceInputForm> {
|
|||
_buildItemsSection(fmt),
|
||||
const SizedBox(height: 20),
|
||||
_buildSummarySection(fmt),
|
||||
const SizedBox(height: 20),
|
||||
_buildSignatureSection(),
|
||||
const SizedBox(height: 12),
|
||||
_buildEditLogsSection(),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -391,7 +533,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
Widget _buildDateSection() {
|
||||
final fmt = DateFormat('yyyy/MM/dd');
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
onTap: _isViewMode
|
||||
? null
|
||||
: () async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
|
|
@ -403,38 +547,69 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
_pushHistory();
|
||||
}
|
||||
},
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
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)),
|
||||
const Spacer(),
|
||||
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 {
|
||||
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(
|
||||
|
|
@ -459,6 +634,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
if (!_isViewMode && !_isLocked)
|
||||
TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")),
|
||||
],
|
||||
),
|
||||
|
|
@ -467,18 +643,43 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
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<InvoiceInputForm> {
|
|||
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_circle_outline, color: Colors.redAccent),
|
||||
onPressed: () {
|
||||
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, 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()),
|
||||
onTap: () async {
|
||||
if (_isViewMode || _isLocked) return;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final product = await Navigator.push<Product>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)),
|
||||
);
|
||||
},
|
||||
),
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (product != null) {
|
||||
if (!mounted) return;
|
||||
final prevDesc = item.description;
|
||||
setState(() {
|
||||
_items[idx] = item.copyWith(
|
||||
description: descCtrl.text,
|
||||
quantity: int.tryParse(qtyCtrl.text) ?? item.quantity,
|
||||
unitPrice: int.tryParse(priceCtrl.text) ?? item.unitPrice,
|
||||
productId: product.id,
|
||||
description: product.name,
|
||||
unitPrice: product.defaultUnitPrice,
|
||||
);
|
||||
});
|
||||
_pushHistory();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text("更新"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
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,30 +787,66 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
final int tax = _includeTax ? (subtotal * _taxRate).floor() : 0;
|
||||
final int total = subtotal + tax;
|
||||
|
||||
return Container(
|
||||
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<String>(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette, color: Colors.indigo),
|
||||
title: const Text('インディゴ'),
|
||||
onTap: () => Navigator.pop(context, 'blue'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette, color: Colors.grey),
|
||||
title: const Text('白'),
|
||||
onTap: () => Navigator.pop(context, 'white'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected == null) return;
|
||||
setState(() => _summaryIsBlue = selected == 'blue');
|
||||
await _settingsRepo.setSummaryTheme(selected);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.indigo,
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70),
|
||||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor),
|
||||
if (tax > 0) ...[
|
||||
const Divider(color: Colors.white24),
|
||||
_buildSummaryRow("消費税", "¥${formatter.format(tax)}", Colors.white70),
|
||||
Divider(color: dividerColor),
|
||||
_buildSummaryRow("消費税", "¥${formatter.format(tax)}", labelColor),
|
||||
],
|
||||
const Divider(color: Colors.white24),
|
||||
Divider(color: dividerColor),
|
||||
_buildSummaryRow(
|
||||
tax > 0 ? "合計金額 (税込)" : "合計金額",
|
||||
"¥${formatter.format(total)}",
|
||||
Colors.white,
|
||||
totalColor,
|
||||
isTotal: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -634,43 +877,6 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
);
|
||||
}
|
||||
|
||||
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,7 +903,24 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
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("保存"),
|
||||
|
|
@ -706,7 +929,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -726,12 +949,16 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
|||
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<InvoiceInputForm> {
|
|||
],
|
||||
);
|
||||
}
|
||||
|
||||
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<Offset?> 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;
|
||||
}
|
||||
|
|
|
|||
80
lib/screens/master_hub_page.dart
Normal file
80
lib/screens/master_hub_page.dart
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'customer_master_screen.dart';
|
||||
import 'product_master_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
|
||||
class MasterHubPage extends StatelessWidget {
|
||||
const MasterHubPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = <MasterEntry>[
|
||||
MasterEntry(
|
||||
title: '顧客マスター',
|
||||
description: '顧客情報の管理・編集',
|
||||
icon: Icons.people,
|
||||
builder: (_) => const CustomerMasterScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '商品マスター',
|
||||
description: '商品情報の管理・編集',
|
||||
icon: Icons.inventory_2,
|
||||
builder: (_) => const ProductMasterScreen(),
|
||||
),
|
||||
MasterEntry(
|
||||
title: '設定',
|
||||
description: 'アプリ設定・メニュー管理',
|
||||
icon: Icons.settings,
|
||||
builder: (_) => const SettingsScreen(),
|
||||
),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text('マスター管理'),
|
||||
Text('ScreenID: 03', style: TextStyle(fontSize: 11, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 1,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Colors.indigo.shade50,
|
||||
foregroundColor: Colors.indigo.shade700,
|
||||
child: Icon(item.icon),
|
||||
),
|
||||
title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(item.description),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: item.builder)),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, _) => const SizedBox(height: 12),
|
||||
itemCount: items.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MasterEntry {
|
||||
final String title;
|
||||
final String description;
|
||||
final IconData icon;
|
||||
final WidgetBuilder builder;
|
||||
const MasterEntry({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.icon,
|
||||
required this.builder,
|
||||
});
|
||||
}
|
||||
|
|
@ -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<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
||||
|
|
@ -30,7 +31,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
|||
|
||||
Future<void> _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<ProductMasterScreen> {
|
|||
(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<ProductMasterScreen> {
|
|||
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<ProductMasterScreen> {
|
|||
],
|
||||
),
|
||||
),
|
||||
title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)),
|
||||
title: Text(
|
||||
p.name + (p.isHidden ? " (非表示)" : ""),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: p.isHidden
|
||||
? Colors.grey
|
||||
: (p.isLocked ? Colors.grey : Colors.black87),
|
||||
),
|
||||
),
|
||||
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
|
||||
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<ProductMasterScreen> {
|
|||
_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),
|
||||
|
|
|
|||
|
|
@ -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<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
// シンプルなアイコンマップ(拡張可)
|
||||
const Map<String, IconData> kIconsMap = {
|
||||
'list_alt': Icons.list_alt,
|
||||
'edit_note': Icons.edit_note,
|
||||
'history': Icons.history,
|
||||
'settings': Icons.settings,
|
||||
'invoice': Icons.receipt_long,
|
||||
'dashboard': Icons.dashboard,
|
||||
'home': Icons.home,
|
||||
'info': Icons.info,
|
||||
'mail': Icons.mail,
|
||||
'shopping_cart': Icons.shopping_cart,
|
||||
};
|
||||
|
||||
class _SettingsScreenState extends State<SettingsScreen> {
|
||||
// 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<SettingsScreen> {
|
|||
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<DashboardMenuItem> _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<void> _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<SettingsScreen> {
|
|||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void> _saveStaff() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kStaffName, _staffNameCtrl.text);
|
||||
await prefs.setString(_kStaffMail, _staffMailCtrl.text);
|
||||
_showSnackbar('担当者情報を保存しました');
|
||||
Future<void> _persistMenu() async {
|
||||
await _appSettingsRepo.setDashboardMenu(_menuItems);
|
||||
}
|
||||
|
||||
Future<void> _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<String>(
|
||||
initialValue: route,
|
||||
decoration: const InputDecoration(labelText: '遷移先'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'invoice_history', child: Text('A2:伝票一覧')),
|
||||
DropdownMenuItem(value: 'invoice_input', child: Text('A1:伝票入力')),
|
||||
DropdownMenuItem(value: 'customer_master', child: Text('C1:顧客マスター')),
|
||||
DropdownMenuItem(value: 'product_master', child: Text('P1:商品マスター')),
|
||||
DropdownMenuItem(value: 'master_hub', child: Text('M1:マスター管理')),
|
||||
DropdownMenuItem(value: 'settings', child: Text('S1:設定')),
|
||||
],
|
||||
onChanged: (v) => route = v ?? 'invoice_history',
|
||||
),
|
||||
TextField(controller: iconCtrl, decoration: const InputDecoration(labelText: 'Materialアイコン名 (例: list_alt)')),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Text(customIconPath ?? 'カスタムアイコン: 未選択', style: const TextStyle(fontSize: 12))),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.image_search),
|
||||
tooltip: 'ギャラリーから選択',
|
||||
onPressed: () async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
customIconPath = picked.path;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
if (titleCtrl.text.trim().isEmpty) return;
|
||||
setState(() {
|
||||
_menuItems = [
|
||||
..._menuItems,
|
||||
DashboardMenuItem(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
title: titleCtrl.text.trim(),
|
||||
route: route,
|
||||
iconName: iconCtrl.text.trim().isEmpty ? 'list_alt' : iconCtrl.text.trim(),
|
||||
customIconPath: customIconPath,
|
||||
),
|
||||
];
|
||||
});
|
||||
_persistMenu();
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
child: const Text('追加'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _removeMenuItem(String id) {
|
||||
setState(() {
|
||||
_menuItems = _menuItems.where((e) => e.id != id).toList();
|
||||
});
|
||||
_persistMenu();
|
||||
}
|
||||
|
||||
void _reorderMenu(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) newIndex -= 1;
|
||||
final item = _menuItems.removeAt(oldIndex);
|
||||
_menuItems.insert(newIndex, item);
|
||||
});
|
||||
_persistMenu();
|
||||
}
|
||||
|
||||
String _routeLabel(String route) {
|
||||
switch (route) {
|
||||
case 'invoice_history':
|
||||
return 'A2:伝票一覧';
|
||||
case 'invoice_input':
|
||||
return 'A1:伝票入力';
|
||||
case 'customer_master':
|
||||
return 'C1:顧客マスター';
|
||||
case 'product_master':
|
||||
return 'P1:商品マスター';
|
||||
case 'master_hub':
|
||||
return 'M1:マスター管理';
|
||||
case 'settings':
|
||||
return 'S1:設定';
|
||||
default:
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _iconForName(String name) {
|
||||
return kIconsMap[name] ?? Icons.apps;
|
||||
}
|
||||
|
||||
Widget _menuLeading(DashboardMenuItem item) {
|
||||
if (item.customIconPath != null && File(item.customIconPath!).existsSync()) {
|
||||
return CircleAvatar(backgroundImage: FileImage(File(item.customIconPath!)));
|
||||
}
|
||||
return Icon(item.iconName != null ? _iconForName(item.iconName!) : Icons.apps);
|
||||
}
|
||||
|
||||
Future<void> _saveExternalSync() async {
|
||||
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<void> _saveBackup() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kBackupPath, _backupPathCtrl.text);
|
||||
await _appSettingsRepo.setString(_kBackupPath, _backupPathCtrl.text);
|
||||
_showSnackbar('バックアップ設定を保存しました');
|
||||
}
|
||||
|
||||
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
|
||||
|
||||
Future<void> _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<String, dynamic> decoded = jsonDecode(json);
|
||||
|
|
@ -199,31 +269,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
}
|
||||
|
||||
Future<void> _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<int>.generate(pb.length, (i) => pb[i] ^ kb[i % kb.length]);
|
||||
return base64Encode(ob);
|
||||
}
|
||||
|
||||
String _decryptWithFallback(String cipher) {
|
||||
if (cipher.isEmpty) return '';
|
||||
try {
|
||||
final ob = base64Decode(cipher);
|
||||
final kb = utf8.encode(_kCryptKey);
|
||||
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
|
||||
return utf8.decode(pb);
|
||||
} catch (_) {
|
||||
return cipher; // 旧プレーンテキストも許容
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
|
@ -247,33 +296,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
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),
|
||||
),
|
||||
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(
|
||||
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),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('メニューを追加'),
|
||||
onPressed: _loadingAppSettings ? null : _addMenuItem,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('ドラッグで並べ替え / ゴミ箱で削除', style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_loadingAppSettings
|
||||
? const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator()))
|
||||
: ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _menuItems.length,
|
||||
onReorder: _reorderMenu,
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = _menuItems[index];
|
||||
return ListTile(
|
||||
key: ValueKey(item.id),
|
||||
leading: _menuLeading(item),
|
||||
title: Text(item.title),
|
||||
subtitle: Text(_routeLabel(item.route)),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete_forever, color: Colors.redAccent),
|
||||
onPressed: () => _removeMenuItem(item.id),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('ホーム設定を保存'),
|
||||
onPressed: _loadingAppSettings ? null : _saveAppSettings,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -281,32 +365,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
),
|
||||
_section(
|
||||
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<SettingsScreen> {
|
|||
),
|
||||
),
|
||||
_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,
|
||||
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()),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_section(
|
||||
title: 'SMTP情報',
|
||||
subtitle: 'メール送信サーバ設定(テンプレ)',
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')),
|
||||
TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number),
|
||||
TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')),
|
||||
TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
|
||||
TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')),
|
||||
SwitchListTile(
|
||||
title: const Text('STARTTLS を使用'),
|
||||
value: _smtpTls,
|
||||
onChanged: (v) => setState(() => _smtpTls = v),
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('保存'),
|
||||
onPressed: _saveSmtp,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -413,7 +480,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
|||
ElevatedButton.icon(
|
||||
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('テーマ設定を保存しました');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
144
lib/services/app_settings_repository.dart
Normal file
144
lib/services/app_settings_repository.dart
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import 'dart:convert';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'database_helper.dart';
|
||||
|
||||
class AppSettingsRepository {
|
||||
static const _kHomeMode = 'home_mode'; // 'invoice_history' or 'dashboard'
|
||||
static const _kDashboardStatusEnabled = 'dashboard_status_enabled';
|
||||
static const _kDashboardStatusText = 'dashboard_status_text';
|
||||
static const _kDashboardMenu = 'dashboard_menu';
|
||||
static const _kDashboardHistoryUnlocked = 'dashboard_history_unlocked';
|
||||
static const _kTheme = 'app_theme'; // light / dark / system
|
||||
static const _kSummaryTheme = 'summary_theme'; // 'white' or 'blue'
|
||||
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> _ensureTable() async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<String> getHomeMode() async {
|
||||
final v = await _getValue(_kHomeMode);
|
||||
return v ?? 'invoice_history';
|
||||
}
|
||||
|
||||
Future<void> setHomeMode(String mode) async {
|
||||
await _setValue(_kHomeMode, mode);
|
||||
}
|
||||
|
||||
Future<bool> getDashboardStatusEnabled() async {
|
||||
final v = await _getValue(_kDashboardStatusEnabled);
|
||||
if (v == null) return true; // デフォルト表示ON
|
||||
return v == '1' || v.toLowerCase() == 'true';
|
||||
}
|
||||
|
||||
Future<void> setDashboardStatusEnabled(bool enabled) async {
|
||||
await _setValue(_kDashboardStatusEnabled, enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
Future<String> getDashboardStatusText() async {
|
||||
return await _getValue(_kDashboardStatusText) ?? '工事中';
|
||||
}
|
||||
|
||||
Future<void> setDashboardStatusText(String text) async {
|
||||
await _setValue(_kDashboardStatusText, text);
|
||||
}
|
||||
|
||||
Future<List<DashboardMenuItem>> getDashboardMenu() async {
|
||||
final raw = await _getValue(_kDashboardMenu);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return [DashboardMenuItem(id: 'a2', title: '伝票一覧', route: 'invoice_history', iconName: 'list_alt')];
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
return decoded.map((e) => DashboardMenuItem.fromJson(e as Map<String, dynamic>)).toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
return [DashboardMenuItem(id: 'a2', title: '伝票一覧', route: 'invoice_history', iconName: 'list_alt')];
|
||||
}
|
||||
|
||||
Future<void> setDashboardMenu(List<DashboardMenuItem> items) async {
|
||||
final raw = jsonEncode(items.map((e) => e.toJson()).toList());
|
||||
await _setValue(_kDashboardMenu, raw);
|
||||
}
|
||||
|
||||
Future<bool> getDashboardHistoryUnlocked() async => getBool(_kDashboardHistoryUnlocked, defaultValue: false);
|
||||
Future<void> setDashboardHistoryUnlocked(bool unlocked) async => setBool(_kDashboardHistoryUnlocked, unlocked);
|
||||
|
||||
Future<String> getTheme() async => await getString(_kTheme) ?? 'system';
|
||||
Future<void> setTheme(String theme) async => setString(_kTheme, theme);
|
||||
|
||||
Future<String> getSummaryTheme() async => await getString(_kSummaryTheme) ?? 'white';
|
||||
Future<void> setSummaryTheme(String theme) async => setString(_kSummaryTheme, theme);
|
||||
|
||||
// Generic helpers
|
||||
Future<String?> getString(String key) async => _getValue(key);
|
||||
Future<void> setString(String key, String value) async => _setValue(key, value);
|
||||
|
||||
Future<bool> getBool(String key, {bool defaultValue = false}) async {
|
||||
final v = await _getValue(key);
|
||||
if (v == null) return defaultValue;
|
||||
return v == '1' || v.toLowerCase() == 'true';
|
||||
}
|
||||
|
||||
Future<void> setBool(String key, bool value) async => _setValue(key, value ? '1' : '0');
|
||||
|
||||
Future<String?> _getValue(String key) async {
|
||||
await _ensureTable();
|
||||
final db = await _dbHelper.database;
|
||||
final res = await db.query('app_settings', where: 'key = ?', whereArgs: [key], limit: 1);
|
||||
if (res.isEmpty) return null;
|
||||
return res.first['value'] as String?;
|
||||
}
|
||||
|
||||
Future<void> _setValue(String key, String value) async {
|
||||
await _ensureTable();
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert('app_settings', {'key': key, 'value': value}, conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
}
|
||||
|
||||
class DashboardMenuItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final String route;
|
||||
final String? iconName; // Material icon name
|
||||
final String? customIconPath; // optional local file path
|
||||
|
||||
DashboardMenuItem({required this.id, required this.title, required this.route, this.iconName, this.customIconPath});
|
||||
|
||||
DashboardMenuItem copyWith({String? id, String? title, String? route, String? iconName, String? customIconPath}) {
|
||||
return DashboardMenuItem(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
route: route ?? this.route,
|
||||
iconName: iconName ?? this.iconName,
|
||||
customIconPath: customIconPath ?? this.customIconPath,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'route': route,
|
||||
'iconName': iconName,
|
||||
'customIconPath': customIconPath,
|
||||
};
|
||||
|
||||
factory DashboardMenuItem.fromJson(Map<String, dynamic> json) {
|
||||
return DashboardMenuItem(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
route: json['route'] as String,
|
||||
iconName: json['iconName'] as String?,
|
||||
customIconPath: json['customIconPath'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
265
lib/services/company_profile_service.dart
Normal file
265
lib/services/company_profile_service.dart
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../constants/company_profile_keys.dart';
|
||||
import '../constants/mail_templates.dart';
|
||||
import 'app_settings_repository.dart';
|
||||
|
||||
class CompanyBankAccount {
|
||||
final String bankName;
|
||||
final String branchName;
|
||||
final String accountType;
|
||||
final String accountNumber;
|
||||
final String holderName;
|
||||
final bool isActive;
|
||||
|
||||
const CompanyBankAccount({
|
||||
this.bankName = '',
|
||||
this.branchName = '',
|
||||
this.accountType = '普通',
|
||||
this.accountNumber = '',
|
||||
this.holderName = '',
|
||||
this.isActive = false,
|
||||
});
|
||||
|
||||
CompanyBankAccount copyWith({
|
||||
String? bankName,
|
||||
String? branchName,
|
||||
String? accountType,
|
||||
String? accountNumber,
|
||||
String? holderName,
|
||||
bool? isActive,
|
||||
}) {
|
||||
return CompanyBankAccount(
|
||||
bankName: bankName ?? this.bankName,
|
||||
branchName: branchName ?? this.branchName,
|
||||
accountType: accountType ?? this.accountType,
|
||||
accountNumber: accountNumber ?? this.accountNumber,
|
||||
holderName: holderName ?? this.holderName,
|
||||
isActive: isActive ?? this.isActive,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'bankName': bankName,
|
||||
'branchName': branchName,
|
||||
'accountType': accountType,
|
||||
'accountNumber': accountNumber,
|
||||
'holderName': holderName,
|
||||
'isActive': isActive,
|
||||
};
|
||||
|
||||
factory CompanyBankAccount.fromJson(Map<String, dynamic> json) {
|
||||
return CompanyBankAccount(
|
||||
bankName: json['bankName'] as String? ?? '',
|
||||
branchName: json['branchName'] as String? ?? '',
|
||||
accountType: json['accountType'] as String? ?? '普通',
|
||||
accountNumber: json['accountNumber'] as String? ?? '',
|
||||
holderName: json['holderName'] as String? ?? '',
|
||||
isActive: (json['isActive'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CompanyProfile {
|
||||
final String companyName;
|
||||
final String companyZip;
|
||||
final String companyAddress;
|
||||
final String companyTel;
|
||||
final String companyFax;
|
||||
final String companyEmail;
|
||||
final String companyUrl;
|
||||
final String companyReg;
|
||||
final String staffName;
|
||||
final String staffEmail;
|
||||
final String staffMobile;
|
||||
final List<CompanyBankAccount> bankAccounts;
|
||||
final double taxRate;
|
||||
final String taxDisplayMode;
|
||||
final String? sealPath;
|
||||
|
||||
const CompanyProfile({
|
||||
this.companyName = '',
|
||||
this.companyZip = '',
|
||||
this.companyAddress = '',
|
||||
this.companyTel = '',
|
||||
this.companyFax = '',
|
||||
this.companyEmail = '',
|
||||
this.companyUrl = '',
|
||||
this.companyReg = '',
|
||||
this.staffName = '',
|
||||
this.staffEmail = '',
|
||||
this.staffMobile = '',
|
||||
List<CompanyBankAccount>? bankAccounts,
|
||||
this.taxRate = 0.10,
|
||||
this.taxDisplayMode = 'normal',
|
||||
this.sealPath,
|
||||
}) : bankAccounts = bankAccounts ?? const [
|
||||
CompanyBankAccount(),
|
||||
CompanyBankAccount(),
|
||||
CompanyBankAccount(),
|
||||
CompanyBankAccount(),
|
||||
];
|
||||
|
||||
CompanyProfile copyWith({
|
||||
String? companyName,
|
||||
String? companyZip,
|
||||
String? companyAddress,
|
||||
String? companyTel,
|
||||
String? companyFax,
|
||||
String? companyEmail,
|
||||
String? companyUrl,
|
||||
String? companyReg,
|
||||
String? staffName,
|
||||
String? staffEmail,
|
||||
String? staffMobile,
|
||||
List<CompanyBankAccount>? bankAccounts,
|
||||
double? taxRate,
|
||||
String? taxDisplayMode,
|
||||
String? sealPath,
|
||||
}) {
|
||||
return CompanyProfile(
|
||||
companyName: companyName ?? this.companyName,
|
||||
companyZip: companyZip ?? this.companyZip,
|
||||
companyAddress: companyAddress ?? this.companyAddress,
|
||||
companyTel: companyTel ?? this.companyTel,
|
||||
companyFax: companyFax ?? this.companyFax,
|
||||
companyEmail: companyEmail ?? this.companyEmail,
|
||||
companyUrl: companyUrl ?? this.companyUrl,
|
||||
companyReg: companyReg ?? this.companyReg,
|
||||
staffName: staffName ?? this.staffName,
|
||||
staffEmail: staffEmail ?? this.staffEmail,
|
||||
staffMobile: staffMobile ?? this.staffMobile,
|
||||
bankAccounts: bankAccounts ?? this.bankAccounts,
|
||||
taxRate: taxRate ?? this.taxRate,
|
||||
taxDisplayMode: taxDisplayMode ?? this.taxDisplayMode,
|
||||
sealPath: sealPath ?? this.sealPath,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CompanyProfileService {
|
||||
CompanyProfileService({AppSettingsRepository? repo}) : _repo = repo ?? AppSettingsRepository();
|
||||
|
||||
final AppSettingsRepository _repo;
|
||||
|
||||
Future<CompanyProfile> loadProfile() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
Future<String> loadString(String key) async {
|
||||
final prefValue = prefs.getString(key);
|
||||
if (prefValue != null) return prefValue;
|
||||
return await _repo.getString(key) ?? '';
|
||||
}
|
||||
|
||||
final accountsRaw = prefs.getString(kCompanyBankAccountsKey) ?? await _repo.getString(kCompanyBankAccountsKey);
|
||||
final accounts = _decodeAccounts(accountsRaw);
|
||||
final taxRateStr = prefs.getString(kCompanyTaxRateKey) ?? await _repo.getString(kCompanyTaxRateKey);
|
||||
final taxMode = prefs.getString(kCompanyTaxDisplayModeKey) ?? await _repo.getString(kCompanyTaxDisplayModeKey);
|
||||
final sealPath = prefs.getString(kCompanySealPathKey) ?? await _repo.getString(kCompanySealPathKey);
|
||||
|
||||
return CompanyProfile(
|
||||
companyName: await loadString(kCompanyNameKey),
|
||||
companyZip: await loadString(kCompanyZipKey),
|
||||
companyAddress: await loadString(kCompanyAddressKey),
|
||||
companyTel: await loadString(kCompanyTelKey),
|
||||
companyFax: await loadString(kCompanyFaxKey),
|
||||
companyEmail: await loadString(kCompanyEmailKey),
|
||||
companyUrl: await loadString(kCompanyUrlKey),
|
||||
companyReg: await loadString(kCompanyRegKey),
|
||||
staffName: await loadString(kStaffNameKey),
|
||||
staffEmail: await loadString(kStaffEmailKey),
|
||||
staffMobile: await loadString(kStaffMobileKey),
|
||||
bankAccounts: accounts,
|
||||
taxRate: double.tryParse(taxRateStr ?? '') ?? 0.10,
|
||||
taxDisplayMode: taxMode ?? 'normal',
|
||||
sealPath: sealPath?.isNotEmpty == true ? sealPath : null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> saveProfile(CompanyProfile profile) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
Future<void> persist(String key, String value) async {
|
||||
await prefs.setString(key, value);
|
||||
await _repo.setString(key, value);
|
||||
}
|
||||
|
||||
await persist(kCompanyNameKey, profile.companyName);
|
||||
await persist(kCompanyZipKey, profile.companyZip);
|
||||
await persist(kCompanyAddressKey, profile.companyAddress);
|
||||
await persist(kCompanyTelKey, profile.companyTel);
|
||||
await persist(kCompanyFaxKey, profile.companyFax);
|
||||
await persist(kCompanyEmailKey, profile.companyEmail);
|
||||
await persist(kCompanyUrlKey, profile.companyUrl);
|
||||
await persist(kCompanyRegKey, profile.companyReg);
|
||||
await persist(kStaffNameKey, profile.staffName);
|
||||
await persist(kStaffEmailKey, profile.staffEmail);
|
||||
await persist(kStaffMobileKey, profile.staffMobile);
|
||||
|
||||
final accountsJson = jsonEncode(profile.bankAccounts.map((e) => e.toJson()).toList());
|
||||
await persist(kCompanyBankAccountsKey, accountsJson);
|
||||
await persist(kCompanyTaxRateKey, profile.taxRate.toString());
|
||||
await persist(kCompanyTaxDisplayModeKey, profile.taxDisplayMode);
|
||||
await persist(kCompanySealPathKey, profile.sealPath ?? '');
|
||||
}
|
||||
|
||||
Future<Map<String, String>> buildMailPlaceholderMap({
|
||||
required String filename,
|
||||
required String hash,
|
||||
}) async {
|
||||
final profile = await loadProfile();
|
||||
final activeAccounts = profile.bankAccounts.where((e) => e.isActive && e.bankName.trim().isNotEmpty).toList();
|
||||
final bankText = _composeBankText(activeAccounts);
|
||||
|
||||
return {
|
||||
kMailPlaceholderFilename: filename,
|
||||
kMailPlaceholderHash: hash,
|
||||
kMailPlaceholderCompanyName: profile.companyName.isNotEmpty ? profile.companyName : '弊社',
|
||||
kMailPlaceholderCompanyEmail: profile.companyEmail.isNotEmpty ? profile.companyEmail : profile.staffEmail,
|
||||
kMailPlaceholderCompanyTel: profile.companyTel,
|
||||
kMailPlaceholderCompanyAddress: profile.companyAddress,
|
||||
kMailPlaceholderCompanyReg: profile.companyReg,
|
||||
kMailPlaceholderStaffName: profile.staffName.isNotEmpty ? profile.staffName : '担当者',
|
||||
kMailPlaceholderStaffEmail: profile.staffEmail,
|
||||
kMailPlaceholderStaffMobile: profile.staffMobile.isNotEmpty ? profile.staffMobile : '---',
|
||||
kMailPlaceholderBankAccounts: bankText,
|
||||
};
|
||||
}
|
||||
|
||||
List<CompanyBankAccount> _decodeAccounts(String? raw) {
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return List.generate(kCompanyBankSlotCount, (_) => const CompanyBankAccount());
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is List) {
|
||||
final list = decoded
|
||||
.map((e) => CompanyBankAccount.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList();
|
||||
while (list.length < kCompanyBankSlotCount) {
|
||||
list.add(const CompanyBankAccount());
|
||||
}
|
||||
return list.take(kCompanyBankSlotCount).toList();
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore malformed data
|
||||
}
|
||||
return List.generate(kCompanyBankSlotCount, (_) => const CompanyBankAccount());
|
||||
}
|
||||
|
||||
String _composeBankText(List<CompanyBankAccount> accounts) {
|
||||
if (accounts.isEmpty) {
|
||||
return '振込先: ご入金方法は別途ご案内いたします。';
|
||||
}
|
||||
final buffer = StringBuffer('振込先:\n');
|
||||
for (var i = 0; i < accounts.length && i < kCompanyBankActiveLimit; i++) {
|
||||
final acc = accounts[i];
|
||||
buffer.writeln(
|
||||
'(${i + 1}) ${acc.bankName} ${acc.branchName} ${acc.accountType} ${acc.accountNumber} ${acc.holderName}',
|
||||
);
|
||||
}
|
||||
return buffer.toString().trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -9,21 +9,28 @@ class CustomerRepository {
|
|||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||
|
||||
Future<List<Customer>> getAllCustomers() async {
|
||||
Future<List<Customer>> getAllCustomers({bool includeHidden = false}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final filter = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
|
||||
List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
|
||||
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
|
||||
FROM customers c
|
||||
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||
ORDER BY c.display_name ASC
|
||||
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
|
||||
$filter
|
||||
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
|
||||
''');
|
||||
if (maps.isEmpty) {
|
||||
await _generateSampleCustomers(limit: 3);
|
||||
maps = await db.rawQuery('''
|
||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
|
||||
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
|
||||
FROM customers c
|
||||
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||
ORDER BY c.display_name ASC
|
||||
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
|
||||
$filter
|
||||
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
|
||||
''');
|
||||
}
|
||||
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
||||
|
|
@ -128,14 +135,17 @@ class CustomerRepository {
|
|||
);
|
||||
}
|
||||
|
||||
Future<List<Customer>> searchCustomers(String query) async {
|
||||
Future<List<Customer>> searchCustomers(String query, {bool includeHidden = false}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final where = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
|
||||
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
|
||||
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
|
||||
FROM customers c
|
||||
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||
WHERE c.display_name LIKE ? OR c.formal_name LIKE ?
|
||||
ORDER BY c.display_name ASC
|
||||
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
|
||||
WHERE (c.display_name LIKE ? OR c.formal_name LIKE ?) $where
|
||||
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
|
||||
LIMIT 50
|
||||
''', ['%$query%', '%$query%']);
|
||||
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
||||
|
|
@ -173,6 +183,25 @@ class CustomerRepository {
|
|||
return CustomerContact.fromMap(rows.first);
|
||||
}
|
||||
|
||||
Future<void> setHidden(String id, bool hidden) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert(
|
||||
'master_hidden',
|
||||
{
|
||||
'master_type': 'customer',
|
||||
'master_id': id,
|
||||
'is_hidden': hidden ? 1 : 0,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _logRepo.logAction(
|
||||
action: hidden ? "HIDE_CUSTOMER" : "UNHIDE_CUSTOMER",
|
||||
targetType: "CUSTOMER",
|
||||
targetId: id,
|
||||
details: hidden ? "顧客を非表示にしました" : "顧客を再表示しました",
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async {
|
||||
final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]);
|
||||
final current = res.first['v'] as int?;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
|||
import 'package:path/path.dart';
|
||||
|
||||
class DatabaseHelper {
|
||||
static const _databaseVersion = 20;
|
||||
static const _databaseVersion = 25;
|
||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||
static Database? _database;
|
||||
|
||||
|
|
@ -164,6 +164,37 @@ class DatabaseHelper {
|
|||
if (oldVersion < 20) {
|
||||
await _safeAddColumn(db, 'customers', 'email TEXT');
|
||||
}
|
||||
if (oldVersion < 22) {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
''');
|
||||
}
|
||||
if (oldVersion < 23) {
|
||||
await _safeAddColumn(db, 'customers', 'is_hidden INTEGER DEFAULT 0');
|
||||
await _safeAddColumn(db, 'products', 'is_hidden INTEGER DEFAULT 0');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_customers_hidden ON customers(is_hidden)');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_products_hidden ON products(is_hidden)');
|
||||
}
|
||||
if (oldVersion < 24) {
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS master_hidden (
|
||||
master_type TEXT NOT NULL,
|
||||
master_id TEXT NOT NULL,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
PRIMARY KEY(master_type, master_id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_master_hidden_type ON master_hidden(master_type)');
|
||||
}
|
||||
if (oldVersion < 25) {
|
||||
await _safeAddColumn(db, 'invoices', 'company_snapshot TEXT');
|
||||
await _safeAddColumn(db, 'invoices', 'company_seal_hash TEXT');
|
||||
await _safeAddColumn(db, 'invoices', 'meta_json TEXT');
|
||||
await _safeAddColumn(db, 'invoices', 'meta_hash TEXT');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onCreate(Database db, int version) async {
|
||||
|
|
@ -182,6 +213,7 @@ class DatabaseHelper {
|
|||
head_char1 TEXT,
|
||||
head_char2 TEXT,
|
||||
is_locked INTEGER DEFAULT 0,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
is_synced INTEGER DEFAULT 0,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
|
|
@ -223,12 +255,23 @@ class DatabaseHelper {
|
|||
category TEXT,
|
||||
stock_quantity INTEGER DEFAULT 0,
|
||||
is_locked INTEGER DEFAULT 0,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
odoo_id TEXT
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX idx_products_name ON products(name)');
|
||||
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE master_hidden (
|
||||
master_type TEXT NOT NULL,
|
||||
master_id TEXT NOT NULL,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
PRIMARY KEY(master_type, master_id)
|
||||
)
|
||||
''');
|
||||
await db.execute('CREATE INDEX idx_master_hidden_type ON master_hidden(master_type)');
|
||||
|
||||
// 伝票マスター
|
||||
await db.execute('''
|
||||
CREATE TABLE invoices (
|
||||
|
|
@ -255,6 +298,10 @@ class DatabaseHelper {
|
|||
contact_email_snapshot TEXT,
|
||||
contact_tel_snapshot TEXT,
|
||||
contact_address_snapshot TEXT,
|
||||
company_snapshot TEXT,
|
||||
company_seal_hash TEXT,
|
||||
meta_json TEXT,
|
||||
meta_hash TEXT,
|
||||
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
||||
)
|
||||
''');
|
||||
|
|
@ -305,6 +352,13 @@ class DatabaseHelper {
|
|||
timestamp TEXT NOT NULL
|
||||
)
|
||||
''');
|
||||
|
||||
await db.execute('''
|
||||
CREATE TABLE app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _safeAddColumn(Database db, String table, String columnDef) async {
|
||||
|
|
|
|||
60
lib/services/edit_log_repository.dart
Normal file
60
lib/services/edit_log_repository.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'database_helper.dart';
|
||||
|
||||
class EditLogRepository {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
|
||||
Future<void> _ensureTable() async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS edit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
invoice_id TEXT,
|
||||
message TEXT,
|
||||
created_at INTEGER
|
||||
)
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> addLog(String invoiceId, String message) async {
|
||||
await _ensureTable();
|
||||
final db = await _dbHelper.database;
|
||||
final now = DateTime.now().millisecondsSinceEpoch;
|
||||
// cleanup older than 30 days
|
||||
final cutoff = DateTime.now().subtract(const Duration(days: 30)).millisecondsSinceEpoch;
|
||||
await db.delete('edit_logs', where: 'created_at < ?', whereArgs: [cutoff]);
|
||||
await db.insert('edit_logs', {
|
||||
'invoice_id': invoiceId,
|
||||
'message': message,
|
||||
'created_at': now,
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<EditLogEntry>> getLogs(String invoiceId) async {
|
||||
await _ensureTable();
|
||||
final db = await _dbHelper.database;
|
||||
final cutoff = DateTime.now().subtract(const Duration(days: 14)).millisecondsSinceEpoch;
|
||||
final res = await db.query(
|
||||
'edit_logs',
|
||||
where: 'invoice_id = ? AND created_at >= ?',
|
||||
whereArgs: [invoiceId, cutoff],
|
||||
orderBy: 'created_at DESC',
|
||||
);
|
||||
return res
|
||||
.map((e) => EditLogEntry(
|
||||
id: e['id'] as int,
|
||||
invoiceId: e['invoice_id'] as String,
|
||||
message: e['message'] as String,
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(e['created_at'] as int),
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
class EditLogEntry {
|
||||
final int id;
|
||||
final String invoiceId;
|
||||
final String message;
|
||||
final DateTime createdAt;
|
||||
|
||||
EditLogEntry({required this.id, required this.invoiceId, required this.message, required this.createdAt});
|
||||
}
|
||||
238
lib/services/email_sender.dart
Normal file
238
lib/services/email_sender.dart
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:mailer/mailer.dart';
|
||||
import 'package:mailer/smtp_server.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class EmailSenderConfig {
|
||||
final String host;
|
||||
final int port;
|
||||
final String username;
|
||||
final String password;
|
||||
final bool useTls;
|
||||
final bool ignoreBadCert;
|
||||
final List<String> bcc;
|
||||
|
||||
const EmailSenderConfig({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.username,
|
||||
required this.password,
|
||||
this.useTls = true,
|
||||
this.ignoreBadCert = false,
|
||||
this.bcc = const [],
|
||||
});
|
||||
|
||||
bool get isValid => host.isNotEmpty && username.isNotEmpty && password.isNotEmpty;
|
||||
}
|
||||
|
||||
class EmailSender {
|
||||
static const _kCryptKey = 'test';
|
||||
static const _kLogsKey = 'smtp_logs';
|
||||
static const int _kMaxLogLines = 1000;
|
||||
|
||||
static List<String> parseBcc(String raw) {
|
||||
return raw
|
||||
.split(RegExp('[,\n]'))
|
||||
.map((s) => s.trim())
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static String decrypt(String cipher) {
|
||||
if (cipher.isEmpty) return '';
|
||||
try {
|
||||
final ob = base64Decode(cipher);
|
||||
final kb = utf8.encode(_kCryptKey);
|
||||
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
|
||||
return utf8.decode(pb);
|
||||
} catch (_) {
|
||||
return cipher;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _appendLog(String line) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final now = DateTime.now().toIso8601String();
|
||||
final entry = '[$now] $line';
|
||||
final existing = List<String>.from(prefs.getStringList(_kLogsKey) ?? const <String>[]);
|
||||
existing.add(entry);
|
||||
if (existing.length > _kMaxLogLines) {
|
||||
final dropCount = existing.length - _kMaxLogLines;
|
||||
existing.removeRange(0, dropCount);
|
||||
}
|
||||
await prefs.setStringList(_kLogsKey, existing);
|
||||
}
|
||||
|
||||
static Future<List<String>> loadLogs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getStringList(_kLogsKey) ?? <String>[];
|
||||
}
|
||||
|
||||
static Future<void> clearLogs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kLogsKey);
|
||||
}
|
||||
|
||||
static Future<bool> _checkPortOpen(String host, int port, {Duration timeout = const Duration(seconds: 5)}) async {
|
||||
try {
|
||||
final socket = await Socket.connect(host, port, timeout: timeout);
|
||||
await socket.close();
|
||||
await _appendLog('[TEST][PORT][OK] $host:$port reachable');
|
||||
return true;
|
||||
} catch (e) {
|
||||
await _appendLog('[TEST][PORT][NG] $host:$port err=$e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> _checkAndLogConfig({required EmailSenderConfig config, required String channel}) async {
|
||||
final checks = <String, bool>{
|
||||
'host': config.host.isNotEmpty,
|
||||
'port': config.port > 0,
|
||||
'user': config.username.isNotEmpty,
|
||||
'pass': config.password.isNotEmpty,
|
||||
'bcc': config.bcc.isNotEmpty,
|
||||
};
|
||||
|
||||
String valMask(String key) {
|
||||
switch (key) {
|
||||
case 'host':
|
||||
return config.host;
|
||||
case 'port':
|
||||
return config.port.toString();
|
||||
case 'user':
|
||||
return config.username;
|
||||
case 'pass':
|
||||
return config.password.isNotEmpty ? '***' : '';
|
||||
case 'bcc':
|
||||
return config.bcc.join(',');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
final summary = checks.entries
|
||||
.map((e) => '${e.key}=${valMask(e.key)} (${e.value ? 'OK' : 'NG'})')
|
||||
.join(' | ');
|
||||
final tail = 'tls=${config.useTls} ignoreBadCert=${config.ignoreBadCert}';
|
||||
await _appendLog('[$channel][CFG] $summary | $tail');
|
||||
|
||||
return checks.values.every((v) => v);
|
||||
}
|
||||
|
||||
static SmtpServer _serverFromConfig(EmailSenderConfig config) {
|
||||
return SmtpServer(
|
||||
config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
ssl: !config.useTls,
|
||||
allowInsecure: config.ignoreBadCert || !config.useTls,
|
||||
ignoreBadCertificate: config.ignoreBadCert,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<EmailSenderConfig?> loadConfigFromPrefs() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final host = (prefs.getString('smtp_host') ?? '').trim();
|
||||
final portStr = (prefs.getString('smtp_port') ?? '587').trim();
|
||||
final user = (prefs.getString('smtp_user') ?? '').trim();
|
||||
final passEncrypted = prefs.getString('smtp_pass') ?? '';
|
||||
final pass = decrypt(passEncrypted).trim();
|
||||
final useTls = prefs.getBool('smtp_tls') ?? true;
|
||||
final ignoreBadCert = prefs.getBool('smtp_ignore_bad_cert') ?? false;
|
||||
final bccRaw = prefs.getString('smtp_bcc') ?? '';
|
||||
final bccList = parseBcc(bccRaw);
|
||||
final port = int.tryParse(portStr) ?? 587;
|
||||
|
||||
final config = EmailSenderConfig(
|
||||
host: host,
|
||||
port: port,
|
||||
username: user,
|
||||
password: pass,
|
||||
useTls: useTls,
|
||||
ignoreBadCert: ignoreBadCert,
|
||||
bcc: bccList,
|
||||
);
|
||||
if (!config.isValid) {
|
||||
await _appendLog('[CFG][NG] host/user/pass が未入力の可能性があります');
|
||||
return null;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
static Future<void> sendTest({required EmailSenderConfig config}) async {
|
||||
final server = _serverFromConfig(config);
|
||||
final message = Message()
|
||||
..from = Address(config.username)
|
||||
..bccRecipients = config.bcc
|
||||
..subject = 'SMTPテスト送信'
|
||||
..text = 'これはテストメールです(BCC送信)';
|
||||
|
||||
final configOk = await _checkAndLogConfig(config: config, channel: 'TEST');
|
||||
if (!configOk) {
|
||||
throw StateError('SMTP設定が不足しています');
|
||||
}
|
||||
|
||||
await _checkPortOpen(config.host, config.port);
|
||||
|
||||
try {
|
||||
await send(message, server);
|
||||
await _appendLog('[TEST][OK] bcc: ${config.bcc.join(',')}');
|
||||
} catch (e) {
|
||||
await _appendLog('[TEST][NG] err=$e (認証/暗号化設定を確認してください)');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> sendInvoiceEmail({
|
||||
required EmailSenderConfig config,
|
||||
required String toEmail,
|
||||
required File pdfFile,
|
||||
String? subject,
|
||||
String? attachmentFileName,
|
||||
String? body,
|
||||
}) async {
|
||||
final server = _serverFromConfig(config);
|
||||
final message = Message()
|
||||
..from = Address(config.username)
|
||||
..recipients = [toEmail]
|
||||
..bccRecipients = config.bcc
|
||||
..subject = subject ?? '請求書送付'
|
||||
..text = body ?? '請求書をお送りします。ご確認ください。'
|
||||
..attachments = [
|
||||
FileAttachment(pdfFile)
|
||||
..fileName = attachmentFileName ?? 'invoice.pdf'
|
||||
..contentType = 'application/pdf'
|
||||
];
|
||||
|
||||
final configOk = await _checkAndLogConfig(config: config, channel: 'INVOICE');
|
||||
if (!configOk) {
|
||||
throw StateError('SMTP設定が不足しています');
|
||||
}
|
||||
|
||||
try {
|
||||
await send(message, server);
|
||||
await _appendLog('[INVOICE][OK] to: $toEmail bcc: ${config.bcc.join(',')}');
|
||||
} catch (e) {
|
||||
await _appendLog('[INVOICE][NG] to: $toEmail err: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> logDeviceMailer({
|
||||
required bool success,
|
||||
required String toEmail,
|
||||
required List<String> bcc,
|
||||
String? error,
|
||||
}) async {
|
||||
final status = success ? 'OK' : 'NG';
|
||||
final buffer = StringBuffer('[DEVICE][$status] to: $toEmail bcc: ${bcc.join(',')}');
|
||||
if (error != null && error.isNotEmpty) {
|
||||
buffer.write(' err: $error');
|
||||
}
|
||||
await _appendLog(buffer.toString());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
|
|
@ -6,15 +8,38 @@ import '../models/customer_model.dart';
|
|||
import '../models/customer_contact.dart';
|
||||
import 'database_helper.dart';
|
||||
import 'activity_log_repository.dart';
|
||||
import 'company_repository.dart';
|
||||
|
||||
class InvoiceRepository {
|
||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||
final CompanyRepository _companyRepo = CompanyRepository();
|
||||
|
||||
Future<void> saveInvoice(Invoice invoice) async {
|
||||
final db = await _dbHelper.database;
|
||||
|
||||
// 正式発行(下書きでない)場合はロックを掛ける
|
||||
final companyInfo = await _companyRepo.getCompanyInfo();
|
||||
String? sealHash;
|
||||
if (companyInfo.sealPath != null) {
|
||||
final file = File(companyInfo.sealPath!);
|
||||
if (await file.exists()) {
|
||||
sealHash = sha256.convert(await file.readAsBytes()).toString();
|
||||
}
|
||||
}
|
||||
final companySnapshot = jsonEncode({
|
||||
'name': companyInfo.name,
|
||||
'zipCode': companyInfo.zipCode,
|
||||
'address': companyInfo.address,
|
||||
'tel': companyInfo.tel,
|
||||
'fax': companyInfo.fax,
|
||||
'email': companyInfo.email,
|
||||
'url': companyInfo.url,
|
||||
'defaultTaxRate': companyInfo.defaultTaxRate,
|
||||
'taxDisplayMode': companyInfo.taxDisplayMode,
|
||||
'registrationNumber': companyInfo.registrationNumber,
|
||||
});
|
||||
|
||||
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
|
||||
|
||||
await db.transaction((txn) async {
|
||||
|
|
@ -29,6 +54,10 @@ class InvoiceRepository {
|
|||
contactEmailSnapshot: activeContact?.email,
|
||||
contactTelSnapshot: activeContact?.tel,
|
||||
contactAddressSnapshot: activeContact?.address,
|
||||
companySnapshot: companySnapshot,
|
||||
companySealHash: sealHash,
|
||||
metaJson: null,
|
||||
metaHash: null,
|
||||
);
|
||||
|
||||
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
|
||||
|
|
@ -150,6 +179,10 @@ class InvoiceRepository {
|
|||
contactEmailSnapshot: iMap['contact_email_snapshot'],
|
||||
contactTelSnapshot: iMap['contact_tel_snapshot'],
|
||||
contactAddressSnapshot: iMap['contact_address_snapshot'],
|
||||
companySnapshot: iMap['company_snapshot'],
|
||||
companySealHash: iMap['company_seal_hash'],
|
||||
metaJson: iMap['meta_json'],
|
||||
metaHash: iMap['meta_hash'],
|
||||
));
|
||||
}
|
||||
return invoices;
|
||||
|
|
@ -248,6 +281,21 @@ class InvoiceRepository {
|
|||
}
|
||||
}
|
||||
|
||||
/// meta_json と meta_hash の整合性を検証する(trueなら一致)。
|
||||
bool verifyInvoiceMeta(Invoice invoice) {
|
||||
final metaJson = invoice.metaJson ?? invoice.metaJsonValue;
|
||||
final expected = sha256.convert(utf8.encode(metaJson)).toString();
|
||||
final stored = invoice.metaHash ?? expected;
|
||||
return expected == stored;
|
||||
}
|
||||
|
||||
/// IDを指定してDBから取得し、メタデータ整合性を検証する。
|
||||
Future<bool> verifyInvoiceMetaById(String id, List<Customer> customers) async {
|
||||
final invoices = await getAllInvoices(customers);
|
||||
final target = invoices.firstWhere((i) => i.id == id, orElse: () => throw Exception('invoice not found'));
|
||||
return verifyInvoiceMeta(target);
|
||||
}
|
||||
|
||||
Future<Map<String, int>> getMonthlySales(int year) async {
|
||||
final db = await _dbHelper.database;
|
||||
final String yearStr = year.toString();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,15 @@ import 'activity_log_repository.dart';
|
|||
|
||||
/// PDFドキュメントの構築(プレビューと実保存の両方で使用)
|
||||
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||
final pdf = pw.Document();
|
||||
final metaJson = invoice.metaJsonValue;
|
||||
final metaHash = invoice.metaHashValue;
|
||||
|
||||
final pdf = pw.Document(
|
||||
title: '${invoice.documentTypeName} ${invoice.invoiceNumber}',
|
||||
author: 'h1-app',
|
||||
subject: 'metaHash:$metaHash',
|
||||
keywords: metaJson,
|
||||
);
|
||||
|
||||
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
||||
final ipaex = pw.Font.ttf(fontData);
|
||||
|
|
@ -221,7 +229,7 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
|||
pw.Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: invoice.contentHash, drawText: false),
|
||||
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: metaHash, drawText: false),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -8,27 +8,38 @@ class ProductRepository {
|
|||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||
|
||||
Future<List<Product>> getAllProducts() async {
|
||||
Future<List<Product>> getAllProducts({bool includeHidden = false}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'name ASC');
|
||||
final String where = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
|
||||
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||
SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
|
||||
FROM products p
|
||||
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
|
||||
$where
|
||||
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
|
||||
''');
|
||||
|
||||
if (maps.isEmpty) {
|
||||
await _generateSampleProducts();
|
||||
return getAllProducts();
|
||||
return getAllProducts(includeHidden: includeHidden);
|
||||
}
|
||||
|
||||
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
Future<List<Product>> searchProducts(String query) async {
|
||||
Future<List<Product>> searchProducts(String query, {bool includeHidden = false}) async {
|
||||
final db = await _dbHelper.database;
|
||||
final List<Map<String, dynamic>> maps = await db.query(
|
||||
'products',
|
||||
where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?',
|
||||
whereArgs: ['%$query%', '%$query%', '%$query%'],
|
||||
orderBy: 'name ASC',
|
||||
limit: 50,
|
||||
);
|
||||
final args = ['%$query%', '%$query%', '%$query%'];
|
||||
final String whereHidden = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
|
||||
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||
SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
|
||||
FROM products p
|
||||
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
|
||||
WHERE (p.name LIKE ? OR p.barcode LIKE ? OR p.category LIKE ?)
|
||||
$whereHidden
|
||||
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
|
||||
LIMIT 50
|
||||
''', args);
|
||||
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
||||
}
|
||||
|
||||
|
|
@ -81,4 +92,23 @@ class ProductRepository {
|
|||
details: "商品を削除しました",
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setHidden(String id, bool hidden) async {
|
||||
final db = await _dbHelper.database;
|
||||
await db.insert(
|
||||
'master_hidden',
|
||||
{
|
||||
'master_type': 'product',
|
||||
'master_id': id,
|
||||
'is_hidden': hidden ? 1 : 0,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
await _logRepo.logAction(
|
||||
action: hidden ? "HIDE_PRODUCT" : "UNHIDE_PRODUCT",
|
||||
targetType: "PRODUCT",
|
||||
targetId: id,
|
||||
details: hidden ? "商品を非表示にしました" : "商品を再表示しました",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
31
lib/services/theme_controller.dart
Normal file
31
lib/services/theme_controller.dart
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'app_settings_repository.dart';
|
||||
|
||||
class AppThemeController {
|
||||
AppThemeController._internal();
|
||||
static final AppThemeController instance = AppThemeController._internal();
|
||||
|
||||
final AppSettingsRepository _repo = AppSettingsRepository();
|
||||
final ValueNotifier<ThemeMode> notifier = ValueNotifier<ThemeMode>(ThemeMode.system);
|
||||
|
||||
Future<void> load() async {
|
||||
final theme = await _repo.getTheme();
|
||||
notifier.value = _toMode(theme);
|
||||
}
|
||||
|
||||
Future<void> setTheme(String theme) async {
|
||||
await _repo.setTheme(theme);
|
||||
notifier.value = _toMode(theme);
|
||||
}
|
||||
|
||||
ThemeMode _toMode(String v) {
|
||||
switch (v) {
|
||||
case 'light':
|
||||
return ThemeMode.light;
|
||||
case 'dark':
|
||||
return ThemeMode.dark;
|
||||
default:
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
lib/utils/build_expiry_info.dart
Normal file
39
lib/utils/build_expiry_info.dart
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class BuildExpiryInfo {
|
||||
BuildExpiryInfo._(this.buildTimestamp, this.lifespan, this._hasValidTimestamp);
|
||||
|
||||
factory BuildExpiryInfo.fromEnvironment({Duration lifespan = const Duration(days: 90)}) {
|
||||
const rawTimestamp = String.fromEnvironment('APP_BUILD_TIMESTAMP');
|
||||
if (rawTimestamp.isEmpty) {
|
||||
debugPrint('[BuildExpiry] APP_BUILD_TIMESTAMP is missing; expiry guard disabled.');
|
||||
return BuildExpiryInfo._(null, lifespan, false);
|
||||
}
|
||||
|
||||
final parsed = DateTime.tryParse(rawTimestamp);
|
||||
if (parsed == null) {
|
||||
debugPrint('[BuildExpiry] Invalid APP_BUILD_TIMESTAMP: $rawTimestamp. Expiry guard disabled.');
|
||||
return BuildExpiryInfo._(null, lifespan, false);
|
||||
}
|
||||
|
||||
return BuildExpiryInfo._(parsed.toUtc(), lifespan, true);
|
||||
}
|
||||
|
||||
final DateTime? buildTimestamp;
|
||||
final Duration lifespan;
|
||||
final bool _hasValidTimestamp;
|
||||
|
||||
bool get isEnforced => _hasValidTimestamp && buildTimestamp != null;
|
||||
|
||||
DateTime? get expiryTimestamp => buildTimestamp?.add(lifespan);
|
||||
|
||||
bool get isExpired {
|
||||
if (!isEnforced || expiryTimestamp == null) return false;
|
||||
return DateTime.now().toUtc().isAfter(expiryTimestamp!);
|
||||
}
|
||||
|
||||
Duration? get remaining {
|
||||
if (!isEnforced || expiryTimestamp == null) return null;
|
||||
return expiryTimestamp!.difference(DateTime.now().toUtc());
|
||||
}
|
||||
}
|
||||
127
lib/widgets/contact_picker_sheet.dart
Normal file
127
lib/widgets/contact_picker_sheet.dart
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
|
||||
class ContactPickerSheet extends StatefulWidget {
|
||||
const ContactPickerSheet({super.key, required this.contacts, this.title = '電話帳から選択'});
|
||||
|
||||
final List<Contact> contacts;
|
||||
final String title;
|
||||
|
||||
@override
|
||||
State<ContactPickerSheet> createState() => _ContactPickerSheetState();
|
||||
}
|
||||
|
||||
class _ContactPickerSheetState extends State<ContactPickerSheet> {
|
||||
late List<Contact> _filtered;
|
||||
final TextEditingController _searchCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_filtered = widget.contacts;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _applyFilter(String query) {
|
||||
final lower = query.toLowerCase();
|
||||
setState(() {
|
||||
_filtered = widget.contacts
|
||||
.where((contact) {
|
||||
final org = contact.organizations.isNotEmpty ? contact.organizations.first.company : '';
|
||||
final label = org.isNotEmpty ? org : contact.displayName;
|
||||
return label.toLowerCase().contains(lower);
|
||||
})
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SafeArea(
|
||||
top: true,
|
||||
child: DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.9,
|
||||
minChildSize: 0.6,
|
||||
maxChildSize: 0.95,
|
||||
builder: (context, controller) {
|
||||
return Material(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
elevation: 8,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(color: Colors.grey.shade400, borderRadius: BorderRadius.circular(999)),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(widget.title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '閉じる',
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
hintText: '会社名・氏名で検索',
|
||||
filled: true,
|
||||
fillColor: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
),
|
||||
onChanged: _applyFilter,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _filtered.isEmpty
|
||||
? const Center(child: Text('一致する連絡先が見つかりません'))
|
||||
: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
final contact = _filtered[index];
|
||||
final org = contact.organizations.isNotEmpty ? contact.organizations.first.company : '';
|
||||
final title = org.isNotEmpty ? org : contact.displayName;
|
||||
final tel = contact.phones.isNotEmpty ? contact.phones.first.number : null;
|
||||
final email = contact.emails.isNotEmpty ? contact.emails.first.address : null;
|
||||
final subtitle = [tel, email].where((v) => v != null && v.trim().isNotEmpty).join(' / ');
|
||||
return ListTile(
|
||||
title: Text(title),
|
||||
subtitle: subtitle.isNotEmpty ? Text(subtitle) : null,
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||
child: Text(title.isNotEmpty ? title.characters.first : '?'),
|
||||
),
|
||||
onTap: () => Navigator.pop(context, contact),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
import 'dart:typed_data';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../services/pdf_generator.dart';
|
||||
import 'package:mailer/mailer.dart';
|
||||
import 'package:mailer/smtp_server.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:flutter_email_sender/flutter_email_sender.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:printing/printing.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../constants/mail_send_method.dart';
|
||||
import '../constants/mail_templates.dart';
|
||||
import '../models/invoice_models.dart';
|
||||
import '../services/company_profile_service.dart';
|
||||
import '../services/email_sender.dart';
|
||||
import '../services/pdf_generator.dart';
|
||||
|
||||
class InvoicePdfPreviewPage extends StatelessWidget {
|
||||
final Invoice invoice;
|
||||
|
|
@ -39,24 +45,17 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
|||
Future<void> _sendEmail(BuildContext context) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final host = prefs.getString('smtp_host') ?? '';
|
||||
final portStr = prefs.getString('smtp_port') ?? '587';
|
||||
final user = prefs.getString('smtp_user') ?? '';
|
||||
final pass = prefs.getString('smtp_pass') ?? '';
|
||||
final useTls = prefs.getBool('smtp_tls') ?? true;
|
||||
final mailMethod = normalizeMailSendMethod(prefs.getString(kMailSendMethodPrefKey));
|
||||
final bccRaw = prefs.getString('smtp_bcc') ?? '';
|
||||
final bccList = bccRaw.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
||||
final bccList = EmailSender.parseBcc(bccRaw);
|
||||
|
||||
if (host.isEmpty || user.isEmpty || pass.isEmpty) {
|
||||
if (bccList.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('BCCは必須項目です(設定画面で登録してください)')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final port = int.tryParse(portStr) ?? 587;
|
||||
final smtpServer = SmtpServer(host, port: port, username: user, password: pass, ignoreBadCertificate: false, ssl: !useTls, allowInsecure: !useTls);
|
||||
|
||||
final toEmail = invoice.contactEmailSnapshot ?? invoice.customer.email;
|
||||
if (toEmail == null || toEmail.isEmpty) {
|
||||
if (context.mounted) {
|
||||
|
|
@ -66,18 +65,71 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
|||
}
|
||||
|
||||
final bytes = await _buildPdfBytes();
|
||||
final fileName = invoice.mailAttachmentFileName;
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/invoice.pdf');
|
||||
final file = File('${tempDir.path}/$fileName');
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
final message = Message()
|
||||
..from = Address(user)
|
||||
..recipients = [toEmail]
|
||||
..bccRecipients = bccList
|
||||
..subject = '請求書送付'
|
||||
..text = '請求書をお送りします。ご確認ください。'
|
||||
..attachments = [FileAttachment(file)..fileName = 'invoice.pdf'..contentType = 'application/pdf'];
|
||||
final hash = sha256.convert(bytes).toString();
|
||||
final headerTemplate = prefs.getString(kMailHeaderTextKey) ?? kMailHeaderTemplateDefault;
|
||||
final footerTemplate = prefs.getString(kMailFooterTextKey) ?? kMailFooterTemplateDefault;
|
||||
final placeholderMap = await CompanyProfileService().buildMailPlaceholderMap(filename: fileName, hash: hash);
|
||||
final header = applyMailTemplate(headerTemplate, placeholderMap);
|
||||
final footer = applyMailTemplate(footerTemplate, placeholderMap);
|
||||
final bodyCore = invoice.mailBodyText;
|
||||
final body = [header, bodyCore, footer].where((section) => section.trim().isNotEmpty).join('\n\n');
|
||||
|
||||
await send(message, smtpServer);
|
||||
if (mailMethod == kMailSendMethodDeviceMailer) {
|
||||
final email = Email(
|
||||
body: body,
|
||||
subject: fileName,
|
||||
recipients: [toEmail],
|
||||
bcc: bccList,
|
||||
attachmentPaths: [file.path],
|
||||
isHTML: false,
|
||||
);
|
||||
try {
|
||||
await FlutterEmailSender.send(email);
|
||||
await EmailSender.logDeviceMailer(success: true, toEmail: toEmail, bcc: bccList);
|
||||
} catch (e) {
|
||||
await EmailSender.logDeviceMailer(success: false, toEmail: toEmail, bcc: bccList, error: '$e');
|
||||
rethrow;
|
||||
}
|
||||
} else {
|
||||
final host = prefs.getString('smtp_host') ?? '';
|
||||
final portStr = prefs.getString('smtp_port') ?? '587';
|
||||
final user = prefs.getString('smtp_user') ?? '';
|
||||
final passEncrypted = prefs.getString('smtp_pass') ?? '';
|
||||
final pass = EmailSender.decrypt(passEncrypted);
|
||||
final useTls = prefs.getBool('smtp_tls') ?? true;
|
||||
final ignoreBadCert = prefs.getBool('smtp_ignore_bad_cert') ?? false;
|
||||
|
||||
if (host.isEmpty || user.isEmpty || pass.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final port = int.tryParse(portStr) ?? 587;
|
||||
final smtpConfig = EmailSenderConfig(
|
||||
host: host,
|
||||
port: port,
|
||||
username: user,
|
||||
password: pass,
|
||||
useTls: useTls,
|
||||
ignoreBadCert: ignoreBadCert,
|
||||
bcc: bccList,
|
||||
);
|
||||
|
||||
await EmailSender.sendInvoiceEmail(
|
||||
config: smtpConfig,
|
||||
toEmail: toEmail,
|
||||
pdfFile: file,
|
||||
subject: fileName,
|
||||
attachmentFileName: fileName,
|
||||
body: body,
|
||||
);
|
||||
}
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました')));
|
||||
}
|
||||
|
|
@ -92,7 +144,15 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final isDraft = invoice.isDraft;
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text("PDFプレビュー")),
|
||||
appBar: AppBar(
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text("PDFプレビュー"),
|
||||
Text("ScreenID: 02", style: TextStyle(fontSize: 11, color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
|
|
@ -121,17 +181,28 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
|||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text("正式発行"),
|
||||
label: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Text("正式発行"),
|
||||
if (!isDraft || isLocked)
|
||||
const Positioned(
|
||||
right: 0,
|
||||
child: Icon(Icons.lock, size: 16, color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: showShare
|
||||
onPressed: (showShare && (!isDraft || isLocked))
|
||||
? () async {
|
||||
final bytes = await _buildPdfBytes();
|
||||
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf');
|
||||
final fileName = invoice.mailAttachmentFileName;
|
||||
await Printing.sharePdf(bytes: bytes, filename: fileName);
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.share),
|
||||
|
|
@ -141,7 +212,7 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
|||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: showEmail
|
||||
onPressed: (showEmail && (!isDraft || isLocked))
|
||||
? () async {
|
||||
await _sendEmail(context);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,6 +190,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.9+2"
|
||||
flutter_email_sender:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_email_sender
|
||||
sha256: fb515d4e073d238d0daf1d765e5318487b6396d46b96e0ae9745dbc9a133f97a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.3"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ dependencies:
|
|||
printing: ^5.14.2
|
||||
shared_preferences: ^2.2.2
|
||||
mailer: ^6.0.1
|
||||
flutter_email_sender: ^6.0.3
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
38
scripts/build_with_expiry.sh
Executable file
38
scripts/build_with_expiry.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
BUILD_MODE="${1:-debug}"
|
||||
|
||||
case "${BUILD_MODE}" in
|
||||
debug|profile|release)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [debug|profile|release]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
DART_DEFINE="APP_BUILD_TIMESTAMP=${timestamp}"
|
||||
|
||||
echo "[build_with_expiry] Using timestamp: ${timestamp} (UTC)"
|
||||
echo "[build_with_expiry] Running flutter analyze..."
|
||||
flutter analyze
|
||||
|
||||
echo "[build_with_expiry] Building APK (${BUILD_MODE})..."
|
||||
case "${BUILD_MODE}" in
|
||||
debug)
|
||||
flutter build apk --debug --dart-define="${DART_DEFINE}"
|
||||
;;
|
||||
profile)
|
||||
flutter build apk --profile --dart-define="${DART_DEFINE}"
|
||||
;;
|
||||
release)
|
||||
flutter build apk --release --dart-define="${DART_DEFINE}"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "[build_with_expiry] Done. APK with 90-day lifespan generated."
|
||||
Loading…
Reference in a new issue