Compare commits

...

5 commits

Author SHA1 Message Date
joe
81fe44a4b0 お局様実装 2026-03-02 11:06:47 +09:00
joe
a569f54b0b ChatServer 2026-03-01 20:45:29 +09:00
joe
3ddd56760a 虫取り/表示周り中心の作業を反映 2026-03-01 17:07:08 +09:00
joe
e8382db72f README.md刷新 2026-03-01 16:47:34 +09:00
joe
01f5851ddc smtp実装と寿命実装 2026-03-01 15:59:30 +09:00
55 changed files with 6045 additions and 980 deletions

129
README.md
View file

@ -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)
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
母艦「お局様」はブリッジモニタリングバックアップに専念し、実務機能は販売アシスト1号側に集約する方針です。TV BOX を母艦に据える運用や、単一端末で両役割を兼務するシナリオも想定しています。
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 / AndroidTermux 等)端末でリポジトリを取得
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 に反映していきます。

View file

@ -28,6 +28,10 @@
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
<intent>
<action android:name="android.intent.action.SEND" />
<data android:mimeType="*/*" />
</intent>
</queries>
<application

View 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);
});
}

View 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 UIHTML/JS or Flutter Web`/dashboard` で提供。
5. バックアップ ZIP と世代管理ロジックを追加。
6. 将来の拡張Google Drive 連携、Cloudflare/S SH/Tunnelingをこの土台に差し込む。
## 今後の課題
- 認証方式LAN 内とはいえ API キーやシグネチャを検討)。
- データ移行/マイグレーション(年度パック+最終ハッシュの保持方法)。
- フロントエンドからモジュール追加を制御するインターフェイス。
- チャット/通知の同期待ち行列設計。

View file

@ -12,6 +12,12 @@
@import flutter_contacts;
#endif
#if __has_include(<flutter_email_sender/FlutterEmailSenderPlugin.h>)
#import <flutter_email_sender/FlutterEmailSenderPlugin.h>
#else
@import flutter_email_sender;
#endif
#if __has_include(<geolocator_apple/GeolocatorPlugin.h>)
#import <geolocator_apple/GeolocatorPlugin.h>
#else
@ -82,6 +88,7 @@
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
[FlutterContactsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterContactsPlugin"]];
[FlutterEmailSenderPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterEmailSenderPlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
[MobileScannerPlugin registerWithRegistrar:[registry registrarForPlugin:@"MobileScannerPlugin"]];

View 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;
}
}

View 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 = ['普通', '当座', '貯蓄'];

View 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;
}

View 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;
}

View file

@ -1,85 +1,320 @@
// lib/main.dart
// version: 1.5.02 (Update: Date selection & Tax fix)
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// --- ---
import 'models/invoice_models.dart'; // Invoice, InvoiceItem
import 'screens/invoice_input_screen.dart'; //
import 'screens/invoice_detail_page.dart'; //
import 'screens/invoice_history_screen.dart'; //
import 'screens/dashboard_screen.dart'; //
import 'services/location_service.dart'; //
import 'services/customer_repository.dart'; //
import 'services/app_settings_repository.dart';
import 'services/chat_sync_scheduler.dart';
import 'services/mothership_client.dart';
import 'services/theme_controller.dart';
import 'utils/build_expiry_info.dart';
void main() {
runApp(const MyApp());
void main() async {
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 {
const MyApp({super.key});
class MyApp extends StatefulWidget {
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
Widget build(BuildContext context) {
return MaterialApp(
title: '販売アシスト1号',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
primary: Colors.indigo.shade700,
secondary: Colors.deepOrange.shade400,
surface: Colors.grey.shade50,
onSurface: Colors.blueGrey.shade900,
),
scaffoldBackgroundColor: Colors.grey.shade50,
appBarTheme: AppBarTheme(
backgroundColor: Colors.indigo.shade700,
foregroundColor: Colors.white,
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
return ValueListenableBuilder<ThemeMode>(
valueListenable: AppThemeController.instance.notifier,
builder: (context, mode, _) => MaterialApp(
title: '販売アシスト1号',
navigatorObservers: [
_ZoomResetObserver(_zoomController),
],
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo.shade700).copyWith(
primary: Colors.indigo.shade700,
secondary: Colors.deepOrange.shade400,
surface: Colors.grey.shade100,
onSurface: Colors.blueGrey.shade900,
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: BorderSide(color: Colors.indigo.shade700),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
scaffoldBackgroundColor: Colors.grey.shade100,
appBarTheme: AppBarTheme(
backgroundColor: Colors.indigo.shade700,
foregroundColor: Colors.white,
elevation: 0,
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4),
),
),
visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true,
fontFamily: 'IPAexGothic',
),
builder: (context, child) {
final mq = MediaQuery.of(context);
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: InteractiveViewer(
panEnabled: false,
scaleEnabled: true,
minScale: 0.8,
maxScale: 4.0,
child: child ?? const SizedBox.shrink(),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
),
);
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: BorderSide(color: Colors.indigo.shade700),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
),
inputDecorationTheme: InputDecorationTheme(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.indigo.shade700, width: 1.4),
),
),
visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true,
fontFamily: 'IPAexGothic',
),
darkTheme: ThemeData(
brightness: Brightness.dark,
colorScheme: ColorScheme(
brightness: Brightness.dark,
primary: const Color(0xFF66D9EF),
onPrimary: const Color(0xFF1E1F1C),
secondary: const Color(0xFFF92672),
onSecondary: const Color(0xFF1E1F1C),
surface: const Color(0xFF272822),
onSurface: const Color(0xFFF8F8F2),
error: Colors.red.shade300,
onError: const Color(0xFF1E1F1C),
),
scaffoldBackgroundColor: const Color(0xFF272822),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF32332A),
foregroundColor: Color(0xFFF8F8F2),
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF66D9EF),
foregroundColor: const Color(0xFF1E1F1C),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
side: const BorderSide(color: Color(0xFF66D9EF)),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
textStyle: const TextStyle(fontWeight: FontWeight.bold),
),
),
inputDecorationTheme: const InputDecorationTheme(
filled: true,
fillColor: Color(0xFF32332A),
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide(color: Color(0xFF66D9EF), width: 1.4),
),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: Color(0xFF32332A),
contentTextStyle: TextStyle(color: Color(0xFFF8F8F2)),
),
visualDensity: VisualDensity.adaptivePlatformDensity,
useMaterial3: true,
fontFamily: 'IPAexGothic',
),
themeMode: mode,
builder: (context, child) {
final mq = MediaQuery.of(context);
return Listener(
onPointerDown: (_) => setState(() => _activePointers++),
onPointerUp: (_) => setState(() => _activePointers = (_activePointers - 1).clamp(0, 10)),
onPointerCancel: (_) => setState(() => _activePointers = (_activePointers - 1).clamp(0, 10)),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusScope.of(context).unfocus(),
child: AnimatedPadding(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: EdgeInsets.only(bottom: mq.viewInsets.bottom),
child: InteractiveViewer(
panEnabled: false,
scaleEnabled: true,
minScale: 0.8,
maxScale: 4.0,
transformationController: _zoomController,
child: IgnorePointer(
ignoring: _activePointers > 1,
child: child ?? const SizedBox.shrink(),
),
),
),
),
);
},
home: const _HomeDecider(),
),
);
}
}
class ExpiredApp extends StatelessWidget {
final BuildExpiryInfo expiryInfo;
const ExpiredApp({super.key, required this.expiryInfo});
String _format(DateTime? timestamp) {
if (timestamp == null) return '不明';
final local = timestamp.toLocal();
String two(int v) => v.toString().padLeft(2, '0');
return '${local.year}/${two(local.month)}/${two(local.day)} ${two(local.hour)}:${two(local.minute)}';
}
@override
Widget build(BuildContext context) {
final buildText = _format(expiryInfo.buildTimestamp);
final expiryText = _format(expiryInfo.expiryTimestamp);
return MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.lock_clock, size: 72, color: Colors.white),
const SizedBox(height: 24),
const Text(
'このビルドは有効期限を過ぎています',
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text('ビルド日時: $buildText', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 4),
Text('有効期限: $expiryText', style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 24),
const Text(
'最新版を取得してインストールしてください。',
style: TextStyle(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: Colors.black87),
onPressed: () => SystemNavigator.pop(),
icon: const Icon(Icons.exit_to_app),
label: const Text('アプリを終了する'),
),
],
),
),
),
),
);
}
}
class _ZoomResetObserver extends NavigatorObserver {
final TransformationController controller;
_ZoomResetObserver(this.controller);
void _reset() {
controller.value = Matrix4.identity();
}
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
_reset();
}
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
_reset();
}
@override
void didReplace({Route? newRoute, Route? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
_reset();
}
}
class _HomeDecider extends StatefulWidget {
const _HomeDecider();
@override
State<_HomeDecider> createState() => _HomeDeciderState();
}
class _HomeDeciderState extends State<_HomeDecider> {
final _settings = AppSettingsRepository();
late Future<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(),
);
}
}

View 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,
);
}
}

View file

