Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fe44a4b0 | ||
|
|
a569f54b0b | ||
|
|
3ddd56760a | ||
|
|
e8382db72f | ||
|
|
01f5851ddc |
55 changed files with 6045 additions and 980 deletions
129
README.md
129
README.md
|
|
@ -1,17 +1,124 @@
|
||||||
# gemi_invoice
|
# 販売アシスト1号 / 母艦「お局様」プロジェクト概要
|
||||||
|
|
||||||
A new Flutter project.
|
販売アシスト1号は **オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント** であり、オプション機能として **オンライン接続時に母艦「お局様」とデータ同期・バックアップ・監視を行う二層構造** を目指しています。
|
||||||
|
|
||||||
## Getting Started
|
---
|
||||||
|
|
||||||
This project is a starting point for a Flutter application.
|
## コアコンセプト
|
||||||
|
|
||||||
A few resources to get you started if this is your first Flutter project:
|
| モード | 目的 | 主な特徴 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| オフライン・スタンドアロン | 端末単体で全業務を完結 | SQLite に全データ保存、印影以外は非暗号化、AI などによる再利用も想定 |
|
||||||
|
| オンライン(システムオプション) | 母艦と接続しデータ交換・監視 | SSH/クラウドトンネル経由で同期、APK寿命チェックやバックアップを遠隔制御 |
|
||||||
|
|
||||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
母艦「お局様」はブリッジ/モニタリング/バックアップに専念し、実務機能は販売アシスト1号側に集約する方針です。TV BOX を母艦に据える運用や、単一端末で両役割を兼務するシナリオも想定しています。
|
||||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
|
||||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
|
||||||
|
|
||||||
For help getting started with Flutter development, view the
|
---
|
||||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
|
||||||
samples, guidance on mobile development, and a full API reference.
|
## 現状の実装状況
|
||||||
|
|
||||||
|
- Flutter ベースの販売アシスト1号アプリ
|
||||||
|
- 会社・担当者・銀行口座を統合管理する事業プロフィール画面
|
||||||
|
- Phone ブック取り込みを共通化した `ContactPickerSheet`
|
||||||
|
- 税率・税表示・印影の追加設定
|
||||||
|
- 90 日寿命チェック(`BuildExpiryInfo`)と期限切れ画面
|
||||||
|
- ビルド用スクリプト `scripts/build_with_expiry.sh`
|
||||||
|
- `--dart-define=APP_BUILD_TIMESTAMP` を自動付与し APK を生成
|
||||||
|
- analyze 実行~APK ビルドのワンステップ化
|
||||||
|
- 連絡帳・顧客モジュールの共通化(BusinessProfileScreen / CustomerPickerModal / CustomerMasterScreen)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 将来像・ロードマップ
|
||||||
|
|
||||||
|
1. **母艦お局様(Web UI 100%)**
|
||||||
|
- 各クライアントのハッシュチェーン監視
|
||||||
|
- SSH/Cloudflare/自社 DDNS いずれにも対応するブリッジ機能
|
||||||
|
- Google Drive への自動バックアップ、容量推定
|
||||||
|
2. **販売アシスト1号の拡張モジュール化**
|
||||||
|
- 売上(POS)、仕入、在庫、チャット、通知をモジュールとして追加
|
||||||
|
- ダッシュボードにモジュールカードを組み込む方式へ刷新
|
||||||
|
3. **チャット&サポート**
|
||||||
|
- 「順次対応である」旨を明記した問い合わせチャットをローカル実装
|
||||||
|
- 母艦側で受信・返信・履歴管理ができる仕組みを構築
|
||||||
|
4. **寿命延命と母艦同期**
|
||||||
|
- 母艦と定期同期できた端末は自動で寿命を延長(例: 半年)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## リポジトリ構成(抜粋)
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/user/dev/h-1.flutter.0
|
||||||
|
├── README.md … 本ファイル
|
||||||
|
├── analysis_options.yaml … Lint 設定
|
||||||
|
├── lib/ … Flutter アプリ本体
|
||||||
|
│ ├── screens/ … 各種画面(business_profile_screen 等)
|
||||||
|
│ ├── widgets/ … 共通ウィジェット(contact_picker_sheet 等)
|
||||||
|
│ ├── services/ … 永続化・ユーティリティ(company_profile_service 等)
|
||||||
|
│ └── utils/build_expiry_info.dart … ビルド寿命ユーティリティ
|
||||||
|
├── scripts/build_with_expiry.sh … dart-define 付きビルドスクリプト
|
||||||
|
├── android/, ios/, macos/, windows/, linux/ … 各プラットフォームテンプレート
|
||||||
|
├── assets/ … 画像・リソース
|
||||||
|
├── test/ … テストコード
|
||||||
|
└── 目標.md / 目的.md … 設計メモ
|
||||||
|
```
|
||||||
|
|
||||||
|
※ フルツリーが必要になった場合は `tree` や `list_dir` の出力を README 末尾に追加して更新していきます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## セットアップ & ビルド
|
||||||
|
|
||||||
|
1. Flutter 3.x 環境を用意し、依存パッケージを取得
|
||||||
|
```bash
|
||||||
|
flutter pub get
|
||||||
|
```
|
||||||
|
2. 90 日寿命 APK の生成
|
||||||
|
```bash
|
||||||
|
chmod +x scripts/build_with_expiry.sh
|
||||||
|
./scripts/build_with_expiry.sh [debug|profile|release]
|
||||||
|
```
|
||||||
|
- スクリプト内で `APP_BUILD_TIMESTAMP` を UTC で自動付与
|
||||||
|
- `flutter analyze` → `flutter build apk` を連続実行
|
||||||
|
3. 実機/エミュレータで起動すると、寿命切れ時には `ExpiredApp` が自動表示されます。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 母艦「お局様」LAN サーバの起動
|
||||||
|
|
||||||
|
1. Dart/Flutter SDK が入った Linux / Android(Termux 等)端末でリポジトリを取得
|
||||||
|
2. 監視サーバを起動
|
||||||
|
```bash
|
||||||
|
dart run bin/mothership_server.dart
|
||||||
|
```
|
||||||
|
- 環境変数 `MOTHERSHIP_HOST`, `MOTHERSHIP_PORT`, `MOTHERSHIP_API_KEY`, `MOTHERSHIP_DATA_DIR` で上書き可能
|
||||||
|
- 既定値: `0.0.0.0:8787`, API キー `TEST_MOTHERSHIP_KEY`, 保存先 `data/mothership`
|
||||||
|
- `data/mothership/status.json` に各クライアントの心拍/ハッシュを保存
|
||||||
|
3. ブラウザで `http://<host>:<port>/` を開くとステータス一覧を閲覧できます(CUI 常駐で OK)
|
||||||
|
|
||||||
|
### クライアント(販売アシスト1号)からの接続設定
|
||||||
|
|
||||||
|
1. アプリの `S1:設定` → 「外部同期(母艦システム『お局様』連携)」で以下を入力
|
||||||
|
- ホストドメイン: `http://192.168.0.10:8787` のようにプロトコル付きで指定
|
||||||
|
- パスワード: サーバ側 API キー(例: `TEST_MOTHERSHIP_KEY`)
|
||||||
|
2. 保存するとアプリ起動時に `POST /sync/heartbeat` が自動送信され、寿命残時間が母艦に表示されます。
|
||||||
|
3. 同じ設定でチャット送受信・ハッシュ送信が有効になります(下記参照)。
|
||||||
|
|
||||||
|
### チャット同期(最小構成)
|
||||||
|
|
||||||
|
- Flutter アプリ側では 10 秒間隔の軽量ポーリングをバックグラウンドで実行し、`/chat/send` / `/chat/pending` / `/chat/ack` とローカル SQLite を同期します。
|
||||||
|
- 設定画面からチャット画面を開かなくても新着が取り込まれ、開いた瞬間に最新ログが表示されます。
|
||||||
|
- 端末がスリープに入るとポーリングを停止し、アプリが前面に戻ったタイミングで即時同期→再開します。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 更新ポリシー
|
||||||
|
|
||||||
|
- README は **機能追加・アーキテクチャ変更・モジュール構成の見直し時に必ず更新** します。
|
||||||
|
- 変更履歴とファイルツリーは必要に応じて追記し、最新状態を反映させます。
|
||||||
|
- 設計検討中の内容(母艦 Web UI、チャット、モジュール化など)は本 README の「将来像」節で随時アップデートします。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
ご要望・アイデアがあれば Issue/チャットで共有いただき、README に反映していきます。
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
22
bin/mothership_server.dart
Normal file
22
bin/mothership_server.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:h_1/mothership/chat_store.dart';
|
||||||
|
import 'package:h_1/mothership/config.dart';
|
||||||
|
import 'package:h_1/mothership/data_store.dart';
|
||||||
|
import 'package:h_1/mothership/server.dart';
|
||||||
|
|
||||||
|
Future<void> main(List<String> args) async {
|
||||||
|
final config = MothershipConfig.fromEnv();
|
||||||
|
final dataStore = MothershipDataStore(config.dataDirectory);
|
||||||
|
await dataStore.init();
|
||||||
|
final chatStore = MothershipChatStore(config.dataDirectory);
|
||||||
|
await chatStore.init();
|
||||||
|
final server = MothershipServer(config: config, dataStore: dataStore, chatStore: chatStore);
|
||||||
|
final httpServer = await server.start();
|
||||||
|
stdout.writeln('Mothership listening on http://${config.host}:${config.port}');
|
||||||
|
ProcessSignal.sigint.watch().listen((_) async {
|
||||||
|
stdout.writeln('Stopping mothership...');
|
||||||
|
await httpServer.close();
|
||||||
|
exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
66
docs/mothership_lan_plan.md
Normal file
66
docs/mothership_lan_plan.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# 母艦「お局様」LAN 常駐サーバ叩き台
|
||||||
|
本ドキュメントは、Flutter/Dart プロジェクト内にヘッドレス構成の母艦サーバを実装し、LAN 上で販売アシスト1号クライアントと同期・監視・バックアップを行うための叩き台です。
|
||||||
|
|
||||||
|
## 目的と前提
|
||||||
|
- 端末 1 台だけの利用者から、複数クライアントを抱える小規模事業者までカバーする。
|
||||||
|
- まずは LAN 内で完結する通信を実現し、その後 SSH/Cloudflare/DDNS など外部経路へ拡張できる土台を用意する。
|
||||||
|
- 同一 Flutter プロジェクト内で UI アプリとサーバ常駐処理を併存させる(headless Isolate / `dart` エントリーポイント)。
|
||||||
|
|
||||||
|
## 最小構成
|
||||||
|
1. **同期 API サーバ**
|
||||||
|
- Dart `HttpServer`(shelf など)で起動。
|
||||||
|
- エンドポイント例:
|
||||||
|
- `POST /sync/logs` : ハッシュチェーン・操作ログを受信。
|
||||||
|
- `POST /sync/diff` : 伝票差分や顧客レコードを受信し、母艦側 DB に再構築。
|
||||||
|
- `GET /status` : 各クライアントの最終同期状況を返す。
|
||||||
|
2. **データ保全レイヤ**
|
||||||
|
- 受信データを `data/mothership/<client_id>/YYYY` 配下に JSON/SQLite/ZIP で保存。
|
||||||
|
- 世代管理(例: 年度単位 10 パック)と最終ハッシュの保持。
|
||||||
|
3. **モニタリング Web UI**
|
||||||
|
- 同じサーバで `GET /dashboard` を提供し、ブラウザで一覧表示。
|
||||||
|
- 表示項目: クライアント名、最終同期、寿命残り、チャット未読、バックアップサイズ。
|
||||||
|
4. **バックアップトリガー**
|
||||||
|
- 手動/定期ジョブで `data/` を ZIP 圧縮。
|
||||||
|
- 将来的に Google Drive/API へ転送するフックを用意。
|
||||||
|
|
||||||
|
## 実装候補
|
||||||
|
- `bin/mothership_server.dart` : サーバ起動用 Dart エントリーポイント。
|
||||||
|
- `lib/mothership/` : サーバ用ロジック(API ハンドラ、リポジトリ、チャットキューなど)。
|
||||||
|
- `lib/mothership/ui/dashboard.dart` : Web UI(`shelf_static` で提供 or Flutter Web をビルドして同梱)。
|
||||||
|
- `lib/services/mothership_client.dart` : 販売アシスト1号側から呼び出すクライアント。
|
||||||
|
|
||||||
|
## データフロー概要
|
||||||
|
1. クライアントがオフラインで業務継続。
|
||||||
|
2. LAN で母艦に接続できたタイミングで `POST /sync/...` を実行。
|
||||||
|
3. 母艦は受信データを保存し、最終ハッシュと同期時刻を更新。
|
||||||
|
4. バックアップジョブが ZIP を生成、必要に応じて外部ストレージへ転送。
|
||||||
|
5. Web UI で状態を確認し、チャット/通知も同じ API でやり取り。
|
||||||
|
|
||||||
|
## API 叩き台
|
||||||
|
| メソッド | パス | 概要 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST` | `/sync/heartbeat` | 端末 UUID・アプリバージョン・寿命残りを報告 |
|
||||||
|
| `POST` | `/sync/hash` | `{ clientId, chain }` を送信、整合性チェック |
|
||||||
|
| `POST` | `/sync/diff` | 伝票・顧客など差分データを送付 |
|
||||||
|
| `GET` | `/status` | 母艦が把握している全クライアントの状態を返却 |
|
||||||
|
| `GET` | `/chat/pending` | クライアント向け未読チャット取得 |
|
||||||
|
| `POST` | `/chat/send` | クライアント→母艦の問い合わせ送信 |
|
||||||
|
|
||||||
|
### 認証 (テスト期間)
|
||||||
|
- すべての API リクエストに固定キーを送付(例: `X-Api-Key: TEST_MOTHERSHIP_KEY`)。
|
||||||
|
- サーバ側は一致チェックのみを行い、不一致は `401 Unauthorized` を返す。
|
||||||
|
- 本番想定ではトークンローテーションや署名方式に置き換える前提で、キー値は設定ファイルまたは環境変数から読み込む実装とする。
|
||||||
|
|
||||||
|
## 実装ステップ案
|
||||||
|
1. `bin/mothership_server.dart` を追加し、LAN で HTTP をリッスン。
|
||||||
|
2. `lib/mothership/` に API ハンドラとファイル保存ロジックを実装。
|
||||||
|
3. `lib/services/mothership_client.dart` を作成し、販売アシスト1号側から API を叩けるようにする。
|
||||||
|
4. 簡易 Web UI(HTML/JS or Flutter Web)を `/dashboard` で提供。
|
||||||
|
5. バックアップ ZIP と世代管理ロジックを追加。
|
||||||
|
6. 将来の拡張(Google Drive 連携、Cloudflare/S SH/Tunneling)をこの土台に差し込む。
|
||||||
|
|
||||||
|
## 今後の課題
|
||||||
|
- 認証方式(LAN 内とはいえ API キーやシグネチャを検討)。
|
||||||
|
- データ移行/マイグレーション(年度パック+最終ハッシュの保持方法)。
|
||||||
|
- フロントエンドからモジュール追加を制御するインターフェイス。
|
||||||
|
- チャット/通知の同期待ち行列設計。
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
355
lib/main.dart
355
lib/main.dart
|
|
@ -1,85 +1,320 @@
|
||||||
// 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 'dart:async';
|
||||||
|
|
||||||
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/chat_sync_scheduler.dart';
|
||||||
|
import 'services/mothership_client.dart';
|
||||||
|
import 'services/theme_controller.dart';
|
||||||
|
import 'utils/build_expiry_info.dart';
|
||||||
|
|
||||||
void main() {
|
void main() async {
|
||||||
runApp(const MyApp());
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
await AppThemeController.instance.load();
|
||||||
|
final expiryInfo = BuildExpiryInfo.fromEnvironment();
|
||||||
|
if (expiryInfo.isExpired) {
|
||||||
|
runApp(ExpiredApp(expiryInfo: expiryInfo));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runApp(MyApp(expiryInfo: expiryInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
class MyApp extends StatefulWidget {
|
||||||
const MyApp({super.key});
|
const MyApp({super.key, required this.expiryInfo});
|
||||||
|
|
||||||
|
final BuildExpiryInfo expiryInfo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
final TransformationController _zoomController = TransformationController();
|
||||||
|
int _activePointers = 0;
|
||||||
|
final MothershipClient _mothershipClient = MothershipClient();
|
||||||
|
final ChatSyncScheduler _chatSyncScheduler = ChatSyncScheduler();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_sendHeartbeat();
|
||||||
|
_chatSyncScheduler.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_chatSyncScheduler.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendHeartbeat() {
|
||||||
|
Future.microtask(() => _mothershipClient.sendHeartbeat(widget.expiryInfo));
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return ValueListenableBuilder<ThemeMode>(
|
||||||
title: '販売アシスト1号',
|
valueListenable: AppThemeController.instance.notifier,
|
||||||
theme: ThemeData(
|
builder: (context, mode, _) => MaterialApp(
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
|
title: '販売アシスト1号',
|
||||||
primary: Colors.indigo.shade700,
|
navigatorObservers: [
|
||||||
secondary: Colors.deepOrange.shade400,
|
_ZoomResetObserver(_zoomController),
|
||||||
surface: Colors.grey.shade50,
|
],
|
||||||
onSurface: Colors.blueGrey.shade900,
|
theme: ThemeData(
|
||||||
),
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
|
||||||
scaffoldBackgroundColor: Colors.grey.shade50,
|
primary: Colors.indigo.shade700,
|
||||||
appBarTheme: AppBarTheme(
|
secondary: Colors.deepOrange.shade400,
|
||||||
backgroundColor: Colors.indigo.shade700,
|
surface: Colors.grey.shade100,
|
||||||
foregroundColor: Colors.white,
|
onSurface: Colors.blueGrey.shade900,
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
),
|
||||||
),
|
scaffoldBackgroundColor: Colors.grey.shade100,
|
||||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
appBarTheme: AppBarTheme(
|
||||||
style: OutlinedButton.styleFrom(
|
backgroundColor: Colors.indigo.shade700,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
foregroundColor: Colors.white,
|
||||||
side: BorderSide(color: Colors.indigo.shade700),
|
elevation: 0,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
),
|
||||||
),
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
style: ElevatedButton.styleFrom(
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
focusedBorder: OutlineInputBorder(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
borderRadius: BorderRadius.circular(12),
|
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
visualDensity: VisualDensity.adaptivePlatformDensity,
|
|
||||||
useMaterial3: true,
|
|
||||||
fontFamily: 'IPAexGothic',
|
|
||||||
),
|
|
||||||
builder: (context, child) {
|
|
||||||
final mq = MediaQuery.of(context);
|
|
||||||
return GestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
onTap: () => FocusScope.of(context).unfocus(),
|
|
||||||
child: AnimatedPadding(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
|
|
||||||
child: InteractiveViewer(
|
|
||||||
panEnabled: false,
|
|
||||||
scaleEnabled: true,
|
|
||||||
minScale: 0.8,
|
|
||||||
maxScale: 4.0,
|
|
||||||
child: child ?? const SizedBox.shrink(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
side: BorderSide(color: Colors.indigo.shade700),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||||
|
useMaterial3: true,
|
||||||
|
fontFamily: 'IPAexGothic',
|
||||||
|
),
|
||||||
|
darkTheme: ThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
colorScheme: ColorScheme(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
primary: const Color(0xFF66D9EF),
|
||||||
|
onPrimary: const Color(0xFF1E1F1C),
|
||||||
|
secondary: const Color(0xFFF92672),
|
||||||
|
onSecondary: const Color(0xFF1E1F1C),
|
||||||
|
surface: const Color(0xFF272822),
|
||||||
|
onSurface: const Color(0xFFF8F8F2),
|
||||||
|
error: Colors.red.shade300,
|
||||||
|
onError: const Color(0xFF1E1F1C),
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF272822),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xFF32332A),
|
||||||
|
foregroundColor: Color(0xFFF8F8F2),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF66D9EF),
|
||||||
|
foregroundColor: const Color(0xFF1E1F1C),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
side: const BorderSide(color: Color(0xFF66D9EF)),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: const InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Color(0xFF32332A),
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
borderSide: BorderSide(color: Color(0xFF66D9EF), width: 1.4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackBarTheme: const SnackBarThemeData(
|
||||||
|
backgroundColor: Color(0xFF32332A),
|
||||||
|
contentTextStyle: TextStyle(color: Color(0xFFF8F8F2)),
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.adaptivePlatformDensity,
|
||||||
|
useMaterial3: true,
|
||||||
|
fontFamily: 'IPAexGothic',
|
||||||
|
),
|
||||||
|
themeMode: mode,
|
||||||
|
builder: (context, child) {
|
||||||
|
final mq = MediaQuery.of(context);
|
||||||
|
return Listener(
|
||||||
|
onPointerDown: (_) => setState(() => _activePointers++),
|
||||||
|
onPointerUp: (_) => setState(() => _activePointers = (_activePointers - 1).clamp(0, 10)),
|
||||||
|
onPointerCancel: (_) => setState(() => _activePointers = (_activePointers - 1).clamp(0, 10)),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
|
child: AnimatedPadding(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
|
||||||
|
child: InteractiveViewer(
|
||||||
|
panEnabled: false,
|
||||||
|
scaleEnabled: true,
|
||||||
|
minScale: 0.8,
|
||||||
|
maxScale: 4.0,
|
||||||
|
transformationController: _zoomController,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: _activePointers > 1,
|
||||||
|
child: child ?? const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
home: const _HomeDecider(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpiredApp extends StatelessWidget {
|
||||||
|
final BuildExpiryInfo expiryInfo;
|
||||||
|
const ExpiredApp({super.key, required this.expiryInfo});
|
||||||
|
|
||||||
|
String _format(DateTime? timestamp) {
|
||||||
|
if (timestamp == null) return '不明';
|
||||||
|
final local = timestamp.toLocal();
|
||||||
|
String two(int v) => v.toString().padLeft(2, '0');
|
||||||
|
return '${local.year}/${two(local.month)}/${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final buildText = _format(expiryInfo.buildTimestamp);
|
||||||
|
final expiryText = _format(expiryInfo.expiryTimestamp);
|
||||||
|
return MaterialApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.lock_clock, size: 72, color: Colors.white),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'このビルドは有効期限を過ぎています',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text('ビルド日時: $buildText', style: const TextStyle(color: Colors.white70)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('有効期限: $expiryText', style: const TextStyle(color: Colors.white70)),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text(
|
||||||
|
'最新版を取得してインストールしてください。',
|
||||||
|
style: TextStyle(color: Colors.white70),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.black87),
|
||||||
|
onPressed: () => SystemNavigator.pop(),
|
||||||
|
icon: const Icon(Icons.exit_to_app),
|
||||||
|
label: const Text('アプリを終了する'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ZoomResetObserver extends NavigatorObserver {
|
||||||
|
final TransformationController controller;
|
||||||
|
_ZoomResetObserver(this.controller);
|
||||||
|
|
||||||
|
void _reset() {
|
||||||
|
controller.value = Matrix4.identity();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPush(Route route, Route? previousRoute) {
|
||||||
|
super.didPush(route, previousRoute);
|
||||||
|
_reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPop(Route route, Route? previousRoute) {
|
||||||
|
super.didPop(route, previousRoute);
|
||||||
|
_reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didReplace({Route? newRoute, Route? oldRoute}) {
|
||||||
|
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||||
|
_reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeDecider extends StatefulWidget {
|
||||||
|
const _HomeDecider();
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_HomeDecider> createState() => _HomeDeciderState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HomeDeciderState extends State<_HomeDecider> {
|
||||||
|
final _settings = AppSettingsRepository();
|
||||||
|
late Future<String> _homeFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_homeFuture = _settings.getHomeMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: _homeFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||||
|
}
|
||||||
|
final mode = snapshot.data ?? 'invoice_history';
|
||||||
|
if (mode == 'dashboard') {
|
||||||
|
return const DashboardScreen();
|
||||||
|
}
|
||||||
|
return const InvoiceHistoryScreen();
|
||||||
},
|
},
|
||||||
home: const InvoiceHistoryScreen(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
45
lib/models/chat_message.dart
Normal file
45
lib/models/chat_message.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
enum ChatDirection { outbound, inbound }
|
||||||
|
|
||||||
|
class ChatMessage {
|
||||||
|
ChatMessage({
|
||||||
|
this.id,
|
||||||
|
required this.messageId,
|
||||||
|
required this.clientId,
|
||||||
|
required this.direction,
|
||||||
|
required this.body,
|
||||||
|
required this.createdAt,
|
||||||
|
this.synced = true,
|
||||||
|
this.deliveredAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int? id;
|
||||||
|
final String messageId;
|
||||||
|
final String clientId;
|
||||||
|
final ChatDirection direction;
|
||||||
|
final String body;
|
||||||
|
final DateTime createdAt;
|
||||||
|
final bool synced;
|
||||||
|
final DateTime? deliveredAt;
|
||||||
|
|
||||||
|
ChatMessage copyWith({
|
||||||
|
int? id,
|
||||||
|
String? messageId,
|
||||||
|
String? clientId,
|
||||||
|
ChatDirection? direction,
|
||||||
|
String? body,
|
||||||
|
DateTime? createdAt,
|
||||||
|
bool? synced,
|
||||||
|
DateTime? deliveredAt,
|
||||||
|
}) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: id ?? this.id,
|
||||||
|
messageId: messageId ?? this.messageId,
|
||||||
|
clientId: clientId ?? this.clientId,
|
||||||
|
direction: direction ?? this.direction,
|
||||||
|
body: body ?? this.body,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
synced: synced ?? this.synced,
|
||||||
|
deliveredAt: deliveredAt ?? this.deliveredAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
97
lib/mothership/chat_store.dart
Normal file
97
lib/mothership/chat_store.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class ChatEnvelope {
|
||||||
|
ChatEnvelope({required this.messageId, required this.body, required this.createdAt});
|
||||||
|
|
||||||
|
final String messageId;
|
||||||
|
final String body;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'messageId': messageId,
|
||||||
|
'body': body,
|
||||||
|
'createdAt': createdAt.millisecondsSinceEpoch,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ChatEnvelope.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ChatEnvelope(
|
||||||
|
messageId: json['messageId'] as String,
|
||||||
|
body: json['body'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt'] as int, isUtc: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MothershipChatStore {
|
||||||
|
MothershipChatStore(this.rootDir);
|
||||||
|
|
||||||
|
final Directory rootDir;
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (!await rootDir.exists()) {
|
||||||
|
await rootDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> appendInbound(String clientId, List<ChatEnvelope> messages) async {
|
||||||
|
if (messages.isEmpty) return;
|
||||||
|
final file = await _logFile(clientId);
|
||||||
|
final sink = file.openWrite(mode: FileMode.append);
|
||||||
|
for (final message in messages) {
|
||||||
|
sink.writeln(jsonEncode(message.toJson()));
|
||||||
|
}
|
||||||
|
await sink.flush();
|
||||||
|
await sink.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ChatEnvelope>> pendingOutbound(String clientId) async {
|
||||||
|
final file = await _outboxFile(clientId);
|
||||||
|
if (!await file.exists()) return [];
|
||||||
|
try {
|
||||||
|
final raw = await file.readAsString();
|
||||||
|
if (raw.trim().isEmpty) return [];
|
||||||
|
final decoded = jsonDecode(raw) as List<dynamic>;
|
||||||
|
return decoded.map((e) => ChatEnvelope.fromJson(Map<String, dynamic>.from(e as Map))).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> enqueueOutbound(String clientId, List<ChatEnvelope> messages) async {
|
||||||
|
if (messages.isEmpty) return;
|
||||||
|
final current = await pendingOutbound(clientId);
|
||||||
|
final combined = [...current, ...messages];
|
||||||
|
final file = await _outboxFile(clientId);
|
||||||
|
await file.writeAsString(jsonEncode(combined.map((e) => e.toJson()).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> acknowledge(String clientId, List<String> messageIds) async {
|
||||||
|
if (messageIds.isEmpty) return;
|
||||||
|
final file = await _outboxFile(clientId);
|
||||||
|
if (!await file.exists()) return;
|
||||||
|
final current = await pendingOutbound(clientId);
|
||||||
|
final filtered = current.where((m) => !messageIds.contains(m.messageId)).toList();
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
await file.delete();
|
||||||
|
} else {
|
||||||
|
await file.writeAsString(jsonEncode(filtered.map((e) => e.toJson()).toList()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _logFile(String clientId) async {
|
||||||
|
final dir = Directory('${rootDir.path}/$clientId');
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return File('${dir.path}/log.jsonl');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _outboxFile(String clientId) async {
|
||||||
|
final dir = Directory('${rootDir.path}/$clientId');
|
||||||
|
if (!await dir.exists()) {
|
||||||
|
await dir.create(recursive: true);
|
||||||
|
}
|
||||||
|
return File('${dir.path}/outbox.json');
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lib/mothership/config.dart
Normal file
29
lib/mothership/config.dart
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class MothershipConfig {
|
||||||
|
MothershipConfig({
|
||||||
|
required this.host,
|
||||||
|
required this.port,
|
||||||
|
required this.apiKey,
|
||||||
|
required this.dataDirectory,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory MothershipConfig.fromEnv() {
|
||||||
|
final env = Platform.environment;
|
||||||
|
final host = env['MOTHERSHIP_HOST'] ?? '0.0.0.0';
|
||||||
|
final port = int.tryParse(env['MOTHERSHIP_PORT'] ?? '') ?? 8787;
|
||||||
|
final apiKey = env['MOTHERSHIP_API_KEY'] ?? 'TEST_MOTHERSHIP_KEY';
|
||||||
|
final dataDirPath = env['MOTHERSHIP_DATA_DIR'] ?? 'data/mothership';
|
||||||
|
return MothershipConfig(
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
apiKey: apiKey,
|
||||||
|
dataDirectory: Directory(dataDirPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
final String apiKey;
|
||||||
|
final Directory dataDirectory;
|
||||||
|
}
|
||||||
92
lib/mothership/data_store.dart
Normal file
92
lib/mothership/data_store.dart
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
class ClientStatus {
|
||||||
|
ClientStatus({
|
||||||
|
required this.clientId,
|
||||||
|
required this.lastSync,
|
||||||
|
required this.lastHash,
|
||||||
|
required this.remainingLifespan,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String clientId;
|
||||||
|
final DateTime? lastSync;
|
||||||
|
final String? lastHash;
|
||||||
|
final Duration? remainingLifespan;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'clientId': clientId,
|
||||||
|
'lastSync': lastSync?.toIso8601String(),
|
||||||
|
'lastHash': lastHash,
|
||||||
|
'remainingLifespanSeconds': remainingLifespan?.inSeconds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class MothershipDataStore {
|
||||||
|
MothershipDataStore(this.rootDir);
|
||||||
|
|
||||||
|
final Directory rootDir;
|
||||||
|
final Map<String, ClientStatus> _statuses = {};
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (!await rootDir.exists()) {
|
||||||
|
await rootDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
await _loadStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadStatuses() async {
|
||||||
|
final statusFile = File('${rootDir.path}/status.json');
|
||||||
|
if (await statusFile.exists()) {
|
||||||
|
final decoded = jsonDecode(await statusFile.readAsString()) as List<dynamic>;
|
||||||
|
for (final entry in decoded) {
|
||||||
|
final map = Map<String, dynamic>.from(entry as Map);
|
||||||
|
_statuses[map['clientId'] as String] = ClientStatus(
|
||||||
|
clientId: map['clientId'] as String,
|
||||||
|
lastSync: map['lastSync'] != null ? DateTime.tryParse(map['lastSync'] as String) : null,
|
||||||
|
lastHash: map['lastHash'] as String?,
|
||||||
|
remainingLifespan: map['remainingLifespanSeconds'] != null
|
||||||
|
? Duration(seconds: map['remainingLifespanSeconds'] as int)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persistStatuses() async {
|
||||||
|
final statusFile = File('${rootDir.path}/status.json');
|
||||||
|
final list = _statuses.values.map((e) => e.toJson()).toList();
|
||||||
|
await statusFile.writeAsString(const JsonEncoder.withIndent(' ').convert(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordHeartbeat({
|
||||||
|
required String clientId,
|
||||||
|
required Duration? remaining,
|
||||||
|
}) async {
|
||||||
|
final existing = _statuses[clientId];
|
||||||
|
_statuses[clientId] = ClientStatus(
|
||||||
|
clientId: clientId,
|
||||||
|
lastSync: DateTime.now().toUtc(),
|
||||||
|
lastHash: existing?.lastHash,
|
||||||
|
remainingLifespan: remaining,
|
||||||
|
);
|
||||||
|
await _persistStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> recordHash({
|
||||||
|
required String clientId,
|
||||||
|
required String hash,
|
||||||
|
}) async {
|
||||||
|
final existing = _statuses[clientId];
|
||||||
|
_statuses[clientId] = ClientStatus(
|
||||||
|
clientId: clientId,
|
||||||
|
lastSync: DateTime.now().toUtc(),
|
||||||
|
lastHash: hash,
|
||||||
|
remainingLifespan: existing?.remainingLifespan,
|
||||||
|
);
|
||||||
|
await _persistStatuses();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ClientStatus> listStatuses() => _statuses.values.toList()
|
||||||
|
..sort((a, b) => (a.clientId).compareTo(b.clientId));
|
||||||
|
}
|
||||||
170
lib/mothership/server.dart
Normal file
170
lib/mothership/server.dart
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:shelf/shelf_io.dart';
|
||||||
|
import 'package:shelf_router/shelf_router.dart';
|
||||||
|
|
||||||
|
import 'chat_store.dart';
|
||||||
|
import 'config.dart';
|
||||||
|
import 'data_store.dart';
|
||||||
|
|
||||||
|
class MothershipServer {
|
||||||
|
MothershipServer({required this.config, required this.dataStore, required this.chatStore});
|
||||||
|
|
||||||
|
final MothershipConfig config;
|
||||||
|
final MothershipDataStore dataStore;
|
||||||
|
final MothershipChatStore chatStore;
|
||||||
|
|
||||||
|
Future<HttpServer> start() async {
|
||||||
|
final router = Router()
|
||||||
|
..post('/sync/heartbeat', _handleHeartbeat)
|
||||||
|
..post('/sync/hash', _handleHash)
|
||||||
|
..post('/chat/send', _handleChatSend)
|
||||||
|
..get('/chat/pending', _handleChatPending)
|
||||||
|
..post('/chat/ack', _handleChatAck)
|
||||||
|
..get('/status', _handleStatus)
|
||||||
|
..get('/', _handleDashboard);
|
||||||
|
|
||||||
|
final handler = const Pipeline()
|
||||||
|
.addMiddleware(logRequests())
|
||||||
|
.addMiddleware(_apiKeyMiddleware(config.apiKey))
|
||||||
|
.addHandler(router.call);
|
||||||
|
|
||||||
|
final server = await serve(handler, config.host, config.port);
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
Middleware _apiKeyMiddleware(String expectedKey) {
|
||||||
|
return (innerHandler) {
|
||||||
|
return (request) async {
|
||||||
|
// Dashboard ('/')は無認証、その他は API キーを要求
|
||||||
|
if (request.url.path.isNotEmpty && request.url.path != 'status') {
|
||||||
|
final key = request.headers['x-api-key'];
|
||||||
|
if (key != expectedKey) {
|
||||||
|
return Response(401, body: 'Invalid API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return innerHandler(request);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _handleHeartbeat(Request request) async {
|
||||||
|
final body = await request.readAsString();
|
||||||
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
final clientId = json['clientId'] as String?;
|
||||||
|
if (clientId == null || clientId.isEmpty) {
|
||||||
|
return Response(400, body: 'clientId is required');
|
||||||
|
}
|
||||||
|
final remainingSeconds = json['remainingLifespanSeconds'] as int?;
|
||||||
|
await dataStore.recordHeartbeat(
|
||||||
|
clientId: clientId,
|
||||||
|
remaining: remainingSeconds != null ? Duration(seconds: remainingSeconds) : null,
|
||||||
|
);
|
||||||
|
return Response.ok('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _handleHash(Request request) async {
|
||||||
|
final body = await request.readAsString();
|
||||||
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
final clientId = json['clientId'] as String?;
|
||||||
|
final hash = json['hash'] as String?;
|
||||||
|
if (clientId == null || hash == null) {
|
||||||
|
return Response(400, body: 'clientId and hash are required');
|
||||||
|
}
|
||||||
|
await dataStore.recordHash(clientId: clientId, hash: hash);
|
||||||
|
return Response.ok('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _handleChatSend(Request request) async {
|
||||||
|
final body = await request.readAsString();
|
||||||
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
final clientId = json['clientId'] as String?;
|
||||||
|
if (clientId == null || clientId.isEmpty) {
|
||||||
|
return Response(400, body: 'clientId is required');
|
||||||
|
}
|
||||||
|
final messages = (json['messages'] as List?) ?? [];
|
||||||
|
final envelopes = messages
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => ChatEnvelope(
|
||||||
|
messageId: e['messageId'] as String,
|
||||||
|
body: e['body'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch((e['createdAt'] as int?) ?? 0, isUtc: true),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
await chatStore.appendInbound(clientId, envelopes);
|
||||||
|
return Response.ok(jsonEncode({'stored': envelopes.length}), headers: {'content-type': 'application/json'});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _handleChatPending(Request request) async {
|
||||||
|
final clientId = request.url.queryParameters['clientId'];
|
||||||
|
if (clientId == null || clientId.isEmpty) {
|
||||||
|
return Response(400, body: 'clientId is required');
|
||||||
|
}
|
||||||
|
final messages = await chatStore.pendingOutbound(clientId);
|
||||||
|
final payload = {'messages': messages.map((e) => e.toJson()).toList()};
|
||||||
|
return Response.ok(jsonEncode(payload), headers: {'content-type': 'application/json'});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _handleChatAck(Request request) async {
|
||||||
|
final body = await request.readAsString();
|
||||||
|
final json = jsonDecode(body) as Map<String, dynamic>;
|
||||||
|
final clientId = json['clientId'] as String?;
|
||||||
|
if (clientId == null || clientId.isEmpty) {
|
||||||
|
return Response(400, body: 'clientId is required');
|
||||||
|
}
|
||||||
|
final delivered = (json['delivered'] as List?)?.cast<String>() ?? [];
|
||||||
|
await chatStore.acknowledge(clientId, delivered);
|
||||||
|
return Response.ok('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _handleStatus(Request request) async {
|
||||||
|
final status = dataStore.listStatuses().map((e) => e.toJson()).toList();
|
||||||
|
return Response.ok(jsonEncode({'clients': status}), headers: {'content-type': 'application/json'});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Response> _handleDashboard(Request request) async {
|
||||||
|
final rows = dataStore.listStatuses().map((status) {
|
||||||
|
final lastSync = status.lastSync?.toIso8601String() ?? '-';
|
||||||
|
final remaining = status.remainingLifespan != null
|
||||||
|
? '${status.remainingLifespan!.inHours ~/ 24}d ${status.remainingLifespan!.inHours % 24}h'
|
||||||
|
: '-';
|
||||||
|
return '<tr><td>${status.clientId}</td><td>$lastSync</td><td>${status.lastHash ?? '-'}</td><td>$remaining</td></tr>';
|
||||||
|
}).join();
|
||||||
|
|
||||||
|
final html = '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Mothership Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; margin: 24px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th, td { border: 1px solid #ccc; padding: 8px; text-align: left; }
|
||||||
|
th { background: #f5f5f5; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>母艦お局様 - ステータス</h1>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Last Sync</th>
|
||||||
|
<th>Last Hash</th>
|
||||||
|
<th>Remaining</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
$rows
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
|
||||||
|
return Response.ok(html, headers: {'content-type': 'text/html; charset=utf-8'});
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
208
lib/screens/chat_screen.dart
Normal file
208
lib/screens/chat_screen.dart
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/chat_message.dart';
|
||||||
|
import '../services/chat_repository.dart';
|
||||||
|
import '../services/mothership_chat_client.dart';
|
||||||
|
import '../services/mothership_client.dart';
|
||||||
|
|
||||||
|
class ChatScreen extends StatefulWidget {
|
||||||
|
const ChatScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ChatScreen> createState() => _ChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatScreenState extends State<ChatScreen> {
|
||||||
|
final ChatRepository _repository = ChatRepository();
|
||||||
|
late final MothershipClient _mothershipClient;
|
||||||
|
late final MothershipChatClient _chatClient;
|
||||||
|
final TextEditingController _inputController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
bool _syncing = false;
|
||||||
|
bool _sending = false;
|
||||||
|
List<ChatMessage> _messages = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_mothershipClient = MothershipClient();
|
||||||
|
_chatClient = MothershipChatClient(repository: _repository, baseClient: _mothershipClient);
|
||||||
|
_refreshMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshMessages() async {
|
||||||
|
setState(() => _syncing = true);
|
||||||
|
await _chatClient.sync();
|
||||||
|
final list = await _repository.listMessages();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messages = list;
|
||||||
|
_syncing = false;
|
||||||
|
});
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
_scrollController.animateTo(
|
||||||
|
_scrollController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 250),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendMessage() async {
|
||||||
|
if (_sending) return;
|
||||||
|
final text = _inputController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
setState(() => _sending = true);
|
||||||
|
final clientId = await _mothershipClient.ensureClientId();
|
||||||
|
await _repository.addOutbound(clientId: clientId, body: text);
|
||||||
|
_inputController.clear();
|
||||||
|
await _chatClient.sync();
|
||||||
|
final list = await _repository.listMessages();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_messages = list;
|
||||||
|
_sending = false;
|
||||||
|
});
|
||||||
|
_scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_inputController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('母艦チャット'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: '再同期',
|
||||||
|
onPressed: _syncing ? null : _refreshMessages,
|
||||||
|
icon: _syncing ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
child: _messages.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
_syncing ? '同期中...' : 'まだメッセージはありません',
|
||||||
|
style: theme.textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||||
|
itemCount: _messages.length,
|
||||||
|
itemBuilder: (context, index) => _MessageBubble(message: _messages[index]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _inputController,
|
||||||
|
minLines: 1,
|
||||||
|
maxLines: 4,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'メッセージを入力',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _sending ? null : _sendMessage,
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
label: const Text('送信'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MessageBubble extends StatelessWidget {
|
||||||
|
const _MessageBubble({required this.message});
|
||||||
|
|
||||||
|
final ChatMessage message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isOutbound = message.direction == ChatDirection.outbound;
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final bubbleColor = isOutbound ? theme.colorScheme.primary : Colors.grey.shade200;
|
||||||
|
final textColor = isOutbound ? Colors.white : Colors.grey.shade900;
|
||||||
|
final align = isOutbound ? CrossAxisAlignment.end : CrossAxisAlignment.start;
|
||||||
|
final borderRadius = BorderRadius.only(
|
||||||
|
topLeft: const Radius.circular(16),
|
||||||
|
topRight: const Radius.circular(16),
|
||||||
|
bottomLeft: Radius.circular(isOutbound ? 16 : 4),
|
||||||
|
bottomRight: Radius.circular(isOutbound ? 4 : 16),
|
||||||
|
);
|
||||||
|
final timeText = TimeOfDay.fromDateTime(message.createdAt.toLocal()).format(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: align,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: isOutbound ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: const BoxConstraints(maxWidth: 320),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bubbleColor,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: align,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
message.body,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: textColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
timeText,
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(color: textColor.withValues(alpha: 0.8), fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,23 +856,33 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
leading: const Icon(Icons.edit),
|
leading: const Icon(Icons.edit),
|
||||||
title: const Text('編集'),
|
title: const Text('編集'),
|
||||||
enabled: !c.isLocked,
|
enabled: !c.isLocked,
|
||||||
onTap: c.isLocked
|
onTap: () {
|
||||||
? null
|
Navigator.pop(context);
|
||||||
: () {
|
_addOrEditCustomer(customer: c);
|
||||||
Navigator.pop(context);
|
},
|
||||||
_addOrEditCustomer(customer: c);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.contact_mail),
|
leading: const Icon(Icons.contact_mail),
|
||||||
title: const Text('連絡先を更新'),
|
title: const Text('連絡先を更新'),
|
||||||
|
enabled: !c.isLocked,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (c.isLocked) return;
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_showContactUpdateDialog(c);
|
_showContactUpdateDialog(c);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.delete, color: Colors.redAccent),
|
leading: const Icon(Icons.visibility_off),
|
||||||
|
title: const Text('非表示にする'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _customerRepo.setHidden(c.id, true);
|
||||||
|
if (!mounted) return;
|
||||||
|
_loadCustomers();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||||
title: const Text('削除', style: TextStyle(color: Colors.redAccent)),
|
title: const Text('削除', style: TextStyle(color: Colors.redAccent)),
|
||||||
enabled: !c.isLocked,
|
enabled: !c.isLocked,
|
||||||
onTap: c.isLocked
|
onTap: c.isLocked
|
||||||
|
|
@ -957,10 +962,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () {
|
onPressed: c.isLocked
|
||||||
Navigator.pop(context);
|
? null
|
||||||
_showContactUpdateSheet(c);
|
: () {
|
||||||
},
|
Navigator.pop(context);
|
||||||
|
_showContactUpdateSheet(c);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.contact_mail),
|
icon: const Icon(Icons.contact_mail),
|
||||||
label: const Text("連絡先を更新"),
|
label: const Text("連絡先を更新"),
|
||||||
),
|
),
|
||||||
|
|
@ -1018,7 +1025,9 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.contact_mail),
|
leading: const Icon(Icons.contact_mail),
|
||||||
title: const Text('連絡先を更新'),
|
title: const Text('連絡先を更新'),
|
||||||
|
enabled: !c.isLocked,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (c.isLocked) return;
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_showContactUpdateDialog(c);
|
_showContactUpdateDialog(c);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
282
lib/screens/dashboard_screen.dart
Normal file
282
lib/screens/dashboard_screen.dart
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
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,
|
||||||
|
lockedText: 'スライドでロック解除 (A2)',
|
||||||
|
unlockedText: 'A2解除済',
|
||||||
|
onUnlocked: () async {
|
||||||
|
setState(() => _historyUnlocked = true);
|
||||||
|
await _repo.setDashboardHistoryUnlocked(true);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,25 +480,34 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
|
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
|
||||||
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
|
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 8),
|
||||||
Text("件名: ${_currentInvoice.subject}",
|
const Text("件名", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black54)),
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo)),
|
const SizedBox(height: 2),
|
||||||
],
|
|
||||||
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
|
||||||
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
|
|
||||||
if ((_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address) != null)
|
|
||||||
Text("住所: ${_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address}", style: TextStyle(color: textColor)),
|
|
||||||
if ((_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel) != null)
|
|
||||||
Text("TEL: ${_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel}", style: TextStyle(color: textColor)),
|
|
||||||
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
|
|
||||||
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
|
|
||||||
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
Text(
|
||||||
"備考: ${_currentInvoice.notes}",
|
_currentInvoice.subject!,
|
||||||
style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())),
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
|
||||||
|
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
|
||||||
|
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
|
||||||
|
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
|
||||||
|
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
"備考: ${_currentInvoice.notes}",
|
||||||
|
style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -520,32 +592,43 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
final int tax = (subtotal * currentTaxRate).floor();
|
final int tax = (subtotal * currentTaxRate).floor();
|
||||||
final int total = subtotal + tax;
|
final int total = subtotal + tax;
|
||||||
|
|
||||||
return Container(
|
final bool useBlue = _summaryIsBlue;
|
||||||
width: double.infinity,
|
final Color bgColor = useBlue ? Colors.indigo : Colors.white;
|
||||||
padding: const EdgeInsets.all(16),
|
final Color borderColor = useBlue ? Colors.transparent : Colors.grey.shade300;
|
||||||
decoration: BoxDecoration(
|
final Color labelColor = useBlue ? Colors.white70 : Colors.black87;
|
||||||
color: Colors.indigo,
|
final Color totalColor = useBlue ? Colors.white : Colors.black87;
|
||||||
borderRadius: BorderRadius.circular(12),
|
final Color dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300;
|
||||||
),
|
|
||||||
child: Column(
|
return GestureDetector(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
onLongPress: _pickSummaryColor,
|
||||||
children: [
|
child: Container(
|
||||||
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", Colors.white70),
|
width: double.infinity,
|
||||||
if (currentTaxRate > 0) ...[
|
padding: const EdgeInsets.all(16),
|
||||||
const Divider(color: Colors.white24),
|
decoration: BoxDecoration(
|
||||||
if (_companyInfo?.taxDisplayMode == 'normal')
|
color: bgColor,
|
||||||
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "¥${formatter.format(tax)}", Colors.white70),
|
borderRadius: BorderRadius.circular(12),
|
||||||
if (_companyInfo?.taxDisplayMode == 'text_only')
|
border: Border.all(color: borderColor),
|
||||||
_buildSummaryRow("消費税", "(税別)", Colors.white70),
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildSummaryRow("小計", "¥${formatter.format(subtotal)}", labelColor),
|
||||||
|
if (currentTaxRate > 0) ...[
|
||||||
|
Divider(color: dividerColor),
|
||||||
|
if (_companyInfo?.taxDisplayMode == 'normal')
|
||||||
|
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "¥${formatter.format(tax)}", labelColor),
|
||||||
|
if (_companyInfo?.taxDisplayMode == 'text_only')
|
||||||
|
_buildSummaryRow("消費税", "(税別)", labelColor),
|
||||||
|
],
|
||||||
|
Divider(color: dividerColor),
|
||||||
|
_buildSummaryRow(
|
||||||
|
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
|
||||||
|
"¥${formatter.format(total)}",
|
||||||
|
totalColor,
|
||||||
|
isTotal: true,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
const Divider(color: Colors.white24),
|
),
|
||||||
_buildSummaryRow(
|
|
||||||
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
|
|
||||||
"¥${formatter.format(total)}",
|
|
||||||
Colors.white,
|
|
||||||
isTotal: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -721,7 +804,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text("この下書き伝票を「確定」として正式に発行しますか?"),
|
Row(
|
||||||
|
children: const [
|
||||||
|
_DraftBadge(),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Expanded(child: Text("この伝票を「確定」として正式に発行しますか?")),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
if (showWarning)
|
if (showWarning)
|
||||||
Container(
|
Container(
|
||||||
|
|
@ -785,7 +874,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.drafts, color: Colors.orange),
|
const Icon(Icons.drafts, color: Colors.orange),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
const Expanded(child: Text("下書き状態として保持", style: TextStyle(fontWeight: FontWeight.bold))),
|
const Expanded(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_DraftBadge(),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text("状態として保持", style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Switch(
|
Switch(
|
||||||
value: _currentInvoice.isDraft,
|
value: _currentInvoice.isDraft,
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
|
|
|
||||||
|
|
@ -25,86 +25,171 @@ class InvoiceHistoryItem extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
final cardColor = invoice.isDraft ? Colors.orange.shade50 : Colors.white;
|
||||||
tileColor: invoice.isDraft ? Colors.orange.shade50 : null,
|
final iconBg = isUnlocked
|
||||||
leading: CircleAvatar(
|
? _docTypeColor(invoice.documentType).withValues(alpha: 0.18)
|
||||||
backgroundColor: invoice.isDraft
|
: Colors.grey.shade200;
|
||||||
? Colors.orange.shade100
|
final iconColor = isUnlocked ? _docTypeColor(invoice.documentType) : Colors.grey;
|
||||||
: (isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
|
|
||||||
child: Stack(
|
final hasSubject = invoice.subject?.isNotEmpty ?? false;
|
||||||
children: [
|
final firstItemDesc = invoice.items.isNotEmpty ? invoice.items.first.description : '';
|
||||||
Align(
|
final othersCount = invoice.items.length > 1 ? invoice.items.length - 1 : 0;
|
||||||
alignment: Alignment.center,
|
final subjectLine = hasSubject ? invoice.subject! : firstItemDesc;
|
||||||
child: Icon(
|
final subjectDisplay = hasSubject
|
||||||
invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
|
? subjectLine
|
||||||
color: invoice.isDraft
|
: (othersCount > 0 ? "$subjectLine 他$othersCount件" : subjectLine);
|
||||||
? Colors.orange
|
final customerName = invoice.customerNameForDisplay.endsWith('様')
|
||||||
: (isUnlocked ? Colors.indigo : Colors.grey),
|
? invoice.customerNameForDisplay
|
||||||
|
: '${invoice.customerNameForDisplay} 様';
|
||||||
|
final subjectColor = invoice.isLocked ? Colors.grey.shade500 : Colors.indigo.shade700;
|
||||||
|
final amountColor = invoice.isLocked ? Colors.grey.shade500 : Colors.black87;
|
||||||
|
final dateColor = Colors.black87;
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: cardColor,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
elevation: invoice.isDraft ? 1.5 : 0.5,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: iconBg,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
_docTypeIcon(invoice.documentType),
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (invoice.isLocked)
|
||||||
|
const Align(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
child: Icon(Icons.lock, size: 14, color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
if (invoice.isLocked)
|
Expanded(
|
||||||
const Align(
|
child: Row(
|
||||||
alignment: Alignment.bottomRight,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Icon(Icons.lock, size: 14, color: Colors.redAccent),
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
customerName,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: invoice.isLocked ? Colors.grey : Colors.black87,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
subjectDisplay,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: subjectColor,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (invoice.isDraft)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
margin: const EdgeInsets.only(right: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'下書き',
|
||||||
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
dateFormatter.format(invoice.date),
|
||||||
|
style: TextStyle(fontSize: 12, color: dateColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 140),
|
||||||
|
child: Text(
|
||||||
|
invoice.invoiceNumber,
|
||||||
|
style: const TextStyle(fontSize: 10.5, color: Colors.grey),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
"¥${amountFormatter.format(invoice.totalAmount)}",
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: amountColor),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
invoice.customerNameForDisplay,
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: invoice.isLocked ? Colors.grey : Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (invoice.subject?.isNotEmpty ?? false)
|
|
||||||
Text(
|
|
||||||
invoice.subject!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: Colors.indigo.shade700,
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
|
|
||||||
trailing: SizedBox(
|
|
||||||
height: 48,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
"¥${amountFormatter.format(invoice.totalAmount)}",
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
|
||||||
),
|
|
||||||
if (invoice.isSynced)
|
|
||||||
const Icon(Icons.sync, size: 14, color: Colors.green)
|
|
||||||
else
|
|
||||||
const Icon(Icons.sync_disabled, size: 14, color: Colors.orange),
|
|
||||||
IconButton(
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
constraints: const BoxConstraints.tightFor(width: 28, height: 24),
|
|
||||||
icon: const Icon(Icons.edit, size: 16),
|
|
||||||
tooltip: invoice.isLocked
|
|
||||||
? "ロック中"
|
|
||||||
: (isUnlocked ? "編集" : "アンロックして編集"),
|
|
||||||
onPressed: (invoice.isLocked || !isUnlocked)
|
|
||||||
? null
|
|
||||||
: onEdit,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: onTap,
|
|
||||||
onLongPress: onLongPress,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
IconData _docTypeIcon(DocumentType type) {
|
||||||
|
switch (type) {
|
||||||
|
case DocumentType.estimation:
|
||||||
|
return Icons.request_quote;
|
||||||
|
case DocumentType.delivery:
|
||||||
|
return Icons.local_shipping;
|
||||||
|
case DocumentType.invoice:
|
||||||
|
return Icons.receipt_long;
|
||||||
|
case DocumentType.receipt:
|
||||||
|
return Icons.task_alt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _docTypeColor(DocumentType type) {
|
||||||
|
switch (type) {
|
||||||
|
case DocumentType.estimation:
|
||||||
|
return Colors.blue;
|
||||||
|
case DocumentType.delivery:
|
||||||
|
return Colors.teal;
|
||||||
|
case DocumentType.invoice:
|
||||||
|
return Colors.indigo;
|
||||||
|
case DocumentType.receipt:
|
||||||
|
return Colors.green;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,69 +216,97 @@ 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
|
||||||
child: ListView(
|
: Drawer(
|
||||||
padding: EdgeInsets.zero,
|
child: SafeArea(
|
||||||
children: [
|
child: ListView(
|
||||||
DrawerHeader(
|
padding: EdgeInsets.zero,
|
||||||
decoration: BoxDecoration(color: Colors.indigo.shade700),
|
children: [
|
||||||
child: Column(
|
DrawerHeader(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
decoration: const BoxDecoration(color: Colors.indigo),
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
|
children: const [
|
||||||
const SizedBox(height: 8),
|
Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)),
|
||||||
Text("v$_appVersion", style: const TextStyle(color: Colors.white70)),
|
SizedBox(height: 8),
|
||||||
],
|
Text("クイックメニュー", style: TextStyle(color: Colors.white70)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
_drawerHeading("アクション"),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.receipt_long),
|
leading: const Icon(Icons.add_circle_outline, color: Colors.indigo),
|
||||||
title: const Text("伝票マスター"),
|
title: const Text("新しい伝票を作成"),
|
||||||
onTap: () {
|
subtitle: const Text("ドキュメント種別を選択"),
|
||||||
Navigator.pop(context);
|
onTap: () {
|
||||||
},
|
Navigator.pop(context);
|
||||||
),
|
_showCreateTypeMenu();
|
||||||
ListTile(
|
},
|
||||||
leading: const Icon(Icons.people),
|
),
|
||||||
title: const Text("顧客マスター"),
|
_drawerHeading("マスター"),
|
||||||
onTap: () {
|
ListTile(
|
||||||
Navigator.pop(context);
|
leading: const Icon(Icons.receipt_long),
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
|
title: const Text("伝票マスター"),
|
||||||
},
|
onTap: () => Navigator.pop(context),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.inventory_2),
|
leading: const Icon(Icons.people),
|
||||||
title: const Text("商品マスター"),
|
title: const Text("顧客マスター"),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.settings),
|
leading: const Icon(Icons.inventory_2),
|
||||||
title: const Text("設定"),
|
title: const Text("商品マスター"),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const Divider(),
|
_drawerHeading("システム"),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.admin_panel_settings),
|
leading: const Icon(Icons.settings),
|
||||||
title: const Text("管理メニュー"),
|
title: const Text("設定"),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen()));
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
ListTile(
|
||||||
|
leading: const Icon(Icons.admin_panel_settings),
|
||||||
|
title: const Text("管理メニュー"),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: null,
|
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
// leading removed
|
automaticallyImplyLeading: false,
|
||||||
|
leading: _useDashboardHome
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const DashboardScreen()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: (_isUnlocked
|
||||||
|
? Builder(
|
||||||
|
builder: (ctx) => IconButton(
|
||||||
|
icon: const Icon(Icons.menu),
|
||||||
|
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null),
|
||||||
title: GestureDetector(
|
title: GestureDetector(
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
Navigator.push(
|
Navigator.push(
|
||||||
|
|
@ -307,19 +353,41 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
child: TextField(
|
child: Container(
|
||||||
decoration: InputDecoration(
|
decoration: BoxDecoration(
|
||||||
hintText: "検索 (顧客名、伝票番号...)",
|
color: Colors.grey.shade50,
|
||||||
prefixIcon: const Icon(Icons.search),
|
borderRadius: BorderRadius.circular(16),
|
||||||
filled: true,
|
boxShadow: [
|
||||||
fillColor: Colors.white,
|
// outer shadow
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
BoxShadow(
|
||||||
isDense: true,
|
color: Colors.black.withValues(alpha: 0.08),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
// faux inset highlight
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
blurRadius: 4,
|
||||||
|
spreadRadius: -4,
|
||||||
|
offset: const Offset(-1, -1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "検索 (顧客名、伝票番号...)",
|
||||||
|
prefixIcon: const Icon(Icons.search),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade50,
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
),
|
||||||
|
onChanged: (val) {
|
||||||
|
_searchQuery = val;
|
||||||
|
_applyFilterAndSort();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
onChanged: (val) {
|
|
||||||
_searchQuery = val;
|
|
||||||
_applyFilterAndSort();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -327,14 +395,16 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
if (!_useDashboardHome)
|
||||||
padding: const EdgeInsets.all(16.0),
|
Padding(
|
||||||
child: SlideToUnlock(
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
isLocked: !_isUnlocked,
|
child: SlideToUnlock(
|
||||||
onUnlocked: _toggleUnlock,
|
isLocked: !_isUnlocked,
|
||||||
text: "スライドでロック解除",
|
lockedText: "A2をロック解除",
|
||||||
|
unlockedText: "解除済",
|
||||||
|
onUnlocked: _toggleUnlock,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
|
@ -347,8 +417,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) {},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -377,7 +448,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
onPressed: _isUnlocked
|
onPressed: _isUnlocked
|
||||||
? () => _showCreateTypeMenu()
|
? () => _showCreateTypeMenu()
|
||||||
: _requireUnlock,
|
: _requireUnlock,
|
||||||
label: const Text("新規伝票作成"),
|
label: const Text("新しい伝票"),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
backgroundColor: Colors.indigo,
|
backgroundColor: Colors.indigo,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
|
@ -385,6 +456,13 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _drawerHeading(String label) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||||
|
child: Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey, letterSpacing: 0.5)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _showCreateTypeMenu() {
|
void _showCreateTypeMenu() {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
|
|
@ -393,23 +471,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 +504,8 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
|
||||||
builder: (_) => InvoiceInputForm(
|
builder: (_) => InvoiceInputForm(
|
||||||
onInvoiceGenerated: (inv, path) {},
|
onInvoiceGenerated: (inv, path) {},
|
||||||
initialDocumentType: type,
|
initialDocumentType: type,
|
||||||
|
startViewMode: false,
|
||||||
|
showNewBadge: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
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,14 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.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';
|
||||||
|
import 'chat_screen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
|
|
@ -10,28 +17,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 +48,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 +110,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 +271,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 +298,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: EdgeInsets.only(bottom: listBottomPadding),
|
padding: EdgeInsets.only(bottom: listBottomPadding),
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_section(
|
||||||
width: double.infinity,
|
title: 'ホームモード / ダッシュボード',
|
||||||
padding: const EdgeInsets.all(14),
|
subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)',
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
child: Column(
|
||||||
decoration: BoxDecoration(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: Colors.indigo.shade50,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(color: Colors.indigo.shade100),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.business, color: Colors.indigo, size: 28),
|
SwitchListTile(
|
||||||
const SizedBox(width: 12),
|
title: const Text('ホームをダッシュボードにする'),
|
||||||
const Expanded(
|
value: _homeDashboard,
|
||||||
child: Text(
|
onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v),
|
||||||
"自社情報を開く",
|
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
ElevatedButton.icon(
|
SwitchListTile(
|
||||||
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CompanyInfoScreen())),
|
title: const Text('ステータスを表示する'),
|
||||||
icon: const Icon(Icons.chevron_right),
|
value: _statusEnabled,
|
||||||
label: const Text("詳細"),
|
onChanged: _loadingAppSettings ? null : (v) => setState(() => _statusEnabled = v),
|
||||||
style: ElevatedButton.styleFrom(
|
),
|
||||||
backgroundColor: Colors.indigo,
|
TextField(
|
||||||
foregroundColor: Colors.white,
|
controller: _statusTextCtrl,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
enabled: !_loadingAppSettings && _statusEnabled,
|
||||||
|
decoration: const InputDecoration(labelText: 'ステータス文言', hintText: '例: 工事中'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('メニューを追加'),
|
||||||
|
onPressed: _loadingAppSettings ? null : _addMenuItem,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text('ドラッグで並べ替え / ゴミ箱で削除', style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_loadingAppSettings
|
||||||
|
? const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator()))
|
||||||
|
: ReorderableListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: _menuItems.length,
|
||||||
|
onReorder: _reorderMenu,
|
||||||
|
itemBuilder: (ctx, index) {
|
||||||
|
final item = _menuItems[index];
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
leading: _menuLeading(item),
|
||||||
|
title: Text(item.title),
|
||||||
|
subtitle: Text(_routeLabel(item.route)),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_forever, color: Colors.redAccent),
|
||||||
|
onPressed: () => _removeMenuItem(item.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text('ホーム設定を保存'),
|
||||||
|
onPressed: _loadingAppSettings ? null : _saveAppSettings,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -281,32 +367,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 +398,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,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -360,10 +429,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')),
|
TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')),
|
||||||
TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
|
TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ElevatedButton.icon(
|
Row(
|
||||||
icon: const Icon(Icons.save),
|
children: [
|
||||||
label: const Text('保存'),
|
ElevatedButton.icon(
|
||||||
onPressed: _saveExternalSync,
|
icon: const Icon(Icons.save),
|
||||||
|
label: const Text('保存'),
|
||||||
|
onPressed: _saveExternalSync,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.chat_bubble_outline),
|
||||||
|
label: const Text('チャットを開く'),
|
||||||
|
onPressed: () async {
|
||||||
|
await Navigator.push(context, MaterialPageRoute(builder: (_) => const ChatScreen()));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -413,7 +494,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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
lib/services/chat_repository.dart
Normal file
88
lib/services/chat_repository.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../models/chat_message.dart';
|
||||||
|
import 'database_helper.dart';
|
||||||
|
|
||||||
|
class ChatRepository {
|
||||||
|
ChatRepository();
|
||||||
|
|
||||||
|
final DatabaseHelper _dbHelper = DatabaseHelper();
|
||||||
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
|
Future<Database> _db() => _dbHelper.database;
|
||||||
|
|
||||||
|
Future<List<ChatMessage>> listMessages({int limit = 200}) async {
|
||||||
|
final db = await _db();
|
||||||
|
final rows = await db.query(
|
||||||
|
'chat_messages',
|
||||||
|
orderBy: 'created_at DESC',
|
||||||
|
limit: limit,
|
||||||
|
);
|
||||||
|
return rows.map(_fromRow).toList().reversed.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addOutbound({required String clientId, required String body}) async {
|
||||||
|
final db = await _db();
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
await db.insert(
|
||||||
|
'chat_messages',
|
||||||
|
{
|
||||||
|
'message_id': _uuid.v4(),
|
||||||
|
'client_id': clientId,
|
||||||
|
'direction': 'outbound',
|
||||||
|
'body': body,
|
||||||
|
'created_at': now.millisecondsSinceEpoch,
|
||||||
|
'synced': 0,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertInbound(ChatMessage message) async {
|
||||||
|
final db = await _db();
|
||||||
|
await db.insert(
|
||||||
|
'chat_messages',
|
||||||
|
{
|
||||||
|
'message_id': message.messageId,
|
||||||
|
'client_id': message.clientId,
|
||||||
|
'direction': 'inbound',
|
||||||
|
'body': message.body,
|
||||||
|
'created_at': message.createdAt.millisecondsSinceEpoch,
|
||||||
|
'synced': 1,
|
||||||
|
'delivered_at': DateTime.now().toUtc().millisecondsSinceEpoch,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<ChatMessage>> pendingOutbound() async {
|
||||||
|
final db = await _db();
|
||||||
|
final rows = await db.query('chat_messages', where: 'direction = ? AND synced = 0', whereArgs: ['outbound'], orderBy: 'created_at ASC');
|
||||||
|
return rows.map(_fromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markSynced(List<String> messageIds) async {
|
||||||
|
if (messageIds.isEmpty) return;
|
||||||
|
final db = await _db();
|
||||||
|
await db.update(
|
||||||
|
'chat_messages',
|
||||||
|
{'synced': 1},
|
||||||
|
where: 'message_id IN (${List.filled(messageIds.length, '?').join(',')})',
|
||||||
|
whereArgs: messageIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatMessage _fromRow(Map<String, dynamic> row) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: row['id'] as int?,
|
||||||
|
messageId: row['message_id'] as String,
|
||||||
|
clientId: row['client_id'] as String,
|
||||||
|
direction: (row['direction'] as String) == 'outbound' ? ChatDirection.outbound : ChatDirection.inbound,
|
||||||
|
body: row['body'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int, isUtc: true),
|
||||||
|
synced: (row['synced'] as int? ?? 1) == 1,
|
||||||
|
deliveredAt: row['delivered_at'] != null ? DateTime.fromMillisecondsSinceEpoch(row['delivered_at'] as int, isUtc: true) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lib/services/chat_sync_scheduler.dart
Normal file
68
lib/services/chat_sync_scheduler.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'mothership_chat_client.dart';
|
||||||
|
|
||||||
|
class ChatSyncScheduler with WidgetsBindingObserver {
|
||||||
|
ChatSyncScheduler({Duration? interval}) : _interval = interval ?? const Duration(seconds: 10);
|
||||||
|
|
||||||
|
final Duration _interval;
|
||||||
|
final MothershipChatClient _chatClient = MothershipChatClient();
|
||||||
|
|
||||||
|
Timer? _timer;
|
||||||
|
bool _started = false;
|
||||||
|
bool _syncing = false;
|
||||||
|
bool _appActive = true;
|
||||||
|
|
||||||
|
void start() {
|
||||||
|
if (_started) return;
|
||||||
|
_started = true;
|
||||||
|
final binding = WidgetsBinding.instance;
|
||||||
|
binding.addObserver(this);
|
||||||
|
_appActive = _isActiveState(binding.lifecycleState);
|
||||||
|
if (_appActive) {
|
||||||
|
_scheduleImmediate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop() {
|
||||||
|
if (!_started) return;
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
_started = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() => stop();
|
||||||
|
|
||||||
|
void _scheduleImmediate() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_runSync();
|
||||||
|
_timer = Timer.periodic(_interval, (_) => _runSync());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _runSync() {
|
||||||
|
if (!_appActive || _syncing) return;
|
||||||
|
_syncing = true;
|
||||||
|
unawaited(_chatClient.sync().whenComplete(() {
|
||||||
|
_syncing = false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isActiveState(AppLifecycleState? state) {
|
||||||
|
return state == null || state == AppLifecycleState.resumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
_appActive = _isActiveState(state);
|
||||||
|
if (!_started) return;
|
||||||
|
if (_appActive) {
|
||||||
|
_scheduleImmediate();
|
||||||
|
} else {
|
||||||
|
_timer?.cancel();
|
||||||
|
_timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 = 26;
|
||||||
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
static final DatabaseHelper _instance = DatabaseHelper._internal();
|
||||||
static Database? _database;
|
static Database? _database;
|
||||||
|
|
||||||
|
|
@ -164,6 +164,52 @@ 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');
|
||||||
|
}
|
||||||
|
if (oldVersion < 26) {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id TEXT UNIQUE NOT NULL,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
synced INTEGER DEFAULT 0,
|
||||||
|
delivered_at INTEGER
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at)');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onCreate(Database db, int version) async {
|
Future<void> _onCreate(Database db, int version) async {
|
||||||
|
|
@ -182,6 +228,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 +270,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 +313,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 +367,27 @@ class DatabaseHelper {
|
||||||
timestamp TEXT NOT NULL
|
timestamp TEXT NOT NULL
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE app_settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE chat_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id TEXT UNIQUE NOT NULL,
|
||||||
|
client_id TEXT NOT NULL,
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
synced INTEGER DEFAULT 0,
|
||||||
|
delivered_at INTEGER
|
||||||
|
)
|
||||||
|
''');
|
||||||
|
await db.execute('CREATE INDEX idx_chat_messages_created_at ON chat_messages(created_at)');
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
||||||
|
|
|
||||||
120
lib/services/mothership_chat_client.dart
Normal file
120
lib/services/mothership_chat_client.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../models/chat_message.dart';
|
||||||
|
import 'chat_repository.dart';
|
||||||
|
import 'mothership_client.dart';
|
||||||
|
|
||||||
|
class MothershipChatClient {
|
||||||
|
MothershipChatClient({ChatRepository? repository, MothershipClient? baseClient, http.Client? httpClient})
|
||||||
|
: _repository = repository ?? ChatRepository(),
|
||||||
|
_baseClient = baseClient ?? MothershipClient(),
|
||||||
|
_httpClient = httpClient;
|
||||||
|
|
||||||
|
final ChatRepository _repository;
|
||||||
|
final MothershipClient _baseClient;
|
||||||
|
final http.Client? _httpClient;
|
||||||
|
|
||||||
|
Future<void> sync() async {
|
||||||
|
await Future.wait([_pushPending(), _fetchInbound()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushPending() async {
|
||||||
|
final config = await _baseClient.loadConfig();
|
||||||
|
if (config == null) {
|
||||||
|
debugPrint('[ChatSync] skip push: config missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final clientId = await _baseClient.ensureClientId();
|
||||||
|
final pending = await _repository.pendingOutbound();
|
||||||
|
if (pending.isEmpty) return;
|
||||||
|
final client = _httpClient ?? http.Client();
|
||||||
|
try {
|
||||||
|
final payload = {
|
||||||
|
'clientId': clientId,
|
||||||
|
'messages': pending
|
||||||
|
.map((m) => {
|
||||||
|
'messageId': m.messageId,
|
||||||
|
'body': m.body,
|
||||||
|
'createdAt': m.createdAt.millisecondsSinceEpoch,
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
final res = await client.post(
|
||||||
|
config.chatSendUri,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': config.apiKey,
|
||||||
|
},
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
await _repository.markSynced(pending.map((e) => e.messageId).toList());
|
||||||
|
debugPrint('[ChatSync] pushed ${pending.length} msgs');
|
||||||
|
} else {
|
||||||
|
debugPrint('[ChatSync] push failed ${res.statusCode} ${res.body}');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debugPrint('[ChatSync] push error $err');
|
||||||
|
} finally {
|
||||||
|
if (_httpClient == null) client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchInbound() async {
|
||||||
|
final config = await _baseClient.loadConfig();
|
||||||
|
if (config == null) return;
|
||||||
|
final clientId = await _baseClient.ensureClientId();
|
||||||
|
final client = _httpClient ?? http.Client();
|
||||||
|
try {
|
||||||
|
final uri = config.chatPendingUri.replace(queryParameters: {'clientId': clientId});
|
||||||
|
final res = await client.get(uri, headers: {'x-api-key': config.apiKey});
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
final decoded = jsonDecode(res.body) as Map<String, dynamic>;
|
||||||
|
final messages = (decoded['messages'] as List?) ?? [];
|
||||||
|
final ackIds = <String>[];
|
||||||
|
for (final raw in messages.cast<Map>()) {
|
||||||
|
final msg = ChatMessage(
|
||||||
|
messageId: raw['messageId'] as String,
|
||||||
|
clientId: clientId,
|
||||||
|
direction: ChatDirection.inbound,
|
||||||
|
body: raw['body'] as String,
|
||||||
|
createdAt: DateTime.fromMillisecondsSinceEpoch((raw['createdAt'] as int?) ?? 0, isUtc: true),
|
||||||
|
synced: true,
|
||||||
|
);
|
||||||
|
await _repository.upsertInbound(msg);
|
||||||
|
ackIds.add(msg.messageId);
|
||||||
|
}
|
||||||
|
if (ackIds.isNotEmpty) {
|
||||||
|
await _ack(config, clientId, ackIds);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
debugPrint('[ChatSync] fetch failed ${res.statusCode} ${res.body}');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
debugPrint('[ChatSync] fetch error $err');
|
||||||
|
} finally {
|
||||||
|
if (_httpClient == null) client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _ack(MothershipEndpointConfig config, String clientId, List<String> ids) async {
|
||||||
|
final client = _httpClient ?? http.Client();
|
||||||
|
try {
|
||||||
|
await client.post(
|
||||||
|
config.chatAckUri,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': config.apiKey,
|
||||||
|
},
|
||||||
|
body: jsonEncode({'clientId': clientId, 'delivered': ids}),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
debugPrint('[ChatSync] ack error $err');
|
||||||
|
} finally {
|
||||||
|
if (_httpClient == null) client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
154
lib/services/mothership_client.dart
Normal file
154
lib/services/mothership_client.dart
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
import '../utils/build_expiry_info.dart';
|
||||||
|
import 'app_settings_repository.dart';
|
||||||
|
|
||||||
|
class MothershipClient {
|
||||||
|
MothershipClient({AppSettingsRepository? settingsRepository, http.Client? httpClient})
|
||||||
|
: _settingsRepository = settingsRepository ?? AppSettingsRepository(),
|
||||||
|
_httpClient = httpClient;
|
||||||
|
|
||||||
|
final AppSettingsRepository _settingsRepository;
|
||||||
|
final http.Client? _httpClient;
|
||||||
|
|
||||||
|
static const _clientIdKey = 'mothership_client_id';
|
||||||
|
static const _hostSettingKey = 'external_host';
|
||||||
|
static const _apiKeySettingKey = 'external_pass';
|
||||||
|
|
||||||
|
Future<void> sendHeartbeat(BuildExpiryInfo expiryInfo) async {
|
||||||
|
final config = await loadConfig();
|
||||||
|
if (config == null) {
|
||||||
|
debugPrint('[Mothership] Heartbeat skipped: config not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final clientId = await ensureClientId();
|
||||||
|
final remaining = expiryInfo.remaining?.inSeconds;
|
||||||
|
final payload = <String, dynamic>{'clientId': clientId};
|
||||||
|
if (remaining != null) {
|
||||||
|
payload['remainingLifespanSeconds'] = remaining;
|
||||||
|
}
|
||||||
|
await _postJson(
|
||||||
|
uri: config.heartbeatUri,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
payload: payload,
|
||||||
|
logLabel: 'heartbeat',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendHash(String hash) async {
|
||||||
|
final config = await loadConfig();
|
||||||
|
if (config == null) {
|
||||||
|
debugPrint('[Mothership] Hash push skipped: config not set');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final clientId = await ensureClientId();
|
||||||
|
await _postJson(
|
||||||
|
uri: config.hashUri,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
payload: {
|
||||||
|
'clientId': clientId,
|
||||||
|
'hash': hash,
|
||||||
|
},
|
||||||
|
logLabel: 'hash',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<MothershipEndpointConfig?> loadConfig() async {
|
||||||
|
final host = (await _settingsRepository.getString(_hostSettingKey))?.trim();
|
||||||
|
final apiKey = (await _settingsRepository.getString(_apiKeySettingKey))?.trim();
|
||||||
|
if (host == null || host.isEmpty || apiKey == null || apiKey.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final base = _normalizeBaseUri(host);
|
||||||
|
return MothershipEndpointConfig(
|
||||||
|
apiKey: apiKey,
|
||||||
|
heartbeatUri: base.resolve('/sync/heartbeat'),
|
||||||
|
hashUri: base.resolve('/sync/hash'),
|
||||||
|
chatSendUri: base.resolve('/chat/send'),
|
||||||
|
chatPendingUri: base.resolve('/chat/pending'),
|
||||||
|
chatAckUri: base.resolve('/chat/ack'),
|
||||||
|
);
|
||||||
|
} on FormatException catch (err) {
|
||||||
|
debugPrint('[Mothership] Invalid host "$host": $err');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _normalizeBaseUri(String host) {
|
||||||
|
var normalized = host.trim();
|
||||||
|
if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) {
|
||||||
|
normalized = 'http://$normalized';
|
||||||
|
}
|
||||||
|
if (!normalized.endsWith('/')) {
|
||||||
|
normalized = '$normalized/';
|
||||||
|
}
|
||||||
|
return Uri.parse(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> ensureClientId() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final existing = prefs.getString(_clientIdKey);
|
||||||
|
if (existing != null && existing.isNotEmpty) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
final newId = const Uuid().v4();
|
||||||
|
await prefs.setString(_clientIdKey, newId);
|
||||||
|
return newId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _postJson({
|
||||||
|
required Uri uri,
|
||||||
|
required String apiKey,
|
||||||
|
required Map<String, dynamic> payload,
|
||||||
|
required String logLabel,
|
||||||
|
}) async {
|
||||||
|
final client = _httpClient ?? http.Client();
|
||||||
|
try {
|
||||||
|
final response = await client.post(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
HttpHeaders.contentTypeHeader: 'application/json',
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
},
|
||||||
|
body: jsonEncode(payload),
|
||||||
|
);
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
debugPrint('[Mothership] $logLabel OK (${response.statusCode})');
|
||||||
|
} else {
|
||||||
|
debugPrint('[Mothership] $logLabel failed: ${response.statusCode} ${response.body}');
|
||||||
|
}
|
||||||
|
} catch (err, stack) {
|
||||||
|
debugPrint('[Mothership] $logLabel error: $err');
|
||||||
|
debugPrint('$stack');
|
||||||
|
} finally {
|
||||||
|
if (_httpClient == null) {
|
||||||
|
client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MothershipEndpointConfig {
|
||||||
|
MothershipEndpointConfig({
|
||||||
|
required this.apiKey,
|
||||||
|
required this.heartbeatUri,
|
||||||
|
required this.hashUri,
|
||||||
|
required this.chatSendUri,
|
||||||
|
required this.chatPendingUri,
|
||||||
|
required this.chatAckUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String apiKey;
|
||||||
|
final Uri heartbeatUri;
|
||||||
|
final Uri hashUri;
|
||||||
|
final Uri chatSendUri;
|
||||||
|
final Uri chatPendingUri;
|
||||||
|
final Uri chatAckUri;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,28 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class SlideToUnlock extends StatefulWidget {
|
class SlideToUnlock extends StatefulWidget {
|
||||||
final VoidCallback onUnlocked;
|
final VoidCallback onUnlocked;
|
||||||
final String text;
|
final String lockedText;
|
||||||
|
final String unlockedText;
|
||||||
|
final IconData lockedIcon;
|
||||||
|
final IconData unlockedIcon;
|
||||||
final bool isLocked;
|
final bool isLocked;
|
||||||
|
final double? height;
|
||||||
|
final double? thumbSize;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Color? accentColor;
|
||||||
|
|
||||||
const SlideToUnlock({
|
const SlideToUnlock({
|
||||||
super.key,
|
super.key,
|
||||||
required this.onUnlocked,
|
required this.onUnlocked,
|
||||||
this.text = "スライドして解除",
|
this.lockedText = "スライドして解除",
|
||||||
|
this.unlockedText = "UNLOCKED",
|
||||||
|
this.lockedIcon = Icons.lock,
|
||||||
|
this.unlockedIcon = Icons.check_circle,
|
||||||
this.isLocked = true,
|
this.isLocked = true,
|
||||||
|
this.height = 72,
|
||||||
|
this.thumbSize = 52,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.accentColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -18,7 +32,8 @@ class SlideToUnlock extends StatefulWidget {
|
||||||
|
|
||||||
class _SlideToUnlockState extends State<SlideToUnlock> {
|
class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||||
double _position = 0.0;
|
double _position = 0.0;
|
||||||
final double _thumbSize = 56.0;
|
static const double _trackPadding = 14.0;
|
||||||
|
bool _showSuccessOverlay = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -27,13 +42,20 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final double maxWidth = constraints.maxWidth;
|
final double maxWidth = constraints.maxWidth;
|
||||||
final double trackWidth = (maxWidth - _thumbSize - 12).clamp(0, maxWidth);
|
final double thumbSize = widget.thumbSize ?? 52;
|
||||||
|
final double trackWidth = (maxWidth - thumbSize - (_trackPadding * 2)).clamp(0, maxWidth);
|
||||||
|
final double progressRatio = trackWidth == 0 ? 0 : (_position / trackWidth).clamp(0, 1);
|
||||||
|
final double innerWidth = thumbSize + trackWidth;
|
||||||
|
final double progressWidth = (innerWidth * progressRatio + thumbSize * (1 - progressRatio)).clamp(thumbSize, innerWidth);
|
||||||
|
final Color background = widget.backgroundColor ?? Colors.blueGrey.shade900;
|
||||||
|
final Color accentStart = (widget.accentColor ?? Colors.indigo.shade600).withValues(alpha: 0.9);
|
||||||
|
final Color accentEnd = (widget.accentColor ?? Colors.indigo.shade600);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 64,
|
height: widget.height ?? 72,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blueGrey.shade900,
|
color: background,
|
||||||
borderRadius: BorderRadius.circular(32),
|
borderRadius: BorderRadius.circular(32),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)),
|
BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)),
|
||||||
|
|
@ -41,27 +63,63 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// 背景テキストとアニメーション効果(簡易)
|
// 進行バー
|
||||||
Center(
|
Padding(
|
||||||
child: Opacity(
|
padding: const EdgeInsets.symmetric(horizontal: _trackPadding, vertical: 12),
|
||||||
opacity: (1 - (_position / trackWidth)).clamp(0.2, 1.0),
|
child: Align(
|
||||||
child: Row(
|
alignment: Alignment.centerLeft,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: AnimatedContainer(
|
||||||
children: [
|
duration: const Duration(milliseconds: 150),
|
||||||
const Icon(Icons.keyboard_double_arrow_right, color: Colors.white54, size: 20),
|
curve: Curves.easeOut,
|
||||||
const SizedBox(width: 8),
|
width: progressWidth,
|
||||||
Text(
|
height: double.infinity,
|
||||||
widget.text,
|
decoration: BoxDecoration(
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.2),
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
accentStart,
|
||||||
|
accentEnd,
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
boxShadow: const [
|
||||||
|
BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 2)),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// 背景テキスト
|
||||||
|
Center(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: _showSuccessOverlay
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
key: const ValueKey('unlocked'),
|
||||||
|
children: [
|
||||||
|
Icon(widget.unlockedIcon, color: Colors.white, size: 24),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(widget.unlockedText, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
key: const ValueKey('locked'),
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(widget.lockedIcon, color: Colors.white.withValues(alpha: 0.85), size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
widget.lockedText,
|
||||||
|
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// スライドつまみ
|
// スライドつまみ
|
||||||
Positioned(
|
Positioned(
|
||||||
left: _position + 4,
|
left: _trackPadding + _position,
|
||||||
top: 4,
|
top: ((widget.height ?? 72) - (widget.thumbSize ?? 52)) / 2,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onHorizontalDragUpdate: (details) {
|
onHorizontalDragUpdate: (details) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|
@ -72,31 +130,34 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
|
||||||
},
|
},
|
||||||
onHorizontalDragEnd: (details) {
|
onHorizontalDragEnd: (details) {
|
||||||
if (_position >= trackWidth * 0.65) {
|
if (_position >= trackWidth * 0.65) {
|
||||||
|
setState(() {
|
||||||
|
_position = trackWidth;
|
||||||
|
_showSuccessOverlay = true;
|
||||||
|
});
|
||||||
widget.onUnlocked();
|
widget.onUnlocked();
|
||||||
// 成功時はアニメーションで戻すのではなく、状態が変わるのでリセット
|
Future.delayed(const Duration(milliseconds: 450), () {
|
||||||
setState(() => _position = 0);
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_position = 0;
|
||||||
|
_showSuccessOverlay = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// 失敗時はバネのように戻る(簡易)
|
// 失敗時はバネのように戻る(簡易)
|
||||||
setState(() => _position = 0);
|
setState(() => _position = 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: _thumbSize,
|
width: thumbSize,
|
||||||
height: 56,
|
height: widget.thumbSize ?? 52,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
color: Colors.white,
|
||||||
colors: [Colors.orangeAccent, Colors.deepOrange],
|
borderRadius: BorderRadius.circular((widget.thumbSize ?? 52) / 2),
|
||||||
begin: Alignment.topLeft,
|
boxShadow: const [
|
||||||
end: Alignment.bottomRight,
|
BoxShadow(color: Colors.black38, blurRadius: 8, offset: Offset(0, 4)),
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(color: Colors.black45, blurRadius: 4, offset: const Offset(2, 2)),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Center(
|
child: Icon(Icons.arrow_forward_ios, color: accentEnd, size: 20),
|
||||||
child: Icon(Icons.key, color: Colors.white, size: 24),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
34
pubspec.lock
34
pubspec.lock
|
|
@ -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:
|
||||||
|
|
@ -305,13 +313,21 @@ packages:
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.1"
|
version: "1.0.1"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.6.0"
|
version: "1.6.0"
|
||||||
|
http_methods:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: http_methods
|
||||||
|
sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.1"
|
||||||
http_parser:
|
http_parser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -784,6 +800,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.1"
|
||||||
|
shelf:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf
|
||||||
|
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.2"
|
||||||
|
shelf_router:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: shelf_router
|
||||||
|
sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.1.4"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,10 @@ 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
|
||||||
|
http: ^1.2.2
|
||||||
|
shelf: ^1.4.1
|
||||||
|
shelf_router: ^1.1.4
|
||||||
|
|
||||||
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."
|
||||||
|
|
@ -9,11 +9,13 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
import 'package:h_1/main.dart';
|
import 'package:h_1/main.dart';
|
||||||
|
import 'package:h_1/utils/build_expiry_info.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(const MyApp());
|
final expiryInfo = BuildExpiryInfo.fromEnvironment();
|
||||||
|
await tester.pumpWidget(MyApp(expiryInfo: expiryInfo));
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
// Verify that our counter starts at 0.
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(find.text('0'), findsOneWidget);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue