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" />
|
<action android:name="android.intent.action.PROCESS_TEXT" />
|
||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent>
|
</intent>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,12 @@
|
||||||
@import flutter_contacts;
|
@import flutter_contacts;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<flutter_email_sender/FlutterEmailSenderPlugin.h>)
|
||||||
|
#import <flutter_email_sender/FlutterEmailSenderPlugin.h>
|
||||||
|
#else
|
||||||
|
@import flutter_email_sender;
|
||||||
|
#endif
|
||||||
|
|
||||||
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
|
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
|
||||||
#import <geolocator_apple/GeolocatorPlugin.h>
|
#import <geolocator_apple/GeolocatorPlugin.h>
|
||||||
#else
|
#else
|
||||||
|
|
@ -82,6 +88,7 @@
|
||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
[FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]];
|
[FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]];
|
||||||
|
[FlutterEmailSenderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterEmailSenderPlugin"]];
|
||||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||||
[MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]];
|
[MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]];
|
||||||
|
|
|
||||||
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
|
// lib/main.dart
|
||||||
// version: 1.5.02 (Update: Date selection & Tax fix)
|
// version: 1.5.02 (Update: Date selection & Tax fix)
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
// --- 独自モジュールのインポート ---
|
// --- 独自モジュールのインポート ---
|
||||||
import 'models/invoice_models.dart'; // Invoice, InvoiceItem モデル
|
import 'models/invoice_models.dart'; // Invoice, InvoiceItem モデル
|
||||||
import 'screens/invoice_input_screen.dart'; // 入力フォーム画面
|
import 'screens/invoice_input_screen.dart'; // 入力フォーム画面
|
||||||
import 'screens/invoice_detail_page.dart'; // 詳細表示・編集画面
|
import 'screens/invoice_detail_page.dart'; // 詳細表示・編集画面
|
||||||
import 'screens/invoice_history_screen.dart'; // 履歴画面
|
import 'screens/invoice_history_screen.dart'; // 履歴画面
|
||||||
|
import 'screens/dashboard_screen.dart'; // ダッシュボード
|
||||||
import 'services/location_service.dart'; // 位置情報サービス
|
import 'services/location_service.dart'; // 位置情報サービス
|
||||||
import 'services/customer_repository.dart'; // 顧客リポジトリ
|
import 'services/customer_repository.dart'; // 顧客リポジトリ
|
||||||
|
import 'services/app_settings_repository.dart';
|
||||||
|
import 'services/theme_controller.dart';
|
||||||
|
import 'utils/build_expiry_info.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await AppThemeController.instance.load();
|
||||||
|
final expiryInfo = BuildExpiryInfo.fromEnvironment();
|
||||||
|
if (expiryInfo.isExpired) {
|
||||||
|
runApp(ExpiredApp(expiryInfo: expiryInfo));
|
||||||
|
return;
|
||||||
|
}
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
final TransformationController _zoomController = TransformationController();
|
||||||
|
int _activePointers = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return ValueListenableBuilder<ThemeMode>(
|
||||||
|
valueListenable: AppThemeController.instance.notifier,
|
||||||
|
builder: (context, mode, _) => MaterialApp(
|
||||||
title: '販売アシスト1号',
|
title: '販売アシスト1号',
|
||||||
|
navigatorObservers: [
|
||||||
|
_ZoomResetObserver(_zoomController),
|
||||||
|
],
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
|
||||||
primary: Colors.indigo.shade700,
|
primary: Colors.indigo.shade700,
|
||||||
secondary: Colors.deepOrange.shade400,
|
secondary: Colors.deepOrange.shade400,
|
||||||
surface: Colors.grey.shade50,
|
surface: Colors.grey.shade100,
|
||||||
onSurface: Colors.blueGrey.shade900,
|
onSurface: Colors.blueGrey.shade900,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: Colors.grey.shade50,
|
scaffoldBackgroundColor: Colors.grey.shade100,
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: Colors.indigo.shade700,
|
backgroundColor: Colors.indigo.shade700,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -60,9 +85,67 @@ class MyApp extends StatelessWidget {
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
fontFamily: 'IPAexGothic',
|
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) {
|
builder: (context, child) {
|
||||||
final mq = MediaQuery.of(context);
|
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,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: () => FocusScope.of(context).unfocus(),
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
child: AnimatedPadding(
|
child: AnimatedPadding(
|
||||||
|
|
@ -74,12 +157,139 @@ class MyApp extends StatelessWidget {
|
||||||
scaleEnabled: true,
|
scaleEnabled: true,
|
||||||
minScale: 0.8,
|
minScale: 0.8,
|
||||||
maxScale: 4.0,
|
maxScale: 4.0,
|
||||||
|
transformationController: _zoomController,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: _activePointers > 1,
|
||||||
child: child ?? const SizedBox.shrink(),
|
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 bool isSynced; // 同期フラグ
|
||||||
final DateTime updatedAt; // 最終更新日時
|
final DateTime updatedAt; // 最終更新日時
|
||||||
final bool isLocked; // ロック
|
final bool isLocked; // ロック
|
||||||
|
final bool isHidden; // 非表示
|
||||||
final String? headChar1; // インデックス1
|
final String? headChar1; // インデックス1
|
||||||
final String? headChar2; // インデックス2
|
final String? headChar2; // インデックス2
|
||||||
|
|
||||||
|
|
@ -30,6 +31,7 @@ class Customer {
|
||||||
this.isSynced = false,
|
this.isSynced = false,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
this.isLocked = false,
|
this.isLocked = false,
|
||||||
|
this.isHidden = false,
|
||||||
this.headChar1,
|
this.headChar1,
|
||||||
this.headChar2,
|
this.headChar2,
|
||||||
}) : updatedAt = updatedAt ?? DateTime.now();
|
}) : updatedAt = updatedAt ?? DateTime.now();
|
||||||
|
|
@ -57,6 +59,7 @@ class Customer {
|
||||||
'head_char2': headChar2,
|
'head_char2': headChar2,
|
||||||
'is_locked': isLocked ? 1 : 0,
|
'is_locked': isLocked ? 1 : 0,
|
||||||
'is_synced': isSynced ? 1 : 0,
|
'is_synced': isSynced ? 1 : 0,
|
||||||
|
'is_hidden': isHidden ? 1 : 0,
|
||||||
'updated_at': updatedAt.toIso8601String(),
|
'updated_at': updatedAt.toIso8601String(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +78,7 @@ class Customer {
|
||||||
odooId: map['odoo_id'],
|
odooId: map['odoo_id'],
|
||||||
isLocked: (map['is_locked'] ?? 0) == 1,
|
isLocked: (map['is_locked'] ?? 0) == 1,
|
||||||
isSynced: map['is_synced'] == 1,
|
isSynced: map['is_synced'] == 1,
|
||||||
|
isHidden: (map['is_hidden'] ?? 0) == 1,
|
||||||
updatedAt: DateTime.parse(map['updated_at']),
|
updatedAt: DateTime.parse(map['updated_at']),
|
||||||
headChar1: map['head_char1'],
|
headChar1: map['head_char1'],
|
||||||
headChar2: map['head_char2'],
|
headChar2: map['head_char2'],
|
||||||
|
|
@ -93,6 +97,7 @@ class Customer {
|
||||||
bool? isSynced,
|
bool? isSynced,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
bool? isLocked,
|
bool? isLocked,
|
||||||
|
bool? isHidden,
|
||||||
String? email,
|
String? email,
|
||||||
int? contactVersionId,
|
int? contactVersionId,
|
||||||
String? headChar1,
|
String? headChar1,
|
||||||
|
|
@ -112,6 +117,7 @@ class Customer {
|
||||||
isSynced: isSynced ?? this.isSynced,
|
isSynced: isSynced ?? this.isSynced,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
isLocked: isLocked ?? this.isLocked,
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
isHidden: isHidden ?? this.isHidden,
|
||||||
headChar1: headChar1 ?? this.headChar1,
|
headChar1: headChar1 ?? this.headChar1,
|
||||||
headChar2: headChar2 ?? this.headChar2,
|
headChar2: headChar2 ?? this.headChar2,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,10 @@ enum DocumentType {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Invoice {
|
class Invoice {
|
||||||
|
static const String lockStatement =
|
||||||
|
'正式発行ボタン押下時にこの伝票はロックされ、以後の編集・削除はできません。ロック状態はハッシュチェーンで保護されます。';
|
||||||
|
static const String hashDescription =
|
||||||
|
'metaJson = JSON.stringify({id, invoiceNumber, customer, date, total, documentType, hash, lockStatement, companySnapshot, companySealHash}); metaHash = SHA-256(metaJson).';
|
||||||
final String id;
|
final String id;
|
||||||
final Customer customer;
|
final Customer customer;
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
|
@ -88,6 +92,10 @@ class Invoice {
|
||||||
final String? contactEmailSnapshot;
|
final String? contactEmailSnapshot;
|
||||||
final String? contactTelSnapshot;
|
final String? contactTelSnapshot;
|
||||||
final String? contactAddressSnapshot;
|
final String? contactAddressSnapshot;
|
||||||
|
final String? companySnapshot; // 追加: 発行時会社情報スナップショット
|
||||||
|
final String? companySealHash; // 追加: 角印画像ハッシュ
|
||||||
|
final String? metaJson;
|
||||||
|
final String? metaHash;
|
||||||
|
|
||||||
Invoice({
|
Invoice({
|
||||||
String? id,
|
String? id,
|
||||||
|
|
@ -112,6 +120,10 @@ class Invoice {
|
||||||
this.contactEmailSnapshot,
|
this.contactEmailSnapshot,
|
||||||
this.contactTelSnapshot,
|
this.contactTelSnapshot,
|
||||||
this.contactAddressSnapshot,
|
this.contactAddressSnapshot,
|
||||||
|
this.companySnapshot,
|
||||||
|
this.companySealHash,
|
||||||
|
this.metaJson,
|
||||||
|
this.metaHash,
|
||||||
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
terminalId = terminalId ?? "T1", // デフォルト端末ID
|
||||||
updatedAt = updatedAt ?? DateTime.now();
|
updatedAt = updatedAt ?? DateTime.now();
|
||||||
|
|
@ -132,6 +144,13 @@ class Invoice {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const Map<DocumentType, String> _docTypeShortLabel = {
|
||||||
|
DocumentType.estimation: '見積',
|
||||||
|
DocumentType.delivery: '納品',
|
||||||
|
DocumentType.invoice: '請求',
|
||||||
|
DocumentType.receipt: '領収',
|
||||||
|
};
|
||||||
|
|
||||||
String get invoiceNumberPrefix {
|
String get invoiceNumberPrefix {
|
||||||
switch (documentType) {
|
switch (documentType) {
|
||||||
case DocumentType.estimation: return "EST";
|
case DocumentType.estimation: return "EST";
|
||||||
|
|
@ -143,13 +162,74 @@ class Invoice {
|
||||||
|
|
||||||
String get invoiceNumber => "$invoiceNumberPrefix-$terminalId-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
String get invoiceNumber => "$invoiceNumberPrefix-$terminalId-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
|
||||||
|
|
||||||
// 表示用の宛名(スナップショットがあれば優先)
|
// 表示用の宛名(スナップショットがあれば優先)。必ず敬称を付与。
|
||||||
String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName;
|
String get customerNameForDisplay {
|
||||||
|
final base = customerFormalNameSnapshot ?? customer.formalName;
|
||||||
|
final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base);
|
||||||
|
return hasHonorific ? base : '$base ${customer.title}';
|
||||||
|
}
|
||||||
|
|
||||||
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
|
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
|
||||||
int get tax => (subtotal * taxRate).floor();
|
int get tax => (subtotal * taxRate).floor();
|
||||||
int get totalAmount => subtotal + tax;
|
int get totalAmount => subtotal + tax;
|
||||||
|
|
||||||
|
String get _projectLabel {
|
||||||
|
if (subject != null && subject!.trim().isNotEmpty) {
|
||||||
|
return subject!.trim();
|
||||||
|
}
|
||||||
|
return '案件';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get mailTitleCore {
|
||||||
|
final dateStr = DateFormat('yyyyMMdd').format(date);
|
||||||
|
final docLabel = _docTypeShortLabel[documentType] ?? documentTypeName.replaceAll('書', '');
|
||||||
|
final customerCompact = customerNameForDisplay.replaceAll(RegExp(r'\s+'), '');
|
||||||
|
final amountStr = NumberFormat('#,###').format(totalAmount);
|
||||||
|
final buffer = StringBuffer()
|
||||||
|
..write(dateStr)
|
||||||
|
..write('($docLabel)')
|
||||||
|
..write(_projectLabel)
|
||||||
|
..write('@')
|
||||||
|
..write(customerCompact)
|
||||||
|
..write('_')
|
||||||
|
..write(amountStr)
|
||||||
|
..write('円');
|
||||||
|
final raw = buffer.toString();
|
||||||
|
return _sanitizeForFile(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get mailAttachmentFileName => '$mailTitleCore.PDF';
|
||||||
|
|
||||||
|
String get mailBodyText => '請求書をお送りします。ご確認ください。';
|
||||||
|
|
||||||
|
static String _sanitizeForFile(String input) {
|
||||||
|
var sanitized = input.replaceAll(RegExp(r'[\\/:*?"<>|]'), '-');
|
||||||
|
sanitized = sanitized.replaceAll(RegExp(r'[\r\n]+'), '');
|
||||||
|
sanitized = sanitized.replaceAll(' ', '');
|
||||||
|
sanitized = sanitized.replaceAll(' ', '');
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> metaPayload() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'invoiceNumber': invoiceNumber,
|
||||||
|
'customer': customerNameForDisplay,
|
||||||
|
'date': date.toIso8601String(),
|
||||||
|
'total': totalAmount,
|
||||||
|
'documentType': documentType.name,
|
||||||
|
'hash': contentHash,
|
||||||
|
'lockStatement': lockStatement,
|
||||||
|
'hashDescription': hashDescription,
|
||||||
|
'companySnapshot': companySnapshot,
|
||||||
|
'companySealHash': companySealHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String get metaJsonValue => metaJson ?? jsonEncode(metaPayload());
|
||||||
|
|
||||||
|
String get metaHashValue => metaHash ?? sha256.convert(utf8.encode(metaJsonValue)).toString();
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
return {
|
return {
|
||||||
'id': id,
|
'id': id,
|
||||||
|
|
@ -175,6 +255,10 @@ class Invoice {
|
||||||
'contact_email_snapshot': contactEmailSnapshot,
|
'contact_email_snapshot': contactEmailSnapshot,
|
||||||
'contact_tel_snapshot': contactTelSnapshot,
|
'contact_tel_snapshot': contactTelSnapshot,
|
||||||
'contact_address_snapshot': contactAddressSnapshot,
|
'contact_address_snapshot': contactAddressSnapshot,
|
||||||
|
'company_snapshot': companySnapshot,
|
||||||
|
'company_seal_hash': companySealHash,
|
||||||
|
'meta_json': metaJsonValue,
|
||||||
|
'meta_hash': metaHashValue,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,6 +285,10 @@ class Invoice {
|
||||||
String? contactEmailSnapshot,
|
String? contactEmailSnapshot,
|
||||||
String? contactTelSnapshot,
|
String? contactTelSnapshot,
|
||||||
String? contactAddressSnapshot,
|
String? contactAddressSnapshot,
|
||||||
|
String? companySnapshot,
|
||||||
|
String? companySealHash,
|
||||||
|
String? metaJson,
|
||||||
|
String? metaHash,
|
||||||
}) {
|
}) {
|
||||||
return Invoice(
|
return Invoice(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
|
|
@ -225,6 +313,10 @@ class Invoice {
|
||||||
contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot,
|
contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot,
|
||||||
contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot,
|
contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot,
|
||||||
contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot,
|
contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot,
|
||||||
|
companySnapshot: companySnapshot ?? this.companySnapshot,
|
||||||
|
companySealHash: companySealHash ?? this.companySealHash,
|
||||||
|
metaJson: metaJson ?? this.metaJson,
|
||||||
|
metaHash: metaHash ?? this.metaHash,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ class Product {
|
||||||
final int stockQuantity; // 追加
|
final int stockQuantity; // 追加
|
||||||
final String? odooId;
|
final String? odooId;
|
||||||
final bool isLocked; // ロック
|
final bool isLocked; // ロック
|
||||||
|
final bool isHidden; // 非表示
|
||||||
|
|
||||||
Product({
|
Product({
|
||||||
required this.id,
|
required this.id,
|
||||||
|
|
@ -17,6 +18,7 @@ class Product {
|
||||||
this.stockQuantity = 0, // 追加
|
this.stockQuantity = 0, // 追加
|
||||||
this.odooId,
|
this.odooId,
|
||||||
this.isLocked = false,
|
this.isLocked = false,
|
||||||
|
this.isHidden = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toMap() {
|
Map<String, dynamic> toMap() {
|
||||||
|
|
@ -29,6 +31,7 @@ class Product {
|
||||||
'stock_quantity': stockQuantity, // 追加
|
'stock_quantity': stockQuantity, // 追加
|
||||||
'is_locked': isLocked ? 1 : 0,
|
'is_locked': isLocked ? 1 : 0,
|
||||||
'odoo_id': odooId,
|
'odoo_id': odooId,
|
||||||
|
'is_hidden': isHidden ? 1 : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,6 +45,7 @@ class Product {
|
||||||
stockQuantity: map['stock_quantity'] ?? 0, // 追加
|
stockQuantity: map['stock_quantity'] ?? 0, // 追加
|
||||||
isLocked: (map['is_locked'] ?? 0) == 1,
|
isLocked: (map['is_locked'] ?? 0) == 1,
|
||||||
odooId: map['odoo_id'],
|
odooId: map['odoo_id'],
|
||||||
|
isHidden: (map['is_hidden'] ?? 0) == 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,15 +54,22 @@ class Product {
|
||||||
String? name,
|
String? name,
|
||||||
int? defaultUnitPrice,
|
int? defaultUnitPrice,
|
||||||
String? barcode,
|
String? barcode,
|
||||||
|
String? category,
|
||||||
|
int? stockQuantity,
|
||||||
String? odooId,
|
String? odooId,
|
||||||
bool? isLocked,
|
bool? isLocked,
|
||||||
|
bool? isHidden,
|
||||||
}) {
|
}) {
|
||||||
return Product(
|
return Product(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
|
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
|
||||||
|
barcode: barcode ?? this.barcode,
|
||||||
|
category: category ?? this.category,
|
||||||
|
stockQuantity: stockQuantity ?? this.stockQuantity,
|
||||||
odooId: odooId ?? this.odooId,
|
odooId: odooId ?? this.odooId,
|
||||||
isLocked: isLocked ?? this.isLocked,
|
isLocked: isLocked ?? this.isLocked,
|
||||||
|
isHidden: isHidden ?? this.isHidden,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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()));
|
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text("F1:自社情報"),
|
title: const Text("F1:自社情報"),
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: Colors.indigo,
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import '../models/customer_model.dart';
|
import '../models/customer_model.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
|
import '../widgets/contact_picker_sheet.dart';
|
||||||
|
|
||||||
class CustomerMasterScreen extends StatefulWidget {
|
class CustomerMasterScreen extends StatefulWidget {
|
||||||
final bool selectionMode;
|
final bool selectionMode;
|
||||||
|
final bool showHidden;
|
||||||
|
|
||||||
const CustomerMasterScreen({super.key, this.selectionMode = false});
|
const CustomerMasterScreen({super.key, this.selectionMode = false, this.showHidden = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
|
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
|
||||||
|
|
@ -87,6 +89,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showContactUpdateDialog(Customer customer) async {
|
Future<void> _showContactUpdateDialog(Customer customer) async {
|
||||||
|
if (customer.isLocked) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロック中の顧客は連絡先を更新できません')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
final emailController = TextEditingController(text: customer.email ?? "");
|
final emailController = TextEditingController(text: customer.email ?? "");
|
||||||
final telController = TextEditingController(text: customer.tel ?? "");
|
final telController = TextEditingController(text: customer.tel ?? "");
|
||||||
final addressController = TextEditingController(text: customer.address ?? "");
|
final addressController = TextEditingController(text: customer.address ?? "");
|
||||||
|
|
@ -130,7 +138,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
Future<void> _loadCustomers() async {
|
Future<void> _loadCustomers() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
final customers = await _customerRepo.getAllCustomers();
|
final customers = await _customerRepo.getAllCustomers(includeHidden: widget.showHidden);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_customers = customers;
|
_customers = customers;
|
||||||
|
|
@ -149,13 +157,20 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
List<Customer> list = _customers.where((c) {
|
List<Customer> list = _customers.where((c) {
|
||||||
return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query);
|
return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
if (!widget.showHidden) {
|
||||||
|
list = list.where((c) => !c.isHidden).toList();
|
||||||
|
}
|
||||||
// Kana filtering disabled temporarily for stability
|
// Kana filtering disabled temporarily for stability
|
||||||
switch (_sortKey) {
|
switch (_sortKey) {
|
||||||
case 'name_desc':
|
case 'name_desc':
|
||||||
list.sort((a, b) => _normalizedName(b.displayName).compareTo(_normalizedName(a.displayName)));
|
list.sort((a, b) => widget.showHidden
|
||||||
|
? b.id.compareTo(a.id)
|
||||||
|
: _normalizedName(b.displayName).compareTo(_normalizedName(a.displayName)));
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
list.sort((a, b) => _normalizedName(a.displayName).compareTo(_normalizedName(b.displayName)));
|
list.sort((a, b) => widget.showHidden
|
||||||
|
? b.id.compareTo(a.id)
|
||||||
|
: _normalizedName(a.displayName).compareTo(_normalizedName(b.displayName)));
|
||||||
}
|
}
|
||||||
_filtered = list;
|
_filtered = list;
|
||||||
}
|
}
|
||||||
|
|
@ -205,16 +220,6 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
|
|
||||||
late final Map<String, String> _defaultKanaMap = _buildDefaultKanaMap();
|
late final Map<String, String> _defaultKanaMap = _buildDefaultKanaMap();
|
||||||
|
|
||||||
String _normalizeIndexChar(String input) {
|
|
||||||
var s = input.replaceAll(RegExp(r"\s+|\u3000"), "");
|
|
||||||
if (s.isEmpty) return '';
|
|
||||||
String ch = s.characters.first;
|
|
||||||
final code = ch.codeUnitAt(0);
|
|
||||||
if (code >= 0x30A1 && code <= 0x30F6) {
|
|
||||||
ch = String.fromCharCode(code - 0x60); // katakana -> hiragana
|
|
||||||
}
|
|
||||||
return ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _addOrEditCustomer({Customer? customer}) async {
|
Future<void> _addOrEditCustomer({Customer? customer}) async {
|
||||||
final isEdit = customer != null;
|
final isEdit = customer != null;
|
||||||
|
|
@ -244,26 +249,8 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
final Contact? picked = await showModalBottomSheet<Contact>(
|
final Contact? picked = await showModalBottomSheet<Contact>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (ctx) => SafeArea(
|
backgroundColor: Colors.transparent,
|
||||||
child: SizedBox(
|
builder: (ctx) => ContactPickerSheet(contacts: contacts, title: isEdit ? '電話帳から上書き' : '電話帳から新規入力'),
|
||||||
height: MediaQuery.of(ctx).size.height * 0.6,
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: contacts.length,
|
|
||||||
itemBuilder: (_, i) {
|
|
||||||
final c = contacts[i];
|
|
||||||
final orgCompany = c.organizations.isNotEmpty ? c.organizations.first.company : '';
|
|
||||||
final personParts = [c.name.last, c.name.first].where((v) => v.isNotEmpty).toList();
|
|
||||||
final person = personParts.isNotEmpty ? personParts.join(' ').trim() : c.displayName;
|
|
||||||
final label = orgCompany.isNotEmpty ? orgCompany : person;
|
|
||||||
return ListTile(
|
|
||||||
title: Text(label),
|
|
||||||
subtitle: person.isNotEmpty ? Text(person) : null,
|
|
||||||
onTap: () => Navigator.pop(ctx, c),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
|
|
@ -404,22 +391,25 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
|
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("表示名と正式名称は必須です")));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final head1 = _normalizeIndexChar(head1Controller.text);
|
final head1 = head1Controller.text.trim();
|
||||||
final head2 = _normalizeIndexChar(head2Controller.text);
|
final head2 = head2Controller.text.trim();
|
||||||
|
final locked = customer?.isLocked ?? false;
|
||||||
|
final newId = locked ? const Uuid().v4() : (customer?.id ?? const Uuid().v4());
|
||||||
final newCustomer = Customer(
|
final newCustomer = Customer(
|
||||||
id: customer?.id ?? const Uuid().v4(),
|
id: newId,
|
||||||
displayName: displayNameController.text,
|
displayName: displayNameController.text.trim(),
|
||||||
formalName: formalNameController.text,
|
formalName: formalNameController.text.trim(),
|
||||||
title: selectedTitle,
|
title: selectedTitle,
|
||||||
department: departmentController.text.isEmpty ? null : departmentController.text,
|
department: departmentController.text.trim().isEmpty ? null : departmentController.text.trim(),
|
||||||
address: addressController.text.isEmpty ? null : addressController.text,
|
address: addressController.text.trim().isEmpty ? null : addressController.text.trim(),
|
||||||
tel: telController.text.isEmpty ? null : telController.text,
|
tel: telController.text.trim().isEmpty ? null : telController.text.trim(),
|
||||||
email: emailController.text.isEmpty ? null : emailController.text,
|
email: emailController.text.trim().isEmpty ? null : emailController.text.trim(),
|
||||||
headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1,
|
headChar1: head1.isEmpty ? null : head1,
|
||||||
headChar2: head2.isEmpty ? null : head2,
|
headChar2: head2.isEmpty ? null : head2,
|
||||||
isLocked: customer?.isLocked ?? false,
|
isLocked: false,
|
||||||
);
|
);
|
||||||
Navigator.pop(context, newCustomer);
|
Navigator.pop(context, newCustomer);
|
||||||
},
|
},
|
||||||
|
|
@ -783,7 +773,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
),
|
),
|
||||||
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)),
|
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)),
|
||||||
subtitle: Text("${c.formalName} ${c.title}"),
|
subtitle: Text("${c.formalName} ${c.title}"),
|
||||||
onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c),
|
onTap: widget.selectionMode
|
||||||
|
? () {
|
||||||
|
if (c.isHidden) return; // do not select hidden
|
||||||
|
Navigator.pop(context, c);
|
||||||
|
}
|
||||||
|
: () => _showDetailPane(c),
|
||||||
trailing: widget.selectionMode
|
trailing: widget.selectionMode
|
||||||
? null
|
? null
|
||||||
: IconButton(
|
: IconButton(
|
||||||
|
|
@ -861,9 +856,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
leading: const Icon(Icons.edit),
|
leading: const Icon(Icons.edit),
|
||||||
title: const Text('編集'),
|
title: const Text('編集'),
|
||||||
enabled: !c.isLocked,
|
enabled: !c.isLocked,
|
||||||
onTap: c.isLocked
|
onTap: () {
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_addOrEditCustomer(customer: c);
|
_addOrEditCustomer(customer: c);
|
||||||
},
|
},
|
||||||
|
|
@ -871,13 +864,25 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.contact_mail),
|
leading: const Icon(Icons.contact_mail),
|
||||||
title: const Text('連絡先を更新'),
|
title: const Text('連絡先を更新'),
|
||||||
|
enabled: !c.isLocked,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (c.isLocked) return;
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_showContactUpdateDialog(c);
|
_showContactUpdateDialog(c);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete, color: Colors.redAccent),
|
leading: const Icon(Icons.visibility_off),
|
||||||
|
title: const Text('非表示にする'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _customerRepo.setHidden(c.id, true);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadCustomers();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||||
title: const Text('削除', style: TextStyle(color: Colors.redAccent)),
|
title: const Text('削除', style: TextStyle(color: Colors.redAccent)),
|
||||||
enabled: !c.isLocked,
|
enabled: !c.isLocked,
|
||||||
onTap: c.isLocked
|
onTap: c.isLocked
|
||||||
|
|
@ -957,7 +962,9 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: c.isLocked
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_showContactUpdateSheet(c);
|
_showContactUpdateSheet(c);
|
||||||
},
|
},
|
||||||
|
|
@ -1018,7 +1025,9 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.contact_mail),
|
leading: const Icon(Icons.contact_mail),
|
||||||
title: const Text('連絡先を更新'),
|
title: const Text('連絡先を更新'),
|
||||||
|
enabled: !c.isLocked,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (c.isLocked) return;
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_showContactUpdateDialog(c);
|
_showContactUpdateDialog(c);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart';
|
||||||
import '../models/customer_model.dart';
|
import '../models/customer_model.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
import '../widgets/keyboard_inset_wrapper.dart';
|
import '../widgets/keyboard_inset_wrapper.dart';
|
||||||
|
import '../widgets/contact_picker_sheet.dart';
|
||||||
|
|
||||||
/// 顧客マスターからの選択、登録、編集、削除を行うモーダル
|
/// 顧客マスターからの選択、登録、編集、削除を行うモーダル
|
||||||
class CustomerPickerModal extends StatefulWidget {
|
class CustomerPickerModal extends StatefulWidget {
|
||||||
|
|
@ -55,7 +56,8 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
||||||
final Contact? selectedContact = await showModalBottomSheet<Contact?>(
|
final Contact? selectedContact = await showModalBottomSheet<Contact?>(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder: (context) => _PhoneContactListSelector(contacts: contacts),
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) => ContactPickerSheet(contacts: contacts, title: '電話帳から顧客候補を選択'),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
@ -316,66 +318,3 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 電話帳から一人選ぶための内部ウィジェット
|
|
||||||
class _PhoneContactListSelector extends StatefulWidget {
|
|
||||||
final List<Contact> contacts;
|
|
||||||
const _PhoneContactListSelector({required this.contacts});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> {
|
|
||||||
List<Contact> _filtered = [];
|
|
||||||
final _searchController = TextEditingController();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_filtered = widget.contacts;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSearch(String q) {
|
|
||||||
setState(() {
|
|
||||||
_filtered = widget.contacts
|
|
||||||
.where((c) {
|
|
||||||
final org = c.organizations.isNotEmpty ? c.organizations.first.company : '';
|
|
||||||
final label = org.isNotEmpty ? org : c.displayName;
|
|
||||||
return label.toLowerCase().contains(q.toLowerCase());
|
|
||||||
})
|
|
||||||
.toList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return FractionallySizedBox(
|
|
||||||
heightFactor: 0.8,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: TextField(
|
|
||||||
controller: _searchController,
|
|
||||||
decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)),
|
|
||||||
onChanged: _onSearch,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: _filtered.length,
|
|
||||||
itemBuilder: (context, index) => ListTile(
|
|
||||||
title: Text(
|
|
||||||
_filtered[index].organizations.isNotEmpty && _filtered[index].organizations.first.company.isNotEmpty
|
|
||||||
? _filtered[index].organizations.first.company
|
|
||||||
: _filtered[index].displayName,
|
|
||||||
),
|
|
||||||
onTap: () => Navigator.pop(context, _filtered[index]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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 'product_picker_modal.dart';
|
||||||
import '../models/company_model.dart';
|
import '../models/company_model.dart';
|
||||||
import '../widgets/keyboard_inset_wrapper.dart';
|
import '../widgets/keyboard_inset_wrapper.dart';
|
||||||
|
import '../services/app_settings_repository.dart';
|
||||||
|
|
||||||
class _DetailSnapshot {
|
class _DetailSnapshot {
|
||||||
final String formalName;
|
final String formalName;
|
||||||
|
|
@ -31,6 +32,25 @@ class _DetailSnapshot {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DraftBadge extends StatelessWidget {
|
||||||
|
const _DraftBadge();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'下書き',
|
||||||
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<InvoiceItem> _cloneItemsDetail(List<InvoiceItem> source) {
|
List<InvoiceItem> _cloneItemsDetail(List<InvoiceItem> source) {
|
||||||
return source
|
return source
|
||||||
.map((e) => InvoiceItem(
|
.map((e) => InvoiceItem(
|
||||||
|
|
@ -63,14 +83,16 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
late double _taxRate; // 追加
|
late double _taxRate; // 追加
|
||||||
late bool _includeTax; // 追加
|
late bool _includeTax; // 追加
|
||||||
String? _currentFilePath;
|
String? _currentFilePath;
|
||||||
final _invoiceRepo = InvoiceRepository();
|
final InvoiceRepository _invoiceRepo = InvoiceRepository();
|
||||||
final _customerRepo = CustomerRepository();
|
final CustomerRepository _customerRepo = CustomerRepository();
|
||||||
final _companyRepo = CompanyRepository();
|
final CompanyRepository _companyRepo = CompanyRepository();
|
||||||
|
final AppSettingsRepository _settingsRepo = AppSettingsRepository(); // 追加
|
||||||
CompanyInfo? _companyInfo;
|
CompanyInfo? _companyInfo;
|
||||||
bool _showFormalWarning = true;
|
bool _showFormalWarning = true;
|
||||||
final List<_DetailSnapshot> _undoStack = [];
|
final List<_DetailSnapshot> _undoStack = [];
|
||||||
final List<_DetailSnapshot> _redoStack = [];
|
final List<_DetailSnapshot> _redoStack = [];
|
||||||
bool _isApplyingSnapshot = false;
|
bool _isApplyingSnapshot = false;
|
||||||
|
bool _summaryIsBlue = false; // デフォルトは白
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -84,6 +106,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
_includeTax = _currentInvoice.taxRate > 0; // 初期化
|
_includeTax = _currentInvoice.taxRate > 0; // 初期化
|
||||||
_isEditing = false;
|
_isEditing = false;
|
||||||
_loadCompanyInfo();
|
_loadCompanyInfo();
|
||||||
|
_loadSummaryTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadSummaryTheme() async {
|
||||||
|
final saved = await _settingsRepo.getSummaryTheme();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _summaryIsBlue = saved == 'blue');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCompanyInfo() async {
|
Future<void> _loadCompanyInfo() async {
|
||||||
|
|
@ -186,12 +215,39 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV'));
|
SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickSummaryColor() async {
|
||||||
|
final selected = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.palette, color: Colors.indigo),
|
||||||
|
title: const Text('インディゴ'),
|
||||||
|
onTap: () => Navigator.pop(context, 'blue'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.palette, color: Colors.grey),
|
||||||
|
title: const Text('白'),
|
||||||
|
onTap: () => Navigator.pop(context, 'white'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected == null) return;
|
||||||
|
setState(() => _summaryIsBlue = selected == 'blue');
|
||||||
|
await _settingsRepo.setSummaryTheme(selected);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fmt = NumberFormat("#,###");
|
final fmt = NumberFormat("#,###");
|
||||||
final isDraft = _currentInvoice.isDraft;
|
final isDraft = _currentInvoice.isDraft;
|
||||||
final docTypeName = _currentInvoice.documentTypeName;
|
final docTypeName = _currentInvoice.documentTypeName;
|
||||||
final themeColor = Colors.white; // 常に明色
|
final themeColor = Theme.of(context).scaffoldBackgroundColor;
|
||||||
final textColor = Colors.black87;
|
final textColor = Colors.black87;
|
||||||
|
|
||||||
final locked = _currentInvoice.isLocked;
|
final locked = _currentInvoice.isLocked;
|
||||||
|
|
@ -292,18 +348,18 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.indigo.shade800,
|
color: Colors.indigo, // 合計金額と同じカラー
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: Colors.indigo.shade900),
|
border: Border.all(color: Colors.indigo.shade700),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.edit_note, color: Colors.white70),
|
const Icon(Icons.edit_note, color: Colors.white70),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
const Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
"下書き: 未確定・PDFは正式発行で確定",
|
"未確定・PDFは正式発行で確定",
|
||||||
style: const TextStyle(color: Colors.white70),
|
style: TextStyle(color: Colors.white70),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
@ -314,7 +370,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
"下書${docTypeName}",
|
"下書$docTypeName",
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -406,8 +462,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade100,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|
@ -417,16 +480,22 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
|
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
||||||
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
|
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 8),
|
||||||
Text("件名: ${_currentInvoice.subject}",
|
const Text("件名", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black54)),
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo)),
|
const SizedBox(height: 2),
|
||||||
|
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)
|
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
||||||
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
|
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)
|
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
|
||||||
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
|
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
|
||||||
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
|
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 tax = (subtotal * currentTaxRate).floor();
|
||||||
final int total = subtotal + tax;
|
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,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.indigo,
|
color: bgColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: borderColor),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70),
|
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor),
|
||||||
if (currentTaxRate > 0) ...[
|
if (currentTaxRate > 0) ...[
|
||||||
const Divider(color: Colors.white24),
|
Divider(color: dividerColor),
|
||||||
if (_companyInfo?.taxDisplayMode == 'normal')
|
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')
|
if (_companyInfo?.taxDisplayMode == 'text_only')
|
||||||
_buildSummaryRow("消費税", "(税別)", Colors.white70),
|
_buildSummaryRow("消費税", "(税別)", labelColor),
|
||||||
],
|
],
|
||||||
const Divider(color: Colors.white24),
|
Divider(color: dividerColor),
|
||||||
_buildSummaryRow(
|
_buildSummaryRow(
|
||||||
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
|
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
|
||||||
"¥${formatter.format(total)}",
|
"¥${formatter.format(total)}",
|
||||||
Colors.white,
|
totalColor,
|
||||||
isTotal: true,
|
isTotal: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -721,7 +804,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text("この下書き伝票を「確定」として正式に発行しますか?"),
|
Row(
|
||||||
|
children: const [
|
||||||
|
_DraftBadge(),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(child: Text("この伝票を「確定」として正式に発行しますか?")),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (showWarning)
|
if (showWarning)
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -785,7 +874,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.drafts, color: Colors.orange),
|
const Icon(Icons.drafts, color: Colors.orange),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(child: Text("下書き状態として保持", style: TextStyle(fontWeight: FontWeight.bold))),
|
const Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_DraftBadge(),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text("状態として保持", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: _currentInvoice.isDraft,
|
value: _currentInvoice.isDraft,
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
|
|
|
||||||
|
|
@ -25,21 +25,49 @@ class InvoiceHistoryItem extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
final cardColor = invoice.isDraft ? Colors.orange.shade50 : Colors.white;
|
||||||
tileColor: invoice.isDraft ? Colors.orange.shade50 : null,
|
final iconBg = isUnlocked
|
||||||
leading: CircleAvatar(
|
? _docTypeColor(invoice.documentType).withValues(alpha: 0.18)
|
||||||
backgroundColor: invoice.isDraft
|
: Colors.grey.shade200;
|
||||||
? Colors.orange.shade100
|
final iconColor = isUnlocked ? _docTypeColor(invoice.documentType) : Colors.grey;
|
||||||
: (isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
|
|
||||||
|
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(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
|
_docTypeIcon(invoice.documentType),
|
||||||
color: invoice.isDraft
|
color: iconColor,
|
||||||
? Colors.orange
|
|
||||||
: (isUnlocked ? Colors.indigo : Colors.grey),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (invoice.isLocked)
|
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,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
invoice.customerNameForDisplay,
|
customerName,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
color: invoice.isLocked ? Colors.grey : Colors.black87,
|
color: invoice.isLocked ? Colors.grey : Colors.black87,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
if (invoice.subject?.isNotEmpty ?? false)
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
invoice.subject!,
|
subjectDisplay,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
color: Colors.indigo.shade700,
|
fontWeight: FontWeight.w600,
|
||||||
fontWeight: FontWeight.normal,
|
color: subjectColor,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
|
),
|
||||||
trailing: SizedBox(
|
const SizedBox(width: 8),
|
||||||
height: 48,
|
Column(
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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(
|
Text(
|
||||||
"¥${amountFormatter.format(invoice.totalAmount)}",
|
"¥${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(
|
return ListView.builder(
|
||||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: const EdgeInsets.only(bottom: 120), // FAB分の固定余白
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 120), // 横揃えとFAB余白
|
||||||
itemCount: invoices.length,
|
itemCount: invoices.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final invoice = invoices[index];
|
final invoice = invoices[index];
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import 'customer_master_screen.dart';
|
||||||
import 'invoice_input_screen.dart';
|
import 'invoice_input_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import 'company_info_screen.dart';
|
import 'company_info_screen.dart';
|
||||||
|
import 'dashboard_screen.dart';
|
||||||
|
import '../services/app_settings_repository.dart';
|
||||||
import '../widgets/slide_to_unlock.dart';
|
import '../widgets/slide_to_unlock.dart';
|
||||||
// InvoiceFlowScreen import removed; using inline type picker
|
// InvoiceFlowScreen import removed; using inline type picker
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
@ -17,7 +19,8 @@ import '../widgets/invoice_pdf_preview_page.dart';
|
||||||
import 'invoice_history/invoice_history_list.dart';
|
import 'invoice_history/invoice_history_list.dart';
|
||||||
|
|
||||||
class InvoiceHistoryScreen extends StatefulWidget {
|
class InvoiceHistoryScreen extends StatefulWidget {
|
||||||
const InvoiceHistoryScreen({super.key});
|
final bool initialUnlocked;
|
||||||
|
const InvoiceHistoryScreen({super.key, this.initialUnlocked = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InvoiceHistoryScreen> createState() => _InvoiceHistoryScreenState();
|
State<InvoiceHistoryScreen> createState() => _InvoiceHistoryScreenState();
|
||||||
|
|
@ -26,6 +29,7 @@ class InvoiceHistoryScreen extends StatefulWidget {
|
||||||
class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
final InvoiceRepository _invoiceRepo = InvoiceRepository();
|
final InvoiceRepository _invoiceRepo = InvoiceRepository();
|
||||||
final CustomerRepository _customerRepo = CustomerRepository();
|
final CustomerRepository _customerRepo = CustomerRepository();
|
||||||
|
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
|
||||||
List<Invoice> _invoices = [];
|
List<Invoice> _invoices = [];
|
||||||
List<Invoice> _filteredInvoices = [];
|
List<Invoice> _filteredInvoices = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
@ -35,12 +39,26 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
DateTime? _startDate;
|
DateTime? _startDate;
|
||||||
DateTime? _endDate;
|
DateTime? _endDate;
|
||||||
String _appVersion = "1.0.0";
|
String _appVersion = "1.0.0";
|
||||||
|
bool _useDashboardHome = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_isUnlocked = widget.initialUnlocked;
|
||||||
_loadData();
|
_loadData();
|
||||||
_loadVersion();
|
_loadVersion();
|
||||||
|
_loadHomeMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadHomeMode() async {
|
||||||
|
final mode = await _settingsRepo.getHomeMode();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_useDashboardHome = mode == 'dashboard';
|
||||||
|
if (_useDashboardHome && widget.initialUnlocked) {
|
||||||
|
_isUnlocked = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showInvoiceActions(Invoice invoice) async {
|
Future<void> _showInvoiceActions(Invoice invoice) async {
|
||||||
|
|
@ -198,20 +216,20 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
final dateFormatter = DateFormat('yyyy/MM/dd');
|
final dateFormatter = DateFormat('yyyy/MM/dd');
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
drawer: _isUnlocked
|
drawer: (_useDashboardHome || !_isUnlocked)
|
||||||
? Drawer(
|
? null
|
||||||
|
: Drawer(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
DrawerHeader(
|
DrawerHeader(
|
||||||
decoration: BoxDecoration(color: Colors.indigo.shade700),
|
decoration: const BoxDecoration(color: Colors.indigo),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
children: const [
|
||||||
children: [
|
Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)),
|
||||||
const Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
|
SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
Text("メニュー", style: TextStyle(color: Colors.white70)),
|
||||||
Text("v$_appVersion", style: const TextStyle(color: Colors.white70)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -257,10 +275,27 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: null,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
// leading removed
|
automaticallyImplyLeading: false,
|
||||||
|
leading: _useDashboardHome
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const DashboardScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: (_isUnlocked
|
||||||
|
? Builder(
|
||||||
|
builder: (ctx) => IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null),
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
|
|
@ -307,14 +342,35 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: 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(
|
child: TextField(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: "検索 (顧客名、伝票番号...)",
|
hintText: "検索 (顧客名、伝票番号...)",
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: const Icon(Icons.search),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.grey.shade50,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
isDense: true,
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
),
|
),
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
_searchQuery = val;
|
_searchQuery = val;
|
||||||
|
|
@ -324,9 +380,11 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
if (!_useDashboardHome)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: SlideToUnlock(
|
child: SlideToUnlock(
|
||||||
|
|
@ -347,8 +405,9 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
await Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (context) => InvoiceDetailPage(
|
builder: (context) => InvoiceInputForm(
|
||||||
invoice: invoice,
|
existingInvoice: invoice,
|
||||||
|
onInvoiceGenerated: (inv, path) {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -393,23 +452,23 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.insert_drive_file_outlined),
|
leading: CircleAvatar(backgroundColor: Colors.blue.withValues(alpha: 0.12), child: const Icon(Icons.request_quote, color: Colors.blue)),
|
||||||
title: const Text('下書き: 見積書', style: TextStyle(fontSize: 24)),
|
title: const Text('見積書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||||
onTap: () => _startNew(DocumentType.estimation),
|
onTap: () => _startNew(DocumentType.estimation),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.local_shipping_outlined),
|
leading: CircleAvatar(backgroundColor: Colors.teal.withValues(alpha: 0.12), child: const Icon(Icons.local_shipping, color: Colors.teal)),
|
||||||
title: const Text('下書き: 納品書', style: TextStyle(fontSize: 24)),
|
title: const Text('納品書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||||
onTap: () => _startNew(DocumentType.delivery),
|
onTap: () => _startNew(DocumentType.delivery),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.request_quote_outlined),
|
leading: CircleAvatar(backgroundColor: Colors.indigo.withValues(alpha: 0.12), child: const Icon(Icons.receipt_long, color: Colors.indigo)),
|
||||||
title: const Text('下書き: 請求書', style: TextStyle(fontSize: 24)),
|
title: const Text('請求書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||||
onTap: () => _startNew(DocumentType.invoice),
|
onTap: () => _startNew(DocumentType.invoice),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.receipt_long_outlined),
|
leading: CircleAvatar(backgroundColor: Colors.green.withValues(alpha: 0.12), child: const Icon(Icons.task_alt, color: Colors.green)),
|
||||||
title: const Text('下書き: 領収書', style: TextStyle(fontSize: 24)),
|
title: const Text('領収書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
|
||||||
onTap: () => _startNew(DocumentType.receipt),
|
onTap: () => _startNew(DocumentType.receipt),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -426,6 +485,8 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
builder: (_) => InvoiceInputForm(
|
builder: (_) => InvoiceInputForm(
|
||||||
onInvoiceGenerated: (inv, path) {},
|
onInvoiceGenerated: (inv, path) {},
|
||||||
initialDocumentType: type,
|
initialDocumentType: type,
|
||||||
|
startViewMode: false,
|
||||||
|
showNewBadge: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,32 +6,39 @@ import '../services/pdf_generator.dart';
|
||||||
import '../services/invoice_repository.dart';
|
import '../services/invoice_repository.dart';
|
||||||
import '../services/customer_repository.dart';
|
import '../services/customer_repository.dart';
|
||||||
import '../widgets/invoice_pdf_preview_page.dart';
|
import '../widgets/invoice_pdf_preview_page.dart';
|
||||||
import 'invoice_detail_page.dart';
|
|
||||||
import '../services/gps_service.dart';
|
import '../services/gps_service.dart';
|
||||||
import 'customer_master_screen.dart';
|
import 'customer_master_screen.dart';
|
||||||
import 'product_master_screen.dart';
|
import 'product_master_screen.dart';
|
||||||
import '../models/product_model.dart';
|
import '../models/product_model.dart';
|
||||||
|
import '../services/app_settings_repository.dart';
|
||||||
|
import '../services/edit_log_repository.dart';
|
||||||
|
|
||||||
class InvoiceInputForm extends StatefulWidget {
|
class InvoiceInputForm extends StatefulWidget {
|
||||||
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
|
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
|
||||||
final Invoice? existingInvoice; // 追加: 編集時の既存伝票
|
final Invoice? existingInvoice; // 追加: 編集時の既存伝票
|
||||||
final DocumentType initialDocumentType;
|
final DocumentType initialDocumentType;
|
||||||
|
final bool startViewMode;
|
||||||
|
final bool showNewBadge;
|
||||||
|
final bool showCopyBadge;
|
||||||
|
|
||||||
const InvoiceInputForm({
|
const InvoiceInputForm({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onInvoiceGenerated,
|
required this.onInvoiceGenerated,
|
||||||
this.existingInvoice, // 追加
|
this.existingInvoice, // 追加
|
||||||
this.initialDocumentType = DocumentType.invoice,
|
this.initialDocumentType = DocumentType.invoice,
|
||||||
|
this.startViewMode = true,
|
||||||
|
this.showNewBadge = false,
|
||||||
|
this.showCopyBadge = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InvoiceInputForm> createState() => _InvoiceInputFormState();
|
State<InvoiceInputForm> createState() => _InvoiceInputFormState();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<InvoiceItem> _cloneItems(List<InvoiceItem> source) {
|
List<InvoiceItem> _cloneItems(List<InvoiceItem> source, {bool resetIds = false}) {
|
||||||
return source
|
return source
|
||||||
.map((e) => InvoiceItem(
|
.map((e) => InvoiceItem(
|
||||||
id: e.id,
|
id: resetIds ? null : e.id,
|
||||||
productId: e.productId,
|
productId: e.productId,
|
||||||
description: e.description,
|
description: e.description,
|
||||||
quantity: e.quantity,
|
quantity: e.quantity,
|
||||||
|
|
@ -52,27 +59,96 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
bool _isDraft = true; // デフォルトは下書き
|
bool _isDraft = true; // デフォルトは下書き
|
||||||
final TextEditingController _subjectController = TextEditingController(); // 追加
|
final TextEditingController _subjectController = TextEditingController(); // 追加
|
||||||
bool _isSaving = false; // 保存中フラグ
|
bool _isSaving = false; // 保存中フラグ
|
||||||
|
String? _currentId; // 保存対象のID(コピー時に新規になる)
|
||||||
|
bool _isLocked = false;
|
||||||
final List<_InvoiceSnapshot> _undoStack = [];
|
final List<_InvoiceSnapshot> _undoStack = [];
|
||||||
final List<_InvoiceSnapshot> _redoStack = [];
|
final List<_InvoiceSnapshot> _redoStack = [];
|
||||||
bool _isApplyingSnapshot = false;
|
bool _isApplyingSnapshot = false;
|
||||||
bool get _canUndo => _undoStack.length > 1;
|
bool get _canUndo => _undoStack.length > 1;
|
||||||
bool get _canRedo => _redoStack.isNotEmpty;
|
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 = "";
|
||||||
|
|
||||||
// 署名用の実験的パス
|
String _documentTypeLabel(DocumentType type) {
|
||||||
final List<Offset?> _signaturePath = [];
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_subjectController.addListener(_onSubjectChanged);
|
_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.addListener(_onSubjectChanged);
|
||||||
_subjectController.removeListener(_onSubjectChanged);
|
_loadInitialData();
|
||||||
_subjectController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadInitialData() async {
|
Future<void> _loadInitialData() async {
|
||||||
|
|
@ -80,6 +156,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
final customerRepo = CustomerRepository();
|
final customerRepo = CustomerRepository();
|
||||||
await customerRepo.getAllCustomers();
|
await customerRepo.getAllCustomers();
|
||||||
|
|
||||||
|
final savedSummary = await _settingsRepo.getSummaryTheme();
|
||||||
|
_summaryIsBlue = savedSummary == 'blue';
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
// 既存伝票がある場合は初期値を上書き
|
// 既存伝票がある場合は初期値を上書き
|
||||||
if (widget.existingInvoice != null) {
|
if (widget.existingInvoice != null) {
|
||||||
|
|
@ -91,15 +170,39 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
_documentType = inv.documentType;
|
_documentType = inv.documentType;
|
||||||
_selectedDate = inv.date;
|
_selectedDate = inv.date;
|
||||||
_isDraft = inv.isDraft;
|
_isDraft = inv.isDraft;
|
||||||
|
_currentId = inv.id;
|
||||||
|
_isLocked = inv.isLocked;
|
||||||
if (inv.subject != null) _subjectController.text = inv.subject!;
|
if (inv.subject != null) _subjectController.text = inv.subject!;
|
||||||
} else {
|
} else {
|
||||||
_taxRate = 0;
|
_taxRate = 0;
|
||||||
_includeTax = false;
|
_includeTax = false;
|
||||||
_isDraft = true;
|
_isDraft = true;
|
||||||
_documentType = widget.initialDocumentType;
|
_documentType = widget.initialDocumentType;
|
||||||
|
_currentId = null;
|
||||||
|
_isLocked = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
_isViewMode = widget.startViewMode; // 指定に従う
|
||||||
|
_showNewBadge = widget.showNewBadge;
|
||||||
|
_showCopyBadge = widget.showCopyBadge;
|
||||||
_pushHistory(clearRedo: true);
|
_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() {
|
void _onSubjectChanged() {
|
||||||
|
|
@ -122,6 +225,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
_pushHistory();
|
_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(); // 履歴テーブルにも保存
|
await gpsService.logLocation(); // 履歴テーブルにも保存
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final invoiceId = _ensureCurrentId();
|
||||||
|
|
||||||
final invoice = Invoice(
|
final invoice = Invoice(
|
||||||
id: widget.existingInvoice?.id, // 既存IDがあれば引き継ぐ
|
id: invoiceId,
|
||||||
customer: _selectedCustomer!,
|
customer: _selectedCustomer!,
|
||||||
date: _selectedDate,
|
date: _selectedDate,
|
||||||
items: _items,
|
items: _items,
|
||||||
|
|
@ -166,6 +274,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
if (path != null) {
|
if (path != null) {
|
||||||
final updatedInvoice = invoice.copyWith(filePath: path);
|
final updatedInvoice = invoice.copyWith(filePath: path);
|
||||||
await _repository.saveInvoice(updatedInvoice);
|
await _repository.saveInvoice(updatedInvoice);
|
||||||
|
_currentId = updatedInvoice.id;
|
||||||
if (mounted) widget.onInvoiceGenerated(updatedInvoice, path);
|
if (mounted) widget.onInvoiceGenerated(updatedInvoice, path);
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました")));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -173,9 +282,12 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await _repository.saveInvoice(invoice);
|
await _repository.saveInvoice(invoice);
|
||||||
|
_currentId = invoice.id;
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しました(PDF未生成)")));
|
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) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('保存に失敗しました: $e')));
|
||||||
|
|
@ -187,7 +299,10 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
|
|
||||||
void _showPreview() {
|
void _showPreview() {
|
||||||
if (_selectedCustomer == null) return;
|
if (_selectedCustomer == null) return;
|
||||||
|
final id = _ensureCurrentId();
|
||||||
|
_editLogRepo.addLog(id, "PDFプレビューを開きました").then((_) => _loadEditLogs());
|
||||||
final invoice = Invoice(
|
final invoice = Invoice(
|
||||||
|
id: id,
|
||||||
customer: _selectedCustomer!,
|
customer: _selectedCustomer!,
|
||||||
date: _selectedDate, // 修正
|
date: _selectedDate, // 修正
|
||||||
items: _items,
|
items: _items,
|
||||||
|
|
@ -195,6 +310,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
documentType: _documentType,
|
documentType: _documentType,
|
||||||
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
customerFormalNameSnapshot: _selectedCustomer!.formalName,
|
||||||
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
|
||||||
|
isDraft: _isDraft,
|
||||||
|
isLocked: _isLocked,
|
||||||
);
|
);
|
||||||
|
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
|
|
@ -203,34 +320,27 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
builder: (context) => InvoicePdfPreviewPage(
|
builder: (context) => InvoicePdfPreviewPage(
|
||||||
invoice: invoice,
|
invoice: invoice,
|
||||||
isUnlocked: true,
|
isUnlocked: true,
|
||||||
isLocked: false,
|
isLocked: _isLocked,
|
||||||
allowFormalIssue: widget.existingInvoice != null && !(widget.existingInvoice?.isLocked ?? false),
|
allowFormalIssue: invoice.isDraft && !_isLocked,
|
||||||
onFormalIssue: (widget.existingInvoice != null)
|
onFormalIssue: invoice.isDraft
|
||||||
? () async {
|
? () async {
|
||||||
final promoted = invoice.copyWith(isDraft: false);
|
final promoted = invoice.copyWith(id: id, isDraft: false, isLocked: true);
|
||||||
await _invoiceRepo.saveInvoice(promoted);
|
await _invoiceRepo.saveInvoice(promoted);
|
||||||
final newPath = await generateInvoicePdf(promoted);
|
final newPath = await generateInvoicePdf(promoted);
|
||||||
final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted;
|
final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted;
|
||||||
await _invoiceRepo.saveInvoice(saved);
|
await _invoiceRepo.saveInvoice(saved);
|
||||||
|
await _editLogRepo.addLog(_ensureCurrentId(), "正式発行しました");
|
||||||
if (!context.mounted) return false;
|
if (!context.mounted) return false;
|
||||||
Navigator.pop(context); // close preview
|
setState(() {
|
||||||
Navigator.pop(context); // exit edit screen
|
_isDraft = false;
|
||||||
if (!context.mounted) return false;
|
_isLocked = true;
|
||||||
await Navigator.push(
|
});
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => InvoiceDetailPage(
|
|
||||||
invoice: saved,
|
|
||||||
isUnlocked: true,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
showShare: false,
|
showShare: true,
|
||||||
showEmail: false,
|
showEmail: true,
|
||||||
showPrint: false,
|
showPrint: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -317,16 +427,41 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final fmt = NumberFormat("#,###");
|
final fmt = NumberFormat("#,###");
|
||||||
final themeColor = Colors.white;
|
final themeColor = Theme.of(context).scaffoldBackgroundColor;
|
||||||
final textColor = Colors.black87;
|
final textColor = Colors.black87;
|
||||||
|
|
||||||
|
final docColor = _documentTypeColor(_documentType);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: themeColor,
|
backgroundColor: themeColor,
|
||||||
resizeToAvoidBottomInset: false,
|
resizeToAvoidBottomInset: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
backgroundColor: docColor,
|
||||||
leading: const BackButton(),
|
leading: const BackButton(),
|
||||||
title: const Text("A1:伝票入力"),
|
title: Text("A1:${_documentTypeLabel(_documentType)}"),
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.undo),
|
icon: const Icon(Icons.undo),
|
||||||
onPressed: _canUndo ? _undo : null,
|
onPressed: _canUndo ? _undo : null,
|
||||||
|
|
@ -337,6 +472,13 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
onPressed: _canRedo ? _redo : null,
|
onPressed: _canRedo ? _redo : null,
|
||||||
tooltip: "やり直す",
|
tooltip: "やり直す",
|
||||||
),
|
),
|
||||||
|
if (!_isLocked)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
tooltip: "保存",
|
||||||
|
onPressed: _isSaving ? null : () => _saveInvoice(generatePdf: false),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
|
|
@ -359,9 +501,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
_buildItemsSection(fmt),
|
_buildItemsSection(fmt),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildSummarySection(fmt),
|
_buildSummarySection(fmt),
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildSignatureSection(),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_buildEditLogsSection(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -391,7 +533,9 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
Widget _buildDateSection() {
|
Widget _buildDateSection() {
|
||||||
final fmt = DateFormat('yyyy/MM/dd');
|
final fmt = DateFormat('yyyy/MM/dd');
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () async {
|
onTap: _isViewMode
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
final picked = await showDatePicker(
|
final picked = await showDatePicker(
|
||||||
context: context,
|
context: context,
|
||||||
initialDate: _selectedDate,
|
initialDate: _selectedDate,
|
||||||
|
|
@ -403,38 +547,69 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
_pushHistory();
|
_pushHistory();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade100,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
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(
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.calendar_today, size: 18, color: Colors.indigo),
|
const Icon(Icons.calendar_today, size: 18, color: Colors.indigo),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text("伝票日付: ${fmt.format(_selectedDate)}", style: const TextStyle(fontWeight: FontWeight.bold)),
|
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),
|
const Icon(Icons.chevron_right, size: 18, color: Colors.indigo),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCustomerSection() {
|
Widget _buildCustomerSection() {
|
||||||
return Card(
|
return Container(
|
||||||
elevation: 0,
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey.shade50,
|
color: Colors.white,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
|
||||||
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: const Icon(Icons.business, color: Colors.blueGrey),
|
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)),
|
style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)),
|
||||||
subtitle: const Text("顧客マスターから選択"), // 修正
|
subtitle: _isViewMode ? null : const Text("顧客マスターから選択"),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
trailing: (_isViewMode || _isLocked) ? null : const Icon(Icons.chevron_right),
|
||||||
onTap: () async {
|
onTap: (_isViewMode || _isLocked)
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
final Customer? picked = await Navigator.push(
|
final Customer? picked = await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
|
|
@ -459,6 +634,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
if (!_isViewMode && !_isLocked)
|
||||||
TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")),
|
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),
|
padding: EdgeInsets.symmetric(vertical: 20),
|
||||||
child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))),
|
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
|
else
|
||||||
ReorderableListView.builder(
|
ReorderableListView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: _items.length,
|
itemCount: _items.length,
|
||||||
onReorder: (oldIndex, newIndex) {
|
onReorder: (oldIndex, newIndex) async {
|
||||||
|
int targetIndex = newIndex;
|
||||||
setState(() {
|
setState(() {
|
||||||
if (newIndex > oldIndex) newIndex -= 1;
|
if (targetIndex > oldIndex) targetIndex -= 1;
|
||||||
final item = _items.removeAt(oldIndex);
|
final item = _items.removeAt(oldIndex);
|
||||||
_items.insert(newIndex, item);
|
_items.insert(targetIndex, item);
|
||||||
});
|
});
|
||||||
_pushHistory();
|
_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,
|
buildDefaultDragHandles: false,
|
||||||
itemBuilder: (context, idx) {
|
itemBuilder: (context, idx) {
|
||||||
|
|
@ -487,84 +688,90 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
key: ValueKey('item_${idx}_${item.description}'),
|
key: ValueKey('item_${idx}_${item.description}'),
|
||||||
index: idx,
|
index: idx,
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
|
elevation: 0.5,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text(item.description),
|
dense: true,
|
||||||
subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}"),
|
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(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)),
|
if (!_isViewMode && !_isLocked) ...[
|
||||||
const SizedBox(width: 8),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent),
|
icon: const Icon(Icons.remove, size: 18),
|
||||||
onPressed: () {
|
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));
|
setState(() => _items.removeAt(idx));
|
||||||
_pushHistory();
|
_pushHistory();
|
||||||
|
final id = _ensureCurrentId();
|
||||||
|
final msg = "商品「${removed.description}」を削除しました";
|
||||||
|
await _editLogRepo.addLog(id, msg);
|
||||||
|
await _loadEditLogs();
|
||||||
},
|
},
|
||||||
tooltip: "削除",
|
tooltip: "削除",
|
||||||
|
constraints: const BoxConstraints.tightFor(width: 32, height: 32),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
// 簡易編集ダイアログ(キーボードでせり上げない)
|
if (_isViewMode || _isLocked) return;
|
||||||
final descCtrl = TextEditingController(text: item.description);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
final qtyCtrl = TextEditingController(text: item.quantity.toString());
|
final product = await Navigator.push<Product>(
|
||||||
final priceCtrl = TextEditingController(text: item.unitPrice.toString());
|
context,
|
||||||
showDialog(
|
MaterialPageRoute(builder: (_) => const ProductMasterScreen(selectionMode: true)),
|
||||||
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()),
|
|
||||||
);
|
);
|
||||||
},
|
if (product != null) {
|
||||||
),
|
if (!mounted) return;
|
||||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
final prevDesc = item.description;
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_items[idx] = item.copyWith(
|
_items[idx] = item.copyWith(
|
||||||
description: descCtrl.text,
|
productId: product.id,
|
||||||
quantity: int.tryParse(qtyCtrl.text) ?? item.quantity,
|
description: product.name,
|
||||||
unitPrice: int.tryParse(priceCtrl.text) ?? item.unitPrice,
|
unitPrice: product.defaultUnitPrice,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
_pushHistory();
|
_pushHistory();
|
||||||
Navigator.pop(context);
|
final id = _ensureCurrentId();
|
||||||
},
|
final msg = "商品を $prevDesc から ${product.name} に変更しました";
|
||||||
child: const Text("更新"),
|
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 tax = _includeTax ? (subtotal * _taxRate).floor() : 0;
|
||||||
final int total = subtotal + tax;
|
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,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.indigo,
|
color: bgColor,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: borderColor),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70),
|
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor),
|
||||||
if (tax > 0) ...[
|
if (tax > 0) ...[
|
||||||
const Divider(color: Colors.white24),
|
Divider(color: dividerColor),
|
||||||
_buildSummaryRow("消費税", "¥${formatter.format(tax)}", Colors.white70),
|
_buildSummaryRow("消費税", "¥${formatter.format(tax)}", labelColor),
|
||||||
],
|
],
|
||||||
const Divider(color: Colors.white24),
|
Divider(color: dividerColor),
|
||||||
_buildSummaryRow(
|
_buildSummaryRow(
|
||||||
tax > 0 ? "合計金額 (税込)" : "合計金額",
|
tax > 0 ? "合計金額 (税込)" : "合計金額",
|
||||||
"¥${formatter.format(total)}",
|
"¥${formatter.format(total)}",
|
||||||
Colors.white,
|
totalColor,
|
||||||
isTotal: true,
|
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() {
|
Widget _buildBottomActionBar() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||||
|
|
@ -697,7 +903,24 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
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),
|
onPressed: () => _saveInvoice(generatePdf: false),
|
||||||
icon: const Icon(Icons.save),
|
icon: const Icon(Icons.save),
|
||||||
label: const Text("保存"),
|
label: const Text("保存"),
|
||||||
|
|
@ -706,7 +929,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
),
|
),
|
||||||
),
|
)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -726,12 +949,16 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade100,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 3))],
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
focusNode: _subjectFocusNode,
|
||||||
controller: _subjectController,
|
controller: _subjectController,
|
||||||
style: TextStyle(color: textColor),
|
style: TextStyle(color: textColor),
|
||||||
|
readOnly: _isViewMode || _isLocked,
|
||||||
|
enableInteractiveSelection: !(_isViewMode || _isLocked),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: "例:事務所改修工事 / 〇〇月分リース料",
|
hintText: "例:事務所改修工事 / 〇〇月分リース料",
|
||||||
hintStyle: TextStyle(color: textColor.withAlpha((0.5 * 255).round())),
|
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 {
|
class _InvoiceSnapshot {
|
||||||
|
|
@ -767,25 +1079,3 @@ class _InvoiceSnapshot {
|
||||||
required this.subject,
|
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 {
|
class ProductMasterScreen extends StatefulWidget {
|
||||||
final bool selectionMode;
|
final bool selectionMode;
|
||||||
|
final bool showHidden;
|
||||||
|
|
||||||
const ProductMasterScreen({super.key, this.selectionMode = false});
|
const ProductMasterScreen({super.key, this.selectionMode = false, this.showHidden = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
|
||||||
|
|
@ -30,7 +31,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
|
|
||||||
Future<void> _loadProducts() async {
|
Future<void> _loadProducts() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final products = await _productRepo.getAllProducts();
|
final products = await _productRepo.getAllProducts(includeHidden: widget.showHidden);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_products = products;
|
_products = products;
|
||||||
|
|
@ -47,6 +48,12 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
(p.barcode?.toLowerCase().contains(query) ?? false) ||
|
(p.barcode?.toLowerCase().contains(query) ?? false) ||
|
||||||
(p.category?.toLowerCase().contains(query) ?? false);
|
(p.category?.toLowerCase().contains(query) ?? false);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
if (!widget.showHidden) {
|
||||||
|
_filteredProducts = _filteredProducts.where((p) => !p.isHidden).toList();
|
||||||
|
}
|
||||||
|
if (widget.showHidden) {
|
||||||
|
_filteredProducts.sort((a, b) => b.id.compareTo(a.id));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,16 +113,19 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (nameController.text.isEmpty) return;
|
if (nameController.text.isEmpty) return;
|
||||||
|
final locked = product?.isLocked ?? false;
|
||||||
|
final newId = locked ? const Uuid().v4() : (product?.id ?? const Uuid().v4());
|
||||||
Navigator.pop(
|
Navigator.pop(
|
||||||
context,
|
context,
|
||||||
Product(
|
Product(
|
||||||
id: product?.id ?? const Uuid().v4(),
|
id: newId,
|
||||||
name: nameController.text.trim(),
|
name: nameController.text.trim(),
|
||||||
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
|
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
|
||||||
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
|
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
|
||||||
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
|
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
|
||||||
stockQuantity: int.tryParse(stockController.text) ?? 0,
|
stockQuantity: int.tryParse(stockController.text) ?? 0,
|
||||||
odooId: product?.odooId,
|
odooId: product?.odooId,
|
||||||
|
isLocked: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -188,10 +198,19 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)),
|
title: Text(
|
||||||
|
p.name + (p.isHidden ? " (非表示)" : ""),
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: p.isHidden
|
||||||
|
? Colors.grey
|
||||||
|
: (p.isLocked ? Colors.grey : Colors.black87),
|
||||||
|
),
|
||||||
|
),
|
||||||
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
|
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (widget.selectionMode) {
|
if (widget.selectionMode) {
|
||||||
|
if (p.isHidden) return; // safety: do not return hidden in selection
|
||||||
Navigator.pop(context, p);
|
Navigator.pop(context, p);
|
||||||
} else {
|
} else {
|
||||||
_showDetailPane(p);
|
_showDetailPane(p);
|
||||||
|
|
@ -212,6 +231,16 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
|
||||||
_showEditDialog(product: p);
|
_showEditDialog(product: p);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (!p.isHidden)
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.visibility_off),
|
||||||
|
title: const Text("非表示にする"),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
await _productRepo.setHidden(p.id, true);
|
||||||
|
if (mounted) _loadProducts();
|
||||||
|
},
|
||||||
|
),
|
||||||
if (!p.isLocked)
|
if (!p.isLocked)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,12 @@
|
||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../services/app_settings_repository.dart';
|
||||||
|
import '../services/theme_controller.dart';
|
||||||
import 'company_info_screen.dart';
|
import 'company_info_screen.dart';
|
||||||
|
import 'email_settings_screen.dart';
|
||||||
|
import 'business_profile_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
@ -10,28 +15,22 @@ class SettingsScreen extends StatefulWidget {
|
||||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// シンプルなアイコンマップ(拡張可)
|
||||||
|
const Map<String, IconData> kIconsMap = {
|
||||||
|
'list_alt': Icons.list_alt,
|
||||||
|
'edit_note': Icons.edit_note,
|
||||||
|
'history': Icons.history,
|
||||||
|
'settings': Icons.settings,
|
||||||
|
'invoice': Icons.receipt_long,
|
||||||
|
'dashboard': Icons.dashboard,
|
||||||
|
'home': Icons.home,
|
||||||
|
'info': Icons.info,
|
||||||
|
'mail': Icons.mail,
|
||||||
|
'shopping_cart': Icons.shopping_cart,
|
||||||
|
};
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
// Company
|
final _appSettingsRepo = AppSettingsRepository();
|
||||||
final _companyNameCtrl = TextEditingController();
|
|
||||||
final _companyZipCtrl = TextEditingController();
|
|
||||||
final _companyAddrCtrl = TextEditingController();
|
|
||||||
final _companyTelCtrl = TextEditingController();
|
|
||||||
final _companyRegCtrl = TextEditingController();
|
|
||||||
final _companyFaxCtrl = TextEditingController();
|
|
||||||
final _companyEmailCtrl = TextEditingController();
|
|
||||||
final _companyUrlCtrl = TextEditingController();
|
|
||||||
|
|
||||||
// Staff
|
|
||||||
final _staffNameCtrl = TextEditingController();
|
|
||||||
final _staffMailCtrl = TextEditingController();
|
|
||||||
|
|
||||||
// SMTP
|
|
||||||
final _smtpHostCtrl = TextEditingController();
|
|
||||||
final _smtpPortCtrl = TextEditingController(text: '587');
|
|
||||||
final _smtpUserCtrl = TextEditingController();
|
|
||||||
final _smtpPassCtrl = TextEditingController();
|
|
||||||
final _smtpBccCtrl = TextEditingController();
|
|
||||||
bool _smtpTls = true;
|
|
||||||
|
|
||||||
// External sync (母艦システム「お局様」連携)
|
// External sync (母艦システム「お局様」連携)
|
||||||
final _externalHostCtrl = TextEditingController();
|
final _externalHostCtrl = TextEditingController();
|
||||||
|
|
@ -47,85 +46,55 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
final _kanaKeyCtrl = TextEditingController();
|
final _kanaKeyCtrl = TextEditingController();
|
||||||
final _kanaValCtrl = TextEditingController();
|
final _kanaValCtrl = TextEditingController();
|
||||||
|
|
||||||
// SharedPreferences keys
|
// Dashboard / Home
|
||||||
static const _kCompanyName = 'company_name';
|
bool _homeDashboard = false;
|
||||||
static const _kCompanyZip = 'company_zip';
|
bool _statusEnabled = true;
|
||||||
static const _kCompanyAddr = 'company_addr';
|
final _statusTextCtrl = TextEditingController(text: '工事中');
|
||||||
static const _kCompanyTel = 'company_tel';
|
List<DashboardMenuItem> _menuItems = [];
|
||||||
static const _kCompanyReg = 'company_reg';
|
bool _loadingAppSettings = true;
|
||||||
static const _kCompanyFax = 'company_fax';
|
|
||||||
static const _kCompanyEmail = 'company_email';
|
|
||||||
static const _kCompanyUrl = 'company_url';
|
|
||||||
|
|
||||||
static const _kStaffName = 'staff_name';
|
|
||||||
static const _kStaffMail = 'staff_mail';
|
|
||||||
|
|
||||||
static const _kSmtpHost = 'smtp_host';
|
|
||||||
static const _kSmtpPort = 'smtp_port';
|
|
||||||
static const _kSmtpUser = 'smtp_user';
|
|
||||||
static const _kSmtpPass = 'smtp_pass';
|
|
||||||
static const _kSmtpTls = 'smtp_tls';
|
|
||||||
static const _kSmtpBcc = 'smtp_bcc';
|
|
||||||
|
|
||||||
static const _kExternalHost = 'external_host';
|
static const _kExternalHost = 'external_host';
|
||||||
static const _kExternalPass = 'external_pass';
|
static const _kExternalPass = 'external_pass';
|
||||||
|
|
||||||
static const _kCryptKey = 'test';
|
|
||||||
|
|
||||||
static const _kBackupPath = 'backup_path';
|
static const _kBackupPath = 'backup_path';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_companyNameCtrl.dispose();
|
|
||||||
_companyZipCtrl.dispose();
|
|
||||||
_companyAddrCtrl.dispose();
|
|
||||||
_companyTelCtrl.dispose();
|
|
||||||
_companyRegCtrl.dispose();
|
|
||||||
_companyFaxCtrl.dispose();
|
|
||||||
_companyEmailCtrl.dispose();
|
|
||||||
_companyUrlCtrl.dispose();
|
|
||||||
_staffNameCtrl.dispose();
|
|
||||||
_staffMailCtrl.dispose();
|
|
||||||
_smtpHostCtrl.dispose();
|
|
||||||
_smtpPortCtrl.dispose();
|
|
||||||
_smtpUserCtrl.dispose();
|
|
||||||
_smtpPassCtrl.dispose();
|
|
||||||
_smtpBccCtrl.dispose();
|
|
||||||
_externalHostCtrl.dispose();
|
_externalHostCtrl.dispose();
|
||||||
_externalPassCtrl.dispose();
|
_externalPassCtrl.dispose();
|
||||||
_backupPathCtrl.dispose();
|
_backupPathCtrl.dispose();
|
||||||
_kanaKeyCtrl.dispose();
|
_kanaKeyCtrl.dispose();
|
||||||
_kanaValCtrl.dispose();
|
_kanaValCtrl.dispose();
|
||||||
|
_statusTextCtrl.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAll() async {
|
Future<void> _loadAll() async {
|
||||||
await _loadKanaMap();
|
await _loadKanaMap();
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final externalHost = await _appSettingsRepo.getString(_kExternalHost) ?? '';
|
||||||
|
final externalPass = await _appSettingsRepo.getString(_kExternalPass) ?? '';
|
||||||
|
|
||||||
|
final backupPath = await _appSettingsRepo.getString(_kBackupPath) ?? '';
|
||||||
|
final theme = await _appSettingsRepo.getTheme();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_companyNameCtrl.text = prefs.getString(_kCompanyName) ?? '';
|
_externalHostCtrl.text = externalHost;
|
||||||
_companyZipCtrl.text = prefs.getString(_kCompanyZip) ?? '';
|
_externalPassCtrl.text = externalPass;
|
||||||
_companyAddrCtrl.text = prefs.getString(_kCompanyAddr) ?? '';
|
|
||||||
_companyTelCtrl.text = prefs.getString(_kCompanyTel) ?? '';
|
|
||||||
_companyRegCtrl.text = prefs.getString(_kCompanyReg) ?? '';
|
|
||||||
_companyFaxCtrl.text = prefs.getString(_kCompanyFax) ?? '';
|
|
||||||
_companyEmailCtrl.text = prefs.getString(_kCompanyEmail) ?? '';
|
|
||||||
_companyUrlCtrl.text = prefs.getString(_kCompanyUrl) ?? '';
|
|
||||||
|
|
||||||
_staffNameCtrl.text = prefs.getString(_kStaffName) ?? '';
|
_backupPathCtrl.text = backupPath;
|
||||||
_staffMailCtrl.text = prefs.getString(_kStaffMail) ?? '';
|
_theme = theme;
|
||||||
|
});
|
||||||
|
|
||||||
_smtpHostCtrl.text = prefs.getString(_kSmtpHost) ?? '';
|
final homeMode = await _appSettingsRepo.getHomeMode();
|
||||||
_smtpPortCtrl.text = prefs.getString(_kSmtpPort) ?? '587';
|
final statusEnabled = await _appSettingsRepo.getDashboardStatusEnabled();
|
||||||
_smtpUserCtrl.text = prefs.getString(_kSmtpUser) ?? '';
|
final statusText = await _appSettingsRepo.getDashboardStatusText();
|
||||||
_smtpPassCtrl.text = _decryptWithFallback(prefs.getString(_kSmtpPass) ?? '');
|
final menu = await _appSettingsRepo.getDashboardMenu();
|
||||||
_smtpTls = prefs.getBool(_kSmtpTls) ?? true;
|
setState(() {
|
||||||
_smtpBccCtrl.text = prefs.getString(_kSmtpBcc) ?? '';
|
_homeDashboard = homeMode == 'dashboard';
|
||||||
|
_statusEnabled = statusEnabled;
|
||||||
_externalHostCtrl.text = prefs.getString(_kExternalHost) ?? '';
|
_statusTextCtrl.text = statusText;
|
||||||
_externalPassCtrl.text = prefs.getString(_kExternalPass) ?? '';
|
_menuItems = menu;
|
||||||
|
_loadingAppSettings = false;
|
||||||
_backupPathCtrl.text = prefs.getString(_kBackupPath) ?? '';
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -139,55 +108,156 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveCompany() async {
|
Future<void> _saveAppSettings() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _appSettingsRepo.setHomeMode(_homeDashboard ? 'dashboard' : 'invoice_history');
|
||||||
await prefs.setString(_kCompanyName, _companyNameCtrl.text);
|
await _appSettingsRepo.setDashboardStatusEnabled(_statusEnabled);
|
||||||
await prefs.setString(_kCompanyZip, _companyZipCtrl.text);
|
await _appSettingsRepo.setDashboardStatusText(_statusTextCtrl.text.trim().isEmpty ? '工事中' : _statusTextCtrl.text.trim());
|
||||||
await prefs.setString(_kCompanyAddr, _companyAddrCtrl.text);
|
await _appSettingsRepo.setDashboardMenu(_menuItems);
|
||||||
await prefs.setString(_kCompanyTel, _companyTelCtrl.text);
|
_showSnackbar('ホーム/ダッシュボード設定を保存しました');
|
||||||
await prefs.setString(_kCompanyReg, _companyRegCtrl.text);
|
|
||||||
await prefs.setString(_kCompanyFax, _companyFaxCtrl.text);
|
|
||||||
await prefs.setString(_kCompanyEmail, _companyEmailCtrl.text);
|
|
||||||
await prefs.setString(_kCompanyUrl, _companyUrlCtrl.text);
|
|
||||||
_showSnackbar('自社情報を保存しました');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveStaff() async {
|
Future<void> _persistMenu() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _appSettingsRepo.setDashboardMenu(_menuItems);
|
||||||
await prefs.setString(_kStaffName, _staffNameCtrl.text);
|
|
||||||
await prefs.setString(_kStaffMail, _staffMailCtrl.text);
|
|
||||||
_showSnackbar('担当者情報を保存しました');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveSmtp() async {
|
void _addMenuItem() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final titleCtrl = TextEditingController();
|
||||||
await prefs.setString(_kSmtpHost, _smtpHostCtrl.text);
|
String route = 'invoice_history';
|
||||||
await prefs.setString(_kSmtpPort, _smtpPortCtrl.text);
|
final iconCtrl = TextEditingController(text: 'list_alt');
|
||||||
await prefs.setString(_kSmtpUser, _smtpUserCtrl.text);
|
String? customIconPath;
|
||||||
await prefs.setString(_kSmtpPass, _encrypt(_smtpPassCtrl.text));
|
await showDialog(
|
||||||
await prefs.setBool(_kSmtpTls, _smtpTls);
|
context: context,
|
||||||
await prefs.setString(_kSmtpBcc, _smtpBccCtrl.text);
|
builder: (ctx) => AlertDialog(
|
||||||
_showSnackbar('SMTP設定を保存しました');
|
title: const Text('メニューを追加'),
|
||||||
|
content: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: 'タイトル')),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: route,
|
||||||
|
decoration: const InputDecoration(labelText: '遷移先'),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: 'invoice_history', child: Text('A2:伝票一覧')),
|
||||||
|
DropdownMenuItem(value: 'invoice_input', child: Text('A1:伝票入力')),
|
||||||
|
DropdownMenuItem(value: 'customer_master', child: Text('C1:顧客マスター')),
|
||||||
|
DropdownMenuItem(value: 'product_master', child: Text('P1:商品マスター')),
|
||||||
|
DropdownMenuItem(value: 'master_hub', child: Text('M1:マスター管理')),
|
||||||
|
DropdownMenuItem(value: 'settings', child: Text('S1:設定')),
|
||||||
|
],
|
||||||
|
onChanged: (v) => route = v ?? 'invoice_history',
|
||||||
|
),
|
||||||
|
TextField(controller: iconCtrl, decoration: const InputDecoration(labelText: 'Materialアイコン名 (例: list_alt)')),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(customIconPath ?? 'カスタムアイコン: 未選択', style: const TextStyle(fontSize: 12))),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.image_search),
|
||||||
|
tooltip: 'ギャラリーから選択',
|
||||||
|
onPressed: () async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final picked = await picker.pickImage(source: ImageSource.gallery);
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
customIconPath = picked.path;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (titleCtrl.text.trim().isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_menuItems = [
|
||||||
|
..._menuItems,
|
||||||
|
DashboardMenuItem(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
title: titleCtrl.text.trim(),
|
||||||
|
route: route,
|
||||||
|
iconName: iconCtrl.text.trim().isEmpty ? 'list_alt' : iconCtrl.text.trim(),
|
||||||
|
customIconPath: customIconPath,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
_persistMenu();
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
child: const Text('追加'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeMenuItem(String id) {
|
||||||
|
setState(() {
|
||||||
|
_menuItems = _menuItems.where((e) => e.id != id).toList();
|
||||||
|
});
|
||||||
|
_persistMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _reorderMenu(int oldIndex, int newIndex) {
|
||||||
|
setState(() {
|
||||||
|
if (newIndex > oldIndex) newIndex -= 1;
|
||||||
|
final item = _menuItems.removeAt(oldIndex);
|
||||||
|
_menuItems.insert(newIndex, item);
|
||||||
|
});
|
||||||
|
_persistMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _routeLabel(String route) {
|
||||||
|
switch (route) {
|
||||||
|
case 'invoice_history':
|
||||||
|
return 'A2:伝票一覧';
|
||||||
|
case 'invoice_input':
|
||||||
|
return 'A1:伝票入力';
|
||||||
|
case 'customer_master':
|
||||||
|
return 'C1:顧客マスター';
|
||||||
|
case 'product_master':
|
||||||
|
return 'P1:商品マスター';
|
||||||
|
case 'master_hub':
|
||||||
|
return 'M1:マスター管理';
|
||||||
|
case 'settings':
|
||||||
|
return 'S1:設定';
|
||||||
|
default:
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _iconForName(String name) {
|
||||||
|
return kIconsMap[name] ?? Icons.apps;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _menuLeading(DashboardMenuItem item) {
|
||||||
|
if (item.customIconPath != null && File(item.customIconPath!).existsSync()) {
|
||||||
|
return CircleAvatar(backgroundImage: FileImage(File(item.customIconPath!)));
|
||||||
|
}
|
||||||
|
return Icon(item.iconName != null ? _iconForName(item.iconName!) : Icons.apps);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveExternalSync() async {
|
Future<void> _saveExternalSync() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _appSettingsRepo.setString(_kExternalHost, _externalHostCtrl.text);
|
||||||
await prefs.setString(_kExternalHost, _externalHostCtrl.text);
|
await _appSettingsRepo.setString(_kExternalPass, _externalPassCtrl.text);
|
||||||
await prefs.setString(_kExternalPass, _externalPassCtrl.text);
|
|
||||||
_showSnackbar('外部同期設定を保存しました');
|
_showSnackbar('外部同期設定を保存しました');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveBackup() async {
|
Future<void> _saveBackup() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _appSettingsRepo.setString(_kBackupPath, _backupPathCtrl.text);
|
||||||
await prefs.setString(_kBackupPath, _backupPathCtrl.text);
|
|
||||||
_showSnackbar('バックアップ設定を保存しました');
|
_showSnackbar('バックアップ設定を保存しました');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
|
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
|
||||||
|
|
||||||
Future<void> _loadKanaMap() async {
|
Future<void> _loadKanaMap() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final json = await _appSettingsRepo.getString('customKanaMap');
|
||||||
final json = prefs.getString('customKanaMap');
|
|
||||||
if (json != null && json.isNotEmpty) {
|
if (json != null && json.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
final Map<String, dynamic> decoded = jsonDecode(json);
|
final Map<String, dynamic> decoded = jsonDecode(json);
|
||||||
|
|
@ -199,31 +269,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _saveKanaMap() async {
|
Future<void> _saveKanaMap() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
await _appSettingsRepo.setString('customKanaMap', jsonEncode(_customKanaMap));
|
||||||
await prefs.setString('customKanaMap', jsonEncode(_customKanaMap));
|
|
||||||
_showSnackbar('かなインデックスを保存しました');
|
_showSnackbar('かなインデックスを保存しました');
|
||||||
}
|
}
|
||||||
|
|
||||||
String _encrypt(String plain) {
|
|
||||||
if (plain.isEmpty) return '';
|
|
||||||
final pb = utf8.encode(plain);
|
|
||||||
final kb = utf8.encode(_kCryptKey);
|
|
||||||
final ob = List<int>.generate(pb.length, (i) => pb[i] ^ kb[i % kb.length]);
|
|
||||||
return base64Encode(ob);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _decryptWithFallback(String cipher) {
|
|
||||||
if (cipher.isEmpty) return '';
|
|
||||||
try {
|
|
||||||
final ob = base64Decode(cipher);
|
|
||||||
final kb = utf8.encode(_kCryptKey);
|
|
||||||
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
|
|
||||||
return utf8.decode(pb);
|
|
||||||
} catch (_) {
|
|
||||||
return cipher; // 旧プレーンテキストも許容
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
||||||
|
|
@ -247,33 +296,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: EdgeInsets.only(bottom: listBottomPadding),
|
padding: EdgeInsets.only(bottom: listBottomPadding),
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_section(
|
||||||
width: double.infinity,
|
title: 'ホームモード / ダッシュボード',
|
||||||
padding: const EdgeInsets.all(14),
|
subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)',
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: Colors.indigo.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.indigo.shade100),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.business, color: Colors.indigo, size: 28),
|
SwitchListTile(
|
||||||
const SizedBox(width: 12),
|
title: const Text('ホームをダッシュボードにする'),
|
||||||
const Expanded(
|
value: _homeDashboard,
|
||||||
child: Text(
|
onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v),
|
||||||
"自社情報を開く",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo),
|
|
||||||
),
|
),
|
||||||
|
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(
|
ElevatedButton.icon(
|
||||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CompanyInfoScreen())),
|
icon: const Icon(Icons.add),
|
||||||
icon: const Icon(Icons.chevron_right),
|
label: const Text('メニューを追加'),
|
||||||
label: const Text("詳細"),
|
onPressed: _loadingAppSettings ? null : _addMenuItem,
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: Colors.indigo,
|
const SizedBox(width: 12),
|
||||||
foregroundColor: Colors.white,
|
Text('ドラッグで並べ替え / ゴミ箱で削除', style: Theme.of(context).textTheme.bodySmall),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_loadingAppSettings
|
||||||
|
? const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator()))
|
||||||
|
: ReorderableListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: _menuItems.length,
|
||||||
|
onReorder: _reorderMenu,
|
||||||
|
itemBuilder: (ctx, index) {
|
||||||
|
final item = _menuItems[index];
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
leading: _menuLeading(item),
|
||||||
|
title: Text(item.title),
|
||||||
|
subtitle: Text(_routeLabel(item.route)),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_forever, color: Colors.redAccent),
|
||||||
|
onPressed: () => _removeMenuItem(item.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text('ホーム設定を保存'),
|
||||||
|
onPressed: _loadingAppSettings ? null : _saveAppSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -281,32 +365,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
),
|
),
|
||||||
_section(
|
_section(
|
||||||
title: '自社情報',
|
title: '自社情報',
|
||||||
subtitle: '会社名・住所・登録番号など',
|
subtitle: '会社・担当者・振込口座・電話帳取り込み',
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')),
|
const Text('自社/担当者情報、振込口座設定、メールフッタをまとめて編集できます。'),
|
||||||
TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')),
|
const SizedBox(height: 12),
|
||||||
TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')),
|
|
||||||
TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')),
|
|
||||||
TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')),
|
|
||||||
TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')),
|
|
||||||
TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')),
|
|
||||||
TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(Icons.upload_file),
|
icon: const Icon(Icons.info_outline),
|
||||||
label: const Text('画面で編集'),
|
label: const Text('旧画面 (税率/印影)'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
ElevatedButton.icon(
|
Expanded(
|
||||||
icon: const Icon(Icons.save),
|
child: ElevatedButton.icon(
|
||||||
label: const Text('保存'),
|
icon: const Icon(Icons.business),
|
||||||
onPressed: _saveCompany,
|
label: const Text('自社情報ページを開く'),
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.push(context, MaterialPageRoute(builder: (context) => const BusinessProfileScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -314,40 +396,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_section(
|
_section(
|
||||||
title: '担当者情報',
|
title: 'メール設定(SM画面へ)',
|
||||||
subtitle: '署名や連絡先(送信者情報)',
|
subtitle: 'SMTP・端末メーラー・BCC必須・ログ閲覧など',
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')),
|
const Text('メール送信に関する設定は専用画面でまとめて編集できます。'),
|
||||||
TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 8),
|
Align(
|
||||||
ElevatedButton.icon(
|
alignment: Alignment.centerRight,
|
||||||
icon: const Icon(Icons.save),
|
child: ElevatedButton.icon(
|
||||||
label: const Text('保存'),
|
icon: const Icon(Icons.mail_outline),
|
||||||
onPressed: _saveStaff,
|
label: const Text('メール設定を開く'),
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const EmailSettingsScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_section(
|
|
||||||
title: 'SMTP情報',
|
|
||||||
subtitle: 'メール送信サーバ設定(テンプレ)',
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')),
|
|
||||||
TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number),
|
|
||||||
TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')),
|
|
||||||
TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
|
|
||||||
TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')),
|
|
||||||
SwitchListTile(
|
|
||||||
title: const Text('STARTTLS を使用'),
|
|
||||||
value: _smtpTls,
|
|
||||||
onChanged: (v) => setState(() => _smtpTls = v),
|
|
||||||
),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.save),
|
|
||||||
label: const Text('保存'),
|
|
||||||
onPressed: _saveSmtp,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -413,7 +480,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.save),
|
icon: const Icon(Icons.save),
|
||||||
label: const Text('保存'),
|
label: const Text('保存'),
|
||||||
onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'),
|
onPressed: () async {
|
||||||
|
await _appSettingsRepo.setTheme(_theme);
|
||||||
|
await AppThemeController.instance.setTheme(_theme);
|
||||||
|
if (!mounted) return;
|
||||||
|
_showSnackbar('テーマ設定を保存しました');
|
||||||
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
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 DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||||
|
|
||||||
Future<List<Customer>> getAllCustomers() async {
|
Future<List<Customer>> getAllCustomers({bool includeHidden = false}) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
|
final filter = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
|
||||||
List<Map<String, dynamic>> maps = await db.rawQuery('''
|
List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
|
||||||
|
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
|
||||||
FROM customers c
|
FROM customers c
|
||||||
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||||
ORDER BY c.display_name ASC
|
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
|
||||||
|
$filter
|
||||||
|
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
|
||||||
''');
|
''');
|
||||||
if (maps.isEmpty) {
|
if (maps.isEmpty) {
|
||||||
await _generateSampleCustomers(limit: 3);
|
await _generateSampleCustomers(limit: 3);
|
||||||
maps = await db.rawQuery('''
|
maps = await db.rawQuery('''
|
||||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
|
||||||
|
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
|
||||||
FROM customers c
|
FROM customers c
|
||||||
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||||
ORDER BY c.display_name ASC
|
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
|
||||||
|
$filter
|
||||||
|
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
|
||||||
''');
|
''');
|
||||||
}
|
}
|
||||||
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
||||||
|
|
@ -128,14 +135,17 @@ class CustomerRepository {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Customer>> searchCustomers(String query) async {
|
Future<List<Customer>> searchCustomers(String query, {bool includeHidden = false}) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
|
final where = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
|
||||||
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||||
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
|
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
|
||||||
|
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
|
||||||
FROM customers c
|
FROM customers c
|
||||||
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
|
||||||
WHERE c.display_name LIKE ? OR c.formal_name LIKE ?
|
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
|
||||||
ORDER BY c.display_name ASC
|
WHERE (c.display_name LIKE ? OR c.formal_name LIKE ?) $where
|
||||||
|
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
''', ['%$query%', '%$query%']);
|
''', ['%$query%', '%$query%']);
|
||||||
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
|
||||||
|
|
@ -173,6 +183,25 @@ class CustomerRepository {
|
||||||
return CustomerContact.fromMap(rows.first);
|
return CustomerContact.fromMap(rows.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setHidden(String id, bool hidden) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.insert(
|
||||||
|
'master_hidden',
|
||||||
|
{
|
||||||
|
'master_type': 'customer',
|
||||||
|
'master_id': id,
|
||||||
|
'is_hidden': hidden ? 1 : 0,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
await _logRepo.logAction(
|
||||||
|
action: hidden ? "HIDE_CUSTOMER" : "UNHIDE_CUSTOMER",
|
||||||
|
targetType: "CUSTOMER",
|
||||||
|
targetId: id,
|
||||||
|
details: hidden ? "顧客を非表示にしました" : "顧客を再表示しました",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async {
|
Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async {
|
||||||
final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]);
|
final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]);
|
||||||
final current = res.first['v'] as int?;
|
final current = res.first['v'] as int?;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path/path.dart';
|
import 'package:path/path.dart';
|
||||||
|
|
||||||
class DatabaseHelper {
|
class DatabaseHelper {
|
||||||
static const _databaseVersion = 20;
|
static const _databaseVersion = 25;
|
||||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
|
|
@ -164,6 +164,37 @@ class DatabaseHelper {
|
||||||
if (oldVersion < 20) {
|
if (oldVersion < 20) {
|
||||||
await _safeAddColumn(db, 'customers', 'email TEXT');
|
await _safeAddColumn(db, 'customers', 'email TEXT');
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 22) {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
if (oldVersion < 23) {
|
||||||
|
await _safeAddColumn(db, 'customers', 'is_hidden INTEGER DEFAULT 0');
|
||||||
|
await _safeAddColumn(db, 'products', 'is_hidden INTEGER DEFAULT 0');
|
||||||
|
await db.execute('CREATE INDEX IF NOT EXISTS idx_customers_hidden ON customers(is_hidden)');
|
||||||
|
await db.execute('CREATE INDEX IF NOT EXISTS idx_products_hidden ON products(is_hidden)');
|
||||||
|
}
|
||||||
|
if (oldVersion < 24) {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS master_hidden (
|
||||||
|
master_type TEXT NOT NULL,
|
||||||
|
master_id TEXT NOT NULL,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY(master_type, master_id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('CREATE INDEX IF NOT EXISTS idx_master_hidden_type ON master_hidden(master_type)');
|
||||||
|
}
|
||||||
|
if (oldVersion < 25) {
|
||||||
|
await _safeAddColumn(db, 'invoices', 'company_snapshot TEXT');
|
||||||
|
await _safeAddColumn(db, 'invoices', 'company_seal_hash TEXT');
|
||||||
|
await _safeAddColumn(db, 'invoices', 'meta_json TEXT');
|
||||||
|
await _safeAddColumn(db, 'invoices', 'meta_hash TEXT');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
|
|
@ -182,6 +213,7 @@ class DatabaseHelper {
|
||||||
head_char1 TEXT,
|
head_char1 TEXT,
|
||||||
head_char2 TEXT,
|
head_char2 TEXT,
|
||||||
is_locked INTEGER DEFAULT 0,
|
is_locked INTEGER DEFAULT 0,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
is_synced INTEGER DEFAULT 0,
|
is_synced INTEGER DEFAULT 0,
|
||||||
updated_at TEXT NOT NULL
|
updated_at TEXT NOT NULL
|
||||||
)
|
)
|
||||||
|
|
@ -223,12 +255,23 @@ class DatabaseHelper {
|
||||||
category TEXT,
|
category TEXT,
|
||||||
stock_quantity INTEGER DEFAULT 0,
|
stock_quantity INTEGER DEFAULT 0,
|
||||||
is_locked INTEGER DEFAULT 0,
|
is_locked INTEGER DEFAULT 0,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
odoo_id TEXT
|
odoo_id TEXT
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await db.execute('CREATE INDEX idx_products_name ON products(name)');
|
await db.execute('CREATE INDEX idx_products_name ON products(name)');
|
||||||
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
|
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE master_hidden (
|
||||||
|
master_type TEXT NOT NULL,
|
||||||
|
master_id TEXT NOT NULL,
|
||||||
|
is_hidden INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY(master_type, master_id)
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('CREATE INDEX idx_master_hidden_type ON master_hidden(master_type)');
|
||||||
|
|
||||||
// 伝票マスター
|
// 伝票マスター
|
||||||
await db.execute('''
|
await db.execute('''
|
||||||
CREATE TABLE invoices (
|
CREATE TABLE invoices (
|
||||||
|
|
@ -255,6 +298,10 @@ class DatabaseHelper {
|
||||||
contact_email_snapshot TEXT,
|
contact_email_snapshot TEXT,
|
||||||
contact_tel_snapshot TEXT,
|
contact_tel_snapshot TEXT,
|
||||||
contact_address_snapshot TEXT,
|
contact_address_snapshot TEXT,
|
||||||
|
company_snapshot TEXT,
|
||||||
|
company_seal_hash TEXT,
|
||||||
|
meta_json TEXT,
|
||||||
|
meta_hash TEXT,
|
||||||
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
FOREIGN KEY (customer_id) REFERENCES customers (id)
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
@ -305,6 +352,13 @@ class DatabaseHelper {
|
||||||
timestamp TEXT NOT NULL
|
timestamp TEXT NOT NULL
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _safeAddColumn(Database db, String table, String columnDef) async {
|
Future<void> _safeAddColumn(Database db, String table, String columnDef) async {
|
||||||
|
|
|
||||||
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:io';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import '../models/invoice_models.dart';
|
import '../models/invoice_models.dart';
|
||||||
|
|
@ -6,15 +8,38 @@ import '../models/customer_model.dart';
|
||||||
import '../models/customer_contact.dart';
|
import '../models/customer_contact.dart';
|
||||||
import 'database_helper.dart';
|
import 'database_helper.dart';
|
||||||
import 'activity_log_repository.dart';
|
import 'activity_log_repository.dart';
|
||||||
|
import 'company_repository.dart';
|
||||||
|
|
||||||
class InvoiceRepository {
|
class InvoiceRepository {
|
||||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||||
|
final CompanyRepository _companyRepo = CompanyRepository();
|
||||||
|
|
||||||
Future<void> saveInvoice(Invoice invoice) async {
|
Future<void> saveInvoice(Invoice invoice) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
|
|
||||||
// 正式発行(下書きでない)場合はロックを掛ける
|
// 正式発行(下書きでない)場合はロックを掛ける
|
||||||
|
final companyInfo = await _companyRepo.getCompanyInfo();
|
||||||
|
String? sealHash;
|
||||||
|
if (companyInfo.sealPath != null) {
|
||||||
|
final file = File(companyInfo.sealPath!);
|
||||||
|
if (await file.exists()) {
|
||||||
|
sealHash = sha256.convert(await file.readAsBytes()).toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final companySnapshot = jsonEncode({
|
||||||
|
'name': companyInfo.name,
|
||||||
|
'zipCode': companyInfo.zipCode,
|
||||||
|
'address': companyInfo.address,
|
||||||
|
'tel': companyInfo.tel,
|
||||||
|
'fax': companyInfo.fax,
|
||||||
|
'email': companyInfo.email,
|
||||||
|
'url': companyInfo.url,
|
||||||
|
'defaultTaxRate': companyInfo.defaultTaxRate,
|
||||||
|
'taxDisplayMode': companyInfo.taxDisplayMode,
|
||||||
|
'registrationNumber': companyInfo.registrationNumber,
|
||||||
|
});
|
||||||
|
|
||||||
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
|
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
|
||||||
|
|
||||||
await db.transaction((txn) async {
|
await db.transaction((txn) async {
|
||||||
|
|
@ -29,6 +54,10 @@ class InvoiceRepository {
|
||||||
contactEmailSnapshot: activeContact?.email,
|
contactEmailSnapshot: activeContact?.email,
|
||||||
contactTelSnapshot: activeContact?.tel,
|
contactTelSnapshot: activeContact?.tel,
|
||||||
contactAddressSnapshot: activeContact?.address,
|
contactAddressSnapshot: activeContact?.address,
|
||||||
|
companySnapshot: companySnapshot,
|
||||||
|
companySealHash: sealHash,
|
||||||
|
metaJson: null,
|
||||||
|
metaHash: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
|
// 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く)
|
||||||
|
|
@ -150,6 +179,10 @@ class InvoiceRepository {
|
||||||
contactEmailSnapshot: iMap['contact_email_snapshot'],
|
contactEmailSnapshot: iMap['contact_email_snapshot'],
|
||||||
contactTelSnapshot: iMap['contact_tel_snapshot'],
|
contactTelSnapshot: iMap['contact_tel_snapshot'],
|
||||||
contactAddressSnapshot: iMap['contact_address_snapshot'],
|
contactAddressSnapshot: iMap['contact_address_snapshot'],
|
||||||
|
companySnapshot: iMap['company_snapshot'],
|
||||||
|
companySealHash: iMap['company_seal_hash'],
|
||||||
|
metaJson: iMap['meta_json'],
|
||||||
|
metaHash: iMap['meta_hash'],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return invoices;
|
return invoices;
|
||||||
|
|
@ -248,6 +281,21 @@ class InvoiceRepository {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// meta_json と meta_hash の整合性を検証する(trueなら一致)。
|
||||||
|
bool verifyInvoiceMeta(Invoice invoice) {
|
||||||
|
final metaJson = invoice.metaJson ?? invoice.metaJsonValue;
|
||||||
|
final expected = sha256.convert(utf8.encode(metaJson)).toString();
|
||||||
|
final stored = invoice.metaHash ?? expected;
|
||||||
|
return expected == stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// IDを指定してDBから取得し、メタデータ整合性を検証する。
|
||||||
|
Future<bool> verifyInvoiceMetaById(String id, List<Customer> customers) async {
|
||||||
|
final invoices = await getAllInvoices(customers);
|
||||||
|
final target = invoices.firstWhere((i) => i.id == id, orElse: () => throw Exception('invoice not found'));
|
||||||
|
return verifyInvoiceMeta(target);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, int>> getMonthlySales(int year) async {
|
Future<Map<String, int>> getMonthlySales(int year) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
final String yearStr = year.toString();
|
final String yearStr = year.toString();
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,15 @@ import 'activity_log_repository.dart';
|
||||||
|
|
||||||
/// PDFドキュメントの構築(プレビューと実保存の両方で使用)
|
/// PDFドキュメントの構築(プレビューと実保存の両方で使用)
|
||||||
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||||
final pdf = pw.Document();
|
final metaJson = invoice.metaJsonValue;
|
||||||
|
final metaHash = invoice.metaHashValue;
|
||||||
|
|
||||||
|
final pdf = pw.Document(
|
||||||
|
title: '${invoice.documentTypeName} ${invoice.invoiceNumber}',
|
||||||
|
author: 'h1-app',
|
||||||
|
subject: 'metaHash:$metaHash',
|
||||||
|
keywords: metaJson,
|
||||||
|
);
|
||||||
|
|
||||||
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
|
||||||
final ipaex = pw.Font.ttf(fontData);
|
final ipaex = pw.Font.ttf(fontData);
|
||||||
|
|
@ -221,7 +229,7 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
|
||||||
pw.Container(
|
pw.Container(
|
||||||
width: 50,
|
width: 50,
|
||||||
height: 50,
|
height: 50,
|
||||||
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: invoice.contentHash, drawText: false),
|
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: metaHash, drawText: false),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -8,27 +8,38 @@ class ProductRepository {
|
||||||
final DatabaseHelper _dbHelper = DatabaseHelper();
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
final ActivityLogRepository _logRepo = ActivityLogRepository();
|
||||||
|
|
||||||
Future<List<Product>> getAllProducts() async {
|
Future<List<Product>> getAllProducts({bool includeHidden = false}) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'name ASC');
|
final String where = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
|
||||||
|
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||||
|
SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
|
||||||
|
$where
|
||||||
|
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
|
||||||
|
''');
|
||||||
|
|
||||||
if (maps.isEmpty) {
|
if (maps.isEmpty) {
|
||||||
await _generateSampleProducts();
|
await _generateSampleProducts();
|
||||||
return getAllProducts();
|
return getAllProducts(includeHidden: includeHidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<Product>> searchProducts(String query) async {
|
Future<List<Product>> searchProducts(String query, {bool includeHidden = false}) async {
|
||||||
final db = await _dbHelper.database;
|
final db = await _dbHelper.database;
|
||||||
final List<Map<String, dynamic>> maps = await db.query(
|
final args = ['%$query%', '%$query%', '%$query%'];
|
||||||
'products',
|
final String whereHidden = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
|
||||||
where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?',
|
final List<Map<String, dynamic>> maps = await db.rawQuery('''
|
||||||
whereArgs: ['%$query%', '%$query%', '%$query%'],
|
SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
|
||||||
orderBy: 'name ASC',
|
FROM products p
|
||||||
limit: 50,
|
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
|
||||||
);
|
WHERE (p.name LIKE ? OR p.barcode LIKE ? OR p.category LIKE ?)
|
||||||
|
$whereHidden
|
||||||
|
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
|
||||||
|
LIMIT 50
|
||||||
|
''', args);
|
||||||
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,4 +92,23 @@ class ProductRepository {
|
||||||
details: "商品を削除しました",
|
details: "商品を削除しました",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> setHidden(String id, bool hidden) async {
|
||||||
|
final db = await _dbHelper.database;
|
||||||
|
await db.insert(
|
||||||
|
'master_hidden',
|
||||||
|
{
|
||||||
|
'master_type': 'product',
|
||||||
|
'master_id': id,
|
||||||
|
'is_hidden': hidden ? 1 : 0,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
await _logRepo.logAction(
|
||||||
|
action: hidden ? "HIDE_PRODUCT" : "UNHIDE_PRODUCT",
|
||||||
|
targetType: "PRODUCT",
|
||||||
|
targetId: id,
|
||||||
|
details: hidden ? "商品を非表示にしました" : "商品を再表示しました",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:printing/printing.dart';
|
import 'package:flutter_email_sender/flutter_email_sender.dart';
|
||||||
import '../models/invoice_models.dart';
|
|
||||||
import '../services/pdf_generator.dart';
|
|
||||||
import 'package:mailer/mailer.dart';
|
|
||||||
import 'package:mailer/smtp_server.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:printing/printing.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../constants/mail_send_method.dart';
|
||||||
|
import '../constants/mail_templates.dart';
|
||||||
|
import '../models/invoice_models.dart';
|
||||||
|
import '../services/company_profile_service.dart';
|
||||||
|
import '../services/email_sender.dart';
|
||||||
|
import '../services/pdf_generator.dart';
|
||||||
|
|
||||||
class InvoicePdfPreviewPage extends StatelessWidget {
|
class InvoicePdfPreviewPage extends StatelessWidget {
|
||||||
final Invoice invoice;
|
final Invoice invoice;
|
||||||
|
|
@ -39,24 +45,17 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
||||||
Future<void> _sendEmail(BuildContext context) async {
|
Future<void> _sendEmail(BuildContext context) async {
|
||||||
try {
|
try {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final host = prefs.getString('smtp_host') ?? '';
|
final mailMethod = normalizeMailSendMethod(prefs.getString(kMailSendMethodPrefKey));
|
||||||
final portStr = prefs.getString('smtp_port') ?? '587';
|
|
||||||
final user = prefs.getString('smtp_user') ?? '';
|
|
||||||
final pass = prefs.getString('smtp_pass') ?? '';
|
|
||||||
final useTls = prefs.getBool('smtp_tls') ?? true;
|
|
||||||
final bccRaw = prefs.getString('smtp_bcc') ?? '';
|
final bccRaw = prefs.getString('smtp_bcc') ?? '';
|
||||||
final bccList = bccRaw.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
|
final bccList = EmailSender.parseBcc(bccRaw);
|
||||||
|
|
||||||
if (host.isEmpty || user.isEmpty || pass.isEmpty) {
|
if (bccList.isEmpty) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください')));
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('BCCは必須項目です(設定画面で登録してください)')));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final port = int.tryParse(portStr) ?? 587;
|
|
||||||
final smtpServer = SmtpServer(host, port: port, username: user, password: pass, ignoreBadCertificate: false, ssl: !useTls, allowInsecure: !useTls);
|
|
||||||
|
|
||||||
final toEmail = invoice.contactEmailSnapshot ?? invoice.customer.email;
|
final toEmail = invoice.contactEmailSnapshot ?? invoice.customer.email;
|
||||||
if (toEmail == null || toEmail.isEmpty) {
|
if (toEmail == null || toEmail.isEmpty) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|
@ -66,18 +65,71 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
final bytes = await _buildPdfBytes();
|
final bytes = await _buildPdfBytes();
|
||||||
|
final fileName = invoice.mailAttachmentFileName;
|
||||||
final tempDir = await getTemporaryDirectory();
|
final tempDir = await getTemporaryDirectory();
|
||||||
final file = File('${tempDir.path}/invoice.pdf');
|
final file = File('${tempDir.path}/$fileName');
|
||||||
await file.writeAsBytes(bytes, flush: true);
|
await file.writeAsBytes(bytes, flush: true);
|
||||||
final message = Message()
|
final hash = sha256.convert(bytes).toString();
|
||||||
..from = Address(user)
|
final headerTemplate = prefs.getString(kMailHeaderTextKey) ?? kMailHeaderTemplateDefault;
|
||||||
..recipients = [toEmail]
|
final footerTemplate = prefs.getString(kMailFooterTextKey) ?? kMailFooterTemplateDefault;
|
||||||
..bccRecipients = bccList
|
final placeholderMap = await CompanyProfileService().buildMailPlaceholderMap(filename: fileName, hash: hash);
|
||||||
..subject = '請求書送付'
|
final header = applyMailTemplate(headerTemplate, placeholderMap);
|
||||||
..text = '請求書をお送りします。ご確認ください。'
|
final footer = applyMailTemplate(footerTemplate, placeholderMap);
|
||||||
..attachments = [FileAttachment(file)..fileName = 'invoice.pdf'..contentType = 'application/pdf'];
|
final bodyCore = invoice.mailBodyText;
|
||||||
|
final body = [header, bodyCore, footer].where((section) => section.trim().isNotEmpty).join('\n\n');
|
||||||
|
|
||||||
await send(message, smtpServer);
|
if (mailMethod == kMailSendMethodDeviceMailer) {
|
||||||
|
final email = Email(
|
||||||
|
body: body,
|
||||||
|
subject: fileName,
|
||||||
|
recipients: [toEmail],
|
||||||
|
bcc: bccList,
|
||||||
|
attachmentPaths: [file.path],
|
||||||
|
isHTML: false,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await FlutterEmailSender.send(email);
|
||||||
|
await EmailSender.logDeviceMailer(success: true, toEmail: toEmail, bcc: bccList);
|
||||||
|
} catch (e) {
|
||||||
|
await EmailSender.logDeviceMailer(success: false, toEmail: toEmail, bcc: bccList, error: '$e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final host = prefs.getString('smtp_host') ?? '';
|
||||||
|
final portStr = prefs.getString('smtp_port') ?? '587';
|
||||||
|
final user = prefs.getString('smtp_user') ?? '';
|
||||||
|
final passEncrypted = prefs.getString('smtp_pass') ?? '';
|
||||||
|
final pass = EmailSender.decrypt(passEncrypted);
|
||||||
|
final useTls = prefs.getBool('smtp_tls') ?? true;
|
||||||
|
final ignoreBadCert = prefs.getBool('smtp_ignore_bad_cert') ?? false;
|
||||||
|
|
||||||
|
if (host.isEmpty || user.isEmpty || pass.isEmpty) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください')));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final port = int.tryParse(portStr) ?? 587;
|
||||||
|
final smtpConfig = EmailSenderConfig(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
username: user,
|
||||||
|
password: pass,
|
||||||
|
useTls: useTls,
|
||||||
|
ignoreBadCert: ignoreBadCert,
|
||||||
|
bcc: bccList,
|
||||||
|
);
|
||||||
|
|
||||||
|
await EmailSender.sendInvoiceEmail(
|
||||||
|
config: smtpConfig,
|
||||||
|
toEmail: toEmail,
|
||||||
|
pdfFile: file,
|
||||||
|
subject: fileName,
|
||||||
|
attachmentFileName: fileName,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました')));
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました')));
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +144,15 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isDraft = invoice.isDraft;
|
final isDraft = invoice.isDraft;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text("PDFプレビュー")),
|
appBar: AppBar(
|
||||||
|
title: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: const [
|
||||||
|
Text("PDFプレビュー"),
|
||||||
|
Text("ScreenID: 02", style: TextStyle(fontSize: 11, color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -121,17 +181,28 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.check_circle_outline),
|
icon: const Icon(Icons.check_circle_outline),
|
||||||
label: const Text("正式発行"),
|
label: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
const Text("正式発行"),
|
||||||
|
if (!isDraft || isLocked)
|
||||||
|
const Positioned(
|
||||||
|
right: 0,
|
||||||
|
child: Icon(Icons.lock, size: 16, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: showShare
|
onPressed: (showShare && (!isDraft || isLocked))
|
||||||
? () async {
|
? () async {
|
||||||
final bytes = await _buildPdfBytes();
|
final bytes = await _buildPdfBytes();
|
||||||
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf');
|
final fileName = invoice.mailAttachmentFileName;
|
||||||
|
await Printing.sharePdf(bytes: bytes, filename: fileName);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.share),
|
icon: const Icon(Icons.share),
|
||||||
|
|
@ -141,7 +212,7 @@ class InvoicePdfPreviewPage extends StatelessWidget {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: ElevatedButton.icon(
|
||||||
onPressed: showEmail
|
onPressed: (showEmail && (!isDraft || isLocked))
|
||||||
? () async {
|
? () async {
|
||||||
await _sendEmail(context);
|
await _sendEmail(context);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.9+2"
|
version: "1.1.9+2"
|
||||||
|
flutter_email_sender:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_email_sender
|
||||||
|
sha256: fb515d4e073d238d0daf1d765e5318487b6396d46b96e0ae9745dbc9a133f97a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.0.3"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ dependencies:
|
||||||
printing: ^5.14.2
|
printing: ^5.14.2
|
||||||
shared_preferences: ^2.2.2
|
shared_preferences: ^2.2.2
|
||||||
mailer: ^6.0.1
|
mailer: ^6.0.1
|
||||||
|
flutter_email_sender: ^6.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
||||||
38
scripts/build_with_expiry.sh
Executable file
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