@ -13,6 +13,7 @@ class Customer {
final bool isSynced; //
final DateTime updatedAt; //
final bool isLocked; //
final bool isHidden; //
final String? headChar1; // 1
final String? headChar2; // 2
@ -30,6 +31,7 @@ class Customer {
this.isSynced = false,
DateTime? updatedAt,
this.isLocked = false,
this.isHidden = false,
this.headChar1,
this.headChar2,
}) : updatedAt = updatedAt ?? DateTime.now();
@ -57,6 +59,7 @@ class Customer {
'head_char2': headChar2,
'is_locked': isLocked ? 1 : 0,
'is_synced': isSynced ? 1 : 0,
'is_hidden': isHidden ? 1 : 0,
'updated_at': updatedAt.toIso8601String(),
};
}
@ -75,6 +78,7 @@ class Customer {
odooId: map['odoo_id'],
isLocked: (map['is_locked'] ?? 0) == 1,
isSynced: map['is_synced'] == 1,
isHidden: (map['is_hidden'] ?? 0) == 1,
updatedAt: DateTime.parse(map['updated_at']),
headChar1: map['head_char1'],
headChar2: map['head_char2'],
@ -93,6 +97,7 @@ class Customer {
bool? isSynced,
DateTime? updatedAt,
bool? isLocked,
bool? isHidden,
String? email,
int? contactVersionId,
String? headChar1,
@ -112,6 +117,7 @@ class Customer {
isSynced: isSynced ?? this.isSynced,
updatedAt: updatedAt ?? this.updatedAt,
isLocked: isLocked ?? this.isLocked,
isHidden: isHidden ?? this.isHidden,
headChar1: headChar1 ?? this.headChar1,
headChar2: headChar2 ?? this.headChar2,
);

View file

@ -66,6 +66,10 @@ enum DocumentType {
}
class Invoice {
static const String lockStatement =
'正式発行ボタン押下時にこの伝票はロックされ、以後の編集・削除はできません。ロック状態はハッシュチェーンで保護されます。';
static const String hashDescription =
'metaJson = JSON.stringify({id, invoiceNumber, customer, date, total, documentType, hash, lockStatement, companySnapshot, companySealHash}); metaHash = SHA-256(metaJson).';
final String id;
final Customer customer;
final DateTime date;
@ -88,6 +92,10 @@ class Invoice {
final String? contactEmailSnapshot;
final String? contactTelSnapshot;
final String? contactAddressSnapshot;
final String? companySnapshot; // :
final String? companySealHash; // :
final String? metaJson;
final String? metaHash;
Invoice({
String? id,
@ -112,6 +120,10 @@ class Invoice {
this.contactEmailSnapshot,
this.contactTelSnapshot,
this.contactAddressSnapshot,
this.companySnapshot,
this.companySealHash,
this.metaJson,
this.metaHash,
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
terminalId = terminalId ?? "T1", // ID
updatedAt = updatedAt ?? DateTime.now();
@ -132,6 +144,13 @@ class Invoice {
}
}
static const Map<DocumentType, String> _docTypeShortLabel = {
DocumentType.estimation: '見積',
DocumentType.delivery: '納品',
DocumentType.invoice: '請求',
DocumentType.receipt: '領収',
};
String get invoiceNumberPrefix {
switch (documentType) {
case DocumentType.estimation: return "EST";
@ -143,13 +162,74 @@ class Invoice {
String get invoiceNumber => "$invoiceNumberPrefix-$terminalId-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
//
String get customerNameForDisplay => customerFormalNameSnapshot ?? customer.formalName;
//
String get customerNameForDisplay {
final base = customerFormalNameSnapshot ?? customer.formalName;
final hasHonorific = RegExp(r'(様|御中|殿)$').hasMatch(base);
return hasHonorific ? base : '$base ${customer.title}';
}
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
int get tax => (subtotal * taxRate).floor();
int get totalAmount => subtotal + tax;
String get _projectLabel {
if (subject != null && subject!.trim().isNotEmpty) {
return subject!.trim();
}
return '案件';
}
String get mailTitleCore {
final dateStr = DateFormat('yyyyMMdd').format(date);
final docLabel = _docTypeShortLabel[documentType] ?? documentTypeName.replaceAll('', '');
final customerCompact = customerNameForDisplay.replaceAll(RegExp(r'\s+'), '');
final amountStr = NumberFormat('#,###').format(totalAmount);
final buffer = StringBuffer()
..write(dateStr)
..write('($docLabel)')
..write(_projectLabel)
..write('@')
..write(customerCompact)
..write('_')
..write(amountStr)
..write('');
final raw = buffer.toString();
return _sanitizeForFile(raw);
}
String get mailAttachmentFileName => '$mailTitleCore.PDF';
String get mailBodyText => '請求書をお送りします。ご確認ください。';
static String _sanitizeForFile(String input) {
var sanitized = input.replaceAll(RegExp(r'[\\/:*?"<>|]'), '-');
sanitized = sanitized.replaceAll(RegExp(r'[\r\n]+'), '');
sanitized = sanitized.replaceAll(' ', '');
sanitized = sanitized.replaceAll(' ', '');
return sanitized;
}
Map<String, dynamic> metaPayload() {
return {
'id': id,
'invoiceNumber': invoiceNumber,
'customer': customerNameForDisplay,
'date': date.toIso8601String(),
'total': totalAmount,
'documentType': documentType.name,
'hash': contentHash,
'lockStatement': lockStatement,
'hashDescription': hashDescription,
'companySnapshot': companySnapshot,
'companySealHash': companySealHash,
};
}
String get metaJsonValue => metaJson ?? jsonEncode(metaPayload());
String get metaHashValue => metaHash ?? sha256.convert(utf8.encode(metaJsonValue)).toString();
Map<String, dynamic> toMap() {
return {
'id': id,
@ -175,6 +255,10 @@ class Invoice {
'contact_email_snapshot': contactEmailSnapshot,
'contact_tel_snapshot': contactTelSnapshot,
'contact_address_snapshot': contactAddressSnapshot,
'company_snapshot': companySnapshot,
'company_seal_hash': companySealHash,
'meta_json': metaJsonValue,
'meta_hash': metaHashValue,
};
}
@ -201,6 +285,10 @@ class Invoice {
String? contactEmailSnapshot,
String? contactTelSnapshot,
String? contactAddressSnapshot,
String? companySnapshot,
String? companySealHash,
String? metaJson,
String? metaHash,
}) {
return Invoice(
id: id ?? this.id,
@ -225,6 +313,10 @@ class Invoice {
contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot,
contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot,
contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot,
companySnapshot: companySnapshot ?? this.companySnapshot,
companySealHash: companySealHash ?? this.companySealHash,
metaJson: metaJson ?? this.metaJson,
metaHash: metaHash ?? this.metaHash,
);
}

View file

@ -7,6 +7,7 @@ class Product {
final int stockQuantity; //
final String? odooId;
final bool isLocked; //
final bool isHidden; //
Product({
required this.id,
@ -17,6 +18,7 @@ class Product {
this.stockQuantity = 0, //
this.odooId,
this.isLocked = false,
this.isHidden = false,
});
Map<String, dynamic> toMap() {
@ -29,6 +31,7 @@ class Product {
'stock_quantity': stockQuantity, //
'is_locked': isLocked ? 1 : 0,
'odoo_id': odooId,
'is_hidden': isHidden ? 1 : 0,
};
}
@ -42,6 +45,7 @@ class Product {
stockQuantity: map['stock_quantity'] ?? 0, //
isLocked: (map['is_locked'] ?? 0) == 1,
odooId: map['odoo_id'],
isHidden: (map['is_hidden'] ?? 0) == 1,
);
}
@ -50,15 +54,22 @@ class Product {
String? name,
int? defaultUnitPrice,
String? barcode,
String? category,
int? stockQuantity,
String? odooId,
bool? isLocked,
bool? isHidden,
}) {
return Product(
id: id ?? this.id,
name: name ?? this.name,
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
barcode: barcode ?? this.barcode,
category: category ?? this.category,
stockQuantity: stockQuantity ?? this.stockQuantity,
odooId: odooId ?? this.odooId,
isLocked: isLocked ?? this.isLocked,
isHidden: isHidden ?? this.isHidden,
);
}
}

View 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');
}
}

View 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;
}

View 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
View 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'});
}
}

View 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();
}
}

View 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),
),
],
),
),
),
],
);
}
}

View file

@ -71,6 +71,7 @@ class _CompanyInfoScreenState extends State<CompanyInfoScreen> {
if (_isLoading) return const Scaffold(body: Center(child: CircularProgressIndicator()));
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
title: const Text("F1:自社情報"),
backgroundColor: Colors.indigo,

View file

@ -6,11 +6,13 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';
import '../models/customer_model.dart';
import '../services/customer_repository.dart';
import '../widgets/contact_picker_sheet.dart';
class CustomerMasterScreen extends StatefulWidget {
final bool selectionMode;
final bool showHidden;
const CustomerMasterScreen({super.key, this.selectionMode = false});
const CustomerMasterScreen({super.key, this.selectionMode = false, this.showHidden = false});
@override
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
@ -87,6 +89,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
}
Future<void> _showContactUpdateDialog(Customer customer) async {
if (customer.isLocked) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('ロック中の顧客は連絡先を更新できません')));
}
return;
}
final emailController = TextEditingController(text: customer.email ?? "");
final telController = TextEditingController(text: customer.tel ?? "");
final addressController = TextEditingController(text: customer.address ?? "");
@ -130,7 +138,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
Future<void> _loadCustomers() async {
setState(() => _isLoading = true);
try {
final customers = await _customerRepo.getAllCustomers();
final customers = await _customerRepo.getAllCustomers(includeHidden: widget.showHidden);
if (!mounted) return;
setState(() {
_customers = customers;
@ -149,13 +157,20 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
List<Customer> list = _customers.where((c) {
return c.displayName.toLowerCase().contains(query) || c.formalName.toLowerCase().contains(query);
}).toList();
if (!widget.showHidden) {
list = list.where((c) => !c.isHidden).toList();
}
// Kana filtering disabled temporarily for stability
switch (_sortKey) {
case 'name_desc':
list.sort((a, b) => _normalizedName(b.displayName).compareTo(_normalizedName(a.displayName)));
list.sort((a, b) => widget.showHidden
? b.id.compareTo(a.id)
: _normalizedName(b.displayName).compareTo(_normalizedName(a.displayName)));
break;
default:
list.sort((a, b) => _normalizedName(a.displayName).compareTo(_normalizedName(b.displayName)));
list.sort((a, b) => widget.showHidden
? b.id.compareTo(a.id)
: _normalizedName(a.displayName).compareTo(_normalizedName(b.displayName)));
}
_filtered = list;
}
@ -205,16 +220,6 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
late final Map<String, String> _defaultKanaMap = _buildDefaultKanaMap();
String _normalizeIndexChar(String input) {
var s = input.replaceAll(RegExp(r"\s+|\u3000"), "");
if (s.isEmpty) return '';
String ch = s.characters.first;
final code = ch.codeUnitAt(0);
if (code >= 0x30A1 && code <= 0x30F6) {
ch = String.fromCharCode(code - 0x60); // katakana -> hiragana
}
return ch;
}
Future<void> _addOrEditCustomer({Customer? customer}) async {
final isEdit = customer != null;
@ -244,26 +249,8 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final Contact? picked = await showModalBottomSheet<Contact>(
context: context,
isScrollControlled: true,
builder: (ctx) => SafeArea(
child: SizedBox(
height: MediaQuery.of(ctx).size.height * 0.6,
child: ListView.builder(
itemCount: contacts.length,
itemBuilder: (_, i) {
final c = contacts[i];
final orgCompany = c.organizations.isNotEmpty ? c.organizations.first.company : '';
final personParts = [c.name.last, c.name.first].where((v) => v.isNotEmpty).toList();
final person = personParts.isNotEmpty ? personParts.join(' ').trim() : c.displayName;
final label = orgCompany.isNotEmpty ? orgCompany : person;
return ListTile(
title: Text(label),
subtitle: person.isNotEmpty ? Text(person) : null,
onTap: () => Navigator.pop(ctx, c),
);
},
),
),
),
backgroundColor: Colors.transparent,
builder: (ctx) => ContactPickerSheet(contacts: contacts, title: isEdit ? '電話帳から上書き' : '電話帳から新規入力'),
);
if (!mounted) return;
if (picked != null) {
@ -404,22 +391,25 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
TextButton(
onPressed: () {
if (displayNameController.text.isEmpty || formalNameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("表示名と正式名称は必須です")));
return;
}
final head1 = _normalizeIndexChar(head1Controller.text);
final head2 = _normalizeIndexChar(head2Controller.text);
final head1 = head1Controller.text.trim();
final head2 = head2Controller.text.trim();
final locked = customer?.isLocked ?? false;
final newId = locked ? const Uuid().v4() : (customer?.id ?? const Uuid().v4());
final newCustomer = Customer(
id: customer?.id ?? const Uuid().v4(),
displayName: displayNameController.text,
formalName: formalNameController.text,
id: newId,
displayName: displayNameController.text.trim(),
formalName: formalNameController.text.trim(),
title: selectedTitle,
department: departmentController.text.isEmpty ? null : departmentController.text,
address: addressController.text.isEmpty ? null : addressController.text,
tel: telController.text.isEmpty ? null : telController.text,
email: emailController.text.isEmpty ? null : emailController.text,
headChar1: head1.isEmpty ? _headKana(displayNameController.text) : head1,
department: departmentController.text.trim().isEmpty ? null : departmentController.text.trim(),
address: addressController.text.trim().isEmpty ? null : addressController.text.trim(),
tel: telController.text.trim().isEmpty ? null : telController.text.trim(),
email: emailController.text.trim().isEmpty ? null : emailController.text.trim(),
headChar1: head1.isEmpty ? null : head1,
headChar2: head2.isEmpty ? null : head2,
isLocked: customer?.isLocked ?? false,
isLocked: false,
);
Navigator.pop(context, newCustomer);
},
@ -783,7 +773,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
),
title: Text(c.displayName, style: TextStyle(fontWeight: FontWeight.bold, color: c.isLocked ? Colors.grey : Colors.black87)),
subtitle: Text("${c.formalName} ${c.title}"),
onTap: widget.selectionMode ? () => Navigator.pop(context, c) : () => _showDetailPane(c),
onTap: widget.selectionMode
? () {
if (c.isHidden) return; // do not select hidden
Navigator.pop(context, c);
}
: () => _showDetailPane(c),
trailing: widget.selectionMode
? null
: IconButton(
@ -861,23 +856,33 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
leading: const Icon(Icons.edit),
title: const Text('編集'),
enabled: !c.isLocked,
onTap: c.isLocked
? null
: () {
Navigator.pop(context);
_addOrEditCustomer(customer: c);
},
onTap: () {
Navigator.pop(context);
_addOrEditCustomer(customer: c);
},
),
ListTile(
leading: const Icon(Icons.contact_mail),
title: const Text('連絡先を更新'),
enabled: !c.isLocked,
onTap: () {
if (c.isLocked) return;
Navigator.pop(context);
_showContactUpdateDialog(c);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.redAccent),
leading: const Icon(Icons.visibility_off),
title: const Text('非表示にする'),
onTap: () async {
Navigator.pop(context);
await _customerRepo.setHidden(c.id, true);
if (!mounted) return;
_loadCustomers();
},
),
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),
title: const Text('削除', style: TextStyle(color: Colors.redAccent)),
enabled: !c.isLocked,
onTap: c.isLocked
@ -957,10 +962,12 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
_showContactUpdateSheet(c);
},
onPressed: c.isLocked
? null
: () {
Navigator.pop(context);
_showContactUpdateSheet(c);
},
icon: const Icon(Icons.contact_mail),
label: const Text("連絡先を更新"),
),
@ -1018,7 +1025,9 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
ListTile(
leading: const Icon(Icons.contact_mail),
title: const Text('連絡先を更新'),
enabled: !c.isLocked,
onTap: () {
if (c.isLocked) return;
Navigator.pop(context);
_showContactUpdateDialog(c);
},

View file

@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart';
import '../models/customer_model.dart';
import '../services/customer_repository.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../widgets/contact_picker_sheet.dart';
///
class CustomerPickerModal extends StatefulWidget {
@ -55,7 +56,8 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
final Contact? selectedContact = await showModalBottomSheet<Contact?>(
context: context,
isScrollControlled: true,
builder: (context) => _PhoneContactListSelector(contacts: contacts),
backgroundColor: Colors.transparent,
builder: (context) => ContactPickerSheet(contacts: contacts, title: '電話帳から顧客候補を選択'),
);
if (!context.mounted) return;
@ -316,66 +318,3 @@ class _CustomerPickerModalState extends State<CustomerPickerModal> {
}
}
///
class _PhoneContactListSelector extends StatefulWidget {
final List<Contact> contacts;
const _PhoneContactListSelector({required this.contacts});
@override
State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState();
}
class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> {
List<Contact> _filtered = [];
final _searchController = TextEditingController();
@override
void initState() {
super.initState();
_filtered = widget.contacts;
}
void _onSearch(String q) {
setState(() {
_filtered = widget.contacts
.where((c) {
final org = c.organizations.isNotEmpty ? c.organizations.first.company : '';
final label = org.isNotEmpty ? org : c.displayName;
return label.toLowerCase().contains(q.toLowerCase());
})
.toList();
});
}
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
heightFactor: 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)),
onChanged: _onSearch,
),
),
Expanded(
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (context, index) => ListTile(
title: Text(
_filtered[index].organizations.isNotEmpty && _filtered[index].organizations.first.company.isNotEmpty
? _filtered[index].organizations.first.company
: _filtered[index].displayName,
),
onTap: () => Navigator.pop(context, _filtered[index]),
),
),
),
],
),
);
}
}

View 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,
};

View 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,
],
),
),
);
}
}

View file

@ -12,6 +12,7 @@ import '../services/company_repository.dart';
import 'product_picker_modal.dart';
import '../models/company_model.dart';
import '../widgets/keyboard_inset_wrapper.dart';
import '../services/app_settings_repository.dart';
class _DetailSnapshot {
final String formalName;
@ -31,6 +32,25 @@ class _DetailSnapshot {
});
}
class _DraftBadge extends StatelessWidget {
const _DraftBadge();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'下書き',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
),
);
}
}
List<InvoiceItem> _cloneItemsDetail(List<InvoiceItem> source) {
return source
.map((e) => InvoiceItem(
@ -63,14 +83,16 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
late double _taxRate; //
late bool _includeTax; //
String? _currentFilePath;
final _invoiceRepo = InvoiceRepository();
final _customerRepo = CustomerRepository();
final _companyRepo = CompanyRepository();
final InvoiceRepository _invoiceRepo = InvoiceRepository();
final CustomerRepository _customerRepo = CustomerRepository();
final CompanyRepository _companyRepo = CompanyRepository();
final AppSettingsRepository _settingsRepo = AppSettingsRepository(); //
CompanyInfo? _companyInfo;
bool _showFormalWarning = true;
final List<_DetailSnapshot> _undoStack = [];
final List<_DetailSnapshot> _redoStack = [];
bool _isApplyingSnapshot = false;
bool _summaryIsBlue = false; //
@override
void initState() {
@ -84,6 +106,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
_includeTax = _currentInvoice.taxRate > 0; //
_isEditing = false;
_loadCompanyInfo();
_loadSummaryTheme();
}
Future<void> _loadSummaryTheme() async {
final saved = await _settingsRepo.getSummaryTheme();
if (!mounted) return;
setState(() => _summaryIsBlue = saved == 'blue');
}
Future<void> _loadCompanyInfo() async {
@ -186,12 +215,39 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
SharePlus.instance.share(ShareParams(text: csvData, subject: '請求書データ_CSV'));
}
Future<void> _pickSummaryColor() async {
final selected = await showModalBottomSheet<String>(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.palette, color: Colors.indigo),
title: const Text('インディゴ'),
onTap: () => Navigator.pop(context, 'blue'),
),
ListTile(
leading: const Icon(Icons.palette, color: Colors.grey),
title: const Text(''),
onTap: () => Navigator.pop(context, 'white'),
),
],
),
),
);
if (selected == null) return;
setState(() => _summaryIsBlue = selected == 'blue');
await _settingsRepo.setSummaryTheme(selected);
}
@override
Widget build(BuildContext context) {
final fmt = NumberFormat("#,###");
final isDraft = _currentInvoice.isDraft;
final docTypeName = _currentInvoice.documentTypeName;
final themeColor = Colors.white; //
final themeColor = Theme.of(context).scaffoldBackgroundColor;
final textColor = Colors.black87;
final locked = _currentInvoice.isLocked;
@ -292,18 +348,18 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: Colors.indigo.shade800,
color: Colors.indigo, //
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.indigo.shade900),
border: Border.all(color: Colors.indigo.shade700),
),
child: Row(
children: [
const Icon(Icons.edit_note, color: Colors.white70),
const SizedBox(width: 8),
Expanded(
const Expanded(
child: Text(
"下書き: 未確定・PDFは正式発行で確定",
style: const TextStyle(color: Colors.white70),
"未確定・PDFは正式発行で確定",
style: TextStyle(color: Colors.white70),
),
),
const SizedBox(width: 8),
@ -314,7 +370,7 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
borderRadius: BorderRadius.circular(16),
),
child: Text(
"下書${docTypeName}",
"下書$docTypeName",
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12),
),
),
@ -406,8 +462,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -417,25 +480,34 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)),
if (_currentInvoice.subject?.isNotEmpty ?? false) ...[
const SizedBox(height: 6),
Text("件名: ${_currentInvoice.subject}",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo)),
],
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
if ((_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address) != null)
Text("住所: ${_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address}", style: TextStyle(color: textColor)),
if ((_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel) != null)
Text("TEL: ${_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel}", style: TextStyle(color: textColor)),
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
const SizedBox(height: 6),
const SizedBox(height: 8),
const Text("件名", style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black54)),
const SizedBox(height: 2),
Text(
"備考: ${_currentInvoice.notes}",
style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())),
_currentInvoice.subject!,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.indigo),
),
],
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 14, color: textColor)),
if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null)
Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)),
if (_currentInvoice.notes?.isNotEmpty ?? false) ...[
const SizedBox(height: 6),
Text(
"備考: ${_currentInvoice.notes}",
style: TextStyle(color: textColor.withAlpha((0.9 * 255).round())),
),
],
],
),
),
],
),
),
@ -520,32 +592,43 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
final int tax = (subtotal * currentTaxRate).floor();
final int total = subtotal + tax;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.indigo,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummaryRow("小計", "${formatter.format(subtotal)}", Colors.white70),
if (currentTaxRate > 0) ...[
const Divider(color: Colors.white24),
if (_companyInfo?.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "${formatter.format(tax)}", Colors.white70),
if (_companyInfo?.taxDisplayMode == 'text_only')
_buildSummaryRow("消費税", "(税別)", Colors.white70),
final bool useBlue = _summaryIsBlue;
final Color bgColor = useBlue ? Colors.indigo : Colors.white;
final Color borderColor = useBlue ? Colors.transparent : Colors.grey.shade300;
final Color labelColor = useBlue ? Colors.white70 : Colors.black87;
final Color totalColor = useBlue ? Colors.white : Colors.black87;
final Color dividerColor = useBlue ? Colors.white24 : Colors.grey.shade300;
return GestureDetector(
onLongPress: _pickSummaryColor,
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSummaryRow("小計", "${formatter.format(subtotal)}", labelColor),
if (currentTaxRate > 0) ...[
Divider(color: dividerColor),
if (_companyInfo?.taxDisplayMode == 'normal')
_buildSummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", "${formatter.format(tax)}", labelColor),
if (_companyInfo?.taxDisplayMode == 'text_only')
_buildSummaryRow("消費税", "(税別)", labelColor),
],
Divider(color: dividerColor),
_buildSummaryRow(
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
"${formatter.format(total)}",
totalColor,
isTotal: true,
),
],
const Divider(color: Colors.white24),
_buildSummaryRow(
currentTaxRate > 0 ? "合計金額 (税込)" : "合計金額",
"${formatter.format(total)}",
Colors.white,
isTotal: true,
),
],
),
),
);
}
@ -721,7 +804,13 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("この下書き伝票を「確定」として正式に発行しますか?"),
Row(
children: const [
_DraftBadge(),
SizedBox(width: 8),
Expanded(child: Text("この伝票を「確定」として正式に発行しますか?")),
],
),
const SizedBox(height: 8),
if (showWarning)
Container(
@ -785,7 +874,15 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
children: [
const Icon(Icons.drafts, color: Colors.orange),
const SizedBox(width: 12),
const Expanded(child: Text("下書き状態として保持", style: TextStyle(fontWeight: FontWeight.bold))),
const Expanded(
child: Row(
children: [
_DraftBadge(),
SizedBox(width: 8),
Text("状態として保持", style: TextStyle(fontWeight: FontWeight.bold)),
],
),
),
Switch(
value: _currentInvoice.isDraft,
onChanged: (val) {

View file

@ -25,86 +25,171 @@ class InvoiceHistoryItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListTile(
tileColor: invoice.isDraft ? Colors.orange.shade50 : null,
leading: CircleAvatar(
backgroundColor: invoice.isDraft
? Colors.orange.shade100
: (isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200),
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: Icon(
invoice.isDraft ? Icons.edit_note : Icons.description_outlined,
color: invoice.isDraft
? Colors.orange
: (isUnlocked ? Colors.indigo : Colors.grey),
final cardColor = invoice.isDraft ? Colors.orange.shade50 : Colors.white;
final iconBg = isUnlocked
? _docTypeColor(invoice.documentType).withValues(alpha: 0.18)
: Colors.grey.shade200;
final iconColor = isUnlocked ? _docTypeColor(invoice.documentType) : Colors.grey;
final hasSubject = invoice.subject?.isNotEmpty ?? false;
final firstItemDesc = invoice.items.isNotEmpty ? invoice.items.first.description : '';
final othersCount = invoice.items.length > 1 ? invoice.items.length - 1 : 0;
final subjectLine = hasSubject ? invoice.subject! : firstItemDesc;
final subjectDisplay = hasSubject
? subjectLine
: (othersCount > 0 ? "$subjectLine$othersCount件" : subjectLine);
final customerName = invoice.customerNameForDisplay.endsWith('')
? invoice.customerNameForDisplay
: '${invoice.customerNameForDisplay}';
final subjectColor = invoice.isLocked ? Colors.grey.shade500 : Colors.indigo.shade700;
final amountColor = invoice.isLocked ? Colors.grey.shade500 : Colors.black87;
final dateColor = Colors.black87;
return Card(
color: cardColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
elevation: invoice.isDraft ? 1.5 : 0.5,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
onLongPress: onLongPress,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
backgroundColor: iconBg,
child: Stack(
children: [
Align(
alignment: Alignment.center,
child: Icon(
_docTypeIcon(invoice.documentType),
color: iconColor,
),
),
if (invoice.isLocked)
const Align(
alignment: Alignment.bottomRight,
child: Icon(Icons.lock, size: 14, color: Colors.redAccent),
),
],
),
),
),
if (invoice.isLocked)
const Align(
alignment: Alignment.bottomRight,
child: Icon(Icons.lock, size: 14, color: Colors.redAccent),
const SizedBox(width: 12),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
customerName,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: invoice.isLocked ? Colors.grey : Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
Text(
subjectDisplay,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: subjectColor,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (invoice.isDraft)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: Colors.orange.shade100,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'下書き',
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: Colors.orange),
),
),
Text(
dateFormatter.format(invoice.date),
style: TextStyle(fontSize: 12, color: dateColor),
),
],
),
const SizedBox(height: 2),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 140),
child: Text(
invoice.invoiceNumber,
style: const TextStyle(fontSize: 10.5, color: Colors.grey),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.right,
),
),
const SizedBox(height: 8),
Text(
"${amountFormatter.format(invoice.totalAmount)}",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: amountColor),
textAlign: TextAlign.right,
),
],
),
],
),
),
],
),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invoice.customerNameForDisplay,
style: TextStyle(
fontWeight: FontWeight.bold,
color: invoice.isLocked ? Colors.grey : Colors.black87,
),
],
),
if (invoice.subject?.isNotEmpty ?? false)
Text(
invoice.subject!,
style: TextStyle(
fontSize: 13,
color: Colors.indigo.shade700,
fontWeight: FontWeight.normal,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
trailing: SizedBox(
height: 48,
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"${amountFormatter.format(invoice.totalAmount)}",
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
if (invoice.isSynced)
const Icon(Icons.sync, size: 14, color: Colors.green)
else
const Icon(Icons.sync_disabled, size: 14, color: Colors.orange),
IconButton(
padding: EdgeInsets.zero,
constraints: const BoxConstraints.tightFor(width: 28, height: 24),
icon: const Icon(Icons.edit, size: 16),
tooltip: invoice.isLocked
? "ロック中"
: (isUnlocked ? "編集" : "アンロックして編集"),
onPressed: (invoice.isLocked || !isUnlocked)
? null
: onEdit,
),
],
),
),
onTap: onTap,
onLongPress: onLongPress,
);
}
IconData _docTypeIcon(DocumentType type) {
switch (type) {
case DocumentType.estimation:
return Icons.request_quote;
case DocumentType.delivery:
return Icons.local_shipping;
case DocumentType.invoice:
return Icons.receipt_long;
case DocumentType.receipt:
return Icons.task_alt;
}
}
Color _docTypeColor(DocumentType type) {
switch (type) {
case DocumentType.estimation:
return Colors.blue;
case DocumentType.delivery:
return Colors.teal;
case DocumentType.invoice:
return Colors.indigo;
case DocumentType.receipt:
return Colors.green;
}
}
}

View file

@ -41,7 +41,7 @@ class InvoiceHistoryList extends StatelessWidget {
return ListView.builder(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 120), // FAB分の固定余白
padding: const EdgeInsets.fromLTRB(12, 0, 12, 120), // FAB余白
itemCount: invoices.length,
itemBuilder: (context, index) {
final invoice = invoices[index];

View file

@ -10,6 +10,8 @@ import 'customer_master_screen.dart';
import 'invoice_input_screen.dart';
import 'settings_screen.dart';
import 'company_info_screen.dart';
import 'dashboard_screen.dart';
import '../services/app_settings_repository.dart';
import '../widgets/slide_to_unlock.dart';
// InvoiceFlowScreen import removed; using inline type picker
import 'package:package_info_plus/package_info_plus.dart';
@ -17,7 +19,8 @@ import '../widgets/invoice_pdf_preview_page.dart';
import 'invoice_history/invoice_history_list.dart';
class InvoiceHistoryScreen extends StatefulWidget {
const InvoiceHistoryScreen({super.key});
final bool initialUnlocked;
const InvoiceHistoryScreen({super.key, this.initialUnlocked = false});
@override
State<InvoiceHistoryScreen> createState() => _InvoiceHistoryScreenState();
@ -26,6 +29,7 @@ class InvoiceHistoryScreen extends StatefulWidget {
class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
final InvoiceRepository _invoiceRepo = InvoiceRepository();
final CustomerRepository _customerRepo = CustomerRepository();
final AppSettingsRepository _settingsRepo = AppSettingsRepository();
List<Invoice> _invoices = [];
List<Invoice> _filteredInvoices = [];
bool _isLoading = true;
@ -35,12 +39,26 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
DateTime? _startDate;
DateTime? _endDate;
String _appVersion = "1.0.0";
bool _useDashboardHome = false;
@override
void initState() {
super.initState();
_isUnlocked = widget.initialUnlocked;
_loadData();
_loadVersion();
_loadHomeMode();
}
Future<void> _loadHomeMode() async {
final mode = await _settingsRepo.getHomeMode();
if (!mounted) return;
setState(() {
_useDashboardHome = mode == 'dashboard';
if (_useDashboardHome && widget.initialUnlocked) {
_isUnlocked = true;
}
});
}
Future<void> _showInvoiceActions(Invoice invoice) async {
@ -198,69 +216,97 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
final dateFormatter = DateFormat('yyyy/MM/dd');
return Scaffold(
resizeToAvoidBottomInset: false,
drawer: _isUnlocked
? Drawer(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: BoxDecoration(color: Colors.indigo.shade700),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
const Text("メニュー", style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Text("v$_appVersion", style: const TextStyle(color: Colors.white70)),
],
drawer: (_useDashboardHome || !_isUnlocked)
? null
: Drawer(
child: SafeArea(
child: ListView(
padding: EdgeInsets.zero,
children: [
DrawerHeader(
decoration: const BoxDecoration(color: Colors.indigo),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)),
SizedBox(height: 8),
Text("クイックメニュー", style: TextStyle(color: Colors.white70)),
],
),
),
),
ListTile(
leading: const Icon(Icons.receipt_long),
title: const Text("伝票マスター"),
onTap: () {
Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.people),
title: const Text("顧客マスター"),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
},
),
ListTile(
leading: const Icon(Icons.inventory_2),
title: const Text("商品マスター"),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
},
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text("設定"),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen()));
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.admin_panel_settings),
title: const Text("管理メニュー"),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen()));
},
),
],
_drawerHeading("アクション"),
ListTile(
leading: const Icon(Icons.add_circle_outline, color: Colors.indigo),
title: const Text("新しい伝票を作成"),
subtitle: const Text("ドキュメント種別を選択"),
onTap: () {
Navigator.pop(context);
_showCreateTypeMenu();
},
),
_drawerHeading("マスター"),
ListTile(
leading: const Icon(Icons.receipt_long),
title: const Text("伝票マスター"),
onTap: () => Navigator.pop(context),
),
ListTile(
leading: const Icon(Icons.people),
title: const Text("顧客マスター"),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen()));
},
),
ListTile(
leading: const Icon(Icons.inventory_2),
title: const Text("商品マスター"),
onTap: () {
Navigator.pop(context);
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen()));
},
),
_drawerHeading("システム"),
ListTile(
leading: const Icon(Icons.settings),
title: const Text("設定"),
onTap: () {
Navigator.pop(context);
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(
// leading removed
automaticallyImplyLeading: false,
leading: _useDashboardHome
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const DashboardScreen()),
);
},
)
: (_isUnlocked
? Builder(
builder: (ctx) => IconButton(
icon: const Icon(Icons.menu),
onPressed: () => Scaffold.of(ctx).openDrawer(),
),
)
: null),
title: GestureDetector(
onLongPress: () {
Navigator.push(
@ -307,19 +353,41 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
preferredSize: const Size.fromHeight(60),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: TextField(
decoration: InputDecoration(
hintText: "検索 (顧客名、伝票番号...)",
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
isDense: true,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(16),
boxShadow: [
// outer shadow
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
// faux inset highlight
BoxShadow(
color: Colors.white.withValues(alpha: 0.9),
blurRadius: 4,
spreadRadius: -4,
offset: const Offset(-1, -1),
),
],
),
child: TextField(
decoration: InputDecoration(
hintText: "検索 (顧客名、伝票番号...)",
prefixIcon: const Icon(Icons.search),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 10),
),
onChanged: (val) {
_searchQuery = val;
_applyFilterAndSort();
},
),
onChanged: (val) {
_searchQuery = val;
_applyFilterAndSort();
},
),
),
),
@ -327,14 +395,16 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
body: SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: SlideToUnlock(
isLocked: !_isUnlocked,
onUnlocked: _toggleUnlock,
text: "スライドでロック解除",
if (!_useDashboardHome)
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: SlideToUnlock(
isLocked: !_isUnlocked,
lockedText: "A2をロック解除",
unlockedText: "解除済",
onUnlocked: _toggleUnlock,
),
),
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
@ -347,8 +417,9 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailPage(
invoice: invoice,
builder: (context) => InvoiceInputForm(
existingInvoice: invoice,
onInvoiceGenerated: (inv, path) {},
),
),
);
@ -377,7 +448,7 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
onPressed: _isUnlocked
? () => _showCreateTypeMenu()
: _requireUnlock,
label: const Text("規伝票作成"),
label: const Text("しい伝票"),
icon: const Icon(Icons.add),
backgroundColor: Colors.indigo,
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() {
showModalBottomSheet(
context: context,
@ -393,23 +471,23 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.insert_drive_file_outlined),
title: const Text('下書き: 見積書', style: TextStyle(fontSize: 24)),
leading: CircleAvatar(backgroundColor: Colors.blue.withValues(alpha: 0.12), child: const Icon(Icons.request_quote, color: Colors.blue)),
title: const Text('見積書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.estimation),
),
ListTile(
leading: const Icon(Icons.local_shipping_outlined),
title: const Text('下書き: 納品書', style: TextStyle(fontSize: 24)),
leading: CircleAvatar(backgroundColor: Colors.teal.withValues(alpha: 0.12), child: const Icon(Icons.local_shipping, color: Colors.teal)),
title: const Text('納品書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.delivery),
),
ListTile(
leading: const Icon(Icons.request_quote_outlined),
title: const Text('下書き: 請求書', style: TextStyle(fontSize: 24)),
leading: CircleAvatar(backgroundColor: Colors.indigo.withValues(alpha: 0.12), child: const Icon(Icons.receipt_long, color: Colors.indigo)),
title: const Text('請求書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.invoice),
),
ListTile(
leading: const Icon(Icons.receipt_long_outlined),
title: const Text('下書き: 領収書', style: TextStyle(fontSize: 24)),
leading: CircleAvatar(backgroundColor: Colors.green.withValues(alpha: 0.12), child: const Icon(Icons.task_alt, color: Colors.green)),
title: const Text('領収書', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700)),
onTap: () => _startNew(DocumentType.receipt),
),
],
@ -426,6 +504,8 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
builder: (_) => InvoiceInputForm(
onInvoiceGenerated: (inv, path) {},
initialDocumentType: type,
startViewMode: false,
showNewBadge: true,
),
),
);

File diff suppressed because it is too large Load diff

View 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,
});
}

View file

@ -6,8 +6,9 @@ import 'barcode_scanner_screen.dart';
class ProductMasterScreen extends StatefulWidget {
final bool selectionMode;
final bool showHidden;
const ProductMasterScreen({super.key, this.selectionMode = false});
const ProductMasterScreen({super.key, this.selectionMode = false, this.showHidden = false});
@override
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
@ -30,7 +31,7 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
Future<void> _loadProducts() async {
setState(() => _isLoading = true);
final products = await _productRepo.getAllProducts();
final products = await _productRepo.getAllProducts(includeHidden: widget.showHidden);
if (!mounted) return;
setState(() {
_products = products;
@ -47,6 +48,12 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
(p.barcode?.toLowerCase().contains(query) ?? false) ||
(p.category?.toLowerCase().contains(query) ?? false);
}).toList();
if (!widget.showHidden) {
_filteredProducts = _filteredProducts.where((p) => !p.isHidden).toList();
}
if (widget.showHidden) {
_filteredProducts.sort((a, b) => b.id.compareTo(a.id));
}
});
}
@ -106,16 +113,19 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
ElevatedButton(
onPressed: () {
if (nameController.text.isEmpty) return;
final locked = product?.isLocked ?? false;
final newId = locked ? const Uuid().v4() : (product?.id ?? const Uuid().v4());
Navigator.pop(
context,
Product(
id: product?.id ?? const Uuid().v4(),
id: newId,
name: nameController.text.trim(),
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
barcode: barcodeController.text.isEmpty ? null : barcodeController.text.trim(),
category: categoryController.text.isEmpty ? null : categoryController.text.trim(),
stockQuantity: int.tryParse(stockController.text) ?? 0,
odooId: product?.odooId,
isLocked: false,
),
);
},
@ -188,10 +198,19 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
],
),
),
title: Text(p.name, style: TextStyle(fontWeight: FontWeight.bold, color: p.isLocked ? Colors.grey : Colors.black87)),
title: Text(
p.name + (p.isHidden ? " (非表示)" : ""),
style: TextStyle(
fontWeight: FontWeight.bold,
color: p.isHidden
? Colors.grey
: (p.isLocked ? Colors.grey : Colors.black87),
),
),
subtitle: Text("${p.category ?? '未分類'} - ¥${p.defaultUnitPrice} (在庫: ${p.stockQuantity})"),
onTap: () {
if (widget.selectionMode) {
if (p.isHidden) return; // safety: do not return hidden in selection
Navigator.pop(context, p);
} else {
_showDetailPane(p);
@ -212,6 +231,16 @@ class _ProductMasterScreenState extends State<ProductMasterScreen> {
_showEditDialog(product: p);
},
),
if (!p.isHidden)
ListTile(
leading: const Icon(Icons.visibility_off),
title: const Text("非表示にする"),
onTap: () async {
Navigator.pop(ctx);
await _productRepo.setHidden(p.id, true);
if (mounted) _loadProducts();
},
),
if (!p.isLocked)
ListTile(
leading: const Icon(Icons.delete_outline, color: Colors.redAccent),

View file

@ -1,7 +1,14 @@
import 'package:flutter/material.dart';
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 'email_settings_screen.dart';
import 'business_profile_screen.dart';
import 'chat_screen.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@ -10,28 +17,22 @@ class SettingsScreen extends StatefulWidget {
State<SettingsScreen> createState() => _SettingsScreenState();
}
//
const Map<String, IconData> kIconsMap = {
'list_alt': Icons.list_alt,
'edit_note': Icons.edit_note,
'history': Icons.history,
'settings': Icons.settings,
'invoice': Icons.receipt_long,
'dashboard': Icons.dashboard,
'home': Icons.home,
'info': Icons.info,
'mail': Icons.mail,
'shopping_cart': Icons.shopping_cart,
};
class _SettingsScreenState extends State<SettingsScreen> {
// Company
final _companyNameCtrl = TextEditingController();
final _companyZipCtrl = TextEditingController();
final _companyAddrCtrl = TextEditingController();
final _companyTelCtrl = TextEditingController();
final _companyRegCtrl = TextEditingController();
final _companyFaxCtrl = TextEditingController();
final _companyEmailCtrl = TextEditingController();
final _companyUrlCtrl = TextEditingController();
// Staff
final _staffNameCtrl = TextEditingController();
final _staffMailCtrl = TextEditingController();
// SMTP
final _smtpHostCtrl = TextEditingController();
final _smtpPortCtrl = TextEditingController(text: '587');
final _smtpUserCtrl = TextEditingController();
final _smtpPassCtrl = TextEditingController();
final _smtpBccCtrl = TextEditingController();
bool _smtpTls = true;
final _appSettingsRepo = AppSettingsRepository();
// External sync ()
final _externalHostCtrl = TextEditingController();
@ -47,85 +48,55 @@ class _SettingsScreenState extends State<SettingsScreen> {
final _kanaKeyCtrl = TextEditingController();
final _kanaValCtrl = TextEditingController();
// SharedPreferences keys
static const _kCompanyName = 'company_name';
static const _kCompanyZip = 'company_zip';
static const _kCompanyAddr = 'company_addr';
static const _kCompanyTel = 'company_tel';
static const _kCompanyReg = 'company_reg';
static const _kCompanyFax = 'company_fax';
static const _kCompanyEmail = 'company_email';
static const _kCompanyUrl = 'company_url';
static const _kStaffName = 'staff_name';
static const _kStaffMail = 'staff_mail';
static const _kSmtpHost = 'smtp_host';
static const _kSmtpPort = 'smtp_port';
static const _kSmtpUser = 'smtp_user';
static const _kSmtpPass = 'smtp_pass';
static const _kSmtpTls = 'smtp_tls';
static const _kSmtpBcc = 'smtp_bcc';
// Dashboard / Home
bool _homeDashboard = false;
bool _statusEnabled = true;
final _statusTextCtrl = TextEditingController(text: '工事中');
List<DashboardMenuItem> _menuItems = [];
bool _loadingAppSettings = true;
static const _kExternalHost = 'external_host';
static const _kExternalPass = 'external_pass';
static const _kCryptKey = 'test';
static const _kBackupPath = 'backup_path';
@override
void dispose() {
_companyNameCtrl.dispose();
_companyZipCtrl.dispose();
_companyAddrCtrl.dispose();
_companyTelCtrl.dispose();
_companyRegCtrl.dispose();
_companyFaxCtrl.dispose();
_companyEmailCtrl.dispose();
_companyUrlCtrl.dispose();
_staffNameCtrl.dispose();
_staffMailCtrl.dispose();
_smtpHostCtrl.dispose();
_smtpPortCtrl.dispose();
_smtpUserCtrl.dispose();
_smtpPassCtrl.dispose();
_smtpBccCtrl.dispose();
_externalHostCtrl.dispose();
_externalPassCtrl.dispose();
_backupPathCtrl.dispose();
_kanaKeyCtrl.dispose();
_kanaValCtrl.dispose();
_statusTextCtrl.dispose();
super.dispose();
}
Future<void> _loadAll() async {
await _loadKanaMap();
final prefs = await SharedPreferences.getInstance();
final externalHost = await _appSettingsRepo.getString(_kExternalHost) ?? '';
final externalPass = await _appSettingsRepo.getString(_kExternalPass) ?? '';
final backupPath = await _appSettingsRepo.getString(_kBackupPath) ?? '';
final theme = await _appSettingsRepo.getTheme();
setState(() {
_companyNameCtrl.text = prefs.getString(_kCompanyName) ?? '';
_companyZipCtrl.text = prefs.getString(_kCompanyZip) ?? '';
_companyAddrCtrl.text = prefs.getString(_kCompanyAddr) ?? '';
_companyTelCtrl.text = prefs.getString(_kCompanyTel) ?? '';
_companyRegCtrl.text = prefs.getString(_kCompanyReg) ?? '';
_companyFaxCtrl.text = prefs.getString(_kCompanyFax) ?? '';
_companyEmailCtrl.text = prefs.getString(_kCompanyEmail) ?? '';
_companyUrlCtrl.text = prefs.getString(_kCompanyUrl) ?? '';
_externalHostCtrl.text = externalHost;
_externalPassCtrl.text = externalPass;
_staffNameCtrl.text = prefs.getString(_kStaffName) ?? '';
_staffMailCtrl.text = prefs.getString(_kStaffMail) ?? '';
_backupPathCtrl.text = backupPath;
_theme = theme;
});
_smtpHostCtrl.text = prefs.getString(_kSmtpHost) ?? '';
_smtpPortCtrl.text = prefs.getString(_kSmtpPort) ?? '587';
_smtpUserCtrl.text = prefs.getString(_kSmtpUser) ?? '';
_smtpPassCtrl.text = _decryptWithFallback(prefs.getString(_kSmtpPass) ?? '');
_smtpTls = prefs.getBool(_kSmtpTls) ?? true;
_smtpBccCtrl.text = prefs.getString(_kSmtpBcc) ?? '';
_externalHostCtrl.text = prefs.getString(_kExternalHost) ?? '';
_externalPassCtrl.text = prefs.getString(_kExternalPass) ?? '';
_backupPathCtrl.text = prefs.getString(_kBackupPath) ?? '';
final homeMode = await _appSettingsRepo.getHomeMode();
final statusEnabled = await _appSettingsRepo.getDashboardStatusEnabled();
final statusText = await _appSettingsRepo.getDashboardStatusText();
final menu = await _appSettingsRepo.getDashboardMenu();
setState(() {
_homeDashboard = homeMode == 'dashboard';
_statusEnabled = statusEnabled;
_statusTextCtrl.text = statusText;
_menuItems = menu;
_loadingAppSettings = false;
});
}
@ -139,55 +110,156 @@ class _SettingsScreenState extends State<SettingsScreen> {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
Future<void> _saveCompany() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kCompanyName, _companyNameCtrl.text);
await prefs.setString(_kCompanyZip, _companyZipCtrl.text);
await prefs.setString(_kCompanyAddr, _companyAddrCtrl.text);
await prefs.setString(_kCompanyTel, _companyTelCtrl.text);
await prefs.setString(_kCompanyReg, _companyRegCtrl.text);
await prefs.setString(_kCompanyFax, _companyFaxCtrl.text);
await prefs.setString(_kCompanyEmail, _companyEmailCtrl.text);
await prefs.setString(_kCompanyUrl, _companyUrlCtrl.text);
_showSnackbar('自社情報を保存しました');
Future<void> _saveAppSettings() async {
await _appSettingsRepo.setHomeMode(_homeDashboard ? 'dashboard' : 'invoice_history');
await _appSettingsRepo.setDashboardStatusEnabled(_statusEnabled);
await _appSettingsRepo.setDashboardStatusText(_statusTextCtrl.text.trim().isEmpty ? '工事中' : _statusTextCtrl.text.trim());
await _appSettingsRepo.setDashboardMenu(_menuItems);
_showSnackbar('ホーム/ダッシュボード設定を保存しました');
}
Future<void> _saveStaff() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kStaffName, _staffNameCtrl.text);
await prefs.setString(_kStaffMail, _staffMailCtrl.text);
_showSnackbar('担当者情報を保存しました');
Future<void> _persistMenu() async {
await _appSettingsRepo.setDashboardMenu(_menuItems);
}
Future<void> _saveSmtp() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kSmtpHost, _smtpHostCtrl.text);
await prefs.setString(_kSmtpPort, _smtpPortCtrl.text);
await prefs.setString(_kSmtpUser, _smtpUserCtrl.text);
await prefs.setString(_kSmtpPass, _encrypt(_smtpPassCtrl.text));
await prefs.setBool(_kSmtpTls, _smtpTls);
await prefs.setString(_kSmtpBcc, _smtpBccCtrl.text);
_showSnackbar('SMTP設定を保存しました');
void _addMenuItem() async {
final titleCtrl = TextEditingController();
String route = 'invoice_history';
final iconCtrl = TextEditingController(text: 'list_alt');
String? customIconPath;
await showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('メニューを追加'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: titleCtrl, decoration: const InputDecoration(labelText: 'タイトル')),
DropdownButtonFormField<String>(
initialValue: route,
decoration: const InputDecoration(labelText: '遷移先'),
items: const [
DropdownMenuItem(value: 'invoice_history', child: Text('A2:伝票一覧')),
DropdownMenuItem(value: 'invoice_input', child: Text('A1:伝票入力')),
DropdownMenuItem(value: 'customer_master', child: Text('C1:顧客マスター')),
DropdownMenuItem(value: 'product_master', child: Text('P1:商品マスター')),
DropdownMenuItem(value: 'master_hub', child: Text('M1:マスター管理')),
DropdownMenuItem(value: 'settings', child: Text('S1:設定')),
],
onChanged: (v) => route = v ?? 'invoice_history',
),
TextField(controller: iconCtrl, decoration: const InputDecoration(labelText: 'Materialアイコン名 (例: list_alt)')),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: Text(customIconPath ?? 'カスタムアイコン: 未選択', style: const TextStyle(fontSize: 12))),
IconButton(
icon: const Icon(Icons.image_search),
tooltip: 'ギャラリーから選択',
onPressed: () async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery);
if (picked != null) {
setState(() {
customIconPath = picked.path;
});
}
},
),
],
),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
if (titleCtrl.text.trim().isEmpty) return;
setState(() {
_menuItems = [
..._menuItems,
DashboardMenuItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: titleCtrl.text.trim(),
route: route,
iconName: iconCtrl.text.trim().isEmpty ? 'list_alt' : iconCtrl.text.trim(),
customIconPath: customIconPath,
),
];
});
_persistMenu();
Navigator.pop(ctx);
},
child: const Text('追加'),
),
],
),
);
}
void _removeMenuItem(String id) {
setState(() {
_menuItems = _menuItems.where((e) => e.id != id).toList();
});
_persistMenu();
}
void _reorderMenu(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) newIndex -= 1;
final item = _menuItems.removeAt(oldIndex);
_menuItems.insert(newIndex, item);
});
_persistMenu();
}
String _routeLabel(String route) {
switch (route) {
case 'invoice_history':
return 'A2:伝票一覧';
case 'invoice_input':
return 'A1:伝票入力';
case 'customer_master':
return 'C1:顧客マスター';
case 'product_master':
return 'P1:商品マスター';
case 'master_hub':
return 'M1:マスター管理';
case 'settings':
return 'S1:設定';
default:
return route;
}
}
IconData _iconForName(String name) {
return kIconsMap[name] ?? Icons.apps;
}
Widget _menuLeading(DashboardMenuItem item) {
if (item.customIconPath != null && File(item.customIconPath!).existsSync()) {
return CircleAvatar(backgroundImage: FileImage(File(item.customIconPath!)));
}
return Icon(item.iconName != null ? _iconForName(item.iconName!) : Icons.apps);
}
Future<void> _saveExternalSync() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kExternalHost, _externalHostCtrl.text);
await prefs.setString(_kExternalPass, _externalPassCtrl.text);
await _appSettingsRepo.setString(_kExternalHost, _externalHostCtrl.text);
await _appSettingsRepo.setString(_kExternalPass, _externalPassCtrl.text);
_showSnackbar('外部同期設定を保存しました');
}
Future<void> _saveBackup() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kBackupPath, _backupPathCtrl.text);
await _appSettingsRepo.setString(_kBackupPath, _backupPathCtrl.text);
_showSnackbar('バックアップ設定を保存しました');
}
void _pickBackupPath() => _showSnackbar('バックアップ先の選択は後で実装');
Future<void> _loadKanaMap() async {
final prefs = await SharedPreferences.getInstance();
final json = prefs.getString('customKanaMap');
final json = await _appSettingsRepo.getString('customKanaMap');
if (json != null && json.isNotEmpty) {
try {
final Map<String, dynamic> decoded = jsonDecode(json);
@ -199,31 +271,10 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
Future<void> _saveKanaMap() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('customKanaMap', jsonEncode(_customKanaMap));
await _appSettingsRepo.setString('customKanaMap', jsonEncode(_customKanaMap));
_showSnackbar('かなインデックスを保存しました');
}
String _encrypt(String plain) {
if (plain.isEmpty) return '';
final pb = utf8.encode(plain);
final kb = utf8.encode(_kCryptKey);
final ob = List<int>.generate(pb.length, (i) => pb[i] ^ kb[i % kb.length]);
return base64Encode(ob);
}
String _decryptWithFallback(String cipher) {
if (cipher.isEmpty) return '';
try {
final ob = base64Decode(cipher);
final kb = utf8.encode(_kCryptKey);
final pb = List<int>.generate(ob.length, (i) => ob[i] ^ kb[i % kb.length]);
return utf8.decode(pb);
} catch (_) {
return cipher; //
}
}
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
@ -247,33 +298,68 @@ class _SettingsScreenState extends State<SettingsScreen> {
physics: const AlwaysScrollableScrollPhysics(),
padding: EdgeInsets.only(bottom: listBottomPadding),
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.indigo.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.indigo.shade100),
),
child: Row(
_section(
title: 'ホームモード / ダッシュボード',
subtitle: 'ダッシュボードをホームにする・ステータス表示・メニュー管理 (設定はDB保存)',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.business, color: Colors.indigo, size: 28),
const SizedBox(width: 12),
const Expanded(
child: Text(
"自社情報を開く",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.indigo),
),
SwitchListTile(
title: const Text('ホームをダッシュボードにする'),
value: _homeDashboard,
onChanged: _loadingAppSettings ? null : (v) => setState(() => _homeDashboard = v),
),
ElevatedButton.icon(
onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const CompanyInfoScreen())),
icon: const Icon(Icons.chevron_right),
label: const Text("詳細"),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
SwitchListTile(
title: const Text('ステータスを表示する'),
value: _statusEnabled,
onChanged: _loadingAppSettings ? null : (v) => setState(() => _statusEnabled = v),
),
TextField(
controller: _statusTextCtrl,
enabled: !_loadingAppSettings && _statusEnabled,
decoration: const InputDecoration(labelText: 'ステータス文言', hintText: '例: 工事中'),
),
const SizedBox(height: 8),
Row(
children: [
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('メニューを追加'),
onPressed: _loadingAppSettings ? null : _addMenuItem,
),
const SizedBox(width: 12),
Text('ドラッグで並べ替え / ゴミ箱で削除', style: Theme.of(context).textTheme.bodySmall),
],
),
const SizedBox(height: 8),
_loadingAppSettings
? const Center(child: Padding(padding: EdgeInsets.all(12), child: CircularProgressIndicator()))
: ReorderableListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _menuItems.length,
onReorder: _reorderMenu,
itemBuilder: (ctx, index) {
final item = _menuItems[index];
return ListTile(
key: ValueKey(item.id),
leading: _menuLeading(item),
title: Text(item.title),
subtitle: Text(_routeLabel(item.route)),
trailing: IconButton(
icon: const Icon(Icons.delete_forever, color: Colors.redAccent),
onPressed: () => _removeMenuItem(item.id),
),
);
},
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('ホーム設定を保存'),
onPressed: _loadingAppSettings ? null : _saveAppSettings,
),
),
],
@ -281,32 +367,30 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
_section(
title: '自社情報',
subtitle: '会社名・住所・登録番号など',
subtitle: '会社・担当者・振込口座・電話帳取り込み',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(controller: _companyNameCtrl, decoration: const InputDecoration(labelText: '会社名')),
TextField(controller: _companyZipCtrl, decoration: const InputDecoration(labelText: '郵便番号')),
TextField(controller: _companyAddrCtrl, decoration: const InputDecoration(labelText: '住所')),
TextField(controller: _companyTelCtrl, decoration: const InputDecoration(labelText: '電話番号')),
TextField(controller: _companyFaxCtrl, decoration: const InputDecoration(labelText: 'FAX番号')),
TextField(controller: _companyEmailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')),
TextField(controller: _companyUrlCtrl, decoration: const InputDecoration(labelText: 'URL')),
TextField(controller: _companyRegCtrl, decoration: const InputDecoration(labelText: '登録番号 (インボイス)')),
const SizedBox(height: 8),
const Text('自社/担当者情報、振込口座設定、メールフッタをまとめて編集できます。'),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
icon: const Icon(Icons.upload_file),
label: const Text('画面で編集'),
icon: const Icon(Icons.info_outline),
label: const Text('旧画面 (税率/印影)'),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const CompanyInfoScreen()));
},
),
const SizedBox(width: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveCompany,
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.business),
label: const Text('自社情報ページを開く'),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const BusinessProfileScreen()));
},
),
),
],
),
@ -314,40 +398,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
_section(
title: '担当者情報',
subtitle: '署名や連絡先(送信者情報)',
title: 'メール設定SM画面へ',
subtitle: 'SMTP・端末メーラー・BCC必須・ログ閲覧など',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(controller: _staffNameCtrl, decoration: const InputDecoration(labelText: '担当者名')),
TextField(controller: _staffMailCtrl, decoration: const InputDecoration(labelText: 'メールアドレス')),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveStaff,
),
],
),
),
_section(
title: 'SMTP情報',
subtitle: 'メール送信サーバ設定(テンプレ)',
child: Column(
children: [
TextField(controller: _smtpHostCtrl, decoration: const InputDecoration(labelText: 'ホスト名')),
TextField(controller: _smtpPortCtrl, decoration: const InputDecoration(labelText: 'ポート番号'), keyboardType: TextInputType.number),
TextField(controller: _smtpUserCtrl, decoration: const InputDecoration(labelText: 'ユーザー名')),
TextField(controller: _smtpPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
TextField(controller: _smtpBccCtrl, decoration: const InputDecoration(labelText: 'BCC (カンマ区切り可)')),
SwitchListTile(
title: const Text('STARTTLS を使用'),
value: _smtpTls,
onChanged: (v) => setState(() => _smtpTls = v),
),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveSmtp,
const Text('メール送信に関する設定は専用画面でまとめて編集できます。'),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: ElevatedButton.icon(
icon: const Icon(Icons.mail_outline),
label: const Text('メール設定を開く'),
onPressed: () async {
await Navigator.push(
context,
MaterialPageRoute(builder: (context) => const EmailSettingsScreen()),
);
},
),
),
],
),
@ -360,10 +429,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')),
TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true),
const SizedBox(height: 8),
ElevatedButton.icon(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: _saveExternalSync,
Row(
children: [
ElevatedButton.icon(
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(
icon: const Icon(Icons.save),
label: const Text('保存'),
onPressed: () => _showSnackbar('テーマ設定を保存(テンプレ): $_theme'),
onPressed: () async {
await _appSettingsRepo.setTheme(_theme);
await AppThemeController.instance.setTheme(_theme);
if (!mounted) return;
_showSnackbar('テーマ設定を保存しました');
},
),
],
),

View 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?,
);
}
}

View 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,
);
}
}

View 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;
}
}
}

View 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();
}
}

View file

@ -9,21 +9,28 @@ class CustomerRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository();
Future<List<Customer>> getAllCustomers() async {
Future<List<Customer>> getAllCustomers({bool includeHidden = false}) async {
final db = await _dbHelper.database;
final filter = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
FROM customers c
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
ORDER BY c.display_name ASC
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
$filter
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
''');
if (maps.isEmpty) {
await _generateSampleCustomers(limit: 3);
maps = await db.rawQuery('''
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
FROM customers c
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
ORDER BY c.display_name ASC
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
$filter
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
''');
}
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
@ -128,14 +135,17 @@ class CustomerRepository {
);
}
Future<List<Customer>> searchCustomers(String query) async {
Future<List<Customer>> searchCustomers(String query, {bool includeHidden = false}) async {
final db = await _dbHelper.database;
final where = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, c.is_hidden, 0) = 0';
final List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email
SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email,
COALESCE(mh.is_hidden, c.is_hidden, 0) AS is_hidden
FROM customers c
LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1
WHERE c.display_name LIKE ? OR c.formal_name LIKE ?
ORDER BY c.display_name ASC
LEFT JOIN master_hidden mh ON mh.master_type = 'customer' AND mh.master_id = c.id
WHERE (c.display_name LIKE ? OR c.formal_name LIKE ?) $where
ORDER BY ${includeHidden ? 'c.id DESC' : 'c.display_name ASC'}
LIMIT 50
''', ['%$query%', '%$query%']);
return List.generate(maps.length, (i) => Customer.fromMap(maps[i]));
@ -173,6 +183,25 @@ class CustomerRepository {
return CustomerContact.fromMap(rows.first);
}
Future<void> setHidden(String id, bool hidden) async {
final db = await _dbHelper.database;
await db.insert(
'master_hidden',
{
'master_type': 'customer',
'master_id': id,
'is_hidden': hidden ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _logRepo.logAction(
action: hidden ? "HIDE_CUSTOMER" : "UNHIDE_CUSTOMER",
targetType: "CUSTOMER",
targetId: id,
details: hidden ? "顧客を非表示にしました" : "顧客を再表示しました",
);
}
Future<int> _nextContactVersion(DatabaseExecutor txn, String customerId) async {
final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]);
final current = res.first['v'] as int?;

View file

@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
class DatabaseHelper {
static const _databaseVersion = 20;
static const _databaseVersion = 26;
static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database;
@ -164,6 +164,52 @@ class DatabaseHelper {
if (oldVersion < 20) {
await _safeAddColumn(db, 'customers', 'email TEXT');
}
if (oldVersion < 22) {
await db.execute('''
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
)
''');
}
if (oldVersion < 23) {
await _safeAddColumn(db, 'customers', 'is_hidden INTEGER DEFAULT 0');
await _safeAddColumn(db, 'products', 'is_hidden INTEGER DEFAULT 0');
await db.execute('CREATE INDEX IF NOT EXISTS idx_customers_hidden ON customers(is_hidden)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_products_hidden ON products(is_hidden)');
}
if (oldVersion < 24) {
await db.execute('''
CREATE TABLE IF NOT EXISTS master_hidden (
master_type TEXT NOT NULL,
master_id TEXT NOT NULL,
is_hidden INTEGER DEFAULT 0,
PRIMARY KEY(master_type, master_id)
)
''');
await db.execute('CREATE INDEX IF NOT EXISTS idx_master_hidden_type ON master_hidden(master_type)');
}
if (oldVersion < 25) {
await _safeAddColumn(db, 'invoices', 'company_snapshot TEXT');
await _safeAddColumn(db, 'invoices', 'company_seal_hash TEXT');
await _safeAddColumn(db, 'invoices', 'meta_json TEXT');
await _safeAddColumn(db, 'invoices', 'meta_hash TEXT');
}
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 {
@ -182,6 +228,7 @@ class DatabaseHelper {
head_char1 TEXT,
head_char2 TEXT,
is_locked INTEGER DEFAULT 0,
is_hidden INTEGER DEFAULT 0,
is_synced INTEGER DEFAULT 0,
updated_at TEXT NOT NULL
)
@ -223,12 +270,23 @@ class DatabaseHelper {
category TEXT,
stock_quantity INTEGER DEFAULT 0,
is_locked INTEGER DEFAULT 0,
is_hidden INTEGER DEFAULT 0,
odoo_id TEXT
)
''');
await db.execute('CREATE INDEX idx_products_name ON products(name)');
await db.execute('CREATE INDEX idx_products_barcode ON products(barcode)');
await db.execute('''
CREATE TABLE master_hidden (
master_type TEXT NOT NULL,
master_id TEXT NOT NULL,
is_hidden INTEGER DEFAULT 0,
PRIMARY KEY(master_type, master_id)
)
''');
await db.execute('CREATE INDEX idx_master_hidden_type ON master_hidden(master_type)');
//
await db.execute('''
CREATE TABLE invoices (
@ -255,6 +313,10 @@ class DatabaseHelper {
contact_email_snapshot TEXT,
contact_tel_snapshot TEXT,
contact_address_snapshot TEXT,
company_snapshot TEXT,
company_seal_hash TEXT,
meta_json TEXT,
meta_hash TEXT,
FOREIGN KEY (customer_id) REFERENCES customers (id)
)
''');
@ -305,6 +367,27 @@ class DatabaseHelper {
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 {

View 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});
}

View 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());
}
}

View file

@ -1,4 +1,6 @@
import 'dart:io';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import '../models/invoice_models.dart';
@ -6,15 +8,38 @@ import '../models/customer_model.dart';
import '../models/customer_contact.dart';
import 'database_helper.dart';
import 'activity_log_repository.dart';
import 'company_repository.dart';
class InvoiceRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository();
final CompanyRepository _companyRepo = CompanyRepository();
Future<void> saveInvoice(Invoice invoice) async {
final db = await _dbHelper.database;
//
final companyInfo = await _companyRepo.getCompanyInfo();
String? sealHash;
if (companyInfo.sealPath != null) {
final file = File(companyInfo.sealPath!);
if (await file.exists()) {
sealHash = sha256.convert(await file.readAsBytes()).toString();
}
}
final companySnapshot = jsonEncode({
'name': companyInfo.name,
'zipCode': companyInfo.zipCode,
'address': companyInfo.address,
'tel': companyInfo.tel,
'fax': companyInfo.fax,
'email': companyInfo.email,
'url': companyInfo.url,
'defaultTaxRate': companyInfo.defaultTaxRate,
'taxDisplayMode': companyInfo.taxDisplayMode,
'registrationNumber': companyInfo.registrationNumber,
});
final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true);
await db.transaction((txn) async {
@ -29,6 +54,10 @@ class InvoiceRepository {
contactEmailSnapshot: activeContact?.email,
contactTelSnapshot: activeContact?.tel,
contactAddressSnapshot: activeContact?.address,
companySnapshot: companySnapshot,
companySealHash: sealHash,
metaJson: null,
metaHash: null,
);
// 調
@ -150,6 +179,10 @@ class InvoiceRepository {
contactEmailSnapshot: iMap['contact_email_snapshot'],
contactTelSnapshot: iMap['contact_tel_snapshot'],
contactAddressSnapshot: iMap['contact_address_snapshot'],
companySnapshot: iMap['company_snapshot'],
companySealHash: iMap['company_seal_hash'],
metaJson: iMap['meta_json'],
metaHash: iMap['meta_hash'],
));
}
return invoices;
@ -248,6 +281,21 @@ class InvoiceRepository {
}
}
/// meta_json meta_hash trueなら一致
bool verifyInvoiceMeta(Invoice invoice) {
final metaJson = invoice.metaJson ?? invoice.metaJsonValue;
final expected = sha256.convert(utf8.encode(metaJson)).toString();
final stored = invoice.metaHash ?? expected;
return expected == stored;
}
/// IDを指定してDBから取得し
Future<bool> verifyInvoiceMetaById(String id, List<Customer> customers) async {
final invoices = await getAllInvoices(customers);
final target = invoices.firstWhere((i) => i.id == id, orElse: () => throw Exception('invoice not found'));
return verifyInvoiceMeta(target);
}
Future<Map<String, int>> getMonthlySales(int year) async {
final db = await _dbHelper.database;
final String yearStr = year.toString();

View 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();
}
}
}

View 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;
}

View file

@ -11,7 +11,15 @@ import 'activity_log_repository.dart';
/// PDFドキュメントの構築使
Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
final pdf = pw.Document();
final metaJson = invoice.metaJsonValue;
final metaHash = invoice.metaHashValue;
final pdf = pw.Document(
title: '${invoice.documentTypeName} ${invoice.invoiceNumber}',
author: 'h1-app',
subject: 'metaHash:$metaHash',
keywords: metaJson,
);
final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf");
final ipaex = pw.Font.ttf(fontData);
@ -221,7 +229,7 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
pw.Container(
width: 50,
height: 50,
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: invoice.contentHash, drawText: false),
child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: metaHash, drawText: false),
),
],
),

View file

@ -8,27 +8,38 @@ class ProductRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
final ActivityLogRepository _logRepo = ActivityLogRepository();
Future<List<Product>> getAllProducts() async {
Future<List<Product>> getAllProducts({bool includeHidden = false}) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'name ASC');
final String where = includeHidden ? '' : 'WHERE COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
final List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
FROM products p
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
$where
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
''');
if (maps.isEmpty) {
await _generateSampleProducts();
return getAllProducts();
return getAllProducts(includeHidden: includeHidden);
}
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
}
Future<List<Product>> searchProducts(String query) async {
Future<List<Product>> searchProducts(String query, {bool includeHidden = false}) async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
'products',
where: 'name LIKE ? OR barcode LIKE ? OR category LIKE ?',
whereArgs: ['%$query%', '%$query%', '%$query%'],
orderBy: 'name ASC',
limit: 50,
);
final args = ['%$query%', '%$query%', '%$query%'];
final String whereHidden = includeHidden ? '' : 'AND COALESCE(mh.is_hidden, p.is_hidden, 0) = 0';
final List<Map<String, dynamic>> maps = await db.rawQuery('''
SELECT p.*, COALESCE(mh.is_hidden, p.is_hidden, 0) AS is_hidden
FROM products p
LEFT JOIN master_hidden mh ON mh.master_type = 'product' AND mh.master_id = p.id
WHERE (p.name LIKE ? OR p.barcode LIKE ? OR p.category LIKE ?)
$whereHidden
ORDER BY ${includeHidden ? 'p.id DESC' : 'p.name ASC'}
LIMIT 50
''', args);
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
}
@ -81,4 +92,23 @@ class ProductRepository {
details: "商品を削除しました",
);
}
Future<void> setHidden(String id, bool hidden) async {
final db = await _dbHelper.database;
await db.insert(
'master_hidden',
{
'master_type': 'product',
'master_id': id,
'is_hidden': hidden ? 1 : 0,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await _logRepo.logAction(
action: hidden ? "HIDE_PRODUCT" : "UNHIDE_PRODUCT",
targetType: "PRODUCT",
targetId: id,
details: hidden ? "商品を非表示にしました" : "商品を再表示しました",
);
}
}

View 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;
}
}
}

View 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());
}
}

View 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),
);
},
),
),
],
),
);
},
),
);
}
}

View file

@ -1,13 +1,19 @@
import 'dart:typed_data';
import 'dart:io';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:flutter/material.dart';
import 'package:printing/printing.dart';
import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import 'package:mailer/mailer.dart';
import 'package:mailer/smtp_server.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_email_sender/flutter_email_sender.dart';
import 'package:path_provider/path_provider.dart';
import 'package:printing/printing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../constants/mail_send_method.dart';
import '../constants/mail_templates.dart';
import '../models/invoice_models.dart';
import '../services/company_profile_service.dart';
import '../services/email_sender.dart';
import '../services/pdf_generator.dart';
class InvoicePdfPreviewPage extends StatelessWidget {
final Invoice invoice;
@ -39,24 +45,17 @@ class InvoicePdfPreviewPage extends StatelessWidget {
Future<void> _sendEmail(BuildContext context) async {
try {
final prefs = await SharedPreferences.getInstance();
final host = prefs.getString('smtp_host') ?? '';
final portStr = prefs.getString('smtp_port') ?? '587';
final user = prefs.getString('smtp_user') ?? '';
final pass = prefs.getString('smtp_pass') ?? '';
final useTls = prefs.getBool('smtp_tls') ?? true;
final mailMethod = normalizeMailSendMethod(prefs.getString(kMailSendMethodPrefKey));
final bccRaw = prefs.getString('smtp_bcc') ?? '';
final bccList = bccRaw.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList();
final bccList = EmailSender.parseBcc(bccRaw);
if (host.isEmpty || user.isEmpty || pass.isEmpty) {
if (bccList.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください')));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('BCCは必須項目です設定画面で登録してください')));
}
return;
}
final port = int.tryParse(portStr) ?? 587;
final smtpServer = SmtpServer(host, port: port, username: user, password: pass, ignoreBadCertificate: false, ssl: !useTls, allowInsecure: !useTls);
final toEmail = invoice.contactEmailSnapshot ?? invoice.customer.email;
if (toEmail == null || toEmail.isEmpty) {
if (context.mounted) {
@ -66,18 +65,71 @@ class InvoicePdfPreviewPage extends StatelessWidget {
}
final bytes = await _buildPdfBytes();
final fileName = invoice.mailAttachmentFileName;
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/invoice.pdf');
final file = File('${tempDir.path}/$fileName');
await file.writeAsBytes(bytes, flush: true);
final message = Message()
..from = Address(user)
..recipients = [toEmail]
..bccRecipients = bccList
..subject = '請求書送付'
..text = '請求書をお送りします。ご確認ください。'
..attachments = [FileAttachment(file)..fileName = 'invoice.pdf'..contentType = 'application/pdf'];
final hash = sha256.convert(bytes).toString();
final headerTemplate = prefs.getString(kMailHeaderTextKey) ?? kMailHeaderTemplateDefault;
final footerTemplate = prefs.getString(kMailFooterTextKey) ?? kMailFooterTemplateDefault;
final placeholderMap = await CompanyProfileService().buildMailPlaceholderMap(filename: fileName, hash: hash);
final header = applyMailTemplate(headerTemplate, placeholderMap);
final footer = applyMailTemplate(footerTemplate, placeholderMap);
final bodyCore = invoice.mailBodyText;
final body = [header, bodyCore, footer].where((section) => section.trim().isNotEmpty).join('\n\n');
await send(message, smtpServer);
if (mailMethod == kMailSendMethodDeviceMailer) {
final email = Email(
body: body,
subject: fileName,
recipients: [toEmail],
bcc: bccList,
attachmentPaths: [file.path],
isHTML: false,
);
try {
await FlutterEmailSender.send(email);
await EmailSender.logDeviceMailer(success: true, toEmail: toEmail, bcc: bccList);
} catch (e) {
await EmailSender.logDeviceMailer(success: false, toEmail: toEmail, bcc: bccList, error: '$e');
rethrow;
}
} else {
final host = prefs.getString('smtp_host') ?? '';
final portStr = prefs.getString('smtp_port') ?? '587';
final user = prefs.getString('smtp_user') ?? '';
final passEncrypted = prefs.getString('smtp_pass') ?? '';
final pass = EmailSender.decrypt(passEncrypted);
final useTls = prefs.getBool('smtp_tls') ?? true;
final ignoreBadCert = prefs.getBool('smtp_ignore_bad_cert') ?? false;
if (host.isEmpty || user.isEmpty || pass.isEmpty) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('SMTP設定を先に保存してください')));
}
return;
}
final port = int.tryParse(portStr) ?? 587;
final smtpConfig = EmailSenderConfig(
host: host,
port: port,
username: user,
password: pass,
useTls: useTls,
ignoreBadCert: ignoreBadCert,
bcc: bccList,
);
await EmailSender.sendInvoiceEmail(
config: smtpConfig,
toEmail: toEmail,
pdfFile: file,
subject: fileName,
attachmentFileName: fileName,
body: body,
);
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('メール送信しました')));
}
@ -92,7 +144,15 @@ class InvoicePdfPreviewPage extends StatelessWidget {
Widget build(BuildContext context) {
final isDraft = invoice.isDraft;
return Scaffold(
appBar: AppBar(title: const Text("PDFプレビュー")),
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text("PDFプレビュー"),
Text("ScreenID: 02", style: TextStyle(fontSize: 11, color: Colors.white70)),
],
),
),
body: Column(
children: [
Expanded(
@ -121,17 +181,28 @@ class InvoicePdfPreviewPage extends StatelessWidget {
}
: null,
icon: const Icon(Icons.check_circle_outline),
label: const Text("正式発行"),
label: Stack(
alignment: Alignment.center,
children: [
const Text("正式発行"),
if (!isDraft || isLocked)
const Positioned(
right: 0,
child: Icon(Icons.lock, size: 16, color: Colors.white70),
),
],
),
style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: showShare
onPressed: (showShare && (!isDraft || isLocked))
? () async {
final bytes = await _buildPdfBytes();
await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf');
final fileName = invoice.mailAttachmentFileName;
await Printing.sharePdf(bytes: bytes, filename: fileName);
}
: null,
icon: const Icon(Icons.share),
@ -141,7 +212,7 @@ class InvoicePdfPreviewPage extends StatelessWidget {
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: showEmail
onPressed: (showEmail && (!isDraft || isLocked))
? () async {
await _sendEmail(context);
}

View file

@ -2,14 +2,28 @@ import 'package:flutter/material.dart';
class SlideToUnlock extends StatefulWidget {
final VoidCallback onUnlocked;
final String text;
final String lockedText;
final String unlockedText;
final IconData lockedIcon;
final IconData unlockedIcon;
final bool isLocked;
final double? height;
final double? thumbSize;
final Color? backgroundColor;
final Color? accentColor;
const SlideToUnlock({
super.key,
required this.onUnlocked,
this.text = "スライドして解除",
this.lockedText = "スライドして解除",
this.unlockedText = "UNLOCKED",
this.lockedIcon = Icons.lock,
this.unlockedIcon = Icons.check_circle,
this.isLocked = true,
this.height = 72,
this.thumbSize = 52,
this.backgroundColor,
this.accentColor,
});
@override
@ -18,7 +32,8 @@ class SlideToUnlock extends StatefulWidget {
class _SlideToUnlockState extends State<SlideToUnlock> {
double _position = 0.0;
final double _thumbSize = 56.0;
static const double _trackPadding = 14.0;
bool _showSuccessOverlay = false;
@override
Widget build(BuildContext context) {
@ -27,13 +42,20 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
return LayoutBuilder(
builder: (context, constraints) {
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(
height: 64,
height: widget.height ?? 72,
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.blueGrey.shade900,
color: background,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)),
@ -41,27 +63,63 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
),
child: Stack(
children: [
//
Center(
child: Opacity(
opacity: (1 - (_position / trackWidth)).clamp(0.2, 1.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.keyboard_double_arrow_right, color: Colors.white54, size: 20),
const SizedBox(width: 8),
Text(
widget.text,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.2),
//
Padding(
padding: const EdgeInsets.symmetric(horizontal: _trackPadding, vertical: 12),
child: Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
curve: Curves.easeOut,
width: progressWidth,
height: double.infinity,
decoration: BoxDecoration(
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(
left: _position + 4,
top: 4,
left: _trackPadding + _position,
top: ((widget.height ?? 72) - (widget.thumbSize ?? 52)) / 2,
child: GestureDetector(
onHorizontalDragUpdate: (details) {
setState(() {
@ -72,31 +130,34 @@ class _SlideToUnlockState extends State<SlideToUnlock> {
},
onHorizontalDragEnd: (details) {
if (_position >= trackWidth * 0.65) {
setState(() {
_position = trackWidth;
_showSuccessOverlay = true;
});
widget.onUnlocked();
//
setState(() => _position = 0);
Future.delayed(const Duration(milliseconds: 450), () {
if (!mounted) return;
setState(() {
_position = 0;
_showSuccessOverlay = false;
});
});
} else {
//
setState(() => _position = 0);
}
},
child: Container(
width: _thumbSize,
height: 56,
width: thumbSize,
height: widget.thumbSize ?? 52,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Colors.orangeAccent, Colors.deepOrange],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(color: Colors.black45, blurRadius: 4, offset: const Offset(2, 2)),
color: Colors.white,
borderRadius: BorderRadius.circular((widget.thumbSize ?? 52) / 2),
boxShadow: const [
BoxShadow(color: Colors.black38, blurRadius: 8, offset: Offset(0, 4)),
],
),
child: const Center(
child: Icon(Icons.key, color: Colors.white, size: 24),
),
child: Icon(Icons.arrow_forward_ios, color: accentEnd, size: 20),
),
),
),

View file

@ -190,6 +190,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.9+2"
flutter_email_sender:
dependency: "direct main"
description:
name: flutter_email_sender
sha256: fb515d4e073d238d0daf1d765e5318487b6396d46b96e0ae9745dbc9a133f97a
url: "https://pub.dev"
source: hosted
version: "6.0.3"
flutter_lints:
dependency: "direct dev"
description:
@ -305,13 +313,21 @@ packages:
source: hosted
version: "1.0.1"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -784,6 +800,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description: flutter

View file

@ -53,6 +53,10 @@ dependencies:
printing: ^5.14.2
shared_preferences: ^2.2.2
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:
flutter_test:

38
scripts/build_with_expiry.sh Executable file
View 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."

View file

@ -9,11 +9,13 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:h_1/main.dart';
import 'package:h_1/utils/build_expiry_info.dart';
void main() {
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
// 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.
expect(find.text('0'), findsOneWidget